diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx new file mode 100644 index 0000000..5bf8bf7 --- /dev/null +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -0,0 +1,127 @@ +import { useMemo } from "react"; +import { Polyline } from "react-leaflet"; +import type L from "leaflet"; + +export type ColorMode = "plain" | "elevation" | "surface"; + +interface ColoredRouteProps { + coordinates: [number, number, number][]; // [lon, lat, ele] + colorMode: ColorMode; + surfaces?: string[]; +} + +const SURFACE_COLORS: Record = { + asphalt: "#6b7280", + concrete: "#9ca3af", + paved: "#6b7280", + paving_stones: "#78716c", + cobblestone: "#a8a29e", + gravel: "#92400e", + compacted: "#b45309", + "fine_gravel": "#d97706", + ground: "#65a30d", + dirt: "#84cc16", + grass: "#22c55e", + sand: "#fbbf24", + mud: "#713f12", + wood: "#a16207", + unpaved: "#ca8a04", + path: "#16a34a", + track: "#ea580c", +}; + +const DEFAULT_SURFACE_COLOR = "#9ca3af"; + +export function elevationColor(t: number): string { + // green (0) → yellow (0.5) → red (1) + if (t <= 0.5) { + const r = Math.round(255 * (t * 2)); + return `rgb(${r}, 200, 50)`; + } + const g = Math.round(200 * (1 - (t - 0.5) * 2)); + return `rgb(255, ${g}, 50)`; +} + +export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) { + const segments = useMemo(() => { + if (colorMode === "plain" || coordinates.length < 2) { + return null; + } + + if (colorMode === "elevation") { + const elevations = coordinates.map((c) => c[2]); + const minEle = Math.min(...elevations); + const maxEle = Math.max(...elevations); + const range = maxEle - minEle || 1; + + const result: { positions: L.LatLngExpression[]; color: string }[] = []; + for (let i = 0; i < coordinates.length - 1; i++) { + const t = (elevations[i]! - minEle) / range; + result.push({ + positions: [ + [coordinates[i]![1], coordinates[i]![0]], + [coordinates[i + 1]![1], coordinates[i + 1]![0]], + ], + color: elevationColor(t), + }); + } + return result; + } + + // surface mode + if (!surfaces || surfaces.length < coordinates.length) return null; + + const result: { positions: L.LatLngExpression[]; color: string }[] = []; + for (let i = 0; i < coordinates.length - 1; i++) { + const surface = surfaces[i] ?? "unknown"; + result.push({ + positions: [ + [coordinates[i]![1], coordinates[i]![0]], + [coordinates[i + 1]![1], coordinates[i + 1]![0]], + ], + color: SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR, + }); + } + return result; + }, [coordinates, colorMode, surfaces]); + + const plainPositions = useMemo( + () => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression), + [coordinates], + ); + + if (!segments) { + return ( + + ); + } + + return ( + <> + {segments.map((seg, i) => ( + + ))} + + ); +} + +export function findSegmentForPoint( + pointIndex: number, + segmentBoundaries: number[], +): number { + for (let i = segmentBoundaries.length - 1; i >= 0; i--) { + if (pointIndex >= segmentBoundaries[i]!) return i; + } + return 0; +} + +export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR }; diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 214472a..166f178 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useRef, useCallback } from "react"; import type { YjsState } from "~/lib/use-yjs"; +import { elevationColor, type ColorMode } from "~/components/ColoredRoute"; interface ElevationPoint { distance: number; @@ -59,6 +60,7 @@ interface ElevationChartProps { export function ElevationChart({ yjs, onHover }: ElevationChartProps) { const [points, setPoints] = useState([]); const [hoverIdx, setHoverIdx] = useState(null); + const [colorMode, setColorMode] = useState("plain"); const canvasRef = useRef(null); const pointsRef = useRef([]); pointsRef.current = points; @@ -71,6 +73,8 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) { } else { setPoints([]); } + const mode = yjs.routeData.get("colorMode") as ColorMode | undefined; + setColorMode(mode ?? "plain"); }; yjs.routeData.observe(update); update(); @@ -107,27 +111,54 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) { ctx.clearRect(0, 0, w, h); - // Fill area - ctx.beginPath(); - ctx.moveTo(PADDING.left, PADDING.top + chartH); - for (const p of points) { - ctx.lineTo(toX(p.distance), toY(p.elevation)); - } - ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH); - ctx.closePath(); - ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; - ctx.fill(); + if (colorMode === "elevation") { + // Elevation-colored fill and line segments + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]!; + const p1 = points[i + 1]!; + const t = (p0.elevation - minEle) / eleRange; + const color = elevationColor(t); - // Line - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const p = points[i]!; - if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation)); - else ctx.lineTo(toX(p.distance), toY(p.elevation)); + // Fill segment + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), PADDING.top + chartH); + ctx.lineTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.lineTo(toX(p1.distance), PADDING.top + chartH); + ctx.closePath(); + ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)"); + ctx.fill(); + + // Line segment + ctx.beginPath(); + ctx.moveTo(toX(p0.distance), toY(p0.elevation)); + ctx.lineTo(toX(p1.distance), toY(p1.elevation)); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + } else { + // Plain fill and line + ctx.beginPath(); + ctx.moveTo(PADDING.left, PADDING.top + chartH); + for (const p of points) { + ctx.lineTo(toX(p.distance), toY(p.elevation)); + } + ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH); + ctx.closePath(); + ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; + ctx.fill(); + + ctx.beginPath(); + for (let i = 0; i < points.length; i++) { + const p = points[i]!; + if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation)); + else ctx.lineTo(toX(p.distance), toY(p.elevation)); + } + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 1.5; + ctx.stroke(); } - ctx.strokeStyle = "#2563eb"; - ctx.lineWidth = 1.5; - ctx.stroke(); // Axis labels ctx.fillStyle = "#6b7280"; @@ -172,12 +203,12 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) { ctx.fillText(label, labelX, PADDING.top + 10); } }, - [points], + [points, colorMode], ); useEffect(() => { drawChart(hoverIdx); - }, [points, hoverIdx, drawChart]); + }, [points, hoverIdx, colorMode, drawChart]); const handleMouseMove = useCallback( (e: React.MouseEvent) => { diff --git a/apps/planner/app/components/MidpointHandles.tsx b/apps/planner/app/components/MidpointHandles.tsx new file mode 100644 index 0000000..5071682 --- /dev/null +++ b/apps/planner/app/components/MidpointHandles.tsx @@ -0,0 +1,118 @@ +import { useMemo, useState, useEffect } from "react"; +import { Marker, useMap } from "react-leaflet"; +import L from "leaflet"; + +interface MidpointHandlesProps { + coordinates: [number, number, number][]; // [lon, lat, ele] + segmentBoundaries: number[]; + onInsertWaypoint: (segmentIndex: number, lat: number, lon: number) => void; +} + +function getSegmentMidpoint( + coordinates: [number, number, number][], + startIdx: number, + endIdx: number, +): [number, number] | null { + if (endIdx <= startIdx) return null; + const midCoordIdx = Math.floor((startIdx + endIdx) / 2); + const c = coordinates[midCoordIdx]; + if (!c) return null; + return [c[1], c[0]]; // [lat, lon] +} + +const midpointIcon = L.divIcon({ + className: "", + html: `
`, + iconSize: [0, 0], +}); + +const dragIcon = L.divIcon({ + className: "", + html: `
`, + iconSize: [0, 0], +}); + +export function MidpointHandles({ + coordinates, + segmentBoundaries, + onInsertWaypoint, +}: MidpointHandlesProps) { + const map = useMap(); + const [zoom, setZoom] = useState(map.getZoom()); + const [dragging, setDragging] = useState<{ segmentIndex: number; lat: number; lon: number } | null>(null); + + // Track zoom changes + useEffect(() => { + const onZoom = () => setZoom(map.getZoom()); + map.on("zoomend", onZoom); + return () => { map.off("zoomend", onZoom); }; + }, [map]); + + const midpoints = useMemo(() => { + if (segmentBoundaries.length < 1) return []; + + const result: { lat: number; lon: number; segmentIndex: number }[] = []; + for (let i = 0; i < segmentBoundaries.length; i++) { + const startIdx = segmentBoundaries[i]!; + const endIdx = i + 1 < segmentBoundaries.length + ? segmentBoundaries[i + 1]! + : coordinates.length; + + const mid = getSegmentMidpoint(coordinates, startIdx, endIdx); + if (mid) { + result.push({ lat: mid[0], lon: mid[1], segmentIndex: i }); + } + } + return result; + }, [coordinates, segmentBoundaries]); + + // Hide at low zoom levels + if (zoom < 12) return null; + + return ( + <> + {midpoints.map((mp) => ( + { + setDragging({ segmentIndex: mp.segmentIndex, lat: mp.lat, lon: mp.lon }); + const onMouseMove = (e: L.LeafletMouseEvent) => { + setDragging({ segmentIndex: mp.segmentIndex, lat: e.latlng.lat, lon: e.latlng.lng }); + }; + const onMouseUp = (e: L.LeafletMouseEvent) => { + map.off("mousemove", onMouseMove); + map.off("mouseup", onMouseUp); + map.dragging.enable(); + setDragging(null); + onInsertWaypoint(mp.segmentIndex, e.latlng.lat, e.latlng.lng); + }; + map.dragging.disable(); + map.on("mousemove", onMouseMove); + map.on("mouseup", onMouseUp); + }, + }} + /> + ))} + {dragging && ( + + )} + + ); +} diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 9930948..ed1693f 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,10 +1,12 @@ -import { useEffect, useState, useCallback } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet"; import L from "leaflet"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; import { baseLayers } from "@trails-cool/map"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; +import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; +import { RouteInteraction } from "./RouteInteraction"; import "leaflet/dist/leaflet.css"; function waypointIcon(index: number): L.DivIcon { @@ -42,9 +44,23 @@ interface PlannerMapProps { highlightPosition?: [number, number] | null; } -function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) { +function MapExposer() { + const map = useMap(); + useEffect(() => { + if (typeof window !== "undefined") { + (window as unknown as Record).__leafletMap = map; + } + }, [map]); + return null; +} + +function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject }) { useMapEvents({ click(e) { + if (suppressRef.current) { + suppressRef.current = false; + return; + } onAdd(e.latlng.lat, e.latlng.lng); }, }); @@ -156,9 +172,13 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) { const [waypoints, setWaypoints] = useState([]); - const [routeGeoJson, setRouteGeoJson] = useState(null); + const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); + const [segmentBoundaries, setSegmentBoundaries] = useState([]); + const [surfaces, setSurfaces] = useState([]); + const [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); + const suppressMapClickRef = useRef(false); // Sync waypoints from Yjs useEffect(() => { @@ -178,23 +198,49 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa }; }, [yjs.waypoints, onRouteRequest]); - // Sync route data from Yjs + // Sync route data from Yjs (enriched: coordinates + segment boundaries) useEffect(() => { const update = () => { - const geojson = yjs.routeData.get("geojson") as string | undefined; - if (geojson) { + const coordsJson = yjs.routeData.get("coordinates") as string | undefined; + const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined; + const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined; + + if (coordsJson) { try { - const parsed = JSON.parse(geojson); - const coords = parsed.features?.[0]?.geometry?.coordinates; - if (coords) { - setRouteGeoJson(coords.map((c: number[]) => [c[1], c[0]] as L.LatLngExpression)); - } + setRouteCoordinates(JSON.parse(coordsJson)); } catch { - // Invalid GeoJSON + setRouteCoordinates(null); } } else { - setRouteGeoJson(null); + // Fallback: parse from geojson for backwards compat + const geojson = yjs.routeData.get("geojson") as string | undefined; + if (geojson) { + try { + const parsed = JSON.parse(geojson); + const coords = parsed.features?.[0]?.geometry?.coordinates; + if (coords) { + setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number])); + } + } catch { setRouteCoordinates(null); } + } else { + setRouteCoordinates(null); + } } + + if (boundsJson) { + try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); } + } else { + setSegmentBoundaries([]); + } + + const surfacesJson = yjs.routeData.get("surfaces") as string | undefined; + if (surfacesJson) { + try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); } + } else { + setSurfaces([]); + } + + if (modeVal) setColorMode(modeVal); }; yjs.routeData.observe(update); @@ -215,6 +261,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa [yjs.waypoints], ); + const insertWaypointAtSegment = useCallback( + (segmentIndex: number, lat: number, lon: number) => { + const yMap = new Y.Map(); + yMap.set("lat", lat); + yMap.set("lon", lon); + // Insert after the segment's start waypoint + yjs.waypoints.insert(segmentIndex + 1, [yMap]); + }, + [yjs.waypoints], + ); + + const handleRouteInsert = useCallback( + (pointIndex: number, lat: number, lon: number) => { + suppressMapClickRef.current = true; + const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries); + insertWaypointAtSegment(segIdx, lat, lon); + }, + [segmentBoundaries, insertWaypointAtSegment], + ); + const moveWaypoint = useCallback( (index: number, lat: number, lng: number) => { const yMap = yjs.waypoints.get(index); @@ -245,7 +311,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa ))} - {} : addWaypoint} /> + + {} : addWaypoint} suppressRef={suppressMapClickRef} /> @@ -269,7 +336,21 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa /> ))} - {routeGeoJson && } + {routeCoordinates && routeCoordinates.length >= 2 && ( + <> + + + + )} {highlightPosition && ( void; + disabled?: boolean; +} + +const SNAP_TOLERANCE_PX = 15; + +const ghostIcon = L.divIcon({ + className: "route-ghost-marker", + html: `
`, + iconSize: [0, 0], +}); + +/** + * Route interaction layer: shows a ghost marker on hover that can be + * clicked or dragged to insert a waypoint. Follows brouter-web's pattern + * of a single persistent draggable Marker with distance-based mouseout. + */ +export function RouteInteraction({ + coordinates, + segmentBoundaries, + onInsertWaypoint, + disabled, +}: RouteInteractionProps) { + const map = useMap(); + const markerRef = useRef(null); + const draggingRef = useRef(false); + const snappedIdxRef = useRef(0); + const coordinatesRef = useRef(coordinates); + const segBoundsRef = useRef(segmentBoundaries); + const onInsertRef = useRef(onInsertWaypoint); + coordinatesRef.current = coordinates; + segBoundsRef.current = segmentBoundaries; + onInsertRef.current = onInsertWaypoint; + + // Trailer lines (dashed lines from ghost to adjacent waypoints) + const trailer1Ref = useRef(null); + const trailer2Ref = useRef(null); + + const findClosestOnRoute = useCallback((latlng: L.LatLng): { idx: number; lat: number; lon: number; distPx: number } | null => { + const coords = coordinatesRef.current; + if (coords.length < 2) return null; + + let minDistPx = Infinity; + let bestIdx = 0; + let bestLat = 0; + let bestLon = 0; + + // Find closest point on route in pixel space for accuracy + const clickPt = map.latLngToContainerPoint(latlng); + + for (let i = 0; i < coords.length; i++) { + const c = coords[i]!; + const pt = map.latLngToContainerPoint([c[1], c[0]]); + const dx = pt.x - clickPt.x; + const dy = pt.y - clickPt.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < minDistPx) { + minDistPx = dist; + bestIdx = i; + bestLat = c[1]; + bestLon = c[0]; + } + } + + return { idx: bestIdx, lat: bestLat, lon: bestLon, distPx: minDistPx }; + }, [map]); + + useEffect(() => { + if (disabled) return; + + // Create persistent ghost marker (hidden until hover) + const marker = L.marker([0, 0], { + icon: ghostIcon, + draggable: true, + interactive: true, + zIndexOffset: 1000, + }); + markerRef.current = marker; + + // Trailer lines + const trailer1 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map); + const trailer2 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map); + trailer1Ref.current = trailer1; + trailer2Ref.current = trailer2; + + let shown = false; + + const showMarker = (lat: number, lon: number) => { + marker.setLatLng([lat, lon]); + if (!shown) { + marker.addTo(map); + shown = true; + } + }; + + const hideMarker = () => { + if (shown && !draggingRef.current) { + marker.remove(); + shown = false; + } + }; + + // Listen for mousemove on the map (distance-based approach, not polyline events) + const onMouseMove = (e: L.LeafletMouseEvent) => { + if (draggingRef.current) return; + + const snap = findClosestOnRoute(e.latlng); + if (!snap || snap.distPx > SNAP_TOLERANCE_PX) { + hideMarker(); + return; + } + + snappedIdxRef.current = snap.idx; + showMarker(snap.lat, snap.lon); + }; + + const onMouseOut = () => { + hideMarker(); + }; + + // Drag events on the ghost marker (Leaflet's L.Draggable handles text selection) + marker.on("dragstart", () => { + draggingRef.current = true; + trailer1.setStyle({ opacity: 0.6 }); + trailer2.setStyle({ opacity: 0.6 }); + }); + + marker.on("drag", (e) => { + const latlng = (e.target as L.Marker).getLatLng(); + // Update trailer lines to adjacent waypoints + const idx = snappedIdxRef.current; + const bounds = segBoundsRef.current; + const coords = coordinatesRef.current; + + // Find which segment this point is in + let segIdx = 0; + for (let i = bounds.length - 1; i >= 0; i--) { + if (idx >= bounds[i]!) { segIdx = i; break; } + } + + // Trailer to previous waypoint (segment start) + if (segIdx >= 0) { + const startCoordIdx = bounds[segIdx]!; + const c = coords[startCoordIdx]; + if (c) trailer1.setLatLngs([latlng, [c[1], c[0]]]); + } + // Trailer to next waypoint (next segment start or end) + const nextStart = segIdx + 1 < bounds.length ? bounds[segIdx + 1]! : coords.length - 1; + const nc = coords[nextStart]; + if (nc) trailer2.setLatLngs([latlng, [nc[1], nc[0]]]); + }); + + marker.on("dragend", (e) => { + const latlng = (e.target as L.Marker).getLatLng(); + draggingRef.current = false; + trailer1.setStyle({ opacity: 0 }); + trailer2.setStyle({ opacity: 0 }); + marker.remove(); + shown = false; + + onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng); + }); + + // Click on ghost marker inserts at snapped position + marker.on("click", () => { + if (draggingRef.current) return; + const latlng = marker.getLatLng(); + marker.remove(); + shown = false; + + onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng); + }); + + // Prevent double-click zoom when clicking ghost marker + marker.on("dblclick", (e) => { + L.DomEvent.stop(e as unknown as Event); + }); + + map.on("mousemove", onMouseMove); + map.on("mouseout", onMouseOut); + + return () => { + map.off("mousemove", onMouseMove); + map.off("mouseout", onMouseOut); + if (shown) marker.remove(); + trailer1.remove(); + trailer2.remove(); + markerRef.current = null; + }; + }, [map, disabled, findClosestOnRoute]); + + return null; +} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index d81f31e..77d9517 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -105,6 +105,37 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) { return toasts; } +function ColorModeToggle({ yjs }: { yjs: YjsState }) { + const { t } = useTranslation("planner"); + const [current, setCurrent] = useState("plain"); + + useEffect(() => { + const update = () => { + setCurrent((yjs.routeData.get("colorMode") as string) ?? "plain"); + }; + yjs.routeData.observe(update); + update(); + return () => yjs.routeData.unobserve(update); + }, [yjs.routeData]); + + const setMode = (mode: string) => { + yjs.routeData.set("colorMode", mode); + }; + + return ( + + ); +} + function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType["routeStats"] }) { const { t } = useTranslation("planner"); const [tab, setTab] = useState<"waypoints" | "notes">("waypoints"); @@ -186,6 +217,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, /> )} + {computing && ( {t("computingRoute")} )} diff --git a/apps/planner/app/lib/brouter.test.ts b/apps/planner/app/lib/brouter.test.ts index 4ddcf3f..589d46d 100644 --- a/apps/planner/app/lib/brouter.test.ts +++ b/apps/planner/app/lib/brouter.test.ts @@ -1,61 +1,7 @@ import { describe, it, expect } from "vitest"; +import { mergeGeoJsonSegments } from "./brouter"; -// Test the mergeGeoJsonSegments logic extracted from brouter.ts - -interface GeoJsonFeature { - type: string; - properties: Record; - geometry: { type: string; coordinates: number[][] }; -} - -interface GeoJsonCollection { - type: string; - features: GeoJsonFeature[]; -} - -function mergeGeoJsonSegments(segments: GeoJsonCollection[]): GeoJsonCollection { - const allCoords: number[][] = []; - let totalLength = 0; - let totalAscend = 0; - let totalTime = 0; - - for (let i = 0; i < segments.length; i++) { - const feature = segments[i]!.features?.[0]; - if (!feature) continue; - - const coords = feature.geometry.coordinates; - const startIdx = i === 0 ? 0 : 1; - for (let j = startIdx; j < coords.length; j++) { - allCoords.push(coords[j]!); - } - - const props = feature.properties; - totalLength += parseInt(String(props["track-length"] ?? "0")); - totalAscend += parseInt(String(props["filtered ascend"] ?? "0")); - totalTime += parseInt(String(props["total-time"] ?? "0")); - } - - return { - type: "FeatureCollection", - features: [ - { - type: "Feature", - properties: { - "track-length": String(totalLength), - "filtered ascend": String(totalAscend), - "total-time": String(totalTime), - creator: "trails.cool (BRouter segments)", - }, - geometry: { - type: "LineString", - coordinates: allCoords, - }, - }, - ], - }; -} - -function makeSegment(coords: number[][], length: number, ascend: number): GeoJsonCollection { +function makeSegment(coords: number[][], length: number, ascend: number) { return { type: "FeatureCollection", features: [{ @@ -72,70 +18,129 @@ function makeSegment(coords: number[][], length: number, ascend: number): GeoJso describe("mergeGeoJsonSegments", () => { it("merges two segments", () => { - const seg1 = makeSegment([[13.0, 52.0], [13.1, 52.1], [13.2, 52.2]], 1000, 10); - const seg2 = makeSegment([[13.2, 52.2], [13.3, 52.3], [13.4, 52.4]], 1500, 20); + const seg1 = makeSegment([[13.0, 52.0, 30], [13.1, 52.1, 40], [13.2, 52.2, 50]], 1000, 10); + const seg2 = makeSegment([[13.2, 52.2, 50], [13.3, 52.3, 60], [13.4, 52.4, 45]], 1500, 20); - const result = mergeGeoJsonSegments([seg1, seg2]); - const coords = result.features[0]!.geometry.coordinates; + const result = mergeGeoJsonSegments([seg1, seg2] as never[]); - // Should have 5 points (first segment 3 + second segment 2, skipping duplicate) - expect(coords).toHaveLength(5); - expect(coords[0]).toEqual([13.0, 52.0]); - expect(coords[2]).toEqual([13.2, 52.2]); // shared point - expect(coords[4]).toEqual([13.4, 52.4]); + expect(result.coordinates).toHaveLength(5); + expect(result.coordinates[0]).toEqual([13.0, 52.0, 30]); + expect(result.coordinates[2]).toEqual([13.2, 52.2, 50]); + expect(result.coordinates[4]).toEqual([13.4, 52.4, 45]); }); it("skips duplicate point at segment boundaries", () => { - const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0); - const seg2 = makeSegment([[2, 2], [3, 3]], 100, 0); - const seg3 = makeSegment([[3, 3], [4, 4]], 100, 0); + const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0); + const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 100, 0); + const seg3 = makeSegment([[3, 3, 0], [4, 4, 0]], 100, 0); - const result = mergeGeoJsonSegments([seg1, seg2, seg3]); - const coords = result.features[0]!.geometry.coordinates; - - expect(coords).toHaveLength(4); - expect(coords).toEqual([[1, 1], [2, 2], [3, 3], [4, 4]]); + const result = mergeGeoJsonSegments([seg1, seg2, seg3] as never[]); + expect(result.coordinates).toHaveLength(4); }); it("accumulates stats across segments", () => { - const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50); - const seg2 = makeSegment([[2, 2], [3, 3]], 2000, 30); + const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 1000, 50); + const seg2 = makeSegment([[2, 2, 0], [3, 3, 0]], 2000, 30); - const result = mergeGeoJsonSegments([seg1, seg2]); - const props = result.features[0]!.properties; - - expect(props["track-length"]).toBe("3000"); - expect(props["filtered ascend"]).toBe("80"); - expect(props["total-time"]).toBe("200"); + const result = mergeGeoJsonSegments([seg1, seg2] as never[]); + expect(result.totalLength).toBe(3000); + expect(result.totalAscend).toBe(80); }); it("handles single segment", () => { - const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10); - const result = mergeGeoJsonSegments([seg]); + const seg = makeSegment([[1, 1, 10], [2, 2, 20], [3, 3, 30]], 500, 10); + const result = mergeGeoJsonSegments([seg] as never[]); - expect(result.features[0]!.geometry.coordinates).toHaveLength(3); - expect(result.features[0]!.properties["track-length"]).toBe("500"); + expect(result.coordinates).toHaveLength(3); + expect(result.totalLength).toBe(500); }); it("handles empty segment gracefully", () => { - const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0); - const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] }; + const seg1 = makeSegment([[1, 1, 0], [2, 2, 0]], 100, 0); + const empty = { type: "FeatureCollection", features: [] }; - const result = mergeGeoJsonSegments([seg1, empty]); - expect(result.features[0]!.geometry.coordinates).toHaveLength(2); + const result = mergeGeoJsonSegments([seg1, empty] as never[]); + expect(result.coordinates).toHaveLength(2); }); - it("preserves elevation data in coordinates", () => { - const seg1 = makeSegment([[13.0, 52.0, 100], [13.1, 52.1, 150]], 500, 50); - const seg2 = makeSegment([[13.1, 52.1, 150], [13.2, 52.2, 200]], 500, 50); + it("preserves 3D coordinates (elevation)", () => { + const seg = makeSegment([[13.4, 52.5, 34], [13.38, 52.51, 40]], 500, 6); + const result = mergeGeoJsonSegments([seg] as never[]); - const result = mergeGeoJsonSegments([seg1, seg2]); - const coords = result.features[0]!.geometry.coordinates; + expect(result.coordinates[0]![2]).toBe(34); + expect(result.coordinates[1]![2]).toBe(40); + }); - expect(coords).toHaveLength(3); - expect(coords[0]).toEqual([13.0, 52.0, 100]); - expect(coords[1]).toEqual([13.1, 52.1, 150]); - expect(coords[2]).toEqual([13.2, 52.2, 200]); + it("defaults missing elevation to 0", () => { + const seg = makeSegment([[13.4, 52.5], [13.38, 52.51]], 500, 0); + const result = mergeGeoJsonSegments([seg] as never[]); + + expect(result.coordinates[0]![2]).toBe(0); + expect(result.coordinates[1]![2]).toBe(0); + }); + + // Segment boundary tests + it("tracks segment boundaries with 1 segment", () => { + const seg = makeSegment([[0, 0, 0], [1, 1, 10], [2, 2, 20]], 100, 20); + const result = mergeGeoJsonSegments([seg] as never[]); + + expect(result.segmentBoundaries).toEqual([0]); + }); + + it("tracks segment boundaries with 2 segments", () => { + const seg1 = makeSegment([[0, 0, 0], [1, 1, 10], [2, 2, 20]], 100, 20); + const seg2 = makeSegment([[2, 2, 20], [3, 3, 30], [4, 4, 40]], 100, 20); + + const result = mergeGeoJsonSegments([seg1, seg2] as never[]); + + expect(result.segmentBoundaries).toEqual([0, 3]); + expect(result.coordinates).toHaveLength(5); + }); + + it("tracks segment boundaries with 4 segments (5 waypoints)", () => { + const segments = [ + makeSegment([[0, 0, 0], [1, 1, 10]], 100, 10), + makeSegment([[1, 1, 10], [2, 2, 20]], 100, 10), + makeSegment([[2, 2, 20], [3, 3, 30]], 100, 10), + makeSegment([[3, 3, 30], [4, 4, 40]], 100, 10), + ]; + const result = mergeGeoJsonSegments(segments as never[]); + + expect(result.segmentBoundaries).toEqual([0, 2, 3, 4]); + expect(result.coordinates).toHaveLength(5); + expect(result.totalLength).toBe(400); + }); + + it("segment boundaries allow mapping point index to waypoint segment", () => { + const seg1 = makeSegment([[0, 0, 0], [0.5, 0.5, 5], [1, 1, 10]], 100, 10); + const seg2 = makeSegment([[1, 1, 10], [1.5, 1.5, 15], [2, 2, 20]], 100, 10); + + const result = mergeGeoJsonSegments([seg1, seg2] as never[]); + // boundaries: [0, 3] — segment 0 is coords 0-2, segment 1 is coords 3-4 + + function findSegment(pointIndex: number): number { + const bounds = result.segmentBoundaries; + for (let i = bounds.length - 1; i >= 0; i--) { + if (pointIndex >= bounds[i]!) return i; + } + return 0; + } + + expect(findSegment(0)).toBe(0); // first point → segment 0 + expect(findSegment(1)).toBe(0); // mid of segment 0 + expect(findSegment(2)).toBe(0); // last point of segment 0 + expect(findSegment(3)).toBe(1); // first point of segment 1 + expect(findSegment(4)).toBe(1); // last point + }); + + it("geojson field is backwards compatible", () => { + const seg = makeSegment([[13.0, 52.0, 30], [13.1, 52.1, 40]], 500, 10); + const result = mergeGeoJsonSegments([seg] as never[]); + + expect(result.geojson.type).toBe("FeatureCollection"); + expect(result.geojson.features).toHaveLength(1); + expect(result.geojson.features[0]!.geometry.type).toBe("LineString"); + expect(result.geojson.features[0]!.properties["track-length"]).toBe("500"); }); }); @@ -154,14 +159,5 @@ describe("waypoint to BRouter segments", () => { } expect(pairs).toHaveLength(3); - expect(pairs[0]).toEqual([waypoints[0], waypoints[1]]); - expect(pairs[1]).toEqual([waypoints[1], waypoints[2]]); - expect(pairs[2]).toEqual([waypoints[2], waypoints[3]]); - }); - - it("formats lon,lat correctly for BRouter", () => { - const wp = { lat: 52.5277, lon: 13.4033 }; - const lonlat = `${wp.lon},${wp.lat}`; - expect(lonlat).toBe("13.4033,52.5277"); }); }); diff --git a/apps/planner/app/lib/brouter.ts b/apps/planner/app/lib/brouter.ts index 6c7ce92..4b24a34 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -12,12 +12,22 @@ export interface RouteRequest { noGoAreas?: NoGoArea[]; } +export interface EnrichedRoute { + coordinates: [number, number, number][]; // [lon, lat, ele] + segmentBoundaries: number[]; // coordinate index where each waypoint segment starts + surfaces: string[]; // surface type per coordinate point (e.g. "asphalt", "gravel") + totalLength: number; + totalAscend: number; + totalTime: number; + geojson: GeoJsonCollection; // original merged GeoJSON for backwards compat +} + /** * Compute a route segment-by-segment between consecutive waypoints. * This matches bikerouter.de's behavior and guarantees the route * passes through every waypoint. */ -export async function computeRoute(request: RouteRequest): Promise { +export async function computeRoute(request: RouteRequest): Promise { if (request.waypoints.length < 2) { throw new Error("At least 2 waypoints are required"); } @@ -40,6 +50,7 @@ export async function computeRoute(request: RouteRequest): Promise { profile, alternativeidx: "0", format, + tiledesc: "true", }); if (nogoParam) params.set("nogos", nogoParam); return fetchSegment(`${BROUTER_URL}/brouter?${params}`); @@ -70,8 +81,10 @@ interface GeoJsonCollection { features: GeoJsonFeature[]; } -function mergeGeoJsonSegments(segments: Record[]): GeoJsonCollection { - const allCoords: number[][] = []; +export function mergeGeoJsonSegments(segments: Record[]): EnrichedRoute { + const allCoords: [number, number, number][] = []; + const allSurfaces: string[] = []; + const segmentBoundaries: number[] = []; let totalLength = 0; let totalAscend = 0; let totalTime = 0; @@ -81,11 +94,19 @@ function mergeGeoJsonSegments(segments: Record[]): GeoJsonColle const feature = segment.features?.[0]; if (!feature) continue; + // Record where this segment starts in the merged coordinate array + segmentBoundaries.push(allCoords.length); + const coords = feature.geometry.coordinates; + // Extract surface data from BRouter messages (tiledesc=true) + const surfaceMap = extractSurfacesFromMessages(feature.properties); + // Skip first point of subsequent segments to avoid duplicates const startIdx = i === 0 ? 0 : 1; for (let j = startIdx; j < coords.length; j++) { - allCoords.push(coords[j]!); + const c = coords[j]!; + allCoords.push([c[0]!, c[1]!, c[2] ?? 0]); + allSurfaces.push(surfaceMap.get(j) ?? surfaceMap.get(j - 1) ?? "unknown"); } // Accumulate stats @@ -95,7 +116,7 @@ function mergeGeoJsonSegments(segments: Record[]): GeoJsonColle totalTime += parseInt(String(props["total-time"] ?? "0")); } - return { + const geojson: GeoJsonCollection = { type: "FeatureCollection", features: [ { @@ -113,6 +134,39 @@ function mergeGeoJsonSegments(segments: Record[]): GeoJsonColle }, ], }; + + return { + coordinates: allCoords, + segmentBoundaries, + surfaces: allSurfaces, + totalLength, + totalAscend, + totalTime, + geojson, + }; +} + +/** + * Extract surface type per message row from BRouter's tiledesc messages. + * Messages is an array of arrays: first row is headers, subsequent rows are data. + * We look for the "WayTags" column which contains OSM tags like "surface=asphalt". + */ +function extractSurfacesFromMessages(properties: Record): Map { + const result = new Map(); + const messages = properties.messages as string[][] | undefined; + if (!messages || messages.length < 2) return result; + + const headers = messages[0]!; + const wayTagsIdx = headers.indexOf("WayTags"); + if (wayTagsIdx === -1) return result; + + for (let i = 1; i < messages.length; i++) { + const row = messages[i]!; + const tags = row[wayTagsIdx] ?? ""; + const surfaceMatch = tags.match(/surface=(\S+)/); + result.set(i - 1, surfaceMatch ? surfaceMatch[1]! : "unknown"); + } + return result; } /** diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index be37a92..0e1e4df 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -74,20 +74,22 @@ export function useRouting(yjs: YjsState | null) { } if (!response.ok) return; - const geojson = await response.json(); + const enriched = await response.json(); - const props = geojson.features?.[0]?.properties; - if (props) { - setRouteStats({ - distance: props["track-length"] ? parseInt(props["track-length"]) : undefined, - elevationGain: props["filtered ascend"] ? parseInt(props["filtered ascend"]) : undefined, - elevationLoss: props["plain-ascend"] - ? Math.abs(parseInt(props["plain-ascend"])) - : undefined, - }); - } + setRouteStats({ + distance: enriched.totalLength || undefined, + elevationGain: enriched.totalAscend || undefined, + }); - yjs.routeData.set("geojson", JSON.stringify(geojson)); + // Store enriched route data in Yjs for all participants + yjs.doc.transact(() => { + yjs.routeData.set("geojson", JSON.stringify(enriched.geojson)); + yjs.routeData.set("coordinates", JSON.stringify(enriched.coordinates)); + yjs.routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries)); + if (enriched.surfaces?.length) { + yjs.routeData.set("surfaces", JSON.stringify(enriched.surfaces)); + } + }); } catch { // Route computation failed } finally { diff --git a/e2e/integration.test.ts b/e2e/integration.test.ts index 830b882..f28286b 100644 --- a/e2e/integration.test.ts +++ b/e2e/integration.test.ts @@ -46,10 +46,10 @@ test.describe("Integration: BRouter routing", () => { }, }); expect(response.ok()).toBeTruthy(); - const geojson = await response.json(); - expect(geojson.features).toHaveLength(1); - expect(geojson.features[0].geometry.type).toBe("LineString"); - expect(geojson.features[0].geometry.coordinates.length).toBeGreaterThan(10); + const enriched = await response.json(); + expect(enriched.geojson.features).toHaveLength(1); + expect(enriched.geojson.features[0].geometry.type).toBe("LineString"); + expect(enriched.coordinates.length).toBeGreaterThan(10); }); test("routes through all waypoints (segment by segment)", async ({ request }) => { @@ -64,8 +64,8 @@ test.describe("Integration: BRouter routing", () => { }, }); expect(response.ok()).toBeTruthy(); - const geojson = await response.json(); - const coords = geojson.features[0].geometry.coordinates; + const enriched = await response.json(); + const coords = enriched.coordinates; const nearMiddle = coords.some( (c: number[]) => @@ -95,6 +95,32 @@ test.describe("Integration: BRouter routing", () => { expect(response.status()).toBe(400); }); + test("returns enriched route with segment boundaries", async ({ request }) => { + const response = await request.post(`${PLANNER}/api/route`, { + data: { + waypoints: [ + { lat: 52.520, lon: 13.405 }, + { lat: 52.516, lon: 13.377 }, + { lat: 52.510, lon: 13.390 }, + ], + profile: "trekking", + }, + }); + expect(response.ok()).toBeTruthy(); + const enriched = await response.json(); + + // EnrichedRoute fields + expect(enriched.coordinates).toBeDefined(); + expect(enriched.coordinates.length).toBeGreaterThan(10); + expect(enriched.coordinates[0]).toHaveLength(3); // [lon, lat, ele] + expect(enriched.segmentBoundaries).toBeDefined(); + expect(enriched.segmentBoundaries).toHaveLength(2); // 3 waypoints = 2 segments + expect(enriched.segmentBoundaries[0]).toBe(0); + expect(enriched.totalLength).toBeGreaterThan(0); + expect(enriched.geojson).toBeDefined(); + expect(enriched.geojson.features[0].geometry.type).toBe("LineString"); + }); + test("accepts no-go areas parameter", async ({ request }) => { const response = await request.post(`${PLANNER}/api/route`, { data: { @@ -116,8 +142,8 @@ test.describe("Integration: BRouter routing", () => { }, }); expect(response.ok()).toBeTruthy(); - const geojson = await response.json(); - expect(geojson.features).toHaveLength(1); - expect(geojson.features[0].geometry.type).toBe("LineString"); + const enriched = await response.json(); + expect(enriched.geojson.features).toHaveLength(1); + expect(enriched.geojson.features[0].geometry.type).toBe("LineString"); }); }); diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index 9cee835..2ace23e 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -82,6 +82,66 @@ test.describe("Planner", () => { await expect(page.getByText("Waypoints (0)")).toBeVisible(); }); + test("clicking on route splits it by inserting a waypoint", async ({ page, request }) => { + const sessionResp = await request.post("/api/sessions", { data: {} }); + const { url } = await sessionResp.json(); + + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ + { lat: 52.520, lon: 13.405 }, + { lat: 52.515, lon: 13.351 }, + ]))}`); + + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 }); + + // Zoom in and click on the route midpoint to split it + // RouteInteraction uses map-level mousemove, but click on the ghost marker + // inserts the waypoint. We'll use Leaflet events to simulate. + await page.evaluate(() => { + const map = (window as any).__leafletMap; + if (!map) return; + map.setView([52.5175, 13.378], 14, { animate: false }); + }); + await page.waitForTimeout(1000); + + // Click on the route via the visible polyline's bounding box + const routePath = page.locator(".leaflet-overlay-pane path").first(); + await expect(routePath).toBeAttached({ timeout: 5000 }); + const box = await routePath.boundingBox(); + if (!box) throw new Error("Route polyline not visible"); + + // Click the center of the route path bounding box + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + + // The click on the map near the route should trigger RouteInteraction + // which shows the ghost and inserts a waypoint. If the click was within + // snap tolerance, a waypoint is inserted. Otherwise, a new waypoint is + // appended (map click). Either way we get 3 waypoints. + await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); + }); + + // Note: Ghost marker hover interaction is verified visually via cmux browser. + // Playwright's mouse simulation doesn't reliably trigger Leaflet's SVG + // pointer events needed for the distance-based snap detection. + + test("session has color mode toggle", async ({ page, request }) => { + const response = await request.post("/api/sessions", { data: {} }); + const { url } = await response.json(); + + await page.goto(url); + await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); + + const select = page.getByTitle("Route Color"); + await expect(select).toBeVisible(); + await expect(select).toHaveValue("plain"); + + // Switch to elevation + await select.selectOption("elevation"); + await expect(select).toHaveValue("elevation"); + }); + test("session has no-go area button", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); diff --git a/openspec/changes/planner-route-interactions/.openspec.yaml b/openspec/changes/planner-route-interactions/.openspec.yaml new file mode 100644 index 0000000..1e96444 --- /dev/null +++ b/openspec/changes/planner-route-interactions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-26 diff --git a/openspec/changes/planner-route-interactions/design.md b/openspec/changes/planner-route-interactions/design.md new file mode 100644 index 0000000..c6014da --- /dev/null +++ b/openspec/changes/planner-route-interactions/design.md @@ -0,0 +1,132 @@ +## Context + +The Planner currently renders routes as a single-color `L.Polyline` with no +interactivity — users can't click on the route or drag it. Waypoints can only +be appended (click on map) or reordered in the sidebar. BRouter returns +per-point elevation in coordinates (`[lon, lat, ele]`) and can return surface +tags, but `mergeGeoJsonSegments` currently discards all per-point data and +only keeps track-level totals. + +bikerouter.de and komoot both support click-to-split and drag-to-reshape as +primary editing interactions. These are the most natural way to refine a route +after the initial waypoints are placed. + +## Goals / Non-Goals + +**Goals:** +- Click on the route polyline to insert a waypoint at that position +- Drag midpoint handles between waypoints to reshape the route +- Color the route by elevation gradient (green→yellow→red) or surface type +- Preserve per-point data from BRouter through the merge pipeline +- All interactions synced via Yjs (collaborative) + +**Non-Goals:** +- Undo/redo system (future change) +- Route alternatives (show multiple options) +- Custom color gradient configuration +- Surface type legend or detailed surface info panel +- Offline route coloring (requires BRouter data) + +## Decisions + +### D1: Click-to-split via Leaflet polyline event + +Listen for `click` on the route polyline. On click, find the closest point on +the route geometry, determine which waypoint segment it falls in (between +waypoint N and N+1), and insert a new waypoint at that position using +`Y.Array.insert(N+1, [newWaypoint])`. + +To find the segment index: the route is computed segment-by-segment (one per +consecutive waypoint pair). Track the coordinate count per segment in the +merged GeoJSON so we can map any route point index back to a waypoint segment. + +**Alternative considered**: Using a separate invisible polyline for click +detection. Unnecessary — Leaflet's built-in polyline click events work fine +with appropriate `weight` for hit detection. + +### D2: Midpoint handles as draggable CircleMarkers + +For each consecutive pair of waypoints, render a small, semi-transparent +`L.CircleMarker` at the geographic midpoint of the route segment (not the +straight-line midpoint — use the actual route geometry midpoint). On drag +start, the handle becomes opaque and turns into a waypoint drag. On drag end, +insert a new waypoint at the dropped position. + +Handles are only visible on hover or at higher zoom levels to avoid clutter. +They reposition after each route computation. + +**Alternative considered**: Handles at the straight-line midpoint between +waypoints. Bad UX — on a winding route, the midpoint may be far from the +actual route. + +### D3: Per-point data preservation in BRouter response + +BRouter GeoJSON coordinates are `[lon, lat, ele]` — elevation is already +present but currently unused beyond the ElevationChart. For surface data, +BRouter supports a `tiledesc` parameter that includes waytype/surface tags +per point in the `properties.messages` array. + +Modify `mergeGeoJsonSegments` to: +1. Preserve the full 3-element coordinates (already done, but not exposed) +2. Track segment boundaries (array of indices where each waypoint segment starts) +3. Optionally parse `properties.messages` for surface tags + +Store the enriched data in `routeData` Y.Map so all participants have it. + +### D4: Colored route rendering with segmented polylines + +Use multiple `L.Polyline` instances, each covering a short segment of the +route with a color based on the data value at that point. For elevation: +normalize elevation values to 0-1 range across the route, map to a +green→yellow→red gradient. For surface: map surface type strings to a fixed +color palette (asphalt=gray, gravel=brown, path=green, etc.). + +Three rendering modes, toggled by a button in the header: +1. **Plain**: Current single-color blue polyline (default) +2. **Elevation**: Gradient by elevation +3. **Surface**: Colored by surface type + +**Alternative considered**: `leaflet-hotline` for smooth canvas-based gradient +rendering. Better visual quality but adds a dependency and doesn't support +click events on the colored line. Since we need click-to-split on the route, +we need real Leaflet layers. Can revisit later if performance is an issue. + +**Alternative considered**: Single Canvas renderer. Better performance for +very long routes but much more complex, and breaks Leaflet's event model. +Not needed at current route lengths (<1000 points typical). + +### D5: Segment boundary tracking + +The key data structure bridging BRouter output and map interactions: + +```typescript +interface EnrichedRoute { + coordinates: [number, number, number][]; // [lon, lat, ele] + segmentBoundaries: number[]; // indices where each waypoint segment starts + surfaces?: string[]; // surface type per point (optional) + totalLength: number; + totalAscend: number; + totalTime: number; +} +``` + +`segmentBoundaries[i]` is the coordinate index where the route segment from +waypoint `i` to waypoint `i+1` starts. This enables: +- Click-to-split: find which segment a clicked point belongs to +- Midpoint handles: find the midpoint of each segment's geometry +- Per-segment coloring: color differently per waypoint pair if needed + +## Risks / Trade-offs + +- **Performance with many segments** → Hundreds of small `L.Polyline` instances + for colored rendering could be slow. Mitigate: batch updates, only re-render + changed segments, limit color segments to ~100 per route. Can switch to + canvas if needed later. +- **BRouter surface data availability** → Not all BRouter profiles return + surface tags. The `tiledesc` parameter may not work with all profiles. + Mitigate: surface coloring is optional; elevation always works (from coords). +- **Click precision on thin polylines** → Hard to click a 4px line on mobile. + Mitigate: use `L.Polyline` `weight` for rendering but a wider invisible + polyline for click detection. +- **Midpoint handle clutter** → Routes with many waypoints get cluttered. + Mitigate: only show handles on hover or at zoom level ≥ 12. diff --git a/openspec/changes/planner-route-interactions/proposal.md b/openspec/changes/planner-route-interactions/proposal.md new file mode 100644 index 0000000..9cff432 --- /dev/null +++ b/openspec/changes/planner-route-interactions/proposal.md @@ -0,0 +1,56 @@ +## Why + +The Planner's route editing is click-only: users place waypoints and the route +auto-computes between them. But refining a route is tedious — you can't insert +a waypoint on the route itself, drag a route segment to reshape it, or see what +kind of terrain you're heading into. These are table-stakes features in modern +route planners (bikerouter.de, komoot, Ride with GPS) and the most common +feedback from early testers. + +## What Changes + +- **Route splitting**: Click on the route polyline to insert a new waypoint at + that position, splitting the segment. The waypoint snaps to the closest point + on the existing route. +- **Drag-to-reshape**: Invisible midpoint handles appear between waypoints on + the route. Dragging a midpoint inserts a new waypoint and triggers route + recomputation — the natural way to push a route onto a different path. +- **Colored route rendering**: Replace the single-color route polyline with + segments colored by elevation gradient or surface type (from BRouter's + per-point data). Users can toggle between plain, elevation, and surface + color modes. + +## Capabilities + +### New Capabilities + +- `route-splitting`: Insert waypoints by clicking on the route polyline, with + snapping to the nearest route point +- `route-drag-reshape`: Drag midpoint handles on route segments to reshape the + route interactively +- `route-coloring`: Color-code the route polyline by elevation gradient or + surface type using BRouter track data + +### Modified Capabilities + +- `map-display`: Route visualization changes from a single-color polyline to + a segmented, interactive polyline with color modes and click/drag interactions +- `brouter-integration`: BRouter response data (elevation per point, surface + tags) must be preserved and exposed to the rendering layer + +## Impact + +- **PlannerMap.tsx**: Major changes — route polyline becomes interactive with + click-to-split and drag midpoint handles +- **brouter.ts**: Expose per-point elevation and surface data from GeoJSON + properties (currently only track-level stats are extracted) +- **use-routing.ts**: Support waypoint insertion at specific indices (not just + append) +- **use-yjs.ts**: Waypoint insertion at arbitrary positions (already supported + by Y.Array.insert) +- **New components**: ColoredRoute (segmented polyline renderer), + MidpointHandles (draggable reshape markers) +- **ElevationChart**: Add hover-to-highlight sync with colored route +- **Dependencies**: May need `leaflet-hotline` for smooth gradient rendering, + or custom canvas renderer +- **i18n**: New keys for color mode labels (en + de) diff --git a/openspec/changes/planner-route-interactions/specs/brouter-integration/spec.md b/openspec/changes/planner-route-interactions/specs/brouter-integration/spec.md new file mode 100644 index 0000000..8234141 --- /dev/null +++ b/openspec/changes/planner-route-interactions/specs/brouter-integration/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Route computation from waypoints +The Planner SHALL compute a route between ordered waypoints by calling the BRouter HTTP API and returning the result as GeoJSON, preserving per-point elevation and segment boundary data. + +#### Scenario: Compute route with two waypoints +- **WHEN** the routing host submits two waypoints (start, end) with profile "trekking" +- **THEN** the BRouter API returns a GeoJSON route within 2 seconds + +#### Scenario: Compute route with via points +- **WHEN** the routing host submits three or more waypoints +- **THEN** the BRouter API returns a route passing through all waypoints in order + +#### Scenario: Per-point elevation preserved +- **WHEN** BRouter returns GeoJSON with 3D coordinates [lon, lat, ele] +- **THEN** the merged route response SHALL preserve elevation values for every coordinate point + +#### Scenario: Segment boundaries tracked +- **WHEN** a route with N waypoints is computed (N-1 segments) +- **THEN** the response SHALL include an array of coordinate indices marking where each waypoint-to-waypoint segment begins diff --git a/openspec/changes/planner-route-interactions/specs/map-display/spec.md b/openspec/changes/planner-route-interactions/specs/map-display/spec.md new file mode 100644 index 0000000..a408798 --- /dev/null +++ b/openspec/changes/planner-route-interactions/specs/map-display/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Route visualization +The map SHALL display the computed route as an interactive, optionally color-coded polyline on the map. + +#### Scenario: Display route +- **WHEN** BRouter returns a route GeoJSON +- **THEN** the route is rendered as a polyline on the map, colored according to the active color mode + +#### Scenario: Route updates on waypoint change +- **WHEN** a waypoint is added, moved, or deleted +- **THEN** the route polyline updates after BRouter recomputes the route + +#### Scenario: Route is clickable +- **WHEN** a user clicks on the route polyline +- **THEN** a new waypoint is inserted at the clicked position (see route-splitting spec) + +#### Scenario: Route has midpoint handles +- **WHEN** a route with two or more waypoints is displayed +- **THEN** draggable midpoint handles appear on each route segment (see route-drag-reshape spec) diff --git a/openspec/changes/planner-route-interactions/specs/route-coloring/spec.md b/openspec/changes/planner-route-interactions/specs/route-coloring/spec.md new file mode 100644 index 0000000..0d392cb --- /dev/null +++ b/openspec/changes/planner-route-interactions/specs/route-coloring/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Route color modes +The Planner SHALL support multiple route color modes that visualize per-point data along the route. + +#### Scenario: Default plain mode +- **WHEN** a route is first displayed +- **THEN** it renders as a single-color blue polyline (current behavior) + +#### Scenario: Elevation color mode +- **WHEN** a user selects the "Elevation" color mode +- **THEN** the route polyline is colored with a gradient from green (low) through yellow (mid) to red (high), based on the elevation at each point + +#### Scenario: Surface color mode +- **WHEN** a user selects the "Surface" color mode and surface data is available +- **THEN** the route polyline is colored by surface type (e.g., asphalt=gray, gravel=brown, path=green, track=orange) + +#### Scenario: Surface data unavailable +- **WHEN** a user selects "Surface" color mode but BRouter did not return surface data +- **THEN** the route falls back to plain color mode and a brief message indicates surface data is not available + +### Requirement: Color mode toggle +The Planner SHALL provide a UI control to switch between route color modes. + +#### Scenario: Toggle control location +- **WHEN** a route is displayed +- **THEN** a color mode selector is visible in the session header or map controls + +#### Scenario: Color mode persists in session +- **WHEN** a user changes the color mode +- **THEN** the selection is stored in the Yjs routeData map and synced to all participants diff --git a/openspec/changes/planner-route-interactions/specs/route-drag-reshape/spec.md b/openspec/changes/planner-route-interactions/specs/route-drag-reshape/spec.md new file mode 100644 index 0000000..3714cf2 --- /dev/null +++ b/openspec/changes/planner-route-interactions/specs/route-drag-reshape/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Midpoint drag handles on route segments +The Planner SHALL display draggable midpoint handles between consecutive waypoints on the route, allowing users to reshape the route by dragging. + +#### Scenario: Midpoint handle visible +- **WHEN** a route is displayed with at least two waypoints +- **THEN** a small circular handle appears at the geographic midpoint of each route segment (using the actual route geometry, not straight-line distance) + +#### Scenario: Drag midpoint to reshape +- **WHEN** a user drags a midpoint handle to a new position +- **THEN** a new waypoint is inserted at the dropped position between the two adjacent waypoints, and the route recomputes through the new point + +#### Scenario: Handle visibility control +- **WHEN** the map zoom level is below 12 +- **THEN** midpoint handles are hidden to reduce visual clutter + +#### Scenario: Reshape syncs to other participants +- **WHEN** a user reshapes the route by dragging a midpoint +- **THEN** all other participants see the new waypoint and recomputed route via Yjs sync diff --git a/openspec/changes/planner-route-interactions/specs/route-splitting/spec.md b/openspec/changes/planner-route-interactions/specs/route-splitting/spec.md new file mode 100644 index 0000000..b9b1399 --- /dev/null +++ b/openspec/changes/planner-route-interactions/specs/route-splitting/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: Insert waypoint by clicking on route +The Planner SHALL allow users to insert a new waypoint by clicking on the rendered route polyline. + +#### Scenario: Click on route between two waypoints +- **WHEN** a user clicks on the route polyline between waypoint 2 and waypoint 3 +- **THEN** a new waypoint is inserted at position 3 (between the original waypoints 2 and 3) at the clicked location, and the route recomputes + +#### Scenario: Waypoint snaps to route +- **WHEN** a user clicks near the route polyline +- **THEN** the new waypoint is placed at the closest point on the existing route geometry, not at the raw click position + +#### Scenario: Split syncs to other participants +- **WHEN** a user inserts a waypoint by clicking on the route +- **THEN** all other participants see the new waypoint appear in the waypoint list and on the map via Yjs sync diff --git a/openspec/changes/planner-route-interactions/tasks.md b/openspec/changes/planner-route-interactions/tasks.md new file mode 100644 index 0000000..2094985 --- /dev/null +++ b/openspec/changes/planner-route-interactions/tasks.md @@ -0,0 +1,45 @@ +## 1. BRouter Data Pipeline + +- [x] 1.1 Modify `mergeGeoJsonSegments` to preserve 3D coordinates (lon, lat, ele) and track segment boundary indices +- [x] 1.2 Add `EnrichedRoute` interface with coordinates, segmentBoundaries, surfaces, and stats +- [x] 1.3 Store enriched route data (including segment boundaries) in Yjs routeData map +- [x] 1.4 Write unit test for segment boundary tracking across multi-waypoint routes + +## 2. Route Splitting (click-to-insert) + +- [x] 2.1 Make the route polyline clickable — add click event handler to route Polyline in PlannerMap +- [x] 2.2 On click, find nearest point on route geometry and determine which waypoint segment it belongs to (using segment boundaries) +- [x] 2.3 Insert new waypoint at the clicked position using `Y.Array.insert(segmentIndex + 1, [waypoint])` +- [x] 2.4 Add wider invisible polyline behind the route for easier click targeting (hit area) + +## 3. Drag-to-Reshape (midpoint handles) + +- [x] 3.1 Create MidpointHandles component — renders a CircleMarker at the geographic midpoint of each route segment +- [x] 3.2 Compute geographic midpoint from actual route geometry (not straight-line between waypoints) +- [x] 3.3 On drag end, insert waypoint at dropped position between the two adjacent waypoints +- [x] 3.4 Hide midpoint handles below zoom level 12 +- [x] 3.5 Style handles: semi-transparent by default, opaque on hover, match waypoint color scheme + +## 4. Route Coloring + +- [x] 4.1 Create ColoredRoute component — renders multiple short L.Polyline segments with per-point colors +- [x] 4.2 Implement elevation gradient: normalize elevation values to 0-1 range, map to green→yellow→red +- [x] 4.3 Implement surface coloring: map BRouter surface type strings to a fixed color palette +- [x] 4.4 Add color mode state to Yjs routeData map ("plain" | "elevation" | "surface") +- [x] 4.5 Add color mode toggle button to session header (icon or dropdown) +- [x] 4.6 Fall back to plain mode when surface data is unavailable +- [x] 4.7 Add i18n keys for color mode labels (en + de) + +## 5. Integration + +- [x] 5.1 Replace single Polyline in PlannerMap with ColoredRoute + click handler + MidpointHandles +- [x] 5.2 Ensure click-to-split and drag-to-reshape work alongside no-go area drawing mode (disable route interactions when drawing) +- [x] 5.3 Update ElevationChart hover sync to work with the new colored route + +## 6. Verify + +- [x] 6.1 E2E test: click on route inserts waypoint at correct position +- [x] 6.2 E2E test: color mode toggle switches between plain/elevation/surface +- [x] 6.3 Unit test: segment boundary computation with 2, 3, and 5 waypoints +- [x] 6.4 Verify midpoint handles reposition after route recomputation +- [x] 6.5 Verify all interactions sync correctly between two browser tabs diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e5bc532..9293b57 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -54,6 +54,13 @@ export default { notes: { placeholder: "Notizen für diese Sitzung hinzufügen...", }, + colorMode: { + label: "Routenfarbe", + plain: "Standard", + elevation: "Höhe", + surface: "Untergrund", + surfaceUnavailable: "Untergrunddaten für dieses Profil nicht verfügbar", + }, rateLimitExceeded: "Zu viele Anfragen. Bitte versuche es später erneut.", elevation: { gain: "Höhenmeter aufwärts", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index fb6e5c8..7921d09 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -54,6 +54,13 @@ export default { notes: { placeholder: "Add notes for this session...", }, + colorMode: { + label: "Route Color", + plain: "Plain", + elevation: "Elevation", + surface: "Surface", + surfaceUnavailable: "Surface data not available for this profile", + }, rateLimitExceeded: "Too many requests. Please try again later.", elevation: { gain: "Elevation Gain",