Merge pull request #23 from trails-cool/planner-ui-fixes

Fix waypoint icons, add elevation chart scrubbing
This commit is contained in:
Ullrich Schäfer 2026-03-23 12:03:54 +01:00 committed by GitHub
commit afb413b026
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 899 additions and 106 deletions

View file

@ -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<ElevationPoint[]>([]);
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pointsRef = useRef<ElevationPoint[]>([]);
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<HTMLCanvasElement>) => {
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 (
<div className="border-t border-gray-200 px-2 py-2">
<p className="mb-1 px-2 text-xs font-medium text-gray-500">Elevation Profile</p>
<canvas ref={canvasRef} className="h-24 w-full" />
<canvas
ref={canvasRef}
className="h-24 w-full cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
</div>
);
}

View file

@ -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: `<div style="
width:24px;height:24px;border-radius:50%;
background:#2563eb;color:white;
display:flex;align-items:center;justify-content:center;
font-size:12px;font-weight:600;
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-12px,-12px);
">${index + 1}</div>`,
iconSize: [0, 0],
});
}
interface WaypointData {
lat: number;
@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): 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: `<div style="background:${cursor.color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;transform:translate(10px,-50%)">${cursor.name}</div>`,
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<WaypointData[]>([]);
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(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 && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
{highlightPosition && (
<CircleMarker
center={highlightPosition}
radius={6}
pathOptions={{ color: "#ef4444", fillColor: "#ef4444", fillOpacity: 1, weight: 2 }}
/>
)}
</MapContainer>
);
}

View file

@ -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 }) {
</header>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1 flex flex-col">
<div className="flex-1">
<div className="relative flex-1">
{computing && (
<div className="absolute inset-x-0 top-0 z-[1000]">
<div className="h-1 w-full overflow-hidden bg-blue-100">
<div className="h-full w-1/3 animate-[slide_1s_ease-in-out_infinite] bg-blue-500" />
</div>
</div>
)}
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
@ -58,11 +71,11 @@ export function SessionView({ sessionId }: { sessionId: string }) {
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
</Suspense>
</div>
<Suspense fallback={null}>
<ElevationChart yjs={yjs} />
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
</Suspense>
</main>
<aside className="w-72 border-l border-gray-200 bg-white">
@ -71,6 +84,7 @@ export function SessionView({ sessionId }: { sessionId: string }) {
</Suspense>
</aside>
</div>
<YjsDebugPanel yjs={yjs} />
</>
);
}

View file

@ -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(

View file

@ -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<Record<string, unknown>>;
routeData: Record<string, unknown>;
awareness: Array<{ clientId: number; state: Record<string, unknown> }>;
docSize: number;
}
function serializeYMap(yMap: Y.Map<unknown>): Record<string, unknown> {
const obj: Record<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> });
});
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<DebugState | null>(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<unknown>) => ({
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 (
<div className="fixed bottom-0 right-0 z-[2000] max-h-[50vh] w-96 overflow-auto rounded-tl-lg border-l border-t border-gray-300 bg-gray-900 text-xs text-gray-100 shadow-lg">
<button
onClick={toggleExpanded}
className="sticky top-0 flex w-full items-center justify-between bg-gray-800 px-3 py-1.5 font-mono text-xs hover:bg-gray-700"
>
<span>Yjs Debug</span>
<span className="text-gray-400">
{state.waypoints.length} wps · {state.awareness.length} users · {(state.docSize / 1024).toFixed(1)}KB
</span>
</button>
{expanded && (
<div className="space-y-3 p-3 font-mono">
<Section title="Actions">
<div className="flex flex-wrap gap-1.5">
<button onClick={refetchRoute} className={btnClass}>
Refetch route
</button>
<button onClick={resetSession} className={dangerBtnClass}>
Reset session
</button>
</div>
</Section>
<Section title="Awareness">
{state.awareness.map((a) => (
<div key={a.clientId} className="mb-2">
<div className="flex items-center gap-2">
<span className="text-yellow-400">client:{a.clientId}</span>
{a.clientId === yjs.awareness.clientID && (
<span className="text-green-400">(you)</span>
)}
{Object.keys(a.state).length === 0 && (
<span className="text-gray-500">(server)</span>
)}
{a.clientId !== yjs.awareness.clientID && Object.keys(a.state).length > 0 && (
<button
onClick={() => kickUser(a.clientId)}
className={dangerBtnClass}
>
kick
</button>
)}
</div>
<pre className="ml-2 text-gray-400">{JSON.stringify(a.state, null, 1)}</pre>
</div>
))}
</Section>
<Section title={`Waypoints (${state.waypoints.length})`}>
{state.waypoints.map((wp, i) => (
<div key={i} className="text-gray-300">
[{i}] {JSON.stringify(wp)}
</div>
))}
{state.waypoints.length === 0 && <span className="text-gray-500">empty</span>}
</Section>
<Section title="Route Data">
<pre className="text-gray-300">{JSON.stringify(state.routeData, null, 1)}</pre>
</Section>
<Section title="Doc Stats">
<div className="text-gray-300">
Size: {(state.docSize / 1024).toFixed(1)} KB
</div>
</Section>
</div>
)}
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="mb-1 text-blue-400">{title}</div>
<div className="ml-1">{children}</div>
</div>
);
}

View file

@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";
// Test the mergeGeoJsonSegments logic extracted from brouter.ts
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
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");
});
});

View file

@ -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<unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>>;
}
return response.json();
interface GeoJsonFeature {
type: string;
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][] };
}
interface GeoJsonCollection {
type: string;
features: GeoJsonFeature[];
}
function mergeGeoJsonSegments(segments: Record<string, unknown>[]): 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 {

View file

@ -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<number, Record<string, unknown>> {
const map = new Map<number, Record<string, unknown>>();
for (const e of entries) {
const state: Record<string, unknown> = {};
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" });
});
});

View file

@ -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<number, Record<string, unknown>>,
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" };
}

View file

@ -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<number, Record<string, unknown>>;
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);

View file

@ -16,6 +16,17 @@ function randomItem<T>(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<Y.Map<unknown>>("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 () => {

View file

@ -16,7 +16,8 @@ const messageAwareness = 1;
export function yjsDevPlugin(): Plugin {
const docs = new Map<string, Y.Doc>();
const awarenessMap = new Map<string, awarenessProtocol.Awareness>();
const conns = new Map<WebSocket, { docName: string; awarenessIds: Set<number> }>();
// Map each WebSocket to its doc name and the awareness client IDs it owns
const conns = new Map<WebSocket, { docName: string; clientIds: Set<number> }>();
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);
});

View file

@ -1 +1,6 @@
@import "tailwindcss";
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}