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>
This commit is contained in:
Ullrich Schäfer 2026-03-23 00:32:51 +01:00
parent 9eee93b27b
commit af70c822ee
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 605 additions and 22 deletions

View file

@ -0,0 +1,220 @@
import { useEffect, useState, useCallback } from "react";
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, 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 });
interface WaypointData {
lat: number;
lon: number;
name?: string;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
return waypoints.toArray().map((yMap) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
}));
}
interface PlannerMapProps {
yjs: YjsState;
onRouteRequest?: (waypoints: WaypointData[]) => void;
}
function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onAdd(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
const map = useMap();
const [cursors, setCursors] = useState<Map<number, { lat: number; lng: number; color: string; name: string }>>(new Map());
useEffect(() => {
const localId = awareness.clientID;
const handleMouseMove = (e: L.LeafletMouseEvent) => {
awareness.setLocalStateField("cursor", {
lat: e.latlng.lat,
lng: e.latlng.lng,
});
};
const handleMouseOut = () => {
awareness.setLocalStateField("cursor", null);
};
map.on("mousemove", handleMouseMove);
map.on("mouseout", handleMouseOut);
const updateCursors = () => {
const states = awareness.getStates();
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
states.forEach((state, clientId) => {
if (clientId !== localId && state.cursor && state.user) {
newCursors.set(clientId, {
lat: state.cursor.lat,
lng: state.cursor.lng,
color: state.user.color,
name: state.user.name,
});
}
});
setCursors(newCursors);
};
awareness.on("change", updateCursors);
return () => {
map.off("mousemove", handleMouseMove);
map.off("mouseout", handleMouseOut);
awareness.off("change", updateCursors);
};
}, [map, awareness]);
return (
<>
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
<Marker
key={clientId}
position={[cursor.lat, cursor.lng]}
icon={L.divIcon({
className: "cursor-marker",
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],
})}
/>
))}
</>
);
}
export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
// Sync waypoints from Yjs
useEffect(() => {
const update = () => {
const wps = getWaypointsFromYjs(yjs.waypoints);
setWaypoints(wps);
if (wps.length >= 2 && onRouteRequest) {
onRouteRequest(wps);
}
};
yjs.waypoints.observe(update);
update();
return () => {
yjs.waypoints.unobserve(update);
};
}, [yjs.waypoints, onRouteRequest]);
// Sync route data from Yjs
useEffect(() => {
const update = () => {
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
try {
const parsed = JSON.parse(geojson);
const coords = parsed.features?.[0]?.geometry?.coordinates;
if (coords) {
setRouteGeoJson(coords.map((c: number[]) => [c[1], c[0]] as L.LatLngExpression));
}
} catch {
// Invalid GeoJSON
}
} else {
setRouteGeoJson(null);
}
};
yjs.routeData.observe(update);
update();
return () => {
yjs.routeData.unobserve(update);
};
}, [yjs.routeData]);
const addWaypoint = useCallback(
(lat: number, lng: number) => {
const yMap = new Y.Map();
yMap.set("lat", lat);
yMap.set("lon", lng);
yjs.waypoints.push([yMap]);
},
[yjs.waypoints],
);
const moveWaypoint = useCallback(
(index: number, lat: number, lng: number) => {
const yMap = yjs.waypoints.get(index);
if (yMap) {
yjs.doc.transact(() => {
yMap.set("lat", lat);
yMap.set("lon", lng);
});
}
},
[yjs.waypoints, yjs.doc],
);
const deleteWaypoint = useCallback(
(index: number) => {
yjs.waypoints.delete(index, 1);
},
[yjs.waypoints],
);
return (
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
<LayersControl position="topright">
{baseLayers.map((layer, i) => (
<LayersControl.BaseLayer key={layer.name} checked={i === 0} name={layer.name}>
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
</LayersControl.BaseLayer>
))}
</LayersControl>
<MapClickHandler onAdd={addWaypoint} />
<CursorTracker awareness={yjs.awareness} />
{waypoints.map((wp, i) => (
<Marker
key={i}
position={[wp.lat, wp.lon]}
draggable
eventHandlers={{
dragend: (e) => {
const { lat, lng } = e.target.getLatLng();
moveWaypoint(i, lat, lng);
},
contextmenu: (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
deleteWaypoint(i);
},
}}
/>
))}
{routeGeoJson && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
</MapContainer>
);
}

View file

@ -0,0 +1,149 @@
import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
interface WaypointData {
lat: number;
lon: number;
name?: string;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
return waypoints.toArray().map((yMap) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
}));
}
interface WaypointSidebarProps {
yjs: YjsState;
routeStats?: {
distance?: number;
elevationGain?: number;
elevationLoss?: number;
};
}
export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
useEffect(() => {
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints));
yjs.waypoints.observe(update);
update();
return () => yjs.waypoints.unobserve(update);
}, [yjs.waypoints]);
const deleteWaypoint = useCallback(
(index: number) => yjs.waypoints.delete(index, 1),
[yjs.waypoints],
);
const moveWaypoint = useCallback(
(from: number, to: number) => {
if (from === to || from < 0 || to < 0) return;
const item = yjs.waypoints.get(from);
if (!item) return;
const data = { lat: item.get("lat") as number, lon: item.get("lon") as number, name: item.get("name") as string | undefined };
yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1);
const yMap = new Y.Map();
yMap.set("lat", data.lat);
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
yjs.waypoints.insert(to, [yMap]);
});
},
[yjs.waypoints, yjs.doc],
);
return (
<div className="flex h-full flex-col">
<div className="border-b border-gray-200 px-4 py-3">
<h2 className="text-sm font-medium text-gray-900">
Waypoints ({waypoints.length})
</h2>
</div>
<div className="flex-1 overflow-y-auto">
{waypoints.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-gray-500">
Click on the map to add waypoints
</p>
) : (
<ul className="divide-y divide-gray-100">
{waypoints.map((wp, i) => (
<li key={i} className="group flex items-center gap-2 px-4 py-2 hover:bg-gray-50">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-700">
{i + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-700">
{wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`}
</p>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
{i > 0 && (
<button
onClick={() => moveWaypoint(i, i - 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move up"
>
</button>
)}
{i < waypoints.length - 1 && (
<button
onClick={() => moveWaypoint(i, i + 1)}
className="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="Move down"
>
</button>
)}
<button
onClick={() => deleteWaypoint(i)}
className="rounded p-1 text-gray-400 hover:bg-red-100 hover:text-red-600"
title="Delete"
>
×
</button>
</div>
</li>
))}
</ul>
)}
</div>
{routeStats && routeStats.distance !== undefined && (
<div className="border-t border-gray-200 px-4 py-3">
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<p className="font-medium text-gray-900">
{(routeStats.distance / 1000).toFixed(1)} km
</p>
<p className="text-gray-500">Distance</p>
</div>
{routeStats.elevationGain !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationGain} m
</p>
<p className="text-gray-500">Ascent</p>
</div>
)}
{routeStats.elevationLoss !== undefined && (
<div>
<p className="font-medium text-gray-900">
{routeStats.elevationLoss} m
</p>
<p className="text-gray-500">Descent</p>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,100 @@
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 };
}

View file

@ -0,0 +1,73 @@
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;
}

View file

@ -2,6 +2,16 @@ import { useParams } from "react-router";
import type { Route } from "./+types/session.$id";
import { getSession } from "~/lib/sessions";
import { data } from "react-router";
import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { lazy, Suspense } from "react";
const PlannerMap = lazy(() =>
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
);
const WaypointSidebar = lazy(() =>
import("~/components/WaypointSidebar").then((m) => ({ default: m.WaypointSidebar })),
);
export function meta(_args: Route.MetaArgs) {
return [{ title: "trails.cool Planner — Session" }];
@ -20,24 +30,51 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function SessionPage() {
const { id } = useParams();
const yjs = useYjs(id!);
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
if (!yjs) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-gray-500">Connecting...</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
<span className="text-sm text-gray-500">Session: {id?.slice(0, 8)}...</span>
<div className="flex items-center gap-3">
{computing && (
<span className="text-xs text-blue-600">Computing route...</span>
)}
{isHost && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Host
</span>
)}
<span className="text-sm text-gray-500">
{yjs.connected ? "Connected" : "Connecting..."} · {id?.slice(0, 8)}
</span>
</div>
</header>
<div className="flex flex-1">
<main className="flex-1 bg-gray-100">
<div className="flex h-full items-center justify-center text-gray-500">
Map will be rendered here (task 6.1)
</div>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1">
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
Loading map...
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
</Suspense>
</main>
<aside className="w-80 border-l border-gray-200 bg-white p-4">
<h2 className="text-sm font-medium text-gray-700">Waypoints</h2>
<p className="mt-2 text-sm text-gray-500">
Waypoint list will be rendered here (task 6.5)
</p>
<aside className="w-72 border-l border-gray-200 bg-white">
<Suspense fallback={null}>
<WaypointSidebar yjs={yjs} routeStats={routeStats} />
</Suspense>
</aside>
</div>
</div>