trails/apps/planner/app/lib/use-routing.ts
Ullrich Schäfer af70c822ee
Implement Planner map UI with collaborative editing (Groups 5+6)
Core Planner experience:
- Full-screen Leaflet map with OSM/OpenTopoMap/CyclOSM layers
- Click to add waypoints, drag to move, right-click to delete
- Waypoint sidebar with reorder, delete, route stats
- Real-time Yjs sync between participants
- Live cursors showing other users' mouse positions on the map

Routing:
- Host election via Yjs awareness (lowest client ID)
- Auto-failover when host disconnects
- Debounced BRouter route computation (500ms)
- Route polyline display from BRouter GeoJSON
- Route stats (distance, elevation gain/loss)

Client-side architecture:
- useYjs hook: WebSocket connection, awareness, document management
- useRouting hook: host election, route computation, stats parsing
- PlannerMap component: Leaflet + Yjs integration
- WaypointSidebar component: list with reorder/delete
- Lazy-loaded components for code splitting

Remaining: elevation chart, profile selector, GPX export (tasks 6.7-6.9)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:32:51 +01:00

100 lines
2.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { YjsState } from "./use-yjs";
interface RouteStats {
distance?: number;
elevationGain?: number;
elevationLoss?: number;
}
interface WaypointData {
lat: number;
lon: number;
}
export function useRouting(yjs: YjsState | null) {
const [isHost, setIsHost] = useState(false);
const [computing, setComputing] = useState(false);
const [routeStats, setRouteStats] = useState<RouteStats>({});
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Host election via Yjs awareness
useEffect(() => {
if (!yjs) return;
const checkHost = () => {
const states = yjs.awareness.getStates();
const localId = yjs.awareness.clientID;
// Find the client with the lowest ID (longest connected approximation)
let lowestId = Infinity;
states.forEach((_state, clientId) => {
if (clientId < lowestId) lowestId = clientId;
});
const amHost = localId === lowestId;
setIsHost(amHost);
yjs.awareness.setLocalStateField("role", amHost ? "host" : "participant");
};
yjs.awareness.on("change", checkHost);
checkHost();
return () => {
yjs.awareness.off("change", checkHost);
};
}, [yjs]);
const computeRoute = useCallback(
async (waypoints: WaypointData[]) => {
if (!yjs || !isHost || waypoints.length < 2) return;
setComputing(true);
try {
const response = await fetch("/api/route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
waypoints,
profile: (yjs.routeData.get("profile") as string) ?? "trekking",
}),
});
if (!response.ok) return;
const geojson = await response.json();
// Extract stats from BRouter response
const props = geojson.features?.[0]?.properties;
if (props) {
setRouteStats({
distance: props["track-length"] ? parseInt(props["track-length"]) : undefined,
elevationGain: props["filtered ascend"] ? parseInt(props["filtered ascend"]) : undefined,
elevationLoss: props["plain-ascend"]
? Math.abs(parseInt(props["plain-ascend"]))
: undefined,
});
}
// Store route in Yjs for all participants
yjs.routeData.set("geojson", JSON.stringify(geojson));
} catch {
// Route computation failed — don't crash
} finally {
setComputing(false);
}
},
[yjs, isHost],
);
const requestRoute = useCallback(
(waypoints: WaypointData[]) => {
if (!isHost) return;
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => computeRoute(waypoints), 500);
},
[isHost, computeRoute],
);
return { isHost, computing, routeStats, requestRoute };
}