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:
commit
e94e7ac19a
21 changed files with 921 additions and 322 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<unknown>) => ({
|
||||
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 (
|
||||
<div ref={ref} className="relative z-[1001]">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
setProfile(value);
|
||||
yjs.routeData.set("profile", value);
|
||||
setYjsProfile(yjs.routeData, value);
|
||||
},
|
||||
[yjs.routeData],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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 });
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
yjs: YjsState;
|
||||
routeStats?: {
|
||||
|
|
@ -48,7 +37,7 @@ export function WaypointSidebar({ yjs, routeStats, days, onWaypointHover, onWayp
|
|||
const textareaRef = useRef<HTMLTextAreaElement>(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<string, string> | 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],
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
144
apps/planner/app/lib/gpx-export.test.ts
Normal file
144
apps/planner/app/lib/gpx-export.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
101
apps/planner/app/lib/gpx-export.ts
Normal file
101
apps/planner/app/lib/gpx-export.ts
Normal 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] }) };
|
||||
});
|
||||
}
|
||||
243
apps/planner/app/lib/route-data.test.ts
Normal file
243
apps/planner/app/lib/route-data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
221
apps/planner/app/lib/route-data.ts
Normal file
221
apps/planner/app/lib/route-data.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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<unknown>) => ({
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<unknown>): ElevationData {
|
||||
|
|
@ -76,17 +70,11 @@ export function useElevationData(routeData: Y.Map<unknown>): 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Y.Map<unknown>>): 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<boolean>) {
|
||||
|
|
@ -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<unknown>) => {
|
||||
if (event.keysChanged.has("profile")) {
|
||||
if (event.keysChanged.has(PROFILE_KEY)) {
|
||||
triggerRecompute();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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) };
|
||||
});
|
||||
}
|
||||
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<T>(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<number>(boundsJson),
|
||||
surfaces: parseJsonArray<string>(yjs.routeData.get("surfaces") as string | undefined),
|
||||
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",
|
||||
...computed,
|
||||
routeCoordinates: coordinates,
|
||||
colorMode: getColorMode(yjs.routeData),
|
||||
}));
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
77
apps/planner/app/lib/waypoint-ymap.test.ts
Normal file
77
apps/planner/app/lib/waypoint-ymap.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -39,3 +39,27 @@ export function waypointToYMap(wp: Waypoint): Y.Map<unknown> {
|
|||
applyWaypointToYMap(yMap, wp);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue