From bc453ff375a79a57795ce4496008e13d3c4440f9 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:40:12 +0000 Subject: [PATCH] feat: drag-and-drop stop reordering and fix radial add-stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addIntermediateStop() now accepts optional place parameter - Radial menu add-stop wedge uses addIntermediateStop with coordinates - Replaced up/down chevron buttons with @dnd-kit drag-and-drop - All rows (origin, stops, destination) can be reordered by dragging - GripVertical drag handle on left of each row - On drag end: first item → origin, last → destination, middle → stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 320 +++++++++++++++++------------ src/components/MapView.jsx | 18 +- src/store.js | 11 +- 3 files changed, 208 insertions(+), 141 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 44c0f98..a01f1c9 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,8 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" +import { useEffect, useMemo } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -18,6 +21,40 @@ const BOUNDARY_MODES = [ { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, ] +// Sortable row component +function SortableRow({ id, children }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 1, + } + + return ( +
+ {/* Drag handle */} + + {children} +
+ ) +} + export default function DirectionsPanel({ onClose }) { const routeStart = useStore((s) => s.routeStart) const routeEnd = useStore((s) => s.routeEnd) @@ -42,6 +79,36 @@ export default function DirectionsPanel({ onClose }) { const removeStop = useStore((s) => s.removeStop) const setStops = useStore((s) => s.setStops) + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Build unified list for drag-and-drop: origin + stops + destination + // Each item has: { id, type, data } + const unifiedList = useMemo(() => { + const items = [] + if (routeStart) { + items.push({ id: "origin", type: "origin", data: routeStart }) + } + stops.forEach((stop) => { + items.push({ id: stop.id, type: "stop", data: stop }) + }) + if (routeEnd) { + items.push({ id: "destination", type: "destination", data: routeEnd }) + } + return items + }, [routeStart, stops, routeEnd]) + + const itemIds = useMemo(() => unifiedList.map((item) => item.id), [unifiedList]) + // Auto-fill origin with GPS if available and origin is empty useEffect(() => { if (!routeStart && geoPermission === "granted" && userLocation) { @@ -61,13 +128,6 @@ export default function DirectionsPanel({ onClose }) { } }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - const handleClose = () => { clearRoute() setDirectionsMode(false) @@ -78,24 +138,56 @@ export default function DirectionsPanel({ onClose }) { addIntermediateStop() } - const handleMoveStopUp = (idx) => { - if (idx === 0) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx - 1] - newStops[idx - 1] = temp - setStops(newStops) - computeRoute() - } + // Handle drag end - reorder the unified list + const handleDragEnd = (event) => { + const { active, over } = event + if (!over || active.id === over.id) return - const handleMoveStopDown = (idx) => { - if (idx >= stops.length - 1) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx + 1] - newStops[idx + 1] = temp + const oldIndex = unifiedList.findIndex((item) => item.id === active.id) + const newIndex = unifiedList.findIndex((item) => item.id === over.id) + + if (oldIndex === -1 || newIndex === -1) return + + // Reorder the unified list + const reordered = arrayMove(unifiedList, oldIndex, newIndex) + + // Extract new origin, stops, and destination from reordered list + // First item becomes origin, last becomes destination, middle are stops + if (reordered.length === 0) return + + const newOriginItem = reordered[0] + const newDestItem = reordered.length > 1 ? reordered[reordered.length - 1] : null + const newStopItems = reordered.length > 2 ? reordered.slice(1, -1) : [] + + // Convert items to proper format + const newOrigin = newOriginItem.data ? { + lat: newOriginItem.data.lat, + lon: newOriginItem.data.lon, + name: newOriginItem.data.name, + source: newOriginItem.data.source, + } : null + + const newDest = newDestItem?.data ? { + lat: newDestItem.data.lat, + lon: newDestItem.data.lon, + name: newDestItem.data.name, + source: newDestItem.data.source, + } : null + + const newStops = newStopItems.map((item) => ({ + id: item.id === "origin" || item.id === "destination" ? crypto.randomUUID() : item.id, + lat: item.data?.lat ?? null, + lon: item.data?.lon ?? null, + name: item.data?.name ?? "", + })) + + // Update state + setRouteStart(newOrigin) + setRouteEnd(newDest) setStops(newStops) - computeRoute() + + // Trigger route recalculation + setTimeout(() => computeRoute(), 0) } // Check if route has wilderness segments @@ -117,113 +209,87 @@ export default function DirectionsPanel({ onClose }) { - {/* Origin/Destination inputs */} -
- {/* Origin row with swap button on right */} -
-
- -
- {/* Swap button - only on origin row, swaps origin and destination */} - -
+ {/* Drag-and-drop location list */} + + +
+ {unifiedList.map((item, idx) => ( + +
+ {item.type === "origin" && ( + + )} + {item.type === "destination" && ( + + )} + {item.type === "stop" && ( + { + if (place) { + updateStop(item.id, place) + } + }} + placeholder={`Stop ${idx}`} + icon="stop" + fieldId={`stop-${item.id}`} + autoFocus={item.data.lat == null} + /> + )} +
+ {/* Remove button for intermediate stops only */} + {item.type === "stop" && ( + + )} + {/* Spacer for origin/destination to align with stops that have remove button */} + {item.type !== "stop" && ( +
+ )} + + ))} - {/* Intermediate stops - rendered between origin and destination */} - {stops.map((stop, idx) => ( -
-
- { - if (place) { - updateStop(stop.id, place) - } + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( +
- {/* Reorder buttons */} -
- - -
- {/* Remove button */} - + )}
- ))} - - {/* Destination row */} -
-
- -
- {/* Spacer to align with origin row swap button */} -
-
- - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 8 && ( - - )} -
+ + {/* Travel mode selector */}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0f13fd1..6f6d26b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1721,22 +1721,20 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, diff --git a/src/store.js b/src/store.js index 474163f..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -73,14 +73,17 @@ export const useStore = create((set, get) => ({ // ── INTERMEDIATE STOPS MANAGEMENT ── // stops[] contains ONLY intermediate waypoints, not origin/destination - addIntermediateStop: () => { + // Add intermediate stop - can be called with or without place + // With place: creates pre-filled stop (from radial menu) + // Without place: creates empty placeholder (from Add Stop button) + addIntermediateStop: (place) => { const { stops } = get() if (stops.length >= 8) return false // Max 8 intermediate stops const newStop = { id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.name ?? "", } set({ stops: [...stops, newStop] }) return true