mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
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:
parent
0942b10b27
commit
bc453ff375
3 changed files with 208 additions and 141 deletions
|
|
@ -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,113 +209,87 @@ export default function DirectionsPanel({ onClose }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Origin/Destination inputs */}
|
{/* Drag-and-drop location list */}
|
||||||
<div className="flex flex-col gap-2">
|
<DndContext
|
||||||
{/* Origin row with swap button on right */}
|
sensors={sensors}
|
||||||
<div className="flex items-center gap-1">
|
collisionDetection={closestCenter}
|
||||||
<div className="flex-1">
|
onDragEnd={handleDragEnd}
|
||||||
<LocationInput
|
>
|
||||||
value={routeStart}
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
onChange={setRouteStart}
|
<div className="flex flex-col gap-2">
|
||||||
placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"}
|
{unifiedList.map((item, idx) => (
|
||||||
icon="origin"
|
<SortableRow key={item.id} id={item.id}>
|
||||||
fieldId="origin"
|
<div className="flex-1">
|
||||||
autoFocus={!routeStart}
|
{item.type === "origin" && (
|
||||||
/>
|
<LocationInput
|
||||||
</div>
|
value={routeStart}
|
||||||
{/* Swap button - only on origin row, swaps origin and destination */}
|
onChange={setRouteStart}
|
||||||
<button
|
placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"}
|
||||||
onClick={handleSwap}
|
icon="origin"
|
||||||
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
|
fieldId="origin"
|
||||||
style={{
|
autoFocus={!routeStart}
|
||||||
background: "var(--bg-overlay)",
|
/>
|
||||||
border: "1px solid var(--border)",
|
)}
|
||||||
}}
|
{item.type === "destination" && (
|
||||||
title="Swap origin and destination"
|
<LocationInput
|
||||||
>
|
value={routeEnd}
|
||||||
<ArrowUpDown size={14} style={{ color: "var(--text-secondary)" }} />
|
onChange={setRouteEnd}
|
||||||
</button>
|
placeholder="Choose destination"
|
||||||
</div>
|
icon="destination"
|
||||||
|
fieldId="destination"
|
||||||
|
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>
|
||||||
|
{/* 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" />
|
||||||
|
)}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Intermediate stops - rendered between origin and destination */}
|
{/* Add stop button - only show when route exists */}
|
||||||
{stops.map((stop, idx) => (
|
{routeStart && routeEnd && stops.length < 8 && (
|
||||||
<div key={stop.id} className="flex items-center gap-1">
|
<button
|
||||||
<div className="flex-1">
|
onClick={handleAddStop}
|
||||||
<LocationInput
|
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors ml-6"
|
||||||
value={stop.lat != null ? { lat: stop.lat, lon: stop.lon, name: stop.name } : null}
|
style={{
|
||||||
onChange={(place) => {
|
background: "var(--bg-overlay)",
|
||||||
if (place) {
|
color: "var(--text-secondary)",
|
||||||
updateStop(stop.id, place)
|
border: "1px dashed var(--border)",
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
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)" }} />
|
<Plus size={14} />
|
||||||
|
<span>Add stop</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
))}
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
{/* Destination row */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="flex-1">
|
|
||||||
<LocationInput
|
|
||||||
value={routeEnd}
|
|
||||||
onChange={setRouteEnd}
|
|
||||||
placeholder="Choose destination"
|
|
||||||
icon="destination"
|
|
||||||
fieldId="destination"
|
|
||||||
autoFocus={routeStart && !routeEnd}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Spacer to align with origin row swap button */}
|
|
||||||
<div className="w-[30px] shrink-0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add stop button - only show when route exists */}
|
|
||||||
{routeStart && routeEnd && stops.length < 8 && (
|
|
||||||
<button
|
|
||||||
onClick={handleAddStop}
|
|
||||||
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors"
|
|
||||||
style={{
|
|
||||||
background: "var(--bg-overlay)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
border: "1px dashed var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
<span>Add stop</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Travel mode selector */}
|
{/* Travel mode selector */}
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
} else {
|
if (routeStart && routeEnd) {
|
||||||
const success = addStop(place)
|
computeRoute()
|
||||||
if (!success) {
|
|
||||||
toast("Maximum 10 stops reached")
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
toast("Maximum 8 intermediate stops reached")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
11
src/store.js
11
src/store.js
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue