feat: drag-and-drop stop reordering and fix radial add-stop

- 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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-09 15:40:12 +00:00
commit bc453ff375
3 changed files with 208 additions and 141 deletions

View file

@ -1,5 +1,8 @@
import { useEffect } from "react" import { useEffect, useMemo } from "react"
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-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 { useStore } from "../store"
import LocationInput from "./LocationInput" import LocationInput from "./LocationInput"
import ManeuverList from "./ManeuverList" import ManeuverList from "./ManeuverList"
@ -18,6 +21,40 @@ const BOUNDARY_MODES = [
{ id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, { 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 (
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded cursor-grab active:cursor-grabbing hover:bg-[var(--bg-overlay)] transition-colors shrink-0 touch-none"
title="Drag to reorder"
>
<GripVertical size={14} style={{ color: "var(--text-tertiary)" }} />
</button>
{children}
</div>
)
}
export default function DirectionsPanel({ onClose }) { export default function DirectionsPanel({ onClose }) {
const routeStart = useStore((s) => s.routeStart) const routeStart = useStore((s) => s.routeStart)
const routeEnd = useStore((s) => s.routeEnd) const routeEnd = useStore((s) => s.routeEnd)
@ -42,6 +79,36 @@ export default function DirectionsPanel({ onClose }) {
const removeStop = useStore((s) => s.removeStop) const removeStop = useStore((s) => s.removeStop)
const setStops = useStore((s) => s.setStops) 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 // Auto-fill origin with GPS if available and origin is empty
useEffect(() => { useEffect(() => {
if (!routeStart && geoPermission === "granted" && userLocation) { if (!routeStart && geoPermission === "granted" && userLocation) {
@ -61,13 +128,6 @@ export default function DirectionsPanel({ onClose }) {
} }
}, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon])
const handleSwap = () => {
const tempStart = routeStart
const tempEnd = routeEnd
setRouteStart(tempEnd)
setRouteEnd(tempStart)
}
const handleClose = () => { const handleClose = () => {
clearRoute() clearRoute()
setDirectionsMode(false) setDirectionsMode(false)
@ -78,24 +138,56 @@ export default function DirectionsPanel({ onClose }) {
addIntermediateStop() addIntermediateStop()
} }
const handleMoveStopUp = (idx) => { // Handle drag end - reorder the unified list
if (idx === 0) return const handleDragEnd = (event) => {
const newStops = [...stops] const { active, over } = event
const temp = newStops[idx] if (!over || active.id === over.id) return
newStops[idx] = newStops[idx - 1]
newStops[idx - 1] = temp
setStops(newStops)
computeRoute()
}
const handleMoveStopDown = (idx) => { const oldIndex = unifiedList.findIndex((item) => item.id === active.id)
if (idx >= stops.length - 1) return const newIndex = unifiedList.findIndex((item) => item.id === over.id)
const newStops = [...stops]
const temp = newStops[idx] if (oldIndex === -1 || newIndex === -1) return
newStops[idx] = newStops[idx + 1]
newStops[idx + 1] = temp // 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) setStops(newStops)
computeRoute()
// Trigger route recalculation
setTimeout(() => computeRoute(), 0)
} }
// Check if route has wilderness segments // Check if route has wilderness segments
@ -117,11 +209,18 @@ export default function DirectionsPanel({ onClose }) {
</button> </button>
</div> </div>
{/* Origin/Destination inputs */} {/* Drag-and-drop location list */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/* Origin row with swap button on right */} {unifiedList.map((item, idx) => (
<div className="flex items-center gap-1"> <SortableRow key={item.id} id={item.id}>
<div className="flex-1"> <div className="flex-1">
{item.type === "origin" && (
<LocationInput <LocationInput
value={routeStart} value={routeStart}
onChange={setRouteStart} onChange={setRouteStart}
@ -130,71 +229,8 @@ export default function DirectionsPanel({ onClose }) {
fieldId="origin" fieldId="origin"
autoFocus={!routeStart} autoFocus={!routeStart}
/> />
</div> )}
{/* Swap button - only on origin row, swaps origin and destination */} {item.type === "destination" && (
<button
onClick={handleSwap}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
style={{
background: "var(--bg-overlay)",
border: "1px solid var(--border)",
}}
title="Swap origin and destination"
>
<ArrowUpDown size={14} style={{ color: "var(--text-secondary)" }} />
</button>
</div>
{/* Intermediate stops - rendered between origin and destination */}
{stops.map((stop, idx) => (
<div key={stop.id} className="flex items-center gap-1">
<div className="flex-1">
<LocationInput
value={stop.lat != null ? { lat: stop.lat, lon: stop.lon, name: stop.name } : null}
onChange={(place) => {
if (place) {
updateStop(stop.id, place)
}
}}
placeholder={`Stop ${idx + 1}`}
icon="stop"
fieldId={`stop-${idx}`}
autoFocus={stop.lat == null}
/>
</div>
{/* Reorder buttons */}
<div className="flex flex-col shrink-0">
<button
onClick={() => handleMoveStopUp(idx)}
disabled={idx === 0}
className="p-0.5 rounded hover:bg-[var(--bg-overlay)] transition-colors disabled:opacity-30"
title="Move up"
>
<ChevronUp size={12} style={{ color: "var(--text-tertiary)" }} />
</button>
<button
onClick={() => handleMoveStopDown(idx)}
disabled={idx >= stops.length - 1}
className="p-0.5 rounded hover:bg-[var(--bg-overlay)] transition-colors disabled:opacity-30"
title="Move down"
>
<ChevronDown size={12} style={{ color: "var(--text-tertiary)" }} />
</button>
</div>
{/* Remove button */}
<button
onClick={() => removeStop(stop.id)}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
title="Remove stop"
>
<Trash2 size={14} style={{ color: "var(--text-tertiary)" }} />
</button>
</div>
))}
{/* Destination row */}
<div className="flex items-center gap-1">
<div className="flex-1">
<LocationInput <LocationInput
value={routeEnd} value={routeEnd}
onChange={setRouteEnd} onChange={setRouteEnd}
@ -203,16 +239,44 @@ export default function DirectionsPanel({ onClose }) {
fieldId="destination" fieldId="destination"
autoFocus={routeStart && !routeEnd} autoFocus={routeStart && !routeEnd}
/> />
)}
{item.type === "stop" && (
<LocationInput
value={item.data.lat != null ? { lat: item.data.lat, lon: item.data.lon, name: item.data.name } : null}
onChange={(place) => {
if (place) {
updateStop(item.id, place)
}
}}
placeholder={`Stop ${idx}`}
icon="stop"
fieldId={`stop-${item.id}`}
autoFocus={item.data.lat == null}
/>
)}
</div> </div>
{/* Spacer to align with origin row swap button */} {/* Remove button for intermediate stops only */}
{item.type === "stop" && (
<button
onClick={() => removeStop(item.id)}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
title="Remove stop"
>
<Trash2 size={14} style={{ color: "var(--text-tertiary)" }} />
</button>
)}
{/* Spacer for origin/destination to align with stops that have remove button */}
{item.type !== "stop" && (
<div className="w-[30px] shrink-0" /> <div className="w-[30px] shrink-0" />
</div> )}
</SortableRow>
))}
{/* Add stop button - only show when route exists */} {/* Add stop button - only show when route exists */}
{routeStart && routeEnd && stops.length < 8 && ( {routeStart && routeEnd && stops.length < 8 && (
<button <button
onClick={handleAddStop} onClick={handleAddStop}
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors" className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors ml-6"
style={{ style={{
background: "var(--bg-overlay)", background: "var(--bg-overlay)",
color: "var(--text-secondary)", color: "var(--text-secondary)",
@ -224,6 +288,8 @@ export default function DirectionsPanel({ onClose }) {
</button> </button>
)} )}
</div> </div>
</SortableContext>
</DndContext>
{/* Travel mode selector */} {/* Travel mode selector */}
<div className="flex gap-1"> <div className="flex gap-1">

View file

@ -1721,22 +1721,20 @@ const MapView = forwardRef(function MapView(_, ref) {
icon: Plus, icon: Plus,
onSelect: () => { onSelect: () => {
setRadialMenu((m) => ({ ...m, open: false })) setRadialMenu((m) => ({ ...m, open: false }))
const { stops, addStop } = useStore.getState() const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState()
const place = { const place = {
lat: radialMenu.lat, lat: radialMenu.lat,
lon: radialMenu.lon, lon: radialMenu.lon,
name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5),
source: "radial_menu",
matchCode: null,
} }
if (stops.length === 0) { const success = addIntermediateStop(place)
addStop(place) if (success) {
useStore.setState({ gpsOrigin: false }) // If we have both origin and destination, recalculate route
if (routeStart && routeEnd) {
computeRoute()
}
} else { } else {
const success = addStop(place) toast("Maximum 8 intermediate stops reached")
if (!success) {
toast("Maximum 10 stops reached")
}
} }
}, },
}, },

View file

@ -73,14 +73,17 @@ export const useStore = create((set, get) => ({
// ── INTERMEDIATE STOPS MANAGEMENT ── // ── INTERMEDIATE STOPS MANAGEMENT ──
// stops[] contains ONLY intermediate waypoints, not origin/destination // 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() const { stops } = get()
if (stops.length >= 8) return false // Max 8 intermediate stops if (stops.length >= 8) return false // Max 8 intermediate stops
const newStop = { const newStop = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
lat: null, lat: place?.lat ?? null,
lon: null, lon: place?.lon ?? null,
name: "", name: place?.name ?? "",
} }
set({ stops: [...stops, newStop] }) set({ stops: [...stops, newStop] })
return true return true