Merge pull request #504 from trails-cool/planner-route-data-seam

planner: typed schema seam for the Yjs document + shared GPX assembly
This commit is contained in:
Ullrich Schäfer 2026-06-10 01:51:49 +02:00 committed by GitHub
commit e94e7ac19a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 921 additions and 322 deletions

View file

@ -11,7 +11,11 @@ import {
elevationColor, routeGradeColor, maxspeedColor, elevationColor, routeGradeColor, maxspeedColor,
} from "@trails-cool/map-core"; } 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 { interface ColoredRouteProps {
coordinates: [number, number, number][]; // [lon, lat, ele] coordinates: [number, number, number][]; // [lon, lat, ele]

View file

@ -14,6 +14,7 @@ import {
} from "@trails-cool/map-core"; } from "@trails-cool/map-core";
import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw"; import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw";
import { useElevationData } from "~/lib/use-elevation-data"; import { useElevationData } from "~/lib/use-elevation-data";
import { setColorMode, type ColorMode } from "~/lib/route-data";
interface ElevationChartProps { interface ElevationChartProps {
yjs: YjsState; yjs: YjsState;
@ -371,7 +372,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
); );
const setMode = useCallback((mode: string) => { const setMode = useCallback((mode: string) => {
yjs.routeData.set("colorMode", mode); setColorMode(yjs.routeData, mode as ColorMode);
}, [yjs.routeData]); }, [yjs.routeData]);
if (points.length < 2) return null; if (points.length < 2) return null;

View file

@ -1,33 +1,12 @@
import { useCallback, useState, useRef, useEffect } from "react"; import { useCallback, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import { generateGpx, computeDays } from "@trails-cool/gpx"; import {
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx"; buildDayGpxFiles,
import { waypointFromYMap } from "~/lib/waypoint-ymap"; buildPlanGpx,
buildRouteGpx,
function getTracks(yjs: YjsState): TrackPoint[][] { hasDayBreaks,
const geojsonStr = yjs.routeData.get("geojson") as string | undefined; } from "~/lib/gpx-export";
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<unknown>) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
}
function download(gpx: string, filename: string) { function download(gpx: string, filename: string) {
const blob = new Blob([gpx], { type: "application/gpx+xml" }); const blob = new Blob([gpx], { type: "application/gpx+xml" });
@ -55,68 +34,23 @@ export function ExportButton({ yjs }: { yjs: YjsState }) {
}, [open]); }, [open]);
const handleExportRoute = useCallback(() => { const handleExportRoute = useCallback(() => {
const tracks = getTracks(yjs); download(buildRouteGpx(yjs), "route.gpx");
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
download(gpx, "route.gpx");
setOpen(false); setOpen(false);
}, [yjs]); }, [yjs]);
const handleExportPlan = useCallback(() => { const handleExportPlan = useCallback(() => {
const tracks = getTracks(yjs); download(buildPlanGpx(yjs), "route-plan.gpx");
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");
setOpen(false); setOpen(false);
}, [yjs]); }, [yjs]);
const handleExportDays = useCallback(() => { const handleExportDays = useCallback(() => {
const tracks = getTracks(yjs); for (const file of buildDayGpxFiles(yjs)) {
const waypoints = getWaypoints(yjs); download(file.gpx, file.filename);
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);
} }
setOpen(false); setOpen(false);
}, [yjs]); }, [yjs]);
const hasMultipleDays = (() => { const hasMultipleDays = hasDayBreaks(yjs);
const waypoints = getWaypoints(yjs);
return waypoints.some((w) => w.isDayBreak);
})();
return ( return (
<div ref={ref} className="relative z-[1001]"> <div ref={ref} className="relative z-[1001]">

View file

@ -5,6 +5,7 @@ import type { YjsState } from "~/lib/use-yjs";
import { overlayLayers } from "@trails-cool/map"; import { overlayLayers } from "@trails-cool/map";
import { usePois } from "~/lib/use-pois"; import { usePois } from "~/lib/use-pois";
import { Z_CURSOR } from "@trails-cool/map-core"; 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. // Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations.
export function MapExposer() { export function MapExposer() {
@ -188,11 +189,10 @@ export function OverlaySync({
if (suppressRef.current) return; if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name); const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return; if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined; const current = getOverlays(yjs.routeData) ?? [];
const current: string[] = raw ? JSON.parse(raw) : [];
if (!current.includes(layer.id)) { if (!current.includes(layer.id)) {
const updated = [...current, layer.id]; const updated = [...current, layer.id];
yjs.routeData.set("overlays", JSON.stringify(updated)); setOverlays(yjs.routeData, updated);
onOverlayChange(updated); onOverlayChange(updated);
} }
}; };
@ -201,16 +201,14 @@ export function OverlaySync({
if (suppressRef.current) return; if (suppressRef.current) return;
const layer = overlayLayers.find((l) => l.name === e.name); const layer = overlayLayers.find((l) => l.name === e.name);
if (!layer) return; if (!layer) return;
const raw = yjs.routeData.get("overlays") as string | undefined; const updated = (getOverlays(yjs.routeData) ?? []).filter((id) => id !== layer.id);
const current: string[] = raw ? JSON.parse(raw) : []; setOverlays(yjs.routeData, updated);
const updated = current.filter((id) => id !== layer.id);
yjs.routeData.set("overlays", JSON.stringify(updated));
onOverlayChange(updated); onOverlayChange(updated);
}; };
const handleBaseChange = (e: L.LayersControlEvent) => { const handleBaseChange = (e: L.LayersControlEvent) => {
if (suppressRef.current) return; if (suppressRef.current) return;
yjs.routeData.set("baseLayer", e.name); setBaseLayer(yjs.routeData, e.name);
}; };
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
@ -225,11 +223,9 @@ export function OverlaySync({
useEffect(() => { useEffect(() => {
const handleChange = () => { const handleChange = () => {
const raw = yjs.routeData.get("overlays") as string | undefined; const overlays = getOverlays(yjs.routeData);
if (raw) { if (overlays) onOverlayChange(overlays);
try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ } const base = getBaseLayer(yjs.routeData);
}
const base = yjs.routeData.get("baseLayer") as string | undefined;
if (base) onBaseLayerChange(base); if (base) onBaseLayerChange(base);
}; };
yjs.routeData.observe(handleChange); yjs.routeData.observe(handleChange);

View file

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs"; 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; const PROFILE_IDS = ["fastbike", "safety", "shortest", "car", "trekking"] as const;
@ -10,11 +11,11 @@ interface ProfileSelectorProps {
export function ProfileSelector({ yjs }: ProfileSelectorProps) { export function ProfileSelector({ yjs }: ProfileSelectorProps) {
const { t } = useTranslation("planner"); const { t } = useTranslation("planner");
const [profile, setProfile] = useState("fastbike"); const [profile, setProfile] = useState(DEFAULT_PROFILE);
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
const p = yjs.routeData.get("profile") as string | undefined; const p = getProfile(yjs.routeData);
if (p) setProfile(p); if (p) setProfile(p);
}; };
yjs.routeData.observe(update); yjs.routeData.observe(update);
@ -26,7 +27,7 @@ export function ProfileSelector({ yjs }: ProfileSelectorProps) {
(e: React.ChangeEvent<HTMLSelectElement>) => { (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value; const value = e.target.value;
setProfile(value); setProfile(value);
yjs.routeData.set("profile", value); setYjsProfile(yjs.routeData, value);
}, },
[yjs.routeData], [yjs.routeData],
); );

View file

@ -1,10 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx"; import { buildPlanGpx } from "~/lib/gpx-export";
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
import { waypointFromYMap } from "~/lib/waypoint-ymap";
interface SaveToJournalButtonProps { interface SaveToJournalButtonProps {
yjs: YjsState; yjs: YjsState;
@ -23,28 +20,9 @@ export function SaveToJournalButton({ yjs, sessionId, returnUrl }: SaveToJournal
setError(null); setError(null);
try { try {
// Build GPX from computed track with planning data (no-go areas) // Full plan GPX (track + waypoints + no-go areas + notes) so the
// so the route round-trips correctly through the journal. // route round-trips correctly through the journal.
let tracks: TrackPoint[][] = []; const gpx = buildPlanGpx(yjs);
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<unknown>) => ({
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 });
// POST to the planner's server-side proxy. The proxy attaches the // POST to the planner's server-side proxy. The proxy attaches the
// journal Bearer token (stored on the session row) and forwards // journal Bearer token (stored on the session row) and forwards

View file

@ -6,6 +6,7 @@ import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs"; import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing"; import { useRouting, type RouteError } from "~/lib/use-routing";
import { getCoordinates } from "~/lib/route-data";
import { useDays } from "~/lib/use-days"; import { useDays } from "~/lib/use-days";
import { useUndo, useUndoShortcuts } from "~/lib/use-undo"; import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector"; import { ProfileSelector } from "~/components/ProfileSelector";
@ -208,14 +209,10 @@ export function SessionView({ sessionId, hasJournalCallback, returnUrl, initialW
const handleResetZoom = useCallback(() => { const handleResetZoom = useCallback(() => {
const map = window.__leafletMap; const map = window.__leafletMap;
if (!map || !yjs) return; if (!map || !yjs) return;
const coordsJson = yjs.routeData.get("coordinates") as string | undefined; const coords = getCoordinates(yjs.routeData);
if (!coordsJson) return; if (!coords || coords.length < 2) return;
try { const latLngs = coords.map((c) => [c[1], c[0]] as [number, number]);
const coords: [number, number, number][] = JSON.parse(coordsJson); map.fitBounds(L.latLngBounds(latLngs), { padding: [50, 50] });
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 */ }
setIsZoomedByChart(false); setIsZoomedByChart(false);
}, [yjs]); }, [yjs]);

View file

@ -1,32 +1,21 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import * as Y from "yjs";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import type { DayStage } from "@trails-cool/gpx"; import type { DayStage } from "@trails-cool/gpx";
import { setOvernight, isOvernight } from "~/lib/overnight"; import { setOvernight } from "~/lib/overnight";
import { DayBreakdown } from "./DayBreakdown"; import { DayBreakdown } from "./DayBreakdown";
import { useNearbyPois } from "~/lib/use-nearby-pois"; import { useNearbyPois } from "~/lib/use-nearby-pois";
import { poiCategories } from "@trails-cool/map-core"; import { poiCategories } from "@trails-cool/map-core";
import type { Poi } from "~/lib/overpass"; 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; const NOTE_MAX = 500;
interface WaypointData {
lat: number;
lon: number;
name?: string;
note?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): 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 { interface WaypointSidebarProps {
yjs: YjsState; yjs: YjsState;
routeStats?: { routeStats?: {
@ -48,7 +37,7 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { useEffect(() => {
const update = () => setWaypoints(getWaypointsFromYjs(yjs.waypoints)); const update = () => setWaypoints(extractWaypointData(yjs.waypoints));
yjs.waypoints.observeDeep(update); yjs.waypoints.observeDeep(update);
update(); update();
return () => yjs.waypoints.unobserveDeep(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; if (from === to || from < 0 || to < 0) return;
const item = yjs.waypoints.get(from); const item = yjs.waypoints.get(from);
if (!item) return; if (!item) return;
const data = { const wp = waypointFromYMap(item);
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<string, string> | undefined,
};
yjs.doc.transact(() => { yjs.doc.transact(() => {
yjs.waypoints.delete(from, 1); yjs.waypoints.delete(from, 1);
const yMap = new Y.Map(); yjs.waypoints.insert(to, [waypointToYMap(wp)]);
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]);
}, "local"); }, "local");
}, },
[yjs.waypoints, yjs.doc], [yjs.waypoints, yjs.doc],

View file

@ -2,6 +2,7 @@ import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs"; import * as Y from "yjs";
import * as awarenessProtocol from "y-protocols/awareness"; import * as awarenessProtocol from "y-protocols/awareness";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import { DEFAULT_PROFILE, clearRouteData, getProfile, setGeojson } from "~/lib/route-data";
// --- localStorage helpers --- // --- localStorage helpers ---
@ -125,8 +126,8 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
while (yjs.waypoints.length > 0) { while (yjs.waypoints.length > 0) {
yjs.waypoints.delete(0, 1); yjs.waypoints.delete(0, 1);
} }
yjs.routeData.delete("geojson"); // Nested transact joins this transaction
yjs.routeData.delete("profile"); clearRouteData(yjs.doc, yjs.routeData);
}); });
}, [yjs]); }, [yjs]);
@ -138,7 +139,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
if (waypoints.length < 2) return; 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", { const response = await fetch("/api/route", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -147,7 +148,7 @@ export function YjsDebugPanel({ yjs, sessionId }: { yjs: YjsState; sessionId: st
if (response.ok) { if (response.ok) {
const geojson = await response.json(); const geojson = await response.json();
yjs.routeData.set("geojson", JSON.stringify(geojson)); setGeojson(yjs.routeData, geojson);
} }
}, [yjs, sessionId]); }, [yjs, sessionId]);

View file

@ -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<Y.Map<unknown>>("waypoints"),
noGoAreas: doc.getArray<Y.Map<unknown>>("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("<trk>");
expect(gpx).toContain('lat="52.5"');
expect(gpx).not.toContain("<wpt");
});
it("produces a track-less GPX for an empty document", () => {
const gpx = buildRouteGpx(createDoc());
expect(gpx).toContain("<gpx");
expect(gpx).not.toContain("<trkpt");
});
});
describe("buildPlanGpx", () => {
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<unknown>();
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("<wpt");
expect(gpx).toContain("Start");
expect(gpx).toContain("Pack sunscreen");
expect(gpx).toContain("trails:"); // no-go areas ride in the trails extension namespace
});
it("matches buildRouteGpx track content so save and export cannot diverge", () => {
const doc = createDoc();
setTrack(doc, TRACK);
const routeTrkpts = (buildRouteGpx(doc).match(/<trkpt/g) ?? []).length;
const planTrkpts = (buildPlanGpx(doc).match(/<trkpt/g) ?? []).length;
expect(planTrkpts).toBe(routeTrkpts);
expect(planTrkpts).toBe(TRACK.length);
});
});
describe("day exports", () => {
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(/<trkpt/g) ?? []).length).toBe(2);
expect((files[1]!.gpx.match(/<trkpt/g) ?? []).length).toBe(3);
});
});

View file

@ -0,0 +1,101 @@
import * as Y from "yjs";
import { generateGpx, computeDays } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";
import type { Waypoint } from "@trails-cool/types";
import { getCoordinates, extractNoGoAreas } from "./route-data.ts";
import { extractWaypoints } from "./waypoint-ymap.ts";
// Assembles GPX from the collaborative document. Both "save to journal"
// and the export menu go through here so the persisted plan and the
// downloaded file can never diverge.
/** The document parts GPX assembly needs (structural subset of YjsState). */
export interface PlanDoc {
routeData: Y.Map<unknown>;
waypoints: Y.Array<Y.Map<unknown>>;
noGoAreas: Y.Array<Y.Map<unknown>>;
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] }) };
});
}

View file

@ -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<unknown> } {
const doc = new Y.Doc();
return { doc, routeData: doc.getMap("routeData") };
}
function enrichedFixture(overrides: Partial<EnrichedRoute> = {}): 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<number>("[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<Y.Map<unknown>>("noGoAreas");
const valid = new Y.Map<unknown>();
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<unknown>();
degenerate.set("points", [{ lat: 52.5, lon: 13.4 }]);
const empty = new Y.Map<unknown>();
noGoAreas.push([valid, degenerate, empty]);
const areas = extractNoGoAreas(noGoAreas);
expect(areas).toHaveLength(1);
expect(areas[0]!.points).toHaveLength(3);
});
});

View file

@ -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<RoadMetadataKey, string[]>;
/** 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<T>(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<T>(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<unknown>): 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<unknown>): [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<unknown>): RoadMetadata {
const metadata = {} as RoadMetadata;
for (const key of ROAD_METADATA_KEYS) {
metadata[key] = parseJsonArray<string>(routeData.get(key) as string | undefined);
}
return metadata;
}
export function readComputedRoute(routeData: Y.Map<unknown>): ComputedRoute {
return {
coordinates: getCoordinates(routeData),
segmentBoundaries: parseJsonArray<number>(
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<unknown>,
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<unknown>, 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<unknown>): 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<unknown>): string | undefined {
return routeData.get(PROFILE_KEY) as string | undefined;
}
export function setProfile(routeData: Y.Map<unknown>, profile: string): void {
routeData.set(PROFILE_KEY, profile);
}
// --- View preferences (shared across participants) ---
export function getColorMode(routeData: Y.Map<unknown>): ColorMode {
return (routeData.get("colorMode") as ColorMode | undefined) ?? "plain";
}
export function setColorMode(routeData: Y.Map<unknown>, mode: ColorMode): void {
routeData.set("colorMode", mode);
}
export function getBaseLayer(routeData: Y.Map<unknown>): string | undefined {
return routeData.get("baseLayer") as string | undefined;
}
export function setBaseLayer(routeData: Y.Map<unknown>, name: string): void {
routeData.set("baseLayer", name);
}
export function getOverlays(routeData: Y.Map<unknown>): string[] | undefined {
return parseJsonArrayOrUndefined<string>(routeData.get("overlays") as string | undefined);
}
export function setOverlays(routeData: Y.Map<unknown>, ids: string[]): void {
routeData.set("overlays", JSON.stringify(ids));
}
export function getPoiCategories(routeData: Y.Map<unknown>): string[] | undefined {
return parseJsonArrayOrUndefined<string>(
routeData.get("poiCategories") as string | undefined,
);
}
export function setPoiCategories(routeData: Y.Map<unknown>, 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<Y.Map<unknown>>): NoGoAreaData[] {
return noGoAreas
.toArray()
.map((yMap) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
}))
.filter((a) => a.points.length >= 3);
}

View file

@ -1,10 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as Y from "yjs";
import { computeDays, type DayStage } from "@trails-cool/gpx"; import { computeDays, type DayStage } from "@trails-cool/gpx";
import type { Waypoint } from "@trails-cool/types";
import type { TrackPoint } from "@trails-cool/gpx"; import type { TrackPoint } from "@trails-cool/gpx";
import type { YjsState } from "./use-yjs.ts"; 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. * 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; if (!yjs) return;
const recompute = () => { const recompute = () => {
// Extract waypoints with isDayBreak from Yjs const waypoints = extractWaypoints(yjs.waypoints);
const waypoints: Waypoint[] = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
isDayBreak: isOvernight(yMap) || undefined,
}));
// Check if any waypoint has isDayBreak // Check if any waypoint has isDayBreak
const hasBreaks = waypoints.some((w) => w.isDayBreak); const hasBreaks = waypoints.some((w) => w.isDayBreak);
@ -32,26 +25,18 @@ export function useDays(yjs: YjsState | null): DayStage[] {
return; return;
} }
// Extract track points from route coordinates stored in Yjs const coords = getCoordinates(yjs.routeData);
const coordsStr = yjs.routeData.get("coordinates") as string | undefined; if (!coords) {
if (!coordsStr) {
setDays([]); setDays([]);
return; return;
} }
try { const trackPoints: TrackPoint[] = coords.map((c) => ({
// coordinates are stored as [[lon, lat, ele], ...] (GeoJSON format) lat: c[1],
const coords: number[][] = JSON.parse(coordsStr); lon: c[0],
const trackPoints: TrackPoint[] = coords.map((c) => ({ ele: c[2],
lat: c[1]!, }));
lon: c[0]!, setDays(computeDays(waypoints, [trackPoints]));
ele: c[2],
}));
setDays(computeDays(waypoints, [trackPoints]));
} catch {
setDays([]);
}
}; };
yjs.waypoints.observeDeep(recompute); yjs.waypoints.observeDeep(recompute);

View file

@ -1,7 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import * as Y from "yjs"; import * as Y from "yjs";
import type { ColorMode } from "~/components/ColoredRoute";
import type { ElevationPoint } from "~/lib/elevation-chart-draw"; 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 { function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; const R = 6371000;
@ -44,21 +50,9 @@ function extractElevation(geojsonStr: string): ElevationPoint[] {
} }
} }
function parseJsonArray(json: string | undefined): string[] { export interface ElevationData extends RoadMetadata {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
export interface ElevationData {
points: ElevationPoint[]; points: ElevationPoint[];
colorMode: ColorMode; colorMode: ColorMode;
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
} }
export function useElevationData(routeData: Y.Map<unknown>): ElevationData { export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
@ -76,17 +70,11 @@ export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
const geojson = routeData.get("geojson") as string | undefined; const geojson = getGeojson(routeData);
setData({ setData({
points: geojson ? extractElevation(geojson) : [], points: geojson ? extractElevation(geojson) : [],
colorMode: (routeData.get("colorMode") as ColorMode | undefined) ?? "plain", colorMode: getColorMode(routeData),
surfaces: parseJsonArray(routeData.get("surfaces") as string | undefined), ...readRoadMetadata(routeData),
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),
}); });
}; };
routeData.observe(update); routeData.observe(update);

View file

@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import type { YjsState } from "./use-yjs.ts"; import type { YjsState } from "./use-yjs.ts";
import type { PoiState } from "./use-pois.ts"; import type { PoiState } from "./use-pois.ts";
import { getCategoriesForProfile } from "@trails-cool/map-core"; import { getCategoriesForProfile } from "@trails-cool/map-core";
import { getProfile } from "./route-data.ts";
/** /**
* Auto-enable relevant POI categories when the routing profile changes. * 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; if (!yjs) return;
const handleChange = () => { const handleChange = () => {
const profile = yjs.routeData.get("profile") as string | undefined; const profile = getProfile(yjs.routeData);
if (!profile) return; if (!profile) return;
// Skip initial load — respect existing state // Skip initial load — respect existing state

View file

@ -9,6 +9,14 @@ import {
type NoGoArea, type NoGoArea,
} from "./route-merge.ts"; } from "./route-merge.ts";
import { SegmentCache } from "./segment-cache.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 { interface RouteStats {
distance?: number; distance?: number;
@ -22,10 +30,7 @@ interface WaypointData {
} }
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] { function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
return waypoints.toArray().map((yMap) => ({ return extractWaypoints(waypoints).map((wp) => ({ lat: wp.lat, lon: wp.lon }));
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
}));
} }
function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject<boolean>) { function restoreWaypoints(yjs: YjsState, snapshot: WaypointData[], restoringRef: React.RefObject<boolean>) {
@ -72,9 +77,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
if (!yjs || !isHost || waypoints.length < 2) return; if (!yjs || !isHost || waypoints.length < 2) return;
// Collect no-go areas from Yjs // Collect no-go areas from Yjs
const noGoAreas: NoGoArea[] = yjs.noGoAreas.toArray().map((yMap) => ({ const noGoAreas: NoGoArea[] = extractNoGoAreas(yjs.noGoAreas);
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
// Save current waypoints so we can restore on failure // Save current waypoints so we can restore on failure
const snapshotBeforeCompute = getWaypointsFromYjs(yjs.waypoints); const snapshotBeforeCompute = getWaypointsFromYjs(yjs.waypoints);
@ -84,7 +87,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
const controller = new AbortController(); const controller = new AbortController();
inflightAbortRef.current = controller; inflightAbortRef.current = controller;
const profile = (yjs.routeData.get("profile") as string) ?? "fastbike"; const profile = getProfile(yjs.routeData) ?? DEFAULT_PROFILE;
const noGoHash = hashNoGoAreas(noGoAreas); const noGoHash = hashNoGoAreas(noGoAreas);
const cache = segmentCacheRef.current; 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 // Store enriched route data in Yjs for all participants
yjs.doc.transact(() => { writeComputedRoute(yjs.doc, yjs.routeData, enriched);
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));
}
});
} catch (err) { } catch (err) {
// A superseding request aborted this one — leave state alone so // A superseding request aborted this one — leave state alone so
// the newer call's result becomes authoritative. // the newer call's result becomes authoritative.
@ -222,7 +200,7 @@ export function useRouting(yjs: YjsState | null, sessionId: string) {
// Observe routeData for profile changes // Observe routeData for profile changes
const profileObserver = (event: Y.YMapEvent<unknown>) => { const profileObserver = (event: Y.YMapEvent<unknown>) => {
if (event.keysChanged.has("profile")) { if (event.keysChanged.has(PROFILE_KEY)) {
triggerRecompute(); triggerRecompute();
} }
}; };

View file

@ -1,27 +1,17 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs"; import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs"; import type { YjsState } from "~/lib/use-yjs";
import type { ColorMode } from "~/components/ColoredRoute";
import { usePois } from "~/lib/use-pois"; import { usePois } from "~/lib/use-pois";
import { snapToPoi } from "~/lib/poi-snap"; import { snapToPoi } from "~/lib/poi-snap";
import { isOvernight } from "~/lib/overnight";
import { findSegmentForPoint } from "~/components/ColoredRoute"; 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 { export type { WaypointData };
lat: number;
lon: number;
name?: string;
note?: string;
overnight: boolean;
}
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): 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) };
});
}
function pointToSegmentDist( function pointToSegmentDist(
pLat: number, pLon: number, pLat: number, pLon: number,
@ -38,11 +28,6 @@ function pointToSegmentDist(
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2); return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
} }
function parseJsonArray<T>(json: string | undefined): T[] {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
export interface RouteState { export interface RouteState {
waypoints: WaypointData[]; waypoints: WaypointData[];
routeCoordinates: [number, number, number][] | null; routeCoordinates: [number, number, number][] | null;
@ -80,7 +65,7 @@ export function useWaypointManager(
// Sync waypoints from Yjs // Sync waypoints from Yjs
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
const wps = getWaypointsFromYjs(yjs.waypoints); const wps = extractWaypointData(yjs.waypoints);
setState((prev) => ({ ...prev, waypoints: wps })); setState((prev) => ({ ...prev, waypoints: wps }));
if (wps.length >= 2 && onRouteRequest) { if (wps.length >= 2 && onRouteRequest) {
onRouteRequest(wps); onRouteRequest(wps);
@ -94,39 +79,12 @@ export function useWaypointManager(
// Sync route data from Yjs // Sync route data from Yjs
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
const coordsJson = yjs.routeData.get("coordinates") as string | undefined; const { coordinates, ...computed } = readComputedRoute(yjs.routeData);
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 */ }
}
}
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
routeCoordinates, ...computed,
segmentBoundaries: parseJsonArray<number>(boundsJson), routeCoordinates: coordinates,
surfaces: parseJsonArray<string>(yjs.routeData.get("surfaces") as string | undefined), colorMode: getColorMode(yjs.routeData),
highways: parseJsonArray<string>(yjs.routeData.get("highways") as string | undefined),
maxspeeds: parseJsonArray<string>(yjs.routeData.get("maxspeeds") as string | undefined),
smoothnesses: parseJsonArray<string>(yjs.routeData.get("smoothnesses") as string | undefined),
tracktypes: parseJsonArray<string>(yjs.routeData.get("tracktypes") as string | undefined),
cycleways: parseJsonArray<string>(yjs.routeData.get("cycleways") as string | undefined),
bikeroutes: parseJsonArray<string>(yjs.routeData.get("bikeroutes") as string | undefined),
colorMode: modeVal ?? "plain",
})); }));
}; };
yjs.routeData.observe(update); yjs.routeData.observe(update);

View file

@ -1,8 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { YjsState } from "./use-yjs.ts"; import type { YjsState } from "./use-yjs.ts";
import type { PoiState } from "./use-pois.ts"; import type { PoiState } from "./use-pois.ts";
import { getPoiCategories, setPoiCategories } from "./route-data.ts";
const YJS_KEY_POI_CATEGORIES = "poiCategories";
/** /**
* Bidirectional sync between POI/overlay state and Yjs routeData. * 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; prevCategories.current = current;
suppressYjsUpdate.current = true; 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 // Allow Yjs observer to fire but suppress our handler
queueMicrotask(() => { suppressYjsUpdate.current = false; }); queueMicrotask(() => { suppressYjsUpdate.current = false; });
}, [yjs, poiState.enabledCategories]); }, [yjs, poiState.enabledCategories]);
@ -33,17 +32,12 @@ export function useYjsPoiSync(yjs: YjsState | null, poiState: PoiState): void {
const handleChange = () => { const handleChange = () => {
if (suppressYjsUpdate.current) return; if (suppressYjsUpdate.current) return;
const raw = yjs.routeData.get(YJS_KEY_POI_CATEGORIES) as string | undefined; const categories = getPoiCategories(yjs.routeData);
if (!raw) return; if (!categories) return;
try { if (!arraysEqual(categories, prevCategories.current)) {
const categories = JSON.parse(raw) as string[]; prevCategories.current = categories;
if (!arraysEqual(categories, prevCategories.current)) { poiState.setEnabledCategories(categories);
prevCategories.current = categories;
poiState.setEnabledCategories(categories);
}
} catch {
// Invalid JSON in Yjs — ignore
} }
}; };

View file

@ -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<Y.Map<unknown>> {
return new Y.Doc().getArray<Y.Map<unknown>>("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 });
});
});

View file

@ -39,3 +39,27 @@ export function waypointToYMap(wp: Waypoint): Y.Map<unknown> {
applyWaypointToYMap(yMap, wp); applyWaypointToYMap(yMap, wp);
return yMap; return yMap;
} }
/** Reads the whole shared waypoint list as plain Waypoints. */
export function extractWaypoints(waypoints: Y.Array<Y.Map<unknown>>): 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<Y.Map<unknown>>): WaypointData[] {
return extractWaypoints(waypoints).map((wp) => ({
lat: wp.lat,
lon: wp.lon,
name: wp.name,
note: wp.note,
overnight: wp.isDayBreak === true,
}));
}