trails/apps/planner/app/lib/use-waypoint-manager.ts
Ullrich Schäfer 3b9672e0ff
planner: give the Yjs document a typed schema seam
The routeData Y.Map's ~15 string keys (geojson, coordinates,
segmentBoundaries, road metadata, profile, colorMode, baseLayer,
overlays, poiCategories) were read and written raw at ~30 call sites,
each with its own JSON parsing and casts; parseJsonArray existed twice
and waypoint extraction four times. GPX assembly was duplicated between
SaveToJournalButton and ExportButton, so the saved plan and the
exported file could silently diverge.

- new lib/route-data.ts owns the routeData (+ noGoAreas) schema:
  typed read/write, JSON encoding internal, ColorMode moves here
  (re-exported from ColoredRoute for existing importers)
- new lib/gpx-export.ts owns GPX assembly: buildRouteGpx /
  buildPlanGpx / buildDayGpxFiles / hasDayBreaks; multi-day splitting
  becomes a pure, tested function
- waypoint-ymap.ts gains extractWaypoints / extractWaypointData; the
  four hand-rolled copies (use-routing, use-waypoint-manager,
  WaypointSidebar, use-days) now share it, and WaypointSidebar's
  moveWaypoint reuses the round-trip helpers instead of re-listing
  every waypoint field
- all hooks/components consume the seam; no raw routeData key strings
  remain outside route-data.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:35:04 +02:00

244 lines
7.6 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { usePois } from "~/lib/use-pois";
import { snapToPoi } from "~/lib/poi-snap";
import { findSegmentForPoint } from "~/components/ColoredRoute";
import { extractWaypointData, type WaypointData } from "~/lib/waypoint-ymap";
import {
getColorMode,
readComputedRoute,
type ColorMode,
} from "~/lib/route-data";
export type { WaypointData };
function pointToSegmentDist(
pLat: number, pLon: number,
aLat: number, aLon: number,
bLat: number, bLon: number,
): number {
const dx = bLon - aLon;
const dy = bLat - aLat;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2);
const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq));
const projLon = aLon + t * dx;
const projLat = aLat + t * dy;
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
}
export interface RouteState {
waypoints: WaypointData[];
routeCoordinates: [number, number, number][] | null;
segmentBoundaries: number[];
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
colorMode: ColorMode;
}
export function useWaypointManager(
yjs: YjsState,
poiState: ReturnType<typeof usePois>,
suppressMapClickRef: React.RefObject<boolean>,
onRouteRequest?: (waypoints: WaypointData[]) => void,
) {
const [state, setState] = useState<RouteState>({
waypoints: [],
routeCoordinates: null,
segmentBoundaries: [],
surfaces: [],
highways: [],
maxspeeds: [],
smoothnesses: [],
tracktypes: [],
cycleways: [],
bikeroutes: [],
colorMode: "plain",
});
// Sync waypoints from Yjs
useEffect(() => {
const update = () => {
const wps = extractWaypointData(yjs.waypoints);
setState((prev) => ({ ...prev, waypoints: wps }));
if (wps.length >= 2 && onRouteRequest) {
onRouteRequest(wps);
}
};
yjs.waypoints.observeDeep(update);
update();
return () => yjs.waypoints.unobserveDeep(update);
}, [yjs.waypoints, onRouteRequest]);
// Sync route data from Yjs
useEffect(() => {
const update = () => {
const { coordinates, ...computed } = readComputedRoute(yjs.routeData);
setState((prev) => ({
...prev,
...computed,
routeCoordinates: coordinates,
colorMode: getColorMode(yjs.routeData),
}));
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const addWaypoint = useCallback(
(lat: number, lng: number, name?: string) => {
const { routeCoordinates, segmentBoundaries } = state;
const snap = snapToPoi(lat, lng, poiState.pois);
const finalLat = snap.lat;
const finalLon = snap.snapped ? snap.lon : lng;
let insertIndex = yjs.waypoints.length;
if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) {
let bestDist = Infinity;
let bestSegment = -1;
for (let seg = 0; seg < segmentBoundaries.length; seg++) {
const start = segmentBoundaries[seg]!;
const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length;
for (let i = start; i < end - 1; i++) {
const c1 = routeCoordinates[i]!;
const c2 = routeCoordinates[i + 1]!;
const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!);
if (dist < bestDist) {
bestDist = dist;
bestSegment = seg;
}
}
}
if (bestDist < 0.01 && bestSegment >= 0) {
insertIndex = bestSegment + 1;
}
}
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", finalLat);
yMap.set("lon", finalLon);
if (snap.name) yMap.set("name", snap.name);
else if (name) yMap.set("name", name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(insertIndex, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois, state],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
const snap = snapToPoi(lat, lon, poiState.pois);
yjs.doc.transact(() => {
const yMap = new Y.Map();
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lon);
if (snap.name) yMap.set("name", snap.name);
if (snap.osmId) yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
}, "local");
},
[yjs.doc, yjs.waypoints, poiState.pois],
);
const handleRouteInsert = useCallback(
(pointIndex: number, lat: number, lon: number) => {
suppressMapClickRef.current = true;
const segIdx = findSegmentForPoint(pointIndex, state.segmentBoundaries);
insertWaypointAtSegment(segIdx, lat, lon);
},
[state.segmentBoundaries, insertWaypointAtSegment, suppressMapClickRef],
);
const moveWaypoint = useCallback(
(index: number, lat: number, lng: number) => {
const snap = snapToPoi(lat, lng, poiState.pois);
const yMap = yjs.waypoints.get(index);
if (yMap) {
yjs.doc.transact(() => {
yMap.set("lat", snap.lat);
yMap.set("lon", snap.snapped ? snap.lon : lng);
if (snap.snapped && snap.name) {
yMap.set("name", snap.name);
} else {
yMap.delete("name");
}
if (snap.osmId) {
yMap.set("osmId", snap.osmId);
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
} else {
yMap.delete("osmId");
yMap.delete("poiTags");
}
}, "local");
}
},
[yjs.waypoints, yjs.doc, poiState.pois],
);
const deleteWaypoint = useCallback(
(index: number) => {
yjs.doc.transact(() => {
yjs.waypoints.delete(index, 1);
}, "local");
},
[yjs.waypoints],
);
const handleRoutePolylineHover = useCallback(
(e: { latlng: { lat: number; lng: number } }, onRouteHover: (d: number | null) => void) => {
const { routeCoordinates } = state;
if (!routeCoordinates || routeCoordinates.length < 2) return;
const { lat, lng } = e.latlng;
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < routeCoordinates.length; i++) {
const c = routeCoordinates[i]!;
const dLat = c[1]! - lat;
const dLon = c[0]! - lng;
const dist = dLat * dLat + dLon * dLon;
if (dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
let totalDist = 0;
for (let i = 1; i <= bestIdx; i++) {
const prev = routeCoordinates[i - 1]!;
const curr = routeCoordinates[i]!;
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(curr[1]! - prev[1]!);
const dLon = toRad(curr[0]! - prev[0]!);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2;
totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
onRouteHover(totalDist);
},
[state],
);
return {
...state,
addWaypoint,
insertWaypointAtSegment,
handleRouteInsert,
moveWaypoint,
deleteWaypoint,
handleRoutePolylineHover,
};
}