diff --git a/src/assets/images/prev-waypoint-icon.svg b/src/assets/images/prev-waypoint-icon.svg new file mode 100644 index 0000000..7c8c128 --- /dev/null +++ b/src/assets/images/prev-waypoint-icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/waypoint-icon.svg b/src/assets/images/waypoint-icon.svg new file mode 100644 index 0000000..572cee1 --- /dev/null +++ b/src/assets/images/waypoint-icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/DataBox.jsx b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/DataBox.jsx index 5768f75..3253145 100644 --- a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/DataBox.jsx +++ b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/DataBox.jsx @@ -1,50 +1,135 @@ -import { Box, Button, TextField } from "@mui/material"; +import { Box, Stack, IconButton, Typography, Divider } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import CloseIcon from "@mui/icons-material/Close"; +import AddIcon from "@mui/icons-material/Add"; -const DataBox = ({ isSource, isDest, sourcePosition, destPosition, editingTarget, onEditSource, onEditDest }) => { +const ROW_COLORS = { + source: "#0EA37A", // سبز + waypoint: "#F5A623", // زرد/نارنجی + dest: "#E53935", // قرمز +}; + +const formatCoord = (pos) => (pos ? `${pos.lat.toFixed(2)}, ${pos.lng.toFixed(2)}` : null); + +const Row = ({ color, label, value, onEdit, onRemove, placeholder = "در حال انتخاب..." }) => ( + + + + + {label} + + + {value || placeholder} + + + {onEdit && ( + + + + )} + {onRemove && ( + + + + )} + +); + +const DataBox = ({ + isSource, + isDest, + sourcePosition, + destPosition, + editingTarget, + onEditSource, + onEditDest, + waypoints = [], + onEditWaypoint, + onAddWaypoint, + onRemoveWaypoint, + canAddWaypoint, +}) => { if (!isSource) return null; return ( -
- + + {waypoints.map((wp, index) => ( + onEditWaypoint(index) : null} + onRemove={() => onRemoveWaypoint(index)} /> - {isSource && isDest && ( - - )} -
+ ))} + {isDest && ( -
- - -
+ + )} + + {canAddWaypoint && ( + <> + + + + + افزودن نقطه میانی + + + )}
); diff --git a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/ShowRoute.jsx b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/ShowRoute.jsx index b05cc6a..b9d5143 100644 --- a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/ShowRoute.jsx +++ b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/ShowRoute.jsx @@ -55,3 +55,74 @@ const ShowRoute = ({ area }) => { }; export default ShowRoute; + +// import { useEffect, useState } from "react"; +// import { Marker, Polyline, useMap } from "react-leaflet"; +// import L from "leaflet"; +// import polyline from "@mapbox/polyline"; +// import SourceIcon from "@/assets/images/source-icon.svg"; +// import DestIcon from "@/assets/images/destination-icon.svg"; +// import WaypointIcon from "@/assets/images/waypoint-icon.svg"; +// +// const defaultIconSize = [35, 35]; +// +// const createCustomIcon = (size, iconUrl) => { +// return L.icon({ +// iconUrl: iconUrl, +// iconSize: size, +// iconAnchor: [size[0] / 2, size[1]], +// popupAnchor: [0, -size[1]], +// }); +// }; +// +// const sourceIcon = createCustomIcon(defaultIconSize, SourceIcon.src); +// const destIcon = createCustomIcon(defaultIconSize, DestIcon.src); +// const waypointIcon = createCustomIcon(defaultIconSize, WaypointIcon.src); +// +// // locations: آرایه اختیاری [{lat, lon}, ...] شامل مبدا، نقاط میانی و مقصد به همون ترتیبی که برای روتینگ فرستاده شده +// const ShowRoute = ({ area, locations = [] }) => { +// const map = useMap(); +// const [showRoute, setShowRoute] = useState(false); +// +// const coords = area ? polyline.decode(area, 6) : []; +// +// useEffect(() => { +// if (!coords.length) return; +// +// setShowRoute(false); +// +// map.flyToBounds(coords, { +// padding: [50, 50], +// duration: 1.5, +// }); +// +// map.once("moveend", () => { +// setShowRoute(true); +// }); +// // eslint-disable-next-line react-hooks/exhaustive-deps +// }, [area]); +// +// if (!area || coords.length === 0) return null; +// +// const sourcePosition = coords[0]; +// const destPosition = coords[coords.length - 1]; +// +// // نقاط میانی فقط از روی locations قابل استخراج‌ان (بین اولی و آخری) +// const middleWaypoints = locations.length > 2 ? locations.slice(1, -1) : []; +// +// return ( +// <> +// {showRoute && } +// +// +// +// {middleWaypoints.map((wp, index) => ( +// +// ))} +// +// +// +// ); +// }; +// +// export default ShowRoute; diff --git a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/index.jsx b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/index.jsx index 49c4651..ef3f6a6 100644 --- a/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/index.jsx +++ b/src/components/dashboard/roadMissions/operator/Actions/Create/Form/Area/Routing/index.jsx @@ -5,19 +5,29 @@ import SourceIcon from "@/assets/images/source-icon.svg"; import PrevSourceIcon from "@/assets/images/prev-source-icon.svg"; import PrevDestIcon from "@/assets/images/prev-destination-icon.svg"; import DestIcon from "@/assets/images/destination-icon.svg"; +import WaypointIcon from "@/assets/images/prev-waypoint-icon.svg"; +import PrevWaypointIcon from "@/assets/images/waypoint-icon.svg"; import { useEffect, useRef, useState } from "react"; import DataBox from "./DataBox"; import AlarmText from "./AlarmText"; +const MAX_WAYPOINTS = 1; + const Routing = ({ setArea, setLocations, locations, area }) => { const mapPrevSourceMarker = useRef(); const mapPrevDestMarker = useRef(); + const mapPrevWaypointMarkers = useRef([]); // ref array, یکی به‌ازای هر waypoint const mapRef = useRef(null); const defaultIconSize = [35, 35]; + const [isSource, setIsSource] = useState(false); const [sourcePosition, setSourcePosition] = useState(null); + const [isDest, setIsDest] = useState(false); const [destPosition, setDestPosition] = useState(null); + + const [waypoints, setWaypoints] = useState([]); + const [routeCoords, setRouteCoords] = useState([]); const [alternateCoords, setAlternateCoords] = useState([]); const [editingTarget, setEditingTarget] = useState(null); @@ -42,24 +52,55 @@ const Routing = ({ setArea, setLocations, locations, area }) => { isDest && editingTarget !== "dest" ? DestIcon.src : PrevDestIcon.src ); - const isMovingSource = !isSource || editingTarget === "source"; - const isMovingDest = isSource && (!isDest || editingTarget === "dest"); + const getWaypointIcon = (index) => { + const wp = waypoints[index]; + const isConfirmed = wp?.confirmed && editingTarget !== `waypoint-${index}`; + return createCustomIcon(defaultIconSize, isConfirmed ? WaypointIcon.src : PrevWaypointIcon.src); + }; + + const getActiveTarget = () => { + if (editingTarget) return editingTarget; + if (!isSource) return "source"; + const pendingWaypointIndex = waypoints.findIndex((wp) => !wp.confirmed); + if (pendingWaypointIndex !== -1) return `waypoint-${pendingWaypointIndex}`; + if (!isDest) return "dest"; + return null; + }; const map = useMapEvents({ move(e) { - if (isMovingSource) mapPrevSourceMarker.current.setLatLng(e.target.getCenter()); - if (isMovingDest && mapPrevDestMarker.current) mapPrevDestMarker.current.setLatLng(e.target.getCenter()); + const target = getActiveTarget(); + const center = e.target.getCenter(); + if (target === "source") { + mapPrevSourceMarker.current?.setLatLng(center); + } else if (target === "dest") { + mapPrevDestMarker.current?.setLatLng(center); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setLatLng(center); + } }, movestart() { - if (isMovingSource) mapPrevSourceMarker.current.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon([45, 45], PrevWaypointIcon.src)); + } }, moveend() { - if (isMovingSource) - mapPrevSourceMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon(defaultIconSize, PrevWaypointIcon.src)); + } }, }); @@ -69,12 +110,19 @@ const Routing = ({ setArea, setLocations, locations, area }) => { useEffect(() => { if (locations.length < 2) return; - const restoredSource = { lat: locations[0].lat, lng: locations[0].lon }; - const restoredDest = { lat: locations[1].lat, lng: locations[1].lon }; - setSourcePosition(restoredSource); - setDestPosition(restoredDest); + const first = { lat: locations[0].lat, lng: locations[0].lon }; + const last = { lat: locations[locations.length - 1].lat, lng: locations[locations.length - 1].lon }; + const middle = locations.slice(1, -1).map((loc) => ({ + position: { lat: loc.lat, lng: loc.lon }, + confirmed: true, + })); + + setSourcePosition(first); + setDestPosition(last); + setWaypoints(middle); setIsSource(true); setIsDest(true); + if (area) { const decoded = polyline.decode(area, 6); setRouteCoords(decoded); @@ -83,13 +131,15 @@ const Routing = ({ setArea, setLocations, locations, area }) => { mapRef.current?.flyToBounds(decoded, { padding: [50, 50], duration: 1.5 }); }, 100); } else { - fetchRoute(restoredSource, restoredDest); + fetchRoute(first, last, middle); } }, []); - const fetchRoute = async (source, dest) => { + const fetchRoute = async (source, dest, waypointsList = waypoints) => { + const confirmedWaypoints = waypointsList.filter((wp) => wp.confirmed && wp.position); const locs = [ { lat: source.lat, lon: source.lng }, + ...confirmedWaypoints.map((wp) => ({ lat: wp.position.lat, lon: wp.position.lng })), { lat: dest.lat, lon: dest.lng }, ]; try { @@ -118,18 +168,23 @@ const Routing = ({ setArea, setLocations, locations, area }) => { } }; + const resetRoute = () => { + setRouteCoords([]); + setAlternateCoords([]); + setSelectedRoute(null); + }; + const handleMarkerClick = async () => { if (!isSource || editingTarget === "source") { const clickedPosition = mapPrevSourceMarker.current.getLatLng(); setSourcePosition(clickedPosition); setIsSource(true); + const wasEditing = editingTarget === "source"; setEditingTarget(null); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); - if (editingTarget === "source" && destPosition) { - await fetchRoute(clickedPosition, destPosition); + if (wasEditing && destPosition) { + await fetchRoute(clickedPosition, destPosition, waypoints); } } }; @@ -140,23 +195,65 @@ const Routing = ({ setArea, setLocations, locations, area }) => { setDestPosition(clickedPosition); setIsDest(true); setEditingTarget(null); + resetRoute(); - await fetchRoute(sourcePosition, clickedPosition); + await fetchRoute(sourcePosition, clickedPosition, waypoints); + } + }; + + const handleWaypointMarkerClick = async (index) => { + const wp = waypoints[index]; + if (!wp || wp.confirmed) return; // فقط روی نقطه‌ی تأیید‌نشده کلیک معتبره (یا حالت ادیت) + + const clickedPosition = mapPrevWaypointMarkers.current[index]?.getLatLng(); + const updated = waypoints.map((w, i) => (i === index ? { position: clickedPosition, confirmed: true } : w)); + setWaypoints(updated); + const wasEditing = editingTarget === `waypoint-${index}`; + setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); } }; const handleEditSource = () => { setEditingTarget("source"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); }; const handleEditDest = () => { setEditingTarget("dest"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); + }; + + const handleEditWaypoint = (index) => { + // برای ادیت یک waypoint تأییدشده، موقتاً unconfirmed می‌کنیم تا با حرکت نقشه جابه‌جا بشه + setWaypoints((prev) => prev.map((w, i) => (i === index ? { ...w, confirmed: false } : w))); + setEditingTarget(`waypoint-${index}`); + resetRoute(); + }; + + // اضافه کردن یک نقطه میانی جدید (حداکثر MAX_WAYPOINTS) + const handleAddWaypoint = () => { + if (waypoints.length >= MAX_WAYPOINTS) return; + if (!isSource) return; // قبل از انتخاب مبدا معنی نداره + setWaypoints((prev) => [...prev, { position: null, confirmed: false }]); + setEditingTarget(`waypoint-${waypoints.length}`); + resetRoute(); + }; + + // حذف یک نقطه میانی + const handleRemoveWaypoint = async (index) => { + const updated = waypoints.filter((_, i) => i !== index); + setWaypoints(updated); + mapPrevWaypointMarkers.current = mapPrevWaypointMarkers.current.filter((_, i) => i !== index); + if (editingTarget === `waypoint-${index}`) setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); + } }; const handleSelectRoute = (index, coords) => { @@ -176,14 +273,31 @@ const Routing = ({ setArea, setLocations, locations, area }) => { editingTarget={editingTarget} onEditSource={handleEditSource} onEditDest={handleEditDest} + waypoints={waypoints} + onEditWaypoint={handleEditWaypoint} + onAddWaypoint={handleAddWaypoint} + onRemoveWaypoint={handleRemoveWaypoint} + canAddWaypoint={isSource && waypoints.length < MAX_WAYPOINTS} /> + + + {waypoints.map((wp, index) => ( + (mapPrevWaypointMarkers.current[index] = el)} + eventHandlers={{ click: () => handleWaypointMarkerClick(index) }} + icon={getWaypointIcon(index)} + /> + ))} + {isSource && ( { icon={prevDestIcon} /> )} + {alternateCoords.map((coords, index) => ( { +const ROW_COLORS = { + source: "#0EA37A", // سبز + waypoint: "#F5A623", // زرد/نارنجی + dest: "#E53935", // قرمز +}; + +const formatCoord = (pos) => (pos ? `${pos.lat.toFixed(2)}, ${pos.lng.toFixed(2)}` : null); + +const Row = ({ color, label, value, onEdit, onRemove, placeholder = "در حال انتخاب..." }) => ( + + + + + {label} + + + {value || placeholder} + + + {onEdit && ( + + + + )} + {onRemove && ( + + + + )} + +); + +const DataBox = ({ + isSource, + isDest, + sourcePosition, + destPosition, + editingTarget, + onEditSource, + onEditDest, + waypoints = [], + onEditWaypoint, + onAddWaypoint, + onRemoveWaypoint, + canAddWaypoint, +}) => { if (!isSource) return null; return ( -
- + + {waypoints.map((wp, index) => ( + onEditWaypoint(index) : null} + onRemove={() => onRemoveWaypoint(index)} /> - {isSource && isDest && ( - - )} -
+ ))} + {isDest && ( -
- - -
+ + )} + + {canAddWaypoint && ( + <> + + + + + افزودن نقطه میانی + + + )}
); diff --git a/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/ShowRoute.jsx b/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/ShowRoute.jsx index 6827cde..b9d5143 100644 --- a/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/ShowRoute.jsx +++ b/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/ShowRoute.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Marker, Polyline, useMap } from "react-leaflet"; import L from "leaflet"; import polyline from "@mapbox/polyline"; @@ -20,12 +20,22 @@ const ShowRoute = ({ area }) => { const sourceIcon = createCustomIcon(defaultIconSize, SourceIcon.src); const destIcon = createCustomIcon(defaultIconSize, DestIcon.src); - useEffect(() => { - if (coords.length > 0) { - map.flyToBounds(coords, { padding: [50, 50], duration: 1.5 }); - } - }, [area]); + const [showRoute, setShowRoute] = useState(false); + useEffect(() => { + if (!coords.length) return; + + setShowRoute(false); + + map.flyToBounds(coords, { + padding: [50, 50], + duration: 1.5, + }); + + map.once("moveend", () => { + setShowRoute(true); + }); + }, [area]); if (!area) return null; const coords = polyline.decode(area, 6); @@ -36,7 +46,8 @@ const ShowRoute = ({ area }) => { return ( <> - + {showRoute && } + @@ -44,3 +55,74 @@ const ShowRoute = ({ area }) => { }; export default ShowRoute; + +// import { useEffect, useState } from "react"; +// import { Marker, Polyline, useMap } from "react-leaflet"; +// import L from "leaflet"; +// import polyline from "@mapbox/polyline"; +// import SourceIcon from "@/assets/images/source-icon.svg"; +// import DestIcon from "@/assets/images/destination-icon.svg"; +// import WaypointIcon from "@/assets/images/waypoint-icon.svg"; +// +// const defaultIconSize = [35, 35]; +// +// const createCustomIcon = (size, iconUrl) => { +// return L.icon({ +// iconUrl: iconUrl, +// iconSize: size, +// iconAnchor: [size[0] / 2, size[1]], +// popupAnchor: [0, -size[1]], +// }); +// }; +// +// const sourceIcon = createCustomIcon(defaultIconSize, SourceIcon.src); +// const destIcon = createCustomIcon(defaultIconSize, DestIcon.src); +// const waypointIcon = createCustomIcon(defaultIconSize, WaypointIcon.src); +// +// // locations: آرایه اختیاری [{lat, lon}, ...] شامل مبدا، نقاط میانی و مقصد به همون ترتیبی که برای روتینگ فرستاده شده +// const ShowRoute = ({ area, locations = [] }) => { +// const map = useMap(); +// const [showRoute, setShowRoute] = useState(false); +// +// const coords = area ? polyline.decode(area, 6) : []; +// +// useEffect(() => { +// if (!coords.length) return; +// +// setShowRoute(false); +// +// map.flyToBounds(coords, { +// padding: [50, 50], +// duration: 1.5, +// }); +// +// map.once("moveend", () => { +// setShowRoute(true); +// }); +// // eslint-disable-next-line react-hooks/exhaustive-deps +// }, [area]); +// +// if (!area || coords.length === 0) return null; +// +// const sourcePosition = coords[0]; +// const destPosition = coords[coords.length - 1]; +// +// // نقاط میانی فقط از روی locations قابل استخراج‌ان (بین اولی و آخری) +// const middleWaypoints = locations.length > 2 ? locations.slice(1, -1) : []; +// +// return ( +// <> +// {showRoute && } +// +// +// +// {middleWaypoints.map((wp, index) => ( +// +// ))} +// +// +// +// ); +// }; +// +// export default ShowRoute; diff --git a/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/index.jsx b/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/index.jsx index 49c4651..ef3f6a6 100644 --- a/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/index.jsx +++ b/src/components/dashboard/roadMissions/operator/Actions/Violations/RowActions/MissionCorrection/Form/Area/Routing/index.jsx @@ -5,19 +5,29 @@ import SourceIcon from "@/assets/images/source-icon.svg"; import PrevSourceIcon from "@/assets/images/prev-source-icon.svg"; import PrevDestIcon from "@/assets/images/prev-destination-icon.svg"; import DestIcon from "@/assets/images/destination-icon.svg"; +import WaypointIcon from "@/assets/images/prev-waypoint-icon.svg"; +import PrevWaypointIcon from "@/assets/images/waypoint-icon.svg"; import { useEffect, useRef, useState } from "react"; import DataBox from "./DataBox"; import AlarmText from "./AlarmText"; +const MAX_WAYPOINTS = 1; + const Routing = ({ setArea, setLocations, locations, area }) => { const mapPrevSourceMarker = useRef(); const mapPrevDestMarker = useRef(); + const mapPrevWaypointMarkers = useRef([]); // ref array, یکی به‌ازای هر waypoint const mapRef = useRef(null); const defaultIconSize = [35, 35]; + const [isSource, setIsSource] = useState(false); const [sourcePosition, setSourcePosition] = useState(null); + const [isDest, setIsDest] = useState(false); const [destPosition, setDestPosition] = useState(null); + + const [waypoints, setWaypoints] = useState([]); + const [routeCoords, setRouteCoords] = useState([]); const [alternateCoords, setAlternateCoords] = useState([]); const [editingTarget, setEditingTarget] = useState(null); @@ -42,24 +52,55 @@ const Routing = ({ setArea, setLocations, locations, area }) => { isDest && editingTarget !== "dest" ? DestIcon.src : PrevDestIcon.src ); - const isMovingSource = !isSource || editingTarget === "source"; - const isMovingDest = isSource && (!isDest || editingTarget === "dest"); + const getWaypointIcon = (index) => { + const wp = waypoints[index]; + const isConfirmed = wp?.confirmed && editingTarget !== `waypoint-${index}`; + return createCustomIcon(defaultIconSize, isConfirmed ? WaypointIcon.src : PrevWaypointIcon.src); + }; + + const getActiveTarget = () => { + if (editingTarget) return editingTarget; + if (!isSource) return "source"; + const pendingWaypointIndex = waypoints.findIndex((wp) => !wp.confirmed); + if (pendingWaypointIndex !== -1) return `waypoint-${pendingWaypointIndex}`; + if (!isDest) return "dest"; + return null; + }; const map = useMapEvents({ move(e) { - if (isMovingSource) mapPrevSourceMarker.current.setLatLng(e.target.getCenter()); - if (isMovingDest && mapPrevDestMarker.current) mapPrevDestMarker.current.setLatLng(e.target.getCenter()); + const target = getActiveTarget(); + const center = e.target.getCenter(); + if (target === "source") { + mapPrevSourceMarker.current?.setLatLng(center); + } else if (target === "dest") { + mapPrevDestMarker.current?.setLatLng(center); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setLatLng(center); + } }, movestart() { - if (isMovingSource) mapPrevSourceMarker.current.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon([45, 45], PrevWaypointIcon.src)); + } }, moveend() { - if (isMovingSource) - mapPrevSourceMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon(defaultIconSize, PrevWaypointIcon.src)); + } }, }); @@ -69,12 +110,19 @@ const Routing = ({ setArea, setLocations, locations, area }) => { useEffect(() => { if (locations.length < 2) return; - const restoredSource = { lat: locations[0].lat, lng: locations[0].lon }; - const restoredDest = { lat: locations[1].lat, lng: locations[1].lon }; - setSourcePosition(restoredSource); - setDestPosition(restoredDest); + const first = { lat: locations[0].lat, lng: locations[0].lon }; + const last = { lat: locations[locations.length - 1].lat, lng: locations[locations.length - 1].lon }; + const middle = locations.slice(1, -1).map((loc) => ({ + position: { lat: loc.lat, lng: loc.lon }, + confirmed: true, + })); + + setSourcePosition(first); + setDestPosition(last); + setWaypoints(middle); setIsSource(true); setIsDest(true); + if (area) { const decoded = polyline.decode(area, 6); setRouteCoords(decoded); @@ -83,13 +131,15 @@ const Routing = ({ setArea, setLocations, locations, area }) => { mapRef.current?.flyToBounds(decoded, { padding: [50, 50], duration: 1.5 }); }, 100); } else { - fetchRoute(restoredSource, restoredDest); + fetchRoute(first, last, middle); } }, []); - const fetchRoute = async (source, dest) => { + const fetchRoute = async (source, dest, waypointsList = waypoints) => { + const confirmedWaypoints = waypointsList.filter((wp) => wp.confirmed && wp.position); const locs = [ { lat: source.lat, lon: source.lng }, + ...confirmedWaypoints.map((wp) => ({ lat: wp.position.lat, lon: wp.position.lng })), { lat: dest.lat, lon: dest.lng }, ]; try { @@ -118,18 +168,23 @@ const Routing = ({ setArea, setLocations, locations, area }) => { } }; + const resetRoute = () => { + setRouteCoords([]); + setAlternateCoords([]); + setSelectedRoute(null); + }; + const handleMarkerClick = async () => { if (!isSource || editingTarget === "source") { const clickedPosition = mapPrevSourceMarker.current.getLatLng(); setSourcePosition(clickedPosition); setIsSource(true); + const wasEditing = editingTarget === "source"; setEditingTarget(null); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); - if (editingTarget === "source" && destPosition) { - await fetchRoute(clickedPosition, destPosition); + if (wasEditing && destPosition) { + await fetchRoute(clickedPosition, destPosition, waypoints); } } }; @@ -140,23 +195,65 @@ const Routing = ({ setArea, setLocations, locations, area }) => { setDestPosition(clickedPosition); setIsDest(true); setEditingTarget(null); + resetRoute(); - await fetchRoute(sourcePosition, clickedPosition); + await fetchRoute(sourcePosition, clickedPosition, waypoints); + } + }; + + const handleWaypointMarkerClick = async (index) => { + const wp = waypoints[index]; + if (!wp || wp.confirmed) return; // فقط روی نقطه‌ی تأیید‌نشده کلیک معتبره (یا حالت ادیت) + + const clickedPosition = mapPrevWaypointMarkers.current[index]?.getLatLng(); + const updated = waypoints.map((w, i) => (i === index ? { position: clickedPosition, confirmed: true } : w)); + setWaypoints(updated); + const wasEditing = editingTarget === `waypoint-${index}`; + setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); } }; const handleEditSource = () => { setEditingTarget("source"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); }; const handleEditDest = () => { setEditingTarget("dest"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); + }; + + const handleEditWaypoint = (index) => { + // برای ادیت یک waypoint تأییدشده، موقتاً unconfirmed می‌کنیم تا با حرکت نقشه جابه‌جا بشه + setWaypoints((prev) => prev.map((w, i) => (i === index ? { ...w, confirmed: false } : w))); + setEditingTarget(`waypoint-${index}`); + resetRoute(); + }; + + // اضافه کردن یک نقطه میانی جدید (حداکثر MAX_WAYPOINTS) + const handleAddWaypoint = () => { + if (waypoints.length >= MAX_WAYPOINTS) return; + if (!isSource) return; // قبل از انتخاب مبدا معنی نداره + setWaypoints((prev) => [...prev, { position: null, confirmed: false }]); + setEditingTarget(`waypoint-${waypoints.length}`); + resetRoute(); + }; + + // حذف یک نقطه میانی + const handleRemoveWaypoint = async (index) => { + const updated = waypoints.filter((_, i) => i !== index); + setWaypoints(updated); + mapPrevWaypointMarkers.current = mapPrevWaypointMarkers.current.filter((_, i) => i !== index); + if (editingTarget === `waypoint-${index}`) setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); + } }; const handleSelectRoute = (index, coords) => { @@ -176,14 +273,31 @@ const Routing = ({ setArea, setLocations, locations, area }) => { editingTarget={editingTarget} onEditSource={handleEditSource} onEditDest={handleEditDest} + waypoints={waypoints} + onEditWaypoint={handleEditWaypoint} + onAddWaypoint={handleAddWaypoint} + onRemoveWaypoint={handleRemoveWaypoint} + canAddWaypoint={isSource && waypoints.length < MAX_WAYPOINTS} /> + + + {waypoints.map((wp, index) => ( + (mapPrevWaypointMarkers.current[index] = el)} + eventHandlers={{ click: () => handleWaypointMarkerClick(index) }} + icon={getWaypointIcon(index)} + /> + ))} + {isSource && ( { icon={prevDestIcon} /> )} + {alternateCoords.map((coords, index) => ( { +const ROW_COLORS = { + source: "#0EA37A", // سبز + waypoint: "#F5A623", // زرد/نارنجی + dest: "#E53935", // قرمز +}; + +const formatCoord = (pos) => (pos ? `${pos.lat.toFixed(2)}, ${pos.lng.toFixed(2)}` : null); + +const Row = ({ color, label, value, onEdit, onRemove, placeholder = "در حال انتخاب..." }) => ( + + + + + {label} + + + {value || placeholder} + + + {onEdit && ( + + + + )} + {onRemove && ( + + + + )} + +); + +const DataBox = ({ + isSource, + isDest, + sourcePosition, + destPosition, + editingTarget, + onEditSource, + onEditDest, + waypoints = [], + onEditWaypoint, + onAddWaypoint, + onRemoveWaypoint, + canAddWaypoint, +}) => { if (!isSource) return null; return ( -
- + + {waypoints.map((wp, index) => ( + onEditWaypoint(index) : null} + onRemove={() => onRemoveWaypoint(index)} /> - {isSource && isDest && ( - - )} -
+ ))} + {isDest && ( -
- - -
+ + )} + + {canAddWaypoint && ( + <> + + + + + افزودن نقطه میانی + + + )}
); diff --git a/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/ShowRoute.jsx b/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/ShowRoute.jsx index 7d47ba8..b9d5143 100644 --- a/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/ShowRoute.jsx +++ b/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/ShowRoute.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Marker, Polyline, useMap } from "react-leaflet"; import L from "leaflet"; import polyline from "@mapbox/polyline"; @@ -18,14 +18,24 @@ const ShowRoute = ({ area }) => { }); }; - useEffect(() => { - if (coords.length > 0) { - map.flyToBounds(coords, { padding: [50, 50], duration: 1.5 }); - } - }, [area]); const sourceIcon = createCustomIcon(defaultIconSize, SourceIcon.src); const destIcon = createCustomIcon(defaultIconSize, DestIcon.src); + const [showRoute, setShowRoute] = useState(false); + useEffect(() => { + if (!coords.length) return; + + setShowRoute(false); + + map.flyToBounds(coords, { + padding: [50, 50], + duration: 1.5, + }); + + map.once("moveend", () => { + setShowRoute(true); + }); + }, [area]); if (!area) return null; const coords = polyline.decode(area, 6); @@ -36,7 +46,8 @@ const ShowRoute = ({ area }) => { return ( <> - + {showRoute && } + @@ -44,3 +55,74 @@ const ShowRoute = ({ area }) => { }; export default ShowRoute; + +// import { useEffect, useState } from "react"; +// import { Marker, Polyline, useMap } from "react-leaflet"; +// import L from "leaflet"; +// import polyline from "@mapbox/polyline"; +// import SourceIcon from "@/assets/images/source-icon.svg"; +// import DestIcon from "@/assets/images/destination-icon.svg"; +// import WaypointIcon from "@/assets/images/waypoint-icon.svg"; +// +// const defaultIconSize = [35, 35]; +// +// const createCustomIcon = (size, iconUrl) => { +// return L.icon({ +// iconUrl: iconUrl, +// iconSize: size, +// iconAnchor: [size[0] / 2, size[1]], +// popupAnchor: [0, -size[1]], +// }); +// }; +// +// const sourceIcon = createCustomIcon(defaultIconSize, SourceIcon.src); +// const destIcon = createCustomIcon(defaultIconSize, DestIcon.src); +// const waypointIcon = createCustomIcon(defaultIconSize, WaypointIcon.src); +// +// // locations: آرایه اختیاری [{lat, lon}, ...] شامل مبدا، نقاط میانی و مقصد به همون ترتیبی که برای روتینگ فرستاده شده +// const ShowRoute = ({ area, locations = [] }) => { +// const map = useMap(); +// const [showRoute, setShowRoute] = useState(false); +// +// const coords = area ? polyline.decode(area, 6) : []; +// +// useEffect(() => { +// if (!coords.length) return; +// +// setShowRoute(false); +// +// map.flyToBounds(coords, { +// padding: [50, 50], +// duration: 1.5, +// }); +// +// map.once("moveend", () => { +// setShowRoute(true); +// }); +// // eslint-disable-next-line react-hooks/exhaustive-deps +// }, [area]); +// +// if (!area || coords.length === 0) return null; +// +// const sourcePosition = coords[0]; +// const destPosition = coords[coords.length - 1]; +// +// // نقاط میانی فقط از روی locations قابل استخراج‌ان (بین اولی و آخری) +// const middleWaypoints = locations.length > 2 ? locations.slice(1, -1) : []; +// +// return ( +// <> +// {showRoute && } +// +// +// +// {middleWaypoints.map((wp, index) => ( +// +// ))} +// +// +// +// ); +// }; +// +// export default ShowRoute; diff --git a/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/index.jsx b/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/index.jsx index 49c4651..ef3f6a6 100644 --- a/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/index.jsx +++ b/src/components/dashboard/roadMissions/operator/RowActions/Add/AddController/Dialog/Form/Area/Routing/index.jsx @@ -5,19 +5,29 @@ import SourceIcon from "@/assets/images/source-icon.svg"; import PrevSourceIcon from "@/assets/images/prev-source-icon.svg"; import PrevDestIcon from "@/assets/images/prev-destination-icon.svg"; import DestIcon from "@/assets/images/destination-icon.svg"; +import WaypointIcon from "@/assets/images/prev-waypoint-icon.svg"; +import PrevWaypointIcon from "@/assets/images/waypoint-icon.svg"; import { useEffect, useRef, useState } from "react"; import DataBox from "./DataBox"; import AlarmText from "./AlarmText"; +const MAX_WAYPOINTS = 1; + const Routing = ({ setArea, setLocations, locations, area }) => { const mapPrevSourceMarker = useRef(); const mapPrevDestMarker = useRef(); + const mapPrevWaypointMarkers = useRef([]); // ref array, یکی به‌ازای هر waypoint const mapRef = useRef(null); const defaultIconSize = [35, 35]; + const [isSource, setIsSource] = useState(false); const [sourcePosition, setSourcePosition] = useState(null); + const [isDest, setIsDest] = useState(false); const [destPosition, setDestPosition] = useState(null); + + const [waypoints, setWaypoints] = useState([]); + const [routeCoords, setRouteCoords] = useState([]); const [alternateCoords, setAlternateCoords] = useState([]); const [editingTarget, setEditingTarget] = useState(null); @@ -42,24 +52,55 @@ const Routing = ({ setArea, setLocations, locations, area }) => { isDest && editingTarget !== "dest" ? DestIcon.src : PrevDestIcon.src ); - const isMovingSource = !isSource || editingTarget === "source"; - const isMovingDest = isSource && (!isDest || editingTarget === "dest"); + const getWaypointIcon = (index) => { + const wp = waypoints[index]; + const isConfirmed = wp?.confirmed && editingTarget !== `waypoint-${index}`; + return createCustomIcon(defaultIconSize, isConfirmed ? WaypointIcon.src : PrevWaypointIcon.src); + }; + + const getActiveTarget = () => { + if (editingTarget) return editingTarget; + if (!isSource) return "source"; + const pendingWaypointIndex = waypoints.findIndex((wp) => !wp.confirmed); + if (pendingWaypointIndex !== -1) return `waypoint-${pendingWaypointIndex}`; + if (!isDest) return "dest"; + return null; + }; const map = useMapEvents({ move(e) { - if (isMovingSource) mapPrevSourceMarker.current.setLatLng(e.target.getCenter()); - if (isMovingDest && mapPrevDestMarker.current) mapPrevDestMarker.current.setLatLng(e.target.getCenter()); + const target = getActiveTarget(); + const center = e.target.getCenter(); + if (target === "source") { + mapPrevSourceMarker.current?.setLatLng(center); + } else if (target === "dest") { + mapPrevDestMarker.current?.setLatLng(center); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setLatLng(center); + } }, movestart() { - if (isMovingSource) mapPrevSourceMarker.current.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon([45, 45], PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon([45, 45], PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon([45, 45], PrevWaypointIcon.src)); + } }, moveend() { - if (isMovingSource) - mapPrevSourceMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); - if (isMovingDest && mapPrevDestMarker.current) - mapPrevDestMarker.current.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + const target = getActiveTarget(); + if (target === "source") { + mapPrevSourceMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevSourceIcon.src)); + } else if (target === "dest") { + mapPrevDestMarker.current?.setIcon(createCustomIcon(defaultIconSize, PrevDestIcon.src)); + } else if (target?.startsWith("waypoint-")) { + const idx = Number(target.split("-")[1]); + mapPrevWaypointMarkers.current[idx]?.setIcon(createCustomIcon(defaultIconSize, PrevWaypointIcon.src)); + } }, }); @@ -69,12 +110,19 @@ const Routing = ({ setArea, setLocations, locations, area }) => { useEffect(() => { if (locations.length < 2) return; - const restoredSource = { lat: locations[0].lat, lng: locations[0].lon }; - const restoredDest = { lat: locations[1].lat, lng: locations[1].lon }; - setSourcePosition(restoredSource); - setDestPosition(restoredDest); + const first = { lat: locations[0].lat, lng: locations[0].lon }; + const last = { lat: locations[locations.length - 1].lat, lng: locations[locations.length - 1].lon }; + const middle = locations.slice(1, -1).map((loc) => ({ + position: { lat: loc.lat, lng: loc.lon }, + confirmed: true, + })); + + setSourcePosition(first); + setDestPosition(last); + setWaypoints(middle); setIsSource(true); setIsDest(true); + if (area) { const decoded = polyline.decode(area, 6); setRouteCoords(decoded); @@ -83,13 +131,15 @@ const Routing = ({ setArea, setLocations, locations, area }) => { mapRef.current?.flyToBounds(decoded, { padding: [50, 50], duration: 1.5 }); }, 100); } else { - fetchRoute(restoredSource, restoredDest); + fetchRoute(first, last, middle); } }, []); - const fetchRoute = async (source, dest) => { + const fetchRoute = async (source, dest, waypointsList = waypoints) => { + const confirmedWaypoints = waypointsList.filter((wp) => wp.confirmed && wp.position); const locs = [ { lat: source.lat, lon: source.lng }, + ...confirmedWaypoints.map((wp) => ({ lat: wp.position.lat, lon: wp.position.lng })), { lat: dest.lat, lon: dest.lng }, ]; try { @@ -118,18 +168,23 @@ const Routing = ({ setArea, setLocations, locations, area }) => { } }; + const resetRoute = () => { + setRouteCoords([]); + setAlternateCoords([]); + setSelectedRoute(null); + }; + const handleMarkerClick = async () => { if (!isSource || editingTarget === "source") { const clickedPosition = mapPrevSourceMarker.current.getLatLng(); setSourcePosition(clickedPosition); setIsSource(true); + const wasEditing = editingTarget === "source"; setEditingTarget(null); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); - if (editingTarget === "source" && destPosition) { - await fetchRoute(clickedPosition, destPosition); + if (wasEditing && destPosition) { + await fetchRoute(clickedPosition, destPosition, waypoints); } } }; @@ -140,23 +195,65 @@ const Routing = ({ setArea, setLocations, locations, area }) => { setDestPosition(clickedPosition); setIsDest(true); setEditingTarget(null); + resetRoute(); - await fetchRoute(sourcePosition, clickedPosition); + await fetchRoute(sourcePosition, clickedPosition, waypoints); + } + }; + + const handleWaypointMarkerClick = async (index) => { + const wp = waypoints[index]; + if (!wp || wp.confirmed) return; // فقط روی نقطه‌ی تأیید‌نشده کلیک معتبره (یا حالت ادیت) + + const clickedPosition = mapPrevWaypointMarkers.current[index]?.getLatLng(); + const updated = waypoints.map((w, i) => (i === index ? { position: clickedPosition, confirmed: true } : w)); + setWaypoints(updated); + const wasEditing = editingTarget === `waypoint-${index}`; + setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); } }; const handleEditSource = () => { setEditingTarget("source"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); }; const handleEditDest = () => { setEditingTarget("dest"); - setRouteCoords([]); - setAlternateCoords([]); - setSelectedRoute(null); + resetRoute(); + }; + + const handleEditWaypoint = (index) => { + // برای ادیت یک waypoint تأییدشده، موقتاً unconfirmed می‌کنیم تا با حرکت نقشه جابه‌جا بشه + setWaypoints((prev) => prev.map((w, i) => (i === index ? { ...w, confirmed: false } : w))); + setEditingTarget(`waypoint-${index}`); + resetRoute(); + }; + + // اضافه کردن یک نقطه میانی جدید (حداکثر MAX_WAYPOINTS) + const handleAddWaypoint = () => { + if (waypoints.length >= MAX_WAYPOINTS) return; + if (!isSource) return; // قبل از انتخاب مبدا معنی نداره + setWaypoints((prev) => [...prev, { position: null, confirmed: false }]); + setEditingTarget(`waypoint-${waypoints.length}`); + resetRoute(); + }; + + // حذف یک نقطه میانی + const handleRemoveWaypoint = async (index) => { + const updated = waypoints.filter((_, i) => i !== index); + setWaypoints(updated); + mapPrevWaypointMarkers.current = mapPrevWaypointMarkers.current.filter((_, i) => i !== index); + if (editingTarget === `waypoint-${index}`) setEditingTarget(null); + resetRoute(); + + if (sourcePosition && destPosition) { + await fetchRoute(sourcePosition, destPosition, updated); + } }; const handleSelectRoute = (index, coords) => { @@ -176,14 +273,31 @@ const Routing = ({ setArea, setLocations, locations, area }) => { editingTarget={editingTarget} onEditSource={handleEditSource} onEditDest={handleEditDest} + waypoints={waypoints} + onEditWaypoint={handleEditWaypoint} + onAddWaypoint={handleAddWaypoint} + onRemoveWaypoint={handleRemoveWaypoint} + canAddWaypoint={isSource && waypoints.length < MAX_WAYPOINTS} /> + + + {waypoints.map((wp, index) => ( + (mapPrevWaypointMarkers.current[index] = el)} + eventHandlers={{ click: () => handleWaypointMarkerClick(index) }} + icon={getWaypointIcon(index)} + /> + ))} + {isSource && ( { icon={prevDestIcon} /> )} + {alternateCoords.map((coords, index) => (