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>
244 lines
7.6 KiB
TypeScript
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,
|
|
};
|
|
}
|