trails/apps/planner/app/lib/use-yjs.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

73 lines
1.9 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
const COLORS = [
"#ef4444", "#f97316", "#eab308", "#22c55e",
"#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
];
const NAMES = [
"Hiker", "Cyclist", "Runner", "Explorer",
"Wanderer", "Pathfinder", "Trailblazer", "Navigator",
];
function randomItem<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!;
}
export interface YjsState {
doc: Y.Doc;
provider: WebsocketProvider;
waypoints: Y.Array<Y.Map<unknown>>;
routeData: Y.Map<unknown>;
awareness: WebsocketProvider["awareness"];
connected: boolean;
}
export function useYjs(sessionId: string): YjsState | null {
const [state, setState] = useState<YjsState | null>(null);
const [connected, setConnected] = useState(false);
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
const doc = new Y.Doc();
const wsUrl = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/sync/${sessionId}`;
const provider = new WebsocketProvider(wsUrl, sessionId, doc);
const waypoints = doc.getArray<Y.Map<unknown>>("waypoints");
const routeData = doc.getMap("routeData");
// Set local awareness state
const color = randomItem(COLORS);
const name = randomItem(NAMES);
provider.awareness.setLocalStateField("user", { color, name });
provider.on("status", ({ status }: { status: string }) => {
setConnected(status === "connected");
});
setState({
doc,
provider,
waypoints,
routeData,
awareness: provider.awareness,
connected: false,
});
return () => {
provider.destroy();
doc.destroy();
};
}, [sessionId]);
if (state) {
state.connected = connected;
}
return state;
}