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,
|
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]
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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]">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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);
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue