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..b70e16d 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); @@ -118,11 +127,11 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { } }; - yjs.waypoints.observe(update); + yjs.waypoints.observeDeep(update); update(); return () => { - yjs.waypoints.unobserve(update); + yjs.waypoints.unobserveDeep(update); }; }, [yjs.waypoints, onRouteRequest]); @@ -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..17bee15 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -1,8 +1,9 @@ -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"; import { ExportButton } from "~/components/ExportButton"; +import { YjsDebugPanel } from "~/components/YjsDebugPanel"; const PlannerMap = lazy(() => import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })), @@ -17,6 +18,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 ( @@ -50,7 +56,14 @@ export function SessionView({ sessionId }: { sessionId: string }) {
-
+
+ {computing && ( +
+
+
+
+
+ )} @@ -58,11 +71,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..db10d9c 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); @@ -27,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(); @@ -35,7 +51,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 +79,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 +111,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 +119,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 +137,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); }); diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index f1d8c73..2fa790e 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1 +1,6 @@ @import "tailwindcss"; + +@keyframes slide { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +}