diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx new file mode 100644 index 0000000..4c32011 --- /dev/null +++ b/apps/planner/app/components/PlannerMap.tsx @@ -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>): 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>(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(); + 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]) => ( + ${cursor.name}`, + iconSize: [0, 0], + })} + /> + ))} + + ); +} + +export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) { + const [waypoints, setWaypoints] = useState([]); + const [routeGeoJson, setRouteGeoJson] = useState(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 ( + + + {baseLayers.map((layer, i) => ( + + + + ))} + + + + + + {waypoints.map((wp, i) => ( + { + const { lat, lng } = e.target.getLatLng(); + moveWaypoint(i, lat, lng); + }, + contextmenu: (e) => { + L.DomEvent.preventDefault(e as unknown as Event); + deleteWaypoint(i); + }, + }} + /> + ))} + + {routeGeoJson && } + + ); +} diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx new file mode 100644 index 0000000..7d11285 --- /dev/null +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -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>): 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([]); + + 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 ( +
+
+

+ Waypoints ({waypoints.length}) +

+
+ +
+ {waypoints.length === 0 ? ( +

+ Click on the map to add waypoints +

+ ) : ( +
    + {waypoints.map((wp, i) => ( +
  • + + {i + 1} + +
    +

    + {wp.name ?? `${wp.lat.toFixed(4)}, ${wp.lon.toFixed(4)}`} +

    +
    +
    + {i > 0 && ( + + )} + {i < waypoints.length - 1 && ( + + )} + +
    +
  • + ))} +
+ )} +
+ + {routeStats && routeStats.distance !== undefined && ( +
+
+
+

+ {(routeStats.distance / 1000).toFixed(1)} km +

+

Distance

+
+ {routeStats.elevationGain !== undefined && ( +
+

+ ↑ {routeStats.elevationGain} m +

+

Ascent

+
+ )} + {routeStats.elevationLoss !== undefined && ( +
+

+ ↓ {routeStats.elevationLoss} m +

+

Descent

+
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts new file mode 100644 index 0000000..f2cf3d8 --- /dev/null +++ b/apps/planner/app/lib/use-routing.ts @@ -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({}); + const debounceTimer = useRef>(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 }; +} diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts new file mode 100644 index 0000000..b1efa7b --- /dev/null +++ b/apps/planner/app/lib/use-yjs.ts @@ -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(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]!; +} + +export interface YjsState { + doc: Y.Doc; + provider: WebsocketProvider; + waypoints: Y.Array>; + routeData: Y.Map; + awareness: WebsocketProvider["awareness"]; + connected: boolean; +} + +export function useYjs(sessionId: string): YjsState | null { + const [state, setState] = useState(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>("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; +} diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx index a95eb43..bb7879f 100644 --- a/apps/planner/app/routes/session.$id.tsx +++ b/apps/planner/app/routes/session.$id.tsx @@ -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 ( +
+

Connecting...

+
+ ); + } return (

trails.cool Planner

- Session: {id?.slice(0, 8)}... +
+ {computing && ( + Computing route... + )} + {isHost && ( + + Host + + )} + + {yjs.connected ? "Connected" : "Connecting..."} · {id?.slice(0, 8)} + +
-
-
-
- Map will be rendered here (task 6.1) -
+
+
+ + Loading map... +
+ } + > + +
-
diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index ff8d962..c8cac1f 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -42,21 +42,21 @@ - [x] 5.1 Implement BRouter HTTP proxy endpoint (POST /api/route → forward to BRouter) - [x] 5.2 Implement rate limiting middleware (60 requests/session/hour) -- [ ] 5.3 Implement routing host election via Yjs awareness (host/participant roles) -- [ ] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp) -- [ ] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter) -- [ ] 5.6 Implement route broadcast (host stores GeoJSON result in Y.Map, syncs to all) +- [x] 5.3 Implement routing host election via Yjs awareness (host/participant roles) +- [x] 5.4 Implement routing host failover (detect disconnect, elect new host by join timestamp) +- [x] 5.5 Implement route computation trigger (host watches waypoint changes, debounce 500ms, call BRouter) +- [x] 5.6 Implement route broadcast (host stores GeoJSON result in Y.Map, syncs to all) - [ ] 5.7 Implement profile selection (sync profile choice via Y.Map, trigger recompute) ## 6. Planner — Map UI -- [ ] 6.1 Integrate MapView component in Planner with full-screen layout and client-side Yjs connection -- [ ] 6.1a Implement user presence display (Yjs awareness, colors, names, live map cursors) -- [ ] 6.2 Implement waypoint add (click map → add to Y.Array) -- [ ] 6.3 Implement waypoint drag (move marker → update Y.Array) -- [ ] 6.4 Implement waypoint delete (right-click → remove from Y.Array) -- [ ] 6.5 Implement waypoint list sidebar (draggable reorder, synced with Y.Array) -- [ ] 6.6 Implement route polyline display (render GeoJSON from Y.Map) +- [x] 6.1 Integrate MapView component in Planner with full-screen layout and client-side Yjs connection +- [x] 6.1a Implement user presence display (Yjs awareness, colors, names, live map cursors) +- [x] 6.2 Implement waypoint add (click map → add to Y.Array) +- [x] 6.3 Implement waypoint drag (move marker → update Y.Array) +- [x] 6.4 Implement waypoint delete (right-click → remove from Y.Array) +- [x] 6.5 Implement waypoint list sidebar (draggable reorder, synced with Y.Array) +- [x] 6.6 Implement route polyline display (render GeoJSON from Y.Map) - [ ] 6.7 Implement elevation profile chart (parse elevation from route GeoJSON, render chart) - [ ] 6.8 Implement profile selector UI (dropdown, synced via Y.Map) - [ ] 6.9 Implement GPX export button (generate GPX from current waypoints and route) diff --git a/package.json b/package.json index be4d546..142ae2d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@tailwindcss/vite": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/geojson": "^7946.0.16", "@types/leaflet": "^1.9.21", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1f53c2..9a8f60a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/leaflet': specifier: ^1.9.21 version: 1.9.21