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:
parent
9eee93b27b
commit
af70c822ee
8 changed files with 605 additions and 22 deletions
220
apps/planner/app/components/PlannerMap.tsx
Normal file
220
apps/planner/app/components/PlannerMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
apps/planner/app/components/WaypointSidebar.tsx
Normal file
149
apps/planner/app/components/WaypointSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue