From 6ee07f0570c25679c59f4e92ec5c265938226640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 07:30:00 +0100 Subject: [PATCH 1/4] Fix waypoint icons and add elevation chart scrubbing Waypoint icons: - Replace broken Leaflet default icons with numbered blue circles - Numbers match sidebar order (1, 2, 3...) Elevation chart: - Mouse hover shows crosshair, elevation, and distance at cursor - Red dot appears on the map at the corresponding route position - Dot disappears when mouse leaves the chart Co-Authored-By: Claude Opus 4.6 (1M context) --- .../planner/app/components/ElevationChart.tsx | 224 +++++++++++++----- apps/planner/app/components/PlannerMap.tsx | 36 ++- apps/planner/app/components/SessionView.tsx | 11 +- 3 files changed, 198 insertions(+), 73 deletions(-) diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 0c36eeb..214472a 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import type { YjsState } from "~/lib/use-yjs"; interface ElevationPoint { - distance: number; // meters from start - elevation: number; // meters + distance: number; + elevation: number; + lat: number; + lon: number; } function extractElevation(geojsonStr: string): ElevationPoint[] { @@ -22,7 +24,12 @@ function extractElevation(geojsonStr: string): ElevationPoint[] { totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!); } if (coords[i]![2] !== undefined) { - points.push({ distance: totalDist, elevation: coords[i]![2]! }); + points.push({ + distance: totalDist, + elevation: coords[i]![2]!, + lat: coords[i]![1]!, + lon: coords[i]![0]!, + }); } } return points; @@ -42,9 +49,19 @@ function haversine(lat1: number, lon1: number, lat2: number, lon2: number): numb return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } -export function ElevationChart({ yjs }: { yjs: YjsState }) { +const PADDING = { top: 10, right: 10, bottom: 25, left: 40 }; + +interface ElevationChartProps { + yjs: YjsState; + onHover?: (position: [number, number] | null) => void; +} + +export function ElevationChart({ yjs, onHover }: ElevationChartProps) { const [points, setPoints] = useState([]); + const [hoverIdx, setHoverIdx] = useState(null); const canvasRef = useRef(null); + const pointsRef = useRef([]); + pointsRef.current = points; useEffect(() => { const update = () => { @@ -60,76 +77,161 @@ export function ElevationChart({ yjs }: { yjs: YjsState }) { return () => yjs.routeData.unobserve(update); }, [yjs.routeData]); + const drawChart = useCallback( + (highlightIdx: number | null) => { + const canvas = canvasRef.current; + if (!canvas || points.length < 2) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const w = rect.width; + const h = rect.height; + const chartW = w - PADDING.left - PADDING.right; + const chartH = h - PADDING.top - PADDING.bottom; + + const maxDist = points[points.length - 1]!.distance; + const elevations = points.map((p) => p.elevation); + const minEle = Math.min(...elevations); + const maxEle = Math.max(...elevations); + const eleRange = maxEle - minEle || 1; + + const toX = (d: number) => PADDING.left + (d / maxDist) * chartW; + const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH; + + 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(); + + // 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)); + } + ctx.strokeStyle = "#2563eb"; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Axis labels + ctx.fillStyle = "#6b7280"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "right"; + ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10); + ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH); + ctx.textAlign = "center"; + ctx.fillText("0 km", PADDING.left, h - 4); + ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4); + + // Hover crosshair + info + if (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) { + const p = points[highlightIdx]!; + const hx = toX(p.distance); + const hy = toY(p.elevation); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(hx, PADDING.top); + ctx.lineTo(hx, PADDING.top + chartH); + ctx.strokeStyle = "rgba(239, 68, 68, 0.5)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Dot + ctx.beginPath(); + ctx.arc(hx, hy, 4, 0, Math.PI * 2); + ctx.fillStyle = "#ef4444"; + ctx.fill(); + ctx.strokeStyle = "white"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Info label + ctx.fillStyle = "#1f2937"; + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "left"; + const label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`; + const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8; + ctx.textAlign = hx + 8 > w - 80 ? "right" : "left"; + ctx.fillText(label, labelX, PADDING.top + 10); + } + }, + [points], + ); + useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || points.length < 2) return; + drawChart(hoverIdx); + }, [points, hoverIdx, drawChart]); - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || points.length < 2) return; - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const chartW = rect.width - PADDING.left - PADDING.right; + const ratio = (mouseX - PADDING.left) / chartW; - const w = rect.width; - const h = rect.height; - const padding = { top: 10, right: 10, bottom: 25, left: 40 }; - const chartW = w - padding.left - padding.right; - const chartH = h - padding.top - padding.bottom; + if (ratio < 0 || ratio > 1) { + setHoverIdx(null); + onHover?.(null); + return; + } - const maxDist = points[points.length - 1]!.distance; - const elevations = points.map((p) => p.elevation); - const minEle = Math.min(...elevations); - const maxEle = Math.max(...elevations); - const eleRange = maxEle - minEle || 1; + const maxDist = points[points.length - 1]!.distance; + const targetDist = ratio * maxDist; - ctx.clearRect(0, 0, w, h); + // Find closest point + let closest = 0; + let minDiff = Infinity; + for (let i = 0; i < points.length; i++) { + const diff = Math.abs(points[i]!.distance - targetDist); + if (diff < minDiff) { + minDiff = diff; + closest = i; + } + } - // Fill area - ctx.beginPath(); - ctx.moveTo(padding.left, padding.top + chartH); - for (const p of points) { - const x = padding.left + (p.distance / maxDist) * chartW; - const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH; - ctx.lineTo(x, y); - } - ctx.lineTo(padding.left + chartW, padding.top + chartH); - ctx.closePath(); - ctx.fillStyle = "rgba(37, 99, 235, 0.15)"; - ctx.fill(); + setHoverIdx(closest); + const p = points[closest]!; + onHover?.([p.lat, p.lon]); + }, + [points, onHover], + ); - // Line - ctx.beginPath(); - for (let i = 0; i < points.length; i++) { - const p = points[i]!; - const x = padding.left + (p.distance / maxDist) * chartW; - const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.strokeStyle = "#2563eb"; - ctx.lineWidth = 1.5; - ctx.stroke(); - - // Axis labels - ctx.fillStyle = "#6b7280"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "right"; - ctx.fillText(`${Math.round(maxEle)}m`, padding.left - 4, padding.top + 10); - ctx.fillText(`${Math.round(minEle)}m`, padding.left - 4, padding.top + chartH); - ctx.textAlign = "center"; - ctx.fillText("0 km", padding.left, h - 4); - ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, padding.left + chartW, h - 4); - }, [points]); + const handleMouseLeave = useCallback(() => { + setHoverIdx(null); + onHover?.(null); + }, [onHover]); if (points.length < 2) return null; return (

Elevation Profile

- +
); } diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index 4c32011..eff4d01 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -1,17 +1,25 @@ import { useEffect, useState, useCallback } from "react"; -import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet"; +import { MapContainer, TileLayer, LayersControl, Marker, Polyline, 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 "leaflet/dist/leaflet.css"; -// Fix Leaflet default icon paths in bundled environment -import iconUrl from "leaflet/dist/images/marker-icon.png"; -import iconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png"; -import shadowUrl from "leaflet/dist/images/marker-shadow.png"; - -L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl }); +function waypointIcon(index: number): L.DivIcon { + return L.divIcon({ + className: "", + html: `
${index + 1}
`, + iconSize: [0, 0], + }); +} interface WaypointData { lat: number; @@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] interface PlannerMapProps { yjs: YjsState; onRouteRequest?: (waypoints: WaypointData[]) => void; + highlightPosition?: [number, number] | null; } function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) { @@ -94,7 +103,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { key={clientId} position={[cursor.lat, cursor.lng]} icon={L.divIcon({ - className: "cursor-marker", + className: "", html: `
${cursor.name}
`, iconSize: [0, 0], })} @@ -104,7 +113,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { ); } -export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { +export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) { const [waypoints, setWaypoints] = useState([]); const [routeGeoJson, setRouteGeoJson] = useState(null); @@ -201,6 +210,7 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { key={i} position={[wp.lat, wp.lon]} draggable + icon={waypointIcon(i)} eventHandlers={{ dragend: (e) => { const { lat, lng } = e.target.getLatLng(); @@ -215,6 +225,14 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { ))} {routeGeoJson && } + + {highlightPosition && ( + + )} ); } diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index ad4467a..f8ad6f1 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy } from "react"; +import { Suspense, lazy, useState, useCallback } from "react"; import { useYjs } from "~/lib/use-yjs"; import { useRouting } from "~/lib/use-routing"; import { ProfileSelector } from "~/components/ProfileSelector"; @@ -17,6 +17,11 @@ const ElevationChart = lazy(() => export function SessionView({ sessionId }: { sessionId: string }) { const yjs = useYjs(sessionId); const { isHost, computing, routeStats, requestRoute } = useRouting(yjs); + const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null); + + const handleElevationHover = useCallback((pos: [number, number] | null) => { + setHighlightPosition(pos); + }, []); if (!yjs) { return ( @@ -58,11 +63,11 @@ export function SessionView({ sessionId }: { sessionId: string }) { } > - + - + + ); } diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index 7d11285..a041581 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -30,9 +30,9 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) { useEffect(() => { const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints)); - yjs.waypoints.observe(update); + yjs.waypoints.observeDeep(update); update(); - return () => yjs.waypoints.unobserve(update); + return () => yjs.waypoints.unobserveDeep(update); }, [yjs.waypoints]); const deleteWaypoint = useCallback( diff --git a/apps/planner/app/components/YjsDebugPanel.tsx b/apps/planner/app/components/YjsDebugPanel.tsx new file mode 100644 index 0000000..98a7b06 --- /dev/null +++ b/apps/planner/app/components/YjsDebugPanel.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState, useCallback } from "react"; +import * as Y from "yjs"; +import * as awarenessProtocol from "y-protocols/awareness"; +import type { YjsState } from "~/lib/use-yjs"; + +// --- localStorage helpers --- + +function loadBool(key: string, fallback: boolean): boolean { + try { return localStorage.getItem(key) === "1"; } catch { return fallback; } +} + +function saveBool(key: string, value: boolean) { + try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* localStorage unavailable */ } +} + +// --- Debug state --- + +interface DebugState { + waypoints: Array>; + routeData: Record; + awareness: Array<{ clientId: number; state: Record }>; + docSize: number; +} + +function serializeYMap(yMap: Y.Map): Record { + const obj: Record = {}; + yMap.forEach((value, key) => { + if (value instanceof Y.Map) { + obj[key] = serializeYMap(value); + } else if (value instanceof Y.Array) { + obj[key] = `Y.Array(${value.length})`; + } else if (typeof value === "string" && value.length > 200) { + obj[key] = value.slice(0, 200) + `... (${value.length} chars)`; + } else { + obj[key] = value; + } + }); + return obj; +} + +function getDebugState(yjs: YjsState): DebugState { + const waypoints = yjs.waypoints.toArray().map((yMap) => serializeYMap(yMap)); + + const routeData: Record = {}; + yjs.routeData.forEach((value, key) => { + if (typeof value === "string" && value.length > 100) { + routeData[key] = `string(${value.length} chars)`; + } else { + routeData[key] = value; + } + }); + + const awareness: DebugState["awareness"] = []; + yjs.awareness.getStates().forEach((state, clientId) => { + awareness.push({ clientId, state: state as Record }); + }); + + const docSize = Y.encodeStateAsUpdate(yjs.doc).byteLength; + + return { waypoints, routeData, awareness, docSize }; +} + +// --- Styles --- + +const btnClass = + "rounded bg-gray-700 px-2 py-0.5 text-[10px] hover:bg-gray-600 active:bg-gray-500 transition-colors"; +const dangerBtnClass = + "rounded bg-red-900 px-2 py-0.5 text-[10px] text-red-200 hover:bg-red-800 active:bg-red-700 transition-colors"; + +// --- Component --- + +/** + * Yjs debug panel with: + * - Visibility: shown in dev by default, toggle with Shift+Cmd+Option+T anywhere + * - Expanded state: persisted in localStorage + * - Actions: reset session, refetch route, become host, kick users + * - State inspector: awareness, waypoints, route data, doc stats + */ +export function YjsDebugPanel({ yjs }: { yjs: YjsState }) { + const [visible, setVisible] = useState(() => loadBool("trails:debug", import.meta.env.DEV)); + const [expanded, setExpanded] = useState(() => loadBool("trails:debug:expanded", false)); + const [state, setState] = useState(null); + + // Hotkey: Shift+Cmd+Option+T + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.shiftKey && e.metaKey && e.altKey && e.code === "KeyT") { + e.preventDefault(); + setVisible((v) => { + const next = !v; + saveBool("trails:debug", next); + return next; + }); + } + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, []); + + // Sync Yjs state + useEffect(() => { + if (!visible) return; + + const update = () => setState(getDebugState(yjs)); + + yjs.waypoints.observe(update); + yjs.routeData.observe(update); + yjs.awareness.on("change", update); + update(); + + const interval = setInterval(update, 2000); + + return () => { + yjs.waypoints.unobserve(update); + yjs.routeData.unobserve(update); + yjs.awareness.off("change", update); + clearInterval(interval); + }; + }, [yjs, visible]); + + // Actions + + const resetSession = useCallback(() => { + yjs.doc.transact(() => { + while (yjs.waypoints.length > 0) { + yjs.waypoints.delete(0, 1); + } + yjs.routeData.delete("geojson"); + yjs.routeData.delete("profile"); + }); + }, [yjs]); + + const refetchRoute = useCallback(async () => { + const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ + lat: yMap.get("lat") as number, + lon: yMap.get("lon") as number, + })); + + if (waypoints.length < 2) return; + + const profile = (yjs.routeData.get("profile") as string) ?? "trekking"; + const response = await fetch("/api/route", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ waypoints, profile }), + }); + + if (response.ok) { + const geojson = await response.json(); + yjs.routeData.set("geojson", JSON.stringify(geojson)); + } + }, [yjs]); + + const kickUser = useCallback( + (clientId: number) => { + if (clientId === yjs.awareness.clientID) return; + awarenessProtocol.removeAwarenessStates(yjs.awareness, [clientId], "kicked"); + }, + [yjs], + ); + + const toggleExpanded = useCallback(() => { + setExpanded((v) => { + const next = !v; + saveBool("trails:debug:expanded", next); + return next; + }); + }, []); + + // Render + + if (!visible || !state) return null; + + return ( +
+ + + {expanded && ( +
+
+
+ + +
+
+ +
+ {state.awareness.map((a) => ( +
+
+ client:{a.clientId} + {a.clientId === yjs.awareness.clientID && ( + (you) + )} + {Object.keys(a.state).length === 0 && ( + (server) + )} + {a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && ( + + )} +
+
{JSON.stringify(a.state, null, 1)}
+
+ ))} +
+ +
+ {state.waypoints.map((wp, i) => ( +
+ [{i}] {JSON.stringify(wp)} +
+ ))} + {state.waypoints.length === 0 && empty} +
+ +
+
{JSON.stringify(state.routeData, null, 1)}
+
+ +
+
+ Size: {(state.docSize / 1024).toFixed(1)} KB +
+
+
+ )} +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} diff --git a/apps/planner/app/lib/brouter.test.ts b/apps/planner/app/lib/brouter.test.ts new file mode 100644 index 0000000..4ddcf3f --- /dev/null +++ b/apps/planner/app/lib/brouter.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; + +// 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 { + return { + type: "FeatureCollection", + features: [{ + type: "Feature", + properties: { + "track-length": String(length), + "filtered ascend": String(ascend), + "total-time": "100", + }, + geometry: { type: "LineString", coordinates: coords }, + }], + }; +} + +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 result = mergeGeoJsonSegments([seg1, seg2]); + const coords = result.features[0]!.geometry.coordinates; + + // 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]); + }); + + 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 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]]); + }); + + it("accumulates stats across segments", () => { + const seg1 = makeSegment([[1, 1], [2, 2]], 1000, 50); + const seg2 = makeSegment([[2, 2], [3, 3]], 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"); + }); + + it("handles single segment", () => { + const seg = makeSegment([[1, 1], [2, 2], [3, 3]], 500, 10); + const result = mergeGeoJsonSegments([seg]); + + expect(result.features[0]!.geometry.coordinates).toHaveLength(3); + expect(result.features[0]!.properties["track-length"]).toBe("500"); + }); + + it("handles empty segment gracefully", () => { + const seg1 = makeSegment([[1, 1], [2, 2]], 100, 0); + const empty: GeoJsonCollection = { type: "FeatureCollection", features: [] }; + + const result = mergeGeoJsonSegments([seg1, empty]); + expect(result.features[0]!.geometry.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); + + const result = mergeGeoJsonSegments([seg1, seg2]); + const coords = result.features[0]!.geometry.coordinates; + + 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]); + }); +}); + +describe("waypoint to BRouter segments", () => { + it("splits N waypoints into N-1 pairs", () => { + const waypoints = [ + { lat: 52.0, lon: 13.0 }, + { lat: 52.1, lon: 13.1 }, + { lat: 52.2, lon: 13.2 }, + { lat: 52.3, lon: 13.3 }, + ]; + + const pairs: Array<[typeof waypoints[0], typeof waypoints[0]]> = []; + for (let i = 0; i < waypoints.length - 1; i++) { + pairs.push([waypoints[i]!, waypoints[i + 1]!]); + } + + 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 d9c02d1..e709b86 100644 --- a/apps/planner/app/lib/brouter.ts +++ b/apps/planner/app/lib/brouter.ts @@ -7,29 +7,101 @@ export interface RouteRequest { format?: string; } +/** + * 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 { if (request.waypoints.length < 2) { throw new Error("At least 2 waypoints are required"); } - const lonlats = request.waypoints.map((wp) => `${wp.lon},${wp.lat}`).join("|"); + const profile = request.profile ?? "trekking"; + const format = request.format ?? "geojson"; - const params = new URLSearchParams({ - lonlats, - profile: request.profile ?? "trekking", - alternativeidx: String(request.alternativeIdx ?? 0), - format: request.format ?? "geojson", - }); + // Route each segment independently + const segments = await Promise.all( + request.waypoints.slice(0, -1).map((wp, i) => { + const next = request.waypoints[i + 1]!; + const lonlats = `${wp.lon},${wp.lat}|${next.lon},${next.lat}`; + const params = new URLSearchParams({ + lonlats, + profile, + alternativeidx: "0", + format, + }); + return fetchSegment(`${BROUTER_URL}/brouter?${params}`); + }), + ); - const url = `${BROUTER_URL}/brouter?${params}`; + // Merge GeoJSON segments into a single FeatureCollection + return mergeGeoJsonSegments(segments); +} + +async function fetchSegment(url: string): Promise> { const response = await fetch(url); - if (!response.ok) { const body = await response.text(); throw new Error(`BRouter error (${response.status}): ${body}`); } + return response.json() as Promise>; +} - return response.json(); +interface GeoJsonFeature { + type: string; + properties: Record; + geometry: { type: string; coordinates: number[][] }; +} + +interface GeoJsonCollection { + type: string; + features: GeoJsonFeature[]; +} + +function mergeGeoJsonSegments(segments: Record[]): GeoJsonCollection { + const allCoords: number[][] = []; + let totalLength = 0; + let totalAscend = 0; + let totalTime = 0; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] as unknown as GeoJsonCollection; + const feature = segment.features?.[0]; + if (!feature) continue; + + const coords = feature.geometry.coordinates; + // 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]!); + } + + // Accumulate stats + 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, + }, + }, + ], + }; } export function getBRouterUrl(): string { diff --git a/apps/planner/app/lib/host-election.test.ts b/apps/planner/app/lib/host-election.test.ts new file mode 100644 index 0000000..3467f04 --- /dev/null +++ b/apps/planner/app/lib/host-election.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { electHost } from "./host-election"; + +function makeStates( + entries: Array<{ id: number; user?: boolean; role?: string }>, +): Map> { + const map = new Map>(); + for (const e of entries) { + const state: Record = {}; + if (e.user) state.user = { color: "#000", name: "Test" }; + if (e.role) state.role = e.role; + map.set(e.id, state); + } + return map; +} + +describe("electHost", () => { + it("elects the only real client as host", () => { + const states = makeStates([ + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + }); + + it("elects lowest ID among two real clients", () => { + const states = makeStates([ + { id: 200, user: true }, + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("ignores server docs (no user state)", () => { + const states = makeStates([ + { id: 50 }, // server doc — lowest ID but no user + { id: 200, user: true }, + { id: 100, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("always assigns a role (never undefined)", () => { + const states = makeStates([ + { id: 300, user: true }, + { id: 100, user: true }, + { id: 200, user: true }, + ]); + const result = electHost(states, 200); + expect(result.role).toBeDefined(); + expect(result.role).toBe("participant"); + }); + + it("handles single client with server doc", () => { + const states = makeStates([ + { id: 999 }, // server + { id: 500, user: true }, + ]); + expect(electHost(states, 500)).toEqual({ isHost: true, role: "host" }); + }); + + it("handles no real clients (defensive)", () => { + const states = makeStates([ + { id: 10 }, // server only + ]); + // localId not in states — still returns host as fallback + expect(electHost(states, 42)).toEqual({ isHost: true, role: "host" }); + }); + + it("ignores existing role claims — always uses lowest ID", () => { + const states = makeStates([ + { id: 200, user: true, role: "host" }, + { id: 100, user: true, role: "participant" }, + ]); + // ID 100 should be host regardless of current role claims + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 200)).toEqual({ isHost: false, role: "participant" }); + }); + + it("works with many clients", () => { + const states = makeStates([ + { id: 999, user: true }, + { id: 500, user: true }, + { id: 42 }, // server + { id: 200, user: true }, + { id: 100, user: true }, + { id: 800, user: true }, + ]); + expect(electHost(states, 100)).toEqual({ isHost: true, role: "host" }); + expect(electHost(states, 500)).toEqual({ isHost: false, role: "participant" }); + expect(electHost(states, 999)).toEqual({ isHost: false, role: "participant" }); + }); +}); diff --git a/apps/planner/app/lib/host-election.ts b/apps/planner/app/lib/host-election.ts new file mode 100644 index 0000000..24b7d46 --- /dev/null +++ b/apps/planner/app/lib/host-election.ts @@ -0,0 +1,26 @@ +/** + * Determines which client should be the routing host. + * + * Simple rule: the real client (has user state) with the lowest + * client ID is always the host. Deterministic, no race conditions. + */ +export function electHost( + states: Map>, + localId: number, +): { isHost: boolean; role: "host" | "participant" } { + let lowestRealId = Infinity; + + states.forEach((state, clientId) => { + if (state.user) { + if (clientId < lowestRealId) lowestRealId = clientId; + } + }); + + // No real clients found (shouldn't happen, but defensive) + if (lowestRealId === Infinity) { + return { isHost: true, role: "host" }; + } + + const isHost = localId === lowestRealId; + return { isHost, role: isHost ? "host" : "participant" }; +} diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index fb9ba17..227c967 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import * as Y from "yjs"; import type { YjsState } from "./use-yjs"; +import { electHost } from "./host-election"; interface RouteStats { distance?: number; @@ -31,17 +32,11 @@ export function useRouting(yjs: YjsState | null) { if (!yjs) return; const checkHost = () => { - const states = yjs.awareness.getStates(); + const states = yjs.awareness.getStates() as Map>; const localId = yjs.awareness.clientID; - - let lowestId = Infinity; - states.forEach((_state, clientId) => { - if (clientId < lowestId) lowestId = clientId; - }); - - const amHost = localId === lowestId; + const { isHost: amHost, role } = electHost(states, localId); setIsHost(amHost); - yjs.awareness.setLocalStateField("role", amHost ? "host" : "participant"); + yjs.awareness.setLocalStateField("role", role); }; yjs.awareness.on("change", checkHost); diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts index 7170b87..066c7fb 100644 --- a/apps/planner/app/lib/use-yjs.ts +++ b/apps/planner/app/lib/use-yjs.ts @@ -16,6 +16,17 @@ function randomItem(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]!; } +function getOrCreateUserIdentity(): { color: string; name: string } { + try { + const stored = localStorage.getItem("trails:user"); + if (stored) return JSON.parse(stored); + } catch { /* localStorage unavailable */ } + + const identity = { color: randomItem(COLORS), name: randomItem(NAMES) }; + try { localStorage.setItem("trails:user", JSON.stringify(identity)); } catch { /* localStorage unavailable */ } + return identity; +} + export interface YjsState { doc: Y.Doc; provider: WebsocketProvider; @@ -41,8 +52,8 @@ export function useYjs(sessionId: string): YjsState | null { const waypoints = doc.getArray>("waypoints"); const routeData = doc.getMap("routeData"); - const color = randomItem(COLORS); - const name = randomItem(NAMES); + // Use persistent identity + const { color, name } = getOrCreateUserIdentity(); provider.awareness.setLocalStateField("user", { color, name }); const updateState = (connected: boolean) => { @@ -60,7 +71,6 @@ export function useYjs(sessionId: string): YjsState | null { updateState(status === "connected"); }); - // Set initial state (even before connection) updateState(false); return () => { diff --git a/apps/planner/app/lib/vite-yjs-plugin.ts b/apps/planner/app/lib/vite-yjs-plugin.ts index 219a6ef..13383ec 100644 --- a/apps/planner/app/lib/vite-yjs-plugin.ts +++ b/apps/planner/app/lib/vite-yjs-plugin.ts @@ -16,7 +16,8 @@ const messageAwareness = 1; export function yjsDevPlugin(): Plugin { const docs = new Map(); const awarenessMap = new Map(); - const conns = new Map }>(); + // Map each WebSocket to its doc name and the awareness client IDs it owns + const conns = new Map }>(); function getDoc(docName: string): { doc: Y.Doc; awareness: awarenessProtocol.Awareness } { let doc = docs.get(docName); @@ -35,7 +36,7 @@ export function yjsDevPlugin(): Plugin { const message = encoding.toUint8Array(encoder); for (const [ws, meta] of conns) { - if (meta.docName === docName) { + if (meta.docName === docName && ws.readyState === ws.OPEN) { ws.send(message); } } @@ -63,6 +64,20 @@ export function yjsDevPlugin(): Plugin { case messageAwareness: { const update = decoding.readVarUint8Array(decoder); awarenessProtocol.applyAwarenessUpdate(awareness, update, ws); + + // Track which client IDs this WebSocket is responsible for + // by checking what was added/updated in this awareness update + const meta = conns.get(ws); + if (meta) { + awareness.getStates().forEach((_state, clientId) => { + // The awareness meta stores which "origin" (ws) last updated each client + const awarenessClientMeta = awareness.meta.get(clientId); + if (awarenessClientMeta) { + // If this client ID was just updated from this connection, track it + meta.clientIds.add(clientId); + } + }); + } break; } } @@ -81,7 +96,7 @@ export function yjsDevPlugin(): Plugin { wss.handleUpgrade(request, socket, head, (ws) => { const { doc, awareness } = getDoc(docName); - conns.set(ws, { docName, awarenessIds: new Set() }); + conns.set(ws, { docName, clientIds: new Set() }); // Send sync step 1 const encoder = encoding.createEncoder(); @@ -89,7 +104,7 @@ export function yjsDevPlugin(): Plugin { syncProtocol.writeSyncStep1(encoder, doc); ws.send(encoding.toUint8Array(encoder)); - // Send awareness states + // Send current awareness states const awarenessStates = awareness.getStates(); if (awarenessStates.size > 0) { const awarenessEncoder = encoding.createEncoder(); @@ -107,8 +122,13 @@ export function yjsDevPlugin(): Plugin { ws.on("close", () => { const meta = conns.get(ws); - if (meta) { - awarenessProtocol.removeAwarenessStates(awareness, Array.from(meta.awarenessIds), null); + if (meta && meta.clientIds.size > 0) { + // Immediately remove awareness states for all clients on this connection + awarenessProtocol.removeAwarenessStates( + awareness, + Array.from(meta.clientIds), + null, + ); } conns.delete(ws); }); From 288e9c07f2c93df57b84d2f9f46bb373be89db35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 23 Mar 2026 07:30:00 +0100 Subject: [PATCH 4/4] Fix cross-window Yjs sync: broadcast doc updates to all connections The Vite Yjs plugin was only sending sync responses back to the sender, not broadcasting updates to other WebSocket connections in the same room. This caused cross-window sync to only work via BroadcastChannel (same browser context), not across separate browser windows/incognito. Fix: Add doc.on('update') listener that broadcasts updates to all connections in the room except the origin sender. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/lib/vite-yjs-plugin.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/planner/app/lib/vite-yjs-plugin.ts b/apps/planner/app/lib/vite-yjs-plugin.ts index 13383ec..db10d9c 100644 --- a/apps/planner/app/lib/vite-yjs-plugin.ts +++ b/apps/planner/app/lib/vite-yjs-plugin.ts @@ -28,6 +28,21 @@ export function yjsDevPlugin(): Plugin { awareness = new awarenessProtocol.Awareness(doc); awarenessMap.set(docName, awareness); + // Broadcast doc updates to all connections in this room + doc.on("update", (update: Uint8Array, origin: unknown) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, messageSync); + syncProtocol.writeUpdate(encoder, update); + const message = encoding.toUint8Array(encoder); + + for (const [ws, meta] of conns) { + // Don't send back to the origin connection + if (meta.docName === docName && ws !== origin && ws.readyState === ws.OPEN) { + ws.send(message); + } + } + }); + awareness.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => { const changedClients = added.concat(updated, removed); const encoder = encoding.createEncoder();