diff --git a/apps/planner/app/components/ColoredRoute.tsx b/apps/planner/app/components/ColoredRoute.tsx index f0379d7..b78c5c3 100644 --- a/apps/planner/app/components/ColoredRoute.tsx +++ b/apps/planner/app/components/ColoredRoute.tsx @@ -11,7 +11,11 @@ import { elevationColor, routeGradeColor, maxspeedColor, } from "@trails-cool/map-core"; -export type ColorMode = "plain" | "elevation" | "surface" | "grade" | "highway" | "maxspeed" | "smoothness" | "tracktype" | "cycleway" | "bikeroute"; +import type { ColorMode } from "~/lib/route-data"; + +// ColorMode lives in the routeData schema module; re-exported here for +// existing importers. +export type { ColorMode }; interface ColoredRouteProps { coordinates: [number, number, number][]; // [lon, lat, ele] diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx index 392f8cb..a4b5ada 100644 --- a/apps/planner/app/components/ElevationChart.tsx +++ b/apps/planner/app/components/ElevationChart.tsx @@ -14,6 +14,7 @@ import { } from "@trails-cool/map-core"; import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw"; import { useElevationData } from "~/lib/use-elevation-data"; +import { setColorMode, type ColorMode } from "~/lib/route-data"; interface ElevationChartProps { yjs: YjsState; @@ -371,7 +372,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio ); const setMode = useCallback((mode: string) => { - yjs.routeData.set("colorMode", mode); + setColorMode(yjs.routeData, mode as ColorMode); }, [yjs.routeData]); if (points.length < 2) return null; diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx index 31b99c2..87ceabc 100644 --- a/apps/planner/app/components/ExportButton.tsx +++ b/apps/planner/app/components/ExportButton.tsx @@ -1,33 +1,12 @@ import { useCallback, useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import { generateGpx, computeDays } from "@trails-cool/gpx"; -import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; -import { waypointFromYMap } from "~/lib/waypoint-ymap"; - -function getTracks(yjs: YjsState): TrackPoint[][] { - const geojsonStr = yjs.routeData.get("geojson") as string | undefined; - if (!geojsonStr) return []; - try { - const geojson = JSON.parse(geojsonStr); - const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; - if (coords.length > 0) { - return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; - } - } catch { /* invalid geojson */ } - return []; -} - -function getWaypoints(yjs: YjsState) { - return yjs.waypoints.toArray().map(waypointFromYMap); -} - -function getNoGoAreas(yjs: YjsState): NoGoArea[] { - return yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ - points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], - })).filter((a) => a.points.length >= 3); -} +import { + buildDayGpxFiles, + buildPlanGpx, + buildRouteGpx, + hasDayBreaks, +} from "~/lib/gpx-export"; function download(gpx: string, filename: string) { const blob = new Blob([gpx], { type: "application/gpx+xml" }); @@ -55,68 +34,23 @@ export function ExportButton({ yjs }: { yjs: YjsState }) { }, [open]); const handleExportRoute = useCallback(() => { - const tracks = getTracks(yjs); - const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks }); - download(gpx, "route.gpx"); + download(buildRouteGpx(yjs), "route.gpx"); setOpen(false); }, [yjs]); const handleExportPlan = useCallback(() => { - const tracks = getTracks(yjs); - const waypoints = getWaypoints(yjs); - const noGoAreas = getNoGoAreas(yjs); - const notes = yjs.notes.toString() || undefined; - const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); - download(gpx, "route-plan.gpx"); + download(buildPlanGpx(yjs), "route-plan.gpx"); setOpen(false); }, [yjs]); const handleExportDays = useCallback(() => { - const tracks = getTracks(yjs); - const waypoints = getWaypoints(yjs); - const allPoints = tracks.flat(); - if (allPoints.length === 0 || waypoints.length === 0) return; - - const days = computeDays(waypoints, tracks); - if (days.length <= 1) { - // Single day — just export the full route - const gpx = generateGpx({ name: "trails.cool route", tracks }); - download(gpx, "route.gpx"); - setOpen(false); - return; - } - - // Find closest track index for each waypoint - const wpTrackIndices = waypoints.map((wp) => { - let bestIdx = 0; - let bestDist = Infinity; - for (let i = 0; i < allPoints.length; i++) { - const dx = allPoints[i]!.lat - wp.lat; - const dy = allPoints[i]!.lon - wp.lon; - const d = dx * dx + dy * dy; - if (d < bestDist) { bestDist = d; bestIdx = i; } - } - return bestIdx; - }); - - for (const day of days) { - const startIdx = wpTrackIndices[day.startWaypointIndex]!; - const endIdx = wpTrackIndices[day.endWaypointIndex]!; - const dayPoints = allPoints.slice(startIdx, endIdx + 1); - const dayName = day.startName && day.endName - ? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}` - : `Day ${day.dayNumber}`; - const gpx = generateGpx({ name: dayName, tracks: [dayPoints] }); - const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`; - download(gpx, filename); + for (const file of buildDayGpxFiles(yjs)) { + download(file.gpx, file.filename); } setOpen(false); }, [yjs]); - const hasMultipleDays = (() => { - const waypoints = getWaypoints(yjs); - return waypoints.some((w) => w.isDayBreak); - })(); + const hasMultipleDays = hasDayBreaks(yjs); return (
diff --git a/apps/planner/app/components/MapHelpers.tsx b/apps/planner/app/components/MapHelpers.tsx index e75ba34..2e4b184 100644 --- a/apps/planner/app/components/MapHelpers.tsx +++ b/apps/planner/app/components/MapHelpers.tsx @@ -5,6 +5,7 @@ import type { YjsState } from "~/lib/use-yjs"; import { overlayLayers } from "@trails-cool/map"; import { usePois } from "~/lib/use-pois"; import { Z_CURSOR } from "@trails-cool/map-core"; +import { getBaseLayer, getOverlays, setBaseLayer, setOverlays } from "~/lib/route-data"; // Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations. export function MapExposer() { @@ -188,11 +189,10 @@ export function OverlaySync({ if (suppressRef.current) return; const layer = overlayLayers.find((l) => l.name === e.name); if (!layer) return; - const raw = yjs.routeData.get("overlays") as string | undefined; - const current: string[] = raw ? JSON.parse(raw) : []; + const current = getOverlays(yjs.routeData) ?? []; if (!current.includes(layer.id)) { const updated = [...current, layer.id]; - yjs.routeData.set("overlays", JSON.stringify(updated)); + setOverlays(yjs.routeData, updated); onOverlayChange(updated); } }; @@ -201,16 +201,14 @@ export function OverlaySync({ if (suppressRef.current) return; const layer = overlayLayers.find((l) => l.name === e.name); if (!layer) return; - const raw = yjs.routeData.get("overlays") as string | undefined; - const current: string[] = raw ? JSON.parse(raw) : []; - const updated = current.filter((id) => id !== layer.id); - yjs.routeData.set("overlays", JSON.stringify(updated)); + const updated = (getOverlays(yjs.routeData) ?? []).filter((id) => id !== layer.id); + setOverlays(yjs.routeData, updated); onOverlayChange(updated); }; const handleBaseChange = (e: L.LayersControlEvent) => { if (suppressRef.current) return; - yjs.routeData.set("baseLayer", e.name); + setBaseLayer(yjs.routeData, e.name); }; map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); @@ -225,11 +223,9 @@ export function OverlaySync({ useEffect(() => { const handleChange = () => { - const raw = yjs.routeData.get("overlays") as string | undefined; - if (raw) { - try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ } - } - const base = yjs.routeData.get("baseLayer") as string | undefined; + const overlays = getOverlays(yjs.routeData); + if (overlays) onOverlayChange(overlays); + const base = getBaseLayer(yjs.routeData); if (base) onBaseLayerChange(base); }; yjs.routeData.observe(handleChange); diff --git a/apps/planner/app/components/ProfileSelector.tsx b/apps/planner/app/components/ProfileSelector.tsx index 1c6c5b2..3ee079a 100644 --- a/apps/planner/app/components/ProfileSelector.tsx +++ b/apps/planner/app/components/ProfileSelector.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; +import { DEFAULT_PROFILE, getProfile, setProfile as setYjsProfile } from "~/lib/route-data"; const PROFILE_IDS = ["fastbike", "safety", "shortest", "car", "trekking"] as const; @@ -10,11 +11,11 @@ interface ProfileSelectorProps { export function ProfileSelector({ yjs }: ProfileSelectorProps) { const { t } = useTranslation("planner"); - const [profile, setProfile] = useState("fastbike"); + const [profile, setProfile] = useState(DEFAULT_PROFILE); useEffect(() => { const update = () => { - const p = yjs.routeData.get("profile") as string | undefined; + const p = getProfile(yjs.routeData); if (p) setProfile(p); }; yjs.routeData.observe(update); @@ -26,7 +27,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) { (e: React.ChangeEvent) => { const value = e.target.value; setProfile(value); - yjs.routeData.set("profile", value); + setYjsProfile(yjs.routeData, value); }, [yjs.routeData], ); diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx index 63ad849..056d4bc 100644 --- a/apps/planner/app/components/SaveToJournalButton.tsx +++ b/apps/planner/app/components/SaveToJournalButton.tsx @@ -1,10 +1,7 @@ import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import { generateGpx } from "@trails-cool/gpx"; -import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; -import { waypointFromYMap } from "~/lib/waypoint-ymap"; +import { buildPlanGpx } from "~/lib/gpx-export"; interface SaveToJournalButtonProps { yjs: YjsState; @@ -23,28 +20,9 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal setError(null); try { - // Build GPX from computed track with planning data (no-go areas) - // so the route round-trips correctly through the journal. - let tracks: TrackPoint[][] = []; - const geojsonStr = yjs.routeData.get("geojson") as string | undefined; - if (geojsonStr) { - try { - const geojson = JSON.parse(geojsonStr); - const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? []; - if (coords.length > 0) { - tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))]; - } - } catch { /* invalid geojson */ } - } - - const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap: Y.Map) => ({ - points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], - })).filter((a) => a.points.length >= 3); - - const waypoints = yjs.waypoints.toArray().map(waypointFromYMap); - - const notes = yjs.notes.toString() || undefined; - const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas }); + // Full plan GPX (track + waypoints + no-go areas + notes) so the + // route round-trips correctly through the journal. + const gpx = buildPlanGpx(yjs); // POST to the planner's server-side proxy. The proxy attaches the // journal Bearer token (stored on the session row) and forwards diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 7a9e170..38650d4 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -6,6 +6,7 @@ import type { TFunction } from "i18next"; import * as Sentry from "@sentry/react"; import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useRouting, type RouteError } from "~/lib/use-routing"; +import { getCoordinates } from "~/lib/route-data"; import { useDays } from "~/lib/use-days"; import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { ProfileSelector } from "~/components/ProfileSelector"; @@ -208,14 +209,10 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW const handleResetZoom = useCallback(() => { const map = window.__leafletMap; if (!map || !yjs) return; - const coordsJson = yjs.routeData.get("coordinates") as string | undefined; - if (!coordsJson) return; - try { - const coords: [number, number, number][] = JSON.parse(coordsJson); - if (coords.length < 2) return; - const latLngs = coords.map((c) => [c[1]!, c[0]!] as [number, number]); - map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] }); - } catch { /* ignore */ } + const coords = getCoordinates(yjs.routeData); + if (!coords || coords.length < 2) return; + const latLngs = coords.map((c) => [c[1], c[0]] as [number, number]); + map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] }); setIsZoomedByChart(false); }, [yjs]); diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx index baaf992..0c9c862 100644 --- a/apps/planner/app/components/WaypointSidebar.tsx +++ b/apps/planner/app/components/WaypointSidebar.tsx @@ -1,32 +1,21 @@ import { useEffect, useState, useCallback, useRef } from "react"; -import * as Y from "yjs"; import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; import type { DayStage } from "@trails-cool/gpx"; -import { setOvernight, isOvernight } from "~/lib/overnight"; +import { setOvernight } from "~/lib/overnight"; import { DayBreakdown } from "./DayBreakdown"; import { useNearbyPois } from "~/lib/use-nearby-pois"; import { poiCategories } from "@trails-cool/map-core"; import type { Poi } from "~/lib/overpass"; -import { waypointFromYMap } from "~/lib/waypoint-ymap"; +import { + extractWaypointData, + waypointFromYMap, + waypointToYMap, + type WaypointData, +} from "~/lib/waypoint-ymap"; const NOTE_MAX = 500; -interface WaypointData { - lat: number; - lon: number; - name?: string; - note?: string; - overnight: boolean; -} - -function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] { - return waypoints.toArray().map((yMap) => { - const wp = waypointFromYMap(yMap); - return { lat: wp.lat, lon: wp.lon, name: wp.name, note: wp.note, overnight: isOvernight(yMap) }; - }); -} - interface WaypointSidebarProps { yjs: YjsState; routeStats?: { @@ -48,7 +37,7 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp const textareaRef = useRef(null); useEffect(() => { - const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints)); + const update = () => setWaypoints(extractWaypointData(yjs.waypoints)); yjs.waypoints.observeDeep(update); update(); return () => yjs.waypoints.unobserveDeep(update); @@ -66,26 +55,10 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp 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, - note: item.get("note") as string | undefined, - overnight: isOvernight(item), - osmId: item.get("osmId") as number | undefined, - poiTags: item.get("poiTags") as Record | undefined, - }; + const wp = waypointFromYMap(item); 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); - if (data.note) yMap.set("note", data.note); - if (data.overnight) yMap.set("overnight", true); - if (data.osmId !== undefined) yMap.set("osmId", data.osmId); - if (data.poiTags) yMap.set("poiTags", data.poiTags); - yjs.waypoints.insert(to, [yMap]); + yjs.waypoints.insert(to, [waypointToYMap(wp)]); }, "local"); }, [yjs.waypoints, yjs.doc], diff --git a/apps/planner/app/components/YjsDebugPanel.tsx b/apps/planner/app/components/YjsDebugPanel.tsx index d788b95..7139988 100644 --- a/apps/planner/app/components/YjsDebugPanel.tsx +++ b/apps/planner/app/components/YjsDebugPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import * as Y from "yjs"; import * as awarenessProtocol from "y-protocols/awareness"; import type { YjsState } from "~/lib/use-yjs"; +import { DEFAULT_PROFILE, clearRouteData, getProfile, setGeojson } from "~/lib/route-data"; // --- localStorage helpers --- @@ -125,8 +126,8 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st while (yjs.waypoints.length > 0) { yjs.waypoints.delete(0, 1); } - yjs.routeData.delete("geojson"); - yjs.routeData.delete("profile"); + // Nested transact joins this transaction + clearRouteData(yjs.doc, yjs.routeData); }); }, [yjs]); @@ -138,7 +139,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st if (waypoints.length < 2) return; - const profile = (yjs.routeData.get("profile") as string) ?? "trekking"; + const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE; const response = await fetch("/api/route", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -147,7 +148,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st if (response.ok) { const geojson = await response.json(); - yjs.routeData.set("geojson", JSON.stringify(geojson)); + setGeojson(yjs.routeData, geojson); } }, [yjs, sessionId]); diff --git a/apps/planner/app/lib/gpx-export.test.ts b/apps/planner/app/lib/gpx-export.test.ts new file mode 100644 index 0000000..110d023 --- /dev/null +++ b/apps/planner/app/lib/gpx-export.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import type { Waypoint } from "@trails-cool/types"; +import { waypointToYMap } from "./waypoint-ymap.ts"; +import { + buildDayGpxFiles, + buildPlanGpx, + buildRouteGpx, + hasDayBreaks, + type PlanDoc, +} from "./gpx-export.ts"; + +function createDoc(): PlanDoc & { doc: Y.Doc } { + const doc = new Y.Doc(); + return { + doc, + routeData: doc.getMap("routeData"), + waypoints: doc.getArray>("waypoints"), + noGoAreas: doc.getArray>("noGoAreas"), + notes: doc.getText("notes"), + }; +} + +function addWaypoints(doc: PlanDoc, waypoints: Waypoint[]): void { + doc.waypoints.push(waypoints.map(waypointToYMap)); +} + +function setTrack(doc: PlanDoc, coords: [number, number, number][]): void { + doc.routeData.set("coordinates", JSON.stringify(coords)); +} + +const TRACK: [number, number, number][] = [ + [13.4, 52.5, 35], + [13.41, 52.51, 40], + [13.42, 52.52, 38], + [13.43, 52.53, 42], +]; + +describe("buildRouteGpx", () => { + it("contains the track but no waypoints", () => { + const doc = createDoc(); + setTrack(doc, TRACK); + addWaypoints(doc, [ + { lat: 52.5, lon: 13.4, name: "Start" }, + { lat: 52.53, lon: 13.43, name: "End" }, + ]); + + const gpx = buildRouteGpx(doc); + expect(gpx).toContain(""); + expect(gpx).toContain('lat="52.5"'); + expect(gpx).not.toContain(" { + const gpx = buildRouteGpx(createDoc()); + expect(gpx).toContain(" { + it("includes waypoints, no-go areas, and notes", () => { + const doc = createDoc(); + setTrack(doc, TRACK); + addWaypoints(doc, [ + { lat: 52.5, lon: 13.4, name: "Start" }, + { lat: 52.53, lon: 13.43, name: "End" }, + ]); + const area = new Y.Map(); + area.set("points", [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.51, lon: 13.41 }, + { lat: 52.52, lon: 13.4 }, + ]); + doc.noGoAreas.push([area]); + doc.notes.insert(0, "Pack sunscreen"); + + const gpx = buildPlanGpx(doc); + expect(gpx).toContain(" { + const doc = createDoc(); + setTrack(doc, TRACK); + const routeTrkpts = (buildRouteGpx(doc).match(/ { + it("hasDayBreaks reflects overnight waypoints", () => { + const doc = createDoc(); + addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]); + expect(hasDayBreaks(doc)).toBe(false); + + const withBreak = createDoc(); + addWaypoints(withBreak, [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.51, lon: 13.41, isDayBreak: true }, + { lat: 52.53, lon: 13.43 }, + ]); + expect(hasDayBreaks(withBreak)).toBe(true); + }); + + it("returns [] without a track or waypoints", () => { + expect(buildDayGpxFiles(createDoc())).toEqual([]); + }); + + it("exports a single route file when there are no day breaks", () => { + const doc = createDoc(); + setTrack(doc, TRACK); + addWaypoints(doc, [{ lat: 52.5, lon: 13.4 }, { lat: 52.53, lon: 13.43 }]); + + const files = buildDayGpxFiles(doc); + expect(files).toHaveLength(1); + expect(files[0]!.filename).toBe("route.gpx"); + }); + + it("splits at day-break waypoints into per-day files", () => { + const doc = createDoc(); + setTrack(doc, TRACK); + addWaypoints(doc, [ + { lat: 52.5, lon: 13.4, name: "Alpha" }, + { lat: 52.51, lon: 13.41, name: "Hut", isDayBreak: true }, + { lat: 52.53, lon: 13.43, name: "Omega" }, + ]); + + const files = buildDayGpxFiles(doc); + expect(files).toHaveLength(2); + expect(files[0]!.filename).toBe("day-1-alpha.gpx"); + expect(files[0]!.gpx).toContain("Day 1: Alpha - Hut"); + expect(files[1]!.filename).toBe("day-2-hut.gpx"); + expect(files[1]!.gpx).toContain("Day 2: Hut - Omega"); + + // Day 1 covers track points up to the hut; day 2 the rest. + expect((files[0]!.gpx.match(/; + waypoints: Y.Array>; + noGoAreas: Y.Array>; + notes: Y.Text; +} + +export const ROUTE_NAME = "trails.cool route"; + +function getTracks(doc: PlanDoc): TrackPoint[][] { + const coords = getCoordinates(doc.routeData); + if (!coords || coords.length === 0) return []; + return [coords.map((c) => ({ lat: c[1], lon: c[0], ele: c[2] }))]; +} + +/** The computed track only — no waypoints, no planning data. */ +export function buildRouteGpx(doc: PlanDoc): string { + return generateGpx({ name: ROUTE_NAME, waypoints: [], tracks: getTracks(doc) }); +} + +/** + * The full plan: track, waypoints, no-go areas, and session notes — + * everything needed to round-trip the route through the Journal. + */ +export function buildPlanGpx(doc: PlanDoc): string { + return generateGpx({ + name: ROUTE_NAME, + description: doc.notes.toString() || undefined, + waypoints: extractWaypoints(doc.waypoints), + tracks: getTracks(doc), + noGoAreas: extractNoGoAreas(doc.noGoAreas), + }); +} + +export function hasDayBreaks(doc: PlanDoc): boolean { + return extractWaypoints(doc.waypoints).some((w) => w.isDayBreak); +} + +export interface GpxFile { + filename: string; + gpx: string; +} + +/** + * One GPX file per day stage, split at day-break waypoints. A route + * without day breaks (or without a computed track) yields a single + * full-route file. + */ +export function buildDayGpxFiles(doc: PlanDoc): GpxFile[] { + const tracks = getTracks(doc); + const waypoints = extractWaypoints(doc.waypoints); + const allPoints = tracks.flat(); + if (allPoints.length === 0 || waypoints.length === 0) return []; + + const days = computeDays(waypoints, tracks); + if (days.length <= 1) { + return [{ filename: "route.gpx", gpx: generateGpx({ name: ROUTE_NAME, tracks }) }]; + } + + // Find closest track index for each waypoint + const wpTrackIndices = waypoints.map((wp: Waypoint) => { + let bestIdx = 0; + let bestDist = Infinity; + for (let i = 0; i < allPoints.length; i++) { + const dx = allPoints[i]!.lat - wp.lat; + const dy = allPoints[i]!.lon - wp.lon; + const d = dx * dx + dy * dy; + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + return bestIdx; + }); + + return days.map((day) => { + const startIdx = wpTrackIndices[day.startWaypointIndex]!; + const endIdx = wpTrackIndices[day.endWaypointIndex]!; + const dayPoints = allPoints.slice(startIdx, endIdx + 1); + const dayName = + day.startName && day.endName + ? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}` + : `Day ${day.dayNumber}`; + const filename = `day-${day.dayNumber}${ + day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : "" + }.gpx`; + return { filename, gpx: generateGpx({ name: dayName, tracks: [dayPoints] }) }; + }); +} diff --git a/apps/planner/app/lib/route-data.test.ts b/apps/planner/app/lib/route-data.test.ts new file mode 100644 index 0000000..44b7474 --- /dev/null +++ b/apps/planner/app/lib/route-data.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import type { EnrichedRoute } from "./route-merge.ts"; +import { + DEFAULT_PROFILE, + PROFILE_KEY, + ROAD_METADATA_KEYS, + clearRouteData, + extractNoGoAreas, + getBaseLayer, + getColorMode, + getCoordinates, + getGeojson, + getOverlays, + getPoiCategories, + getProfile, + parseJsonArray, + readComputedRoute, + readRoadMetadata, + setBaseLayer, + setColorMode, + setGeojson, + setOverlays, + setPoiCategories, + setProfile, + writeComputedRoute, +} from "./route-data.ts"; + +function createDoc(): { doc: Y.Doc; routeData: Y.Map } { + const doc = new Y.Doc(); + return { doc, routeData: doc.getMap("routeData") }; +} + +function enrichedFixture(overrides: Partial = {}): EnrichedRoute { + return { + coordinates: [ + [13.4, 52.5, 35], + [13.41, 52.51, 40], + [13.42, 52.52, 38], + ], + segmentBoundaries: [0], + surfaces: ["asphalt", "asphalt", "gravel"], + highways: ["cycleway", "cycleway", "track"], + maxspeeds: ["30", "30", ""], + smoothnesses: ["good", "good", "bad"], + tracktypes: ["", "", "grade2"], + cycleways: ["lane", "lane", ""], + bikeroutes: ["", "rcn", "rcn"], + totalLength: 2500, + totalAscend: 12, + totalTime: 600, + geojson: { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: {}, + geometry: { + type: "LineString", + coordinates: [ + [13.4, 52.5, 35], + [13.41, 52.51, 40], + [13.42, 52.52, 38], + ], + }, + }, + ], + }, + ...overrides, + }; +} + +describe("parseJsonArray", () => { + it("returns [] for undefined and corrupt JSON", () => { + expect(parseJsonArray(undefined)).toEqual([]); + expect(parseJsonArray("{not json")).toEqual([]); + }); + + it("parses a JSON array", () => { + expect(parseJsonArray("[1,2,3]")).toEqual([1, 2, 3]); + }); +}); + +describe("computed route round-trip", () => { + it("writes an enriched route and reads it back", () => { + const { doc, routeData } = createDoc(); + const enriched = enrichedFixture(); + + writeComputedRoute(doc, routeData, enriched); + const computed = readComputedRoute(routeData); + + expect(computed.coordinates).toEqual(enriched.coordinates); + expect(computed.segmentBoundaries).toEqual(enriched.segmentBoundaries); + for (const key of ROAD_METADATA_KEYS) { + expect(computed[key]).toEqual(enriched[key]); + } + expect(getGeojson(routeData)).toBe(JSON.stringify(enriched.geojson)); + }); + + it("keeps previous metadata when a recompute yields empty arrays", () => { + const { doc, routeData } = createDoc(); + writeComputedRoute(doc, routeData, enrichedFixture()); + writeComputedRoute(doc, routeData, enrichedFixture({ surfaces: [] })); + + // empty surfaces are not written; the previous value remains + expect(readComputedRoute(routeData).surfaces).toEqual(["asphalt", "asphalt", "gravel"]); + }); + + it("writes in a single transaction", () => { + const { doc, routeData } = createDoc(); + let events = 0; + routeData.observe(() => events++); + writeComputedRoute(doc, routeData, enrichedFixture()); + expect(events).toBe(1); + }); + + it("returns null coordinates and empty arrays for an empty document", () => { + const { routeData } = createDoc(); + const computed = readComputedRoute(routeData); + expect(computed.coordinates).toBeNull(); + expect(computed.segmentBoundaries).toEqual([]); + expect(computed.surfaces).toEqual([]); + }); +}); + +describe("getCoordinates", () => { + it("falls back to geojson for documents without a coordinates key", () => { + const { routeData } = createDoc(); + setGeojson(routeData, enrichedFixture().geojson); + + expect(getCoordinates(routeData)).toEqual([ + [13.4, 52.5, 35], + [13.41, 52.51, 40], + [13.42, 52.52, 38], + ]); + }); + + it("defaults missing elevation to 0 in the geojson fallback", () => { + const { routeData } = createDoc(); + setGeojson(routeData, { + type: "FeatureCollection", + features: [ + { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [[13.4, 52.5], [13.41, 52.51]] }, + }, + ], + }); + + expect(getCoordinates(routeData)).toEqual([ + [13.4, 52.5, 0], + [13.41, 52.51, 0], + ]); + }); + + it("returns null when neither key is set", () => { + const { routeData } = createDoc(); + expect(getCoordinates(routeData)).toBeNull(); + }); +}); + +describe("profile and view preferences", () => { + it("round-trips the profile", () => { + const { routeData } = createDoc(); + expect(getProfile(routeData)).toBeUndefined(); + setProfile(routeData, DEFAULT_PROFILE); + expect(getProfile(routeData)).toBe(DEFAULT_PROFILE); + expect(routeData.has(PROFILE_KEY)).toBe(true); + }); + + it("defaults colorMode to plain and round-trips it", () => { + const { routeData } = createDoc(); + expect(getColorMode(routeData)).toBe("plain"); + setColorMode(routeData, "surface"); + expect(getColorMode(routeData)).toBe("surface"); + }); + + it("round-trips baseLayer and overlays", () => { + const { routeData } = createDoc(); + expect(getBaseLayer(routeData)).toBeUndefined(); + expect(getOverlays(routeData)).toBeUndefined(); + setBaseLayer(routeData, "OpenTopoMap"); + setOverlays(routeData, ["hiking", "cycling"]); + expect(getBaseLayer(routeData)).toBe("OpenTopoMap"); + expect(getOverlays(routeData)).toEqual(["hiking", "cycling"]); + }); + + it("treats corrupt overlays/poiCategories as unset", () => { + const { routeData } = createDoc(); + routeData.set("overlays", "{corrupt"); + routeData.set("poiCategories", "{corrupt"); + expect(getOverlays(routeData)).toBeUndefined(); + expect(getPoiCategories(routeData)).toBeUndefined(); + }); + + it("round-trips poiCategories", () => { + const { routeData } = createDoc(); + setPoiCategories(routeData, ["water", "camping"]); + expect(getPoiCategories(routeData)).toEqual(["water", "camping"]); + }); +}); + +describe("clearRouteData", () => { + it("removes the computed route and profile but keeps view preferences", () => { + const { doc, routeData } = createDoc(); + writeComputedRoute(doc, routeData, enrichedFixture()); + setProfile(routeData, "trekking"); + setColorMode(routeData, "surface"); + setOverlays(routeData, ["hiking"]); + + clearRouteData(doc, routeData); + + expect(getCoordinates(routeData)).toBeNull(); + expect(getGeojson(routeData)).toBeUndefined(); + expect(getProfile(routeData)).toBeUndefined(); + expect(readRoadMetadata(routeData).surfaces).toEqual([]); + expect(getColorMode(routeData)).toBe("surface"); + expect(getOverlays(routeData)).toEqual(["hiking"]); + }); +}); + +describe("extractNoGoAreas", () => { + it("reads areas and drops degenerate ones", () => { + const doc = new Y.Doc(); + const noGoAreas = doc.getArray>("noGoAreas"); + + const valid = new Y.Map(); + valid.set("points", [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.51, lon: 13.41 }, + { lat: 52.52, lon: 13.4 }, + ]); + const degenerate = new Y.Map(); + degenerate.set("points", [{ lat: 52.5, lon: 13.4 }]); + const empty = new Y.Map(); + noGoAreas.push([valid, degenerate, empty]); + + const areas = extractNoGoAreas(noGoAreas); + expect(areas).toHaveLength(1); + expect(areas[0]!.points).toHaveLength(3); + }); +}); diff --git a/apps/planner/app/lib/route-data.ts b/apps/planner/app/lib/route-data.ts new file mode 100644 index 0000000..45b8ce1 --- /dev/null +++ b/apps/planner/app/lib/route-data.ts @@ -0,0 +1,221 @@ +import * as Y from "yjs"; +import type { EnrichedRoute } from "./route-merge.ts"; + +// The schema of the shared `routeData` Y.Map (and the `noGoAreas` Y.Array). +// This module is the only place that knows the key strings and JSON +// encoding of the collaborative document's route state — consumers read +// and write through the typed functions below. Waypoints have their own +// schema module: waypoint-ymap.ts. + +export type ColorMode = + | "plain" + | "elevation" + | "surface" + | "grade" + | "highway" + | "maxspeed" + | "smoothness" + | "tracktype" + | "cycleway" + | "bikeroute"; + +export const DEFAULT_PROFILE = "fastbike"; + +/** routeData key observed externally (use-routing recomputes on change). */ +export const PROFILE_KEY = "profile"; + +/** Per-coordinate road attributes BRouter enriches the route with. */ +export const ROAD_METADATA_KEYS = [ + "surfaces", + "highways", + "maxspeeds", + "smoothnesses", + "tracktypes", + "cycleways", + "bikeroutes", +] as const; + +export type RoadMetadataKey = (typeof ROAD_METADATA_KEYS)[number]; +export type RoadMetadata = Record; + +/** The computed-route portion of routeData, decoded. */ +export interface ComputedRoute extends RoadMetadata { + /** [lon, lat, ele] triples, GeoJSON axis order. */ + coordinates: [number, number, number][] | null; + segmentBoundaries: number[]; +} + +export interface NoGoAreaData { + points: Array<{ lat: number; lon: number }>; +} + +export function parseJsonArray(json: string | undefined): T[] { + if (!json) return []; + try { + return JSON.parse(json); + } catch { + return []; + } +} + +/** Like parseJsonArray, but distinguishes "absent or unparseable" from "empty". */ +function parseJsonArrayOrUndefined(json: string | undefined): T[] | undefined { + if (!json) return undefined; + try { + return JSON.parse(json); + } catch { + return undefined; + } +} + +// --- Computed route (written by the routing host, read by everyone) --- + +export function getGeojson(routeData: Y.Map): string | undefined { + return routeData.get("geojson") as string | undefined; +} + +/** + * Route coordinates as [lon, lat, ele] triples. Reads the "coordinates" + * key; falls back to extracting them from "geojson" for documents written + * before the coordinates key existed. + */ +export function getCoordinates(routeData: Y.Map): [number, number, number][] | null { + const coordsJson = routeData.get("coordinates") as string | undefined; + if (coordsJson) { + try { + return JSON.parse(coordsJson); + } catch { + /* fall through to geojson */ + } + } + const geojson = getGeojson(routeData); + if (geojson) { + try { + const parsed = JSON.parse(geojson); + const coords: number[][] | undefined = parsed.features?.[0]?.geometry?.coordinates; + if (coords) { + return coords.map((c) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]); + } + } catch { + /* invalid geojson */ + } + } + return null; +} + +export function readRoadMetadata(routeData: Y.Map): RoadMetadata { + const metadata = {} as RoadMetadata; + for (const key of ROAD_METADATA_KEYS) { + metadata[key] = parseJsonArray(routeData.get(key) as string | undefined); + } + return metadata; +} + +export function readComputedRoute(routeData: Y.Map): ComputedRoute { + return { + coordinates: getCoordinates(routeData), + segmentBoundaries: parseJsonArray( + routeData.get("segmentBoundaries") as string | undefined, + ), + ...readRoadMetadata(routeData), + }; +} + +/** + * Stores an enriched route for all participants in one transaction. + * Metadata keys are only written when non-empty, mirroring what the + * routing host has always done — a recompute that yields no metadata + * leaves the previous arrays in place. + */ +export function writeComputedRoute( + doc: Y.Doc, + routeData: Y.Map, + enriched: EnrichedRoute, +): void { + doc.transact(() => { + routeData.set("geojson", JSON.stringify(enriched.geojson)); + routeData.set("coordinates", JSON.stringify(enriched.coordinates)); + routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries)); + for (const key of ROAD_METADATA_KEYS) { + if (enriched[key]?.length) { + routeData.set(key, JSON.stringify(enriched[key])); + } + } + }); +} + +/** Debug/recovery escape hatch: store a raw GeoJSON route without enrichment. */ +export function setGeojson(routeData: Y.Map, geojson: unknown): void { + routeData.set("geojson", JSON.stringify(geojson)); +} + +/** Removes the computed route and profile (view preferences are kept). */ +export function clearRouteData(doc: Y.Doc, routeData: Y.Map): void { + doc.transact(() => { + routeData.delete("geojson"); + routeData.delete("coordinates"); + routeData.delete("segmentBoundaries"); + for (const key of ROAD_METADATA_KEYS) { + routeData.delete(key); + } + routeData.delete(PROFILE_KEY); + }); +} + +// --- Routing profile --- + +export function getProfile(routeData: Y.Map): string | undefined { + return routeData.get(PROFILE_KEY) as string | undefined; +} + +export function setProfile(routeData: Y.Map, profile: string): void { + routeData.set(PROFILE_KEY, profile); +} + +// --- View preferences (shared across participants) --- + +export function getColorMode(routeData: Y.Map): ColorMode { + return (routeData.get("colorMode") as ColorMode | undefined) ?? "plain"; +} + +export function setColorMode(routeData: Y.Map, mode: ColorMode): void { + routeData.set("colorMode", mode); +} + +export function getBaseLayer(routeData: Y.Map): string | undefined { + return routeData.get("baseLayer") as string | undefined; +} + +export function setBaseLayer(routeData: Y.Map, name: string): void { + routeData.set("baseLayer", name); +} + +export function getOverlays(routeData: Y.Map): string[] | undefined { + return parseJsonArrayOrUndefined(routeData.get("overlays") as string | undefined); +} + +export function setOverlays(routeData: Y.Map, ids: string[]): void { + routeData.set("overlays", JSON.stringify(ids)); +} + +export function getPoiCategories(routeData: Y.Map): string[] | undefined { + return parseJsonArrayOrUndefined( + routeData.get("poiCategories") as string | undefined, + ); +} + +export function setPoiCategories(routeData: Y.Map, categories: string[]): void { + routeData.set("poiCategories", JSON.stringify(categories)); +} + +// --- No-go areas (their own Y.Array on the document) --- + +/** Reads all no-go areas, dropping degenerate ones (fewer than 3 points). */ +export function extractNoGoAreas(noGoAreas: Y.Array>): NoGoAreaData[] { + return noGoAreas + .toArray() + .map((yMap) => ({ + points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], + })) + .filter((a) => a.points.length >= 3); +} diff --git a/apps/planner/app/lib/use-days.ts b/apps/planner/app/lib/use-days.ts index f56326a..d67f93e 100644 --- a/apps/planner/app/lib/use-days.ts +++ b/apps/planner/app/lib/use-days.ts @@ -1,10 +1,9 @@ import { useEffect, useState } from "react"; -import * as Y from "yjs"; import { computeDays, type DayStage } from "@trails-cool/gpx"; -import type { Waypoint } from "@trails-cool/types"; import type { TrackPoint } from "@trails-cool/gpx"; import type { YjsState } from "./use-yjs.ts"; -import { isOvernight } from "./overnight.ts"; +import { extractWaypoints } from "./waypoint-ymap.ts"; +import { getCoordinates } from "./route-data.ts"; /** * Reactive hook that computes day stages from Yjs waypoints and route data. @@ -17,13 +16,7 @@ export function useDays(yjs: YjsState | null): DayStage[] { if (!yjs) return; const recompute = () => { - // Extract waypoints with isDayBreak from Yjs - const waypoints: Waypoint[] = yjs.waypoints.toArray().map((yMap: Y.Map) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - name: yMap.get("name") as string | undefined, - isDayBreak: isOvernight(yMap) || undefined, - })); + const waypoints = extractWaypoints(yjs.waypoints); // Check if any waypoint has isDayBreak const hasBreaks = waypoints.some((w) => w.isDayBreak); @@ -32,26 +25,18 @@ export function useDays(yjs: YjsState | null): DayStage[] { return; } - // Extract track points from route coordinates stored in Yjs - const coordsStr = yjs.routeData.get("coordinates") as string | undefined; - if (!coordsStr) { + const coords = getCoordinates(yjs.routeData); + if (!coords) { setDays([]); return; } - try { - // coordinates are stored as [[lon, lat, ele], ...] (GeoJSON format) - const coords: number[][] = JSON.parse(coordsStr); - const trackPoints: TrackPoint[] = coords.map((c) => ({ - lat: c[1]!, - lon: c[0]!, - ele: c[2], - })); - - setDays(computeDays(waypoints, [trackPoints])); - } catch { - setDays([]); - } + const trackPoints: TrackPoint[] = coords.map((c) => ({ + lat: c[1], + lon: c[0], + ele: c[2], + })); + setDays(computeDays(waypoints, [trackPoints])); }; yjs.waypoints.observeDeep(recompute); diff --git a/apps/planner/app/lib/use-elevation-data.ts b/apps/planner/app/lib/use-elevation-data.ts index 9ad3e01..f29e71c 100644 --- a/apps/planner/app/lib/use-elevation-data.ts +++ b/apps/planner/app/lib/use-elevation-data.ts @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; import * as Y from "yjs"; -import type { ColorMode } from "~/components/ColoredRoute"; import type { ElevationPoint } from "~/lib/elevation-chart-draw"; +import { + getColorMode, + getGeojson, + readRoadMetadata, + type ColorMode, + type RoadMetadata, +} from "~/lib/route-data"; function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; @@ -44,21 +50,9 @@ function extractElevation(geojsonStr: string): ElevationPoint[] { } } -function parseJsonArray(json: string | undefined): string[] { - if (!json) return []; - try { return JSON.parse(json); } catch { return []; } -} - -export interface ElevationData { +export interface ElevationData extends RoadMetadata { points: ElevationPoint[]; colorMode: ColorMode; - surfaces: string[]; - highways: string[]; - maxspeeds: string[]; - smoothnesses: string[]; - tracktypes: string[]; - cycleways: string[]; - bikeroutes: string[]; } export function useElevationData(routeData: Y.Map): ElevationData { @@ -76,17 +70,11 @@ export function useElevationData(routeData: Y.Map): ElevationData { useEffect(() => { const update = () => { - const geojson = routeData.get("geojson") as string | undefined; + const geojson = getGeojson(routeData); setData({ points: geojson ? extractElevation(geojson) : [], - colorMode: (routeData.get("colorMode") as ColorMode | undefined) ?? "plain", - surfaces: parseJsonArray(routeData.get("surfaces") as string | undefined), - highways: parseJsonArray(routeData.get("highways") as string | undefined), - maxspeeds: parseJsonArray(routeData.get("maxspeeds") as string | undefined), - smoothnesses: parseJsonArray(routeData.get("smoothnesses") as string | undefined), - tracktypes: parseJsonArray(routeData.get("tracktypes") as string | undefined), - cycleways: parseJsonArray(routeData.get("cycleways") as string | undefined), - bikeroutes: parseJsonArray(routeData.get("bikeroutes") as string | undefined), + colorMode: getColorMode(routeData), + ...readRoadMetadata(routeData), }); }; routeData.observe(update); diff --git a/apps/planner/app/lib/use-profile-defaults.ts b/apps/planner/app/lib/use-profile-defaults.ts index 66cff60..43f7be3 100644 --- a/apps/planner/app/lib/use-profile-defaults.ts +++ b/apps/planner/app/lib/use-profile-defaults.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import type { YjsState } from "./use-yjs.ts"; import type { PoiState } from "./use-pois.ts"; import { getCategoriesForProfile } from "@trails-cool/map-core"; +import { getProfile } from "./route-data.ts"; /** * Auto-enable relevant POI categories when the routing profile changes. @@ -15,7 +16,7 @@ export function useProfileDefaults(yjs: YjsState | null, poiState: PoiState): vo if (!yjs) return; const handleChange = () => { - const profile = yjs.routeData.get("profile") as string | undefined; + const profile = getProfile(yjs.routeData); if (!profile) return; // Skip initial load — respect existing state diff --git a/apps/planner/app/lib/use-routing.ts b/apps/planner/app/lib/use-routing.ts index a9d897a..9ffabe1 100644 --- a/apps/planner/app/lib/use-routing.ts +++ b/apps/planner/app/lib/use-routing.ts @@ -9,6 +9,14 @@ import { type NoGoArea, } from "./route-merge.ts"; import { SegmentCache } from "./segment-cache.ts"; +import { + DEFAULT_PROFILE, + PROFILE_KEY, + extractNoGoAreas, + getProfile, + writeComputedRoute, +} from "./route-data.ts"; +import { extractWaypoints } from "./waypoint-ymap.ts"; interface RouteStats { distance?: number; @@ -22,10 +30,7 @@ interface WaypointData { } function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] { - return waypoints.toArray().map((yMap) => ({ - lat: yMap.get("lat") as number, - lon: yMap.get("lon") as number, - })); + return extractWaypoints(waypoints).map((wp) => ({ lat: wp.lat, lon: wp.lon })); } function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject) { @@ -72,9 +77,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { if (!yjs || !isHost || waypoints.length < 2) return; // Collect no-go areas from Yjs - const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap) => ({ - points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [], - })).filter((a) => a.points.length >= 3); + const noGoAreas: NoGoArea[] = extractNoGoAreas(yjs.noGoAreas); // Save current waypoints so we can restore on failure const snapshotBeforeCompute = getWaypointsFromYjs(yjs.waypoints); @@ -84,7 +87,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { const controller = new AbortController(); inflightAbortRef.current = controller; - const profile = (yjs.routeData.get("profile") as string) ?? "fastbike"; + const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE; const noGoHash = hashNoGoAreas(noGoAreas); const cache = segmentCacheRef.current; @@ -158,32 +161,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { }); // Store enriched route data in Yjs for all participants - yjs.doc.transact(() => { - yjs.routeData.set("geojson", JSON.stringify(enriched.geojson)); - yjs.routeData.set("coordinates", JSON.stringify(enriched.coordinates)); - yjs.routeData.set("segmentBoundaries", JSON.stringify(enriched.segmentBoundaries)); - if (enriched.surfaces?.length) { - yjs.routeData.set("surfaces", JSON.stringify(enriched.surfaces)); - } - if (enriched.highways?.length) { - yjs.routeData.set("highways", JSON.stringify(enriched.highways)); - } - if (enriched.maxspeeds?.length) { - yjs.routeData.set("maxspeeds", JSON.stringify(enriched.maxspeeds)); - } - if (enriched.smoothnesses?.length) { - yjs.routeData.set("smoothnesses", JSON.stringify(enriched.smoothnesses)); - } - if (enriched.tracktypes?.length) { - yjs.routeData.set("tracktypes", JSON.stringify(enriched.tracktypes)); - } - if (enriched.cycleways?.length) { - yjs.routeData.set("cycleways", JSON.stringify(enriched.cycleways)); - } - if (enriched.bikeroutes?.length) { - yjs.routeData.set("bikeroutes", JSON.stringify(enriched.bikeroutes)); - } - }); + writeComputedRoute(yjs.doc, yjs.routeData, enriched); } catch (err) { // A superseding request aborted this one — leave state alone so // the newer call's result becomes authoritative. @@ -222,7 +200,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) { // Observe routeData for profile changes const profileObserver = (event: Y.YMapEvent) => { - if (event.keysChanged.has("profile")) { + if (event.keysChanged.has(PROFILE_KEY)) { triggerRecompute(); } }; diff --git a/apps/planner/app/lib/use-waypoint-manager.ts b/apps/planner/app/lib/use-waypoint-manager.ts index 866e27d..b832d2b 100644 --- a/apps/planner/app/lib/use-waypoint-manager.ts +++ b/apps/planner/app/lib/use-waypoint-manager.ts @@ -1,27 +1,17 @@ import { useEffect, useState, useCallback } from "react"; import * as Y from "yjs"; import type { YjsState } from "~/lib/use-yjs"; -import type { ColorMode } from "~/components/ColoredRoute"; import { usePois } from "~/lib/use-pois"; import { snapToPoi } from "~/lib/poi-snap"; -import { isOvernight } from "~/lib/overnight"; import { findSegmentForPoint } from "~/components/ColoredRoute"; -import { waypointFromYMap } from "~/lib/waypoint-ymap"; +import { extractWaypointData, type WaypointData } from "~/lib/waypoint-ymap"; +import { + getColorMode, + readComputedRoute, + type ColorMode, +} from "~/lib/route-data"; -export interface WaypointData { - lat: number; - lon: number; - name?: string; - note?: string; - overnight: boolean; -} - -function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] { - return waypoints.toArray().map((yMap) => { - const wp = waypointFromYMap(yMap); - return { lat: wp.lat, lon: wp.lon, name: wp.name, note: wp.note, overnight: isOvernight(yMap) }; - }); -} +export type { WaypointData }; function pointToSegmentDist( pLat: number, pLon: number, @@ -38,11 +28,6 @@ function pointToSegmentDist( return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2); } -function parseJsonArray(json: string | undefined): T[] { - if (!json) return []; - try { return JSON.parse(json); } catch { return []; } -} - export interface RouteState { waypoints: WaypointData[]; routeCoordinates: [number, number, number][] | null; @@ -80,7 +65,7 @@ export function useWaypointManager( // Sync waypoints from Yjs useEffect(() => { const update = () => { - const wps = getWaypointsFromYjs(yjs.waypoints); + const wps = extractWaypointData(yjs.waypoints); setState((prev) => ({ ...prev, waypoints: wps })); if (wps.length >= 2 && onRouteRequest) { onRouteRequest(wps); @@ -94,39 +79,12 @@ export function useWaypointManager( // Sync route data from Yjs useEffect(() => { const update = () => { - const coordsJson = yjs.routeData.get("coordinates") as string | undefined; - const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined; - const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined; - - let routeCoordinates: [number, number, number][] | null = null; - if (coordsJson) { - try { routeCoordinates = JSON.parse(coordsJson); } catch { /* ignore */ } - } else { - // Fallback: parse from geojson for backwards compat - 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) { - routeCoordinates = coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]); - } - } catch { /* ignore */ } - } - } - + const { coordinates, ...computed } = readComputedRoute(yjs.routeData); setState((prev) => ({ ...prev, - routeCoordinates, - segmentBoundaries: parseJsonArray(boundsJson), - surfaces: parseJsonArray(yjs.routeData.get("surfaces") as string | undefined), - highways: parseJsonArray(yjs.routeData.get("highways") as string | undefined), - maxspeeds: parseJsonArray(yjs.routeData.get("maxspeeds") as string | undefined), - smoothnesses: parseJsonArray(yjs.routeData.get("smoothnesses") as string | undefined), - tracktypes: parseJsonArray(yjs.routeData.get("tracktypes") as string | undefined), - cycleways: parseJsonArray(yjs.routeData.get("cycleways") as string | undefined), - bikeroutes: parseJsonArray(yjs.routeData.get("bikeroutes") as string | undefined), - colorMode: modeVal ?? "plain", + ...computed, + routeCoordinates: coordinates, + colorMode: getColorMode(yjs.routeData), })); }; yjs.routeData.observe(update); diff --git a/apps/planner/app/lib/use-yjs-poi-sync.ts b/apps/planner/app/lib/use-yjs-poi-sync.ts index fc4ee3c..e7cfcc0 100644 --- a/apps/planner/app/lib/use-yjs-poi-sync.ts +++ b/apps/planner/app/lib/use-yjs-poi-sync.ts @@ -1,8 +1,7 @@ import { useEffect, useRef } from "react"; import type { YjsState } from "./use-yjs.ts"; import type { PoiState } from "./use-pois.ts"; - -const YJS_KEY_POI_CATEGORIES = "poiCategories"; +import { getPoiCategories, setPoiCategories } from "./route-data.ts"; /** * Bidirectional sync between POI/overlay state and Yjs routeData. @@ -21,7 +20,7 @@ export function useYjsPoiSync(yjs: YjsState | null, poiState: PoiState): void { prevCategories.current = current; suppressYjsUpdate.current = true; - yjs.routeData.set(YJS_KEY_POI_CATEGORIES, JSON.stringify(current)); + setPoiCategories(yjs.routeData, current); // Allow Yjs observer to fire but suppress our handler queueMicrotask(() => { suppressYjsUpdate.current = false; }); }, [yjs, poiState.enabledCategories]); @@ -33,17 +32,12 @@ export function useYjsPoiSync(yjs: YjsState | null, poiState: PoiState): void { const handleChange = () => { if (suppressYjsUpdate.current) return; - const raw = yjs.routeData.get(YJS_KEY_POI_CATEGORIES) as string | undefined; - if (!raw) return; + const categories = getPoiCategories(yjs.routeData); + if (!categories) return; - try { - const categories = JSON.parse(raw) as string[]; - if (!arraysEqual(categories, prevCategories.current)) { - prevCategories.current = categories; - poiState.setEnabledCategories(categories); - } - } catch { - // Invalid JSON in Yjs — ignore + if (!arraysEqual(categories, prevCategories.current)) { + prevCategories.current = categories; + poiState.setEnabledCategories(categories); } }; diff --git a/apps/planner/app/lib/waypoint-ymap.test.ts b/apps/planner/app/lib/waypoint-ymap.test.ts new file mode 100644 index 0000000..2421bda --- /dev/null +++ b/apps/planner/app/lib/waypoint-ymap.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import * as Y from "yjs"; +import type { Waypoint } from "@trails-cool/types"; +import { + extractWaypointData, + extractWaypoints, + waypointFromYMap, + waypointToYMap, +} from "./waypoint-ymap.ts"; + +function createArray(): Y.Array> { + return new Y.Doc().getArray>("waypoints"); +} + +/** Y.Maps only expose values once integrated into a document. */ +function roundTrip(wp: Waypoint): Waypoint { + const arr = createArray(); + arr.push([waypointToYMap(wp)]); + return waypointFromYMap(arr.get(0)!); +} + +describe("waypoint round-trip", () => { + it("preserves all fields through toYMap → fromYMap", () => { + const wp: Waypoint = { + lat: 52.5, + lon: 13.4, + name: "Hut", + note: "Book ahead", + isDayBreak: true, + osmId: 12345, + poiTags: { tourism: "alpine_hut" }, + }; + expect(roundTrip(wp)).toEqual(wp); + }); + + it("leaves optional fields undefined", () => { + const wp = roundTrip({ lat: 1, lon: 2 }); + expect(wp).toEqual({ + lat: 1, + lon: 2, + name: undefined, + note: undefined, + isDayBreak: undefined, + osmId: undefined, + poiTags: undefined, + }); + }); +}); + +describe("extractWaypoints", () => { + it("reads the whole list in order", () => { + const arr = createArray(); + arr.push([ + waypointToYMap({ lat: 1, lon: 2, name: "A" }), + waypointToYMap({ lat: 3, lon: 4, isDayBreak: true }), + ]); + + const wps = extractWaypoints(arr); + expect(wps).toHaveLength(2); + expect(wps[0]!.name).toBe("A"); + expect(wps[1]!.isDayBreak).toBe(true); + }); +}); + +describe("extractWaypointData", () => { + it("flattens isDayBreak to a plain overnight boolean", () => { + const arr = createArray(); + arr.push([ + waypointToYMap({ lat: 1, lon: 2 }), + waypointToYMap({ lat: 3, lon: 4, isDayBreak: true, note: "n" }), + ]); + + const data = extractWaypointData(arr); + expect(data[0]!.overnight).toBe(false); + expect(data[1]!).toEqual({ lat: 3, lon: 4, name: undefined, note: "n", overnight: true }); + }); +}); diff --git a/apps/planner/app/lib/waypoint-ymap.ts b/apps/planner/app/lib/waypoint-ymap.ts index d2ac67c..9f27c25 100644 --- a/apps/planner/app/lib/waypoint-ymap.ts +++ b/apps/planner/app/lib/waypoint-ymap.ts @@ -39,3 +39,27 @@ export function waypointToYMap(wp: Waypoint): Y.Map { applyWaypointToYMap(yMap, wp); return yMap; } + +/** Reads the whole shared waypoint list as plain Waypoints. */ +export function extractWaypoints(waypoints: Y.Array>): Waypoint[] { + return waypoints.toArray().map(waypointFromYMap); +} + +/** Waypoint flattened for UI state (isDayBreak as a plain boolean). */ +export interface WaypointData { + lat: number; + lon: number; + name?: string; + note?: string; + overnight: boolean; +} + +export function extractWaypointData(waypoints: Y.Array>): WaypointData[] { + return extractWaypoints(waypoints).map((wp) => ({ + lat: wp.lat, + lon: wp.lon, + name: wp.name, + note: wp.note, + overnight: wp.isDayBreak === true, + })); +}