diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx index cf6afa8..d1d982d 100644 --- a/apps/planner/app/components/PlannerMap.tsx +++ b/apps/planner/app/components/PlannerMap.tsx @@ -10,6 +10,7 @@ import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { isOvernight } from "~/lib/overnight"; import { setOvernight } from "~/lib/overnight"; import { usePois } from "~/lib/use-pois"; +import { snapToPoi } from "~/lib/poi-snap"; import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "~/lib/z-index"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; @@ -362,15 +363,17 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted const addWaypoint = useCallback( (lat: number, lng: number, name?: string) => { + const snap = name ? { lat, lon: lng, name, snapped: false } : snapToPoi(lat, lng, poiState.pois); yjs.doc.transact(() => { const yMap = new Y.Map(); - yMap.set("lat", lat); - yMap.set("lon", lng); - if (name) yMap.set("name", name); + yMap.set("lat", snap.lat); + yMap.set("lon", snap.snapped ? snap.lon : lng); + if (snap.name) yMap.set("name", snap.name); + else if (name) yMap.set("name", name); yjs.waypoints.push([yMap]); }, "local"); }, - [yjs.doc, yjs.waypoints], + [yjs.doc, yjs.waypoints, poiState.pois], ); const insertWaypointAtSegment = useCallback( @@ -396,15 +399,17 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlighted 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", lat); - yMap.set("lon", lng); + yMap.set("lat", snap.lat); + yMap.set("lon", snap.snapped ? snap.lon : lng); + if (snap.snapped && snap.name) yMap.set("name", snap.name); }, "local"); } }, - [yjs.waypoints, yjs.doc], + [yjs.waypoints, yjs.doc, poiState.pois], ); const deleteWaypoint = useCallback( diff --git a/apps/planner/app/lib/poi-snap.ts b/apps/planner/app/lib/poi-snap.ts new file mode 100644 index 0000000..d00424f --- /dev/null +++ b/apps/planner/app/lib/poi-snap.ts @@ -0,0 +1,51 @@ +import type { Poi } from "./overpass.ts"; + +const SNAP_DISTANCE_METERS = 50; + +interface SnapResult { + lat: number; + lon: number; + name?: string; + snapped: boolean; +} + +/** + * Snap a coordinate to the nearest visible POI if within threshold. + * Returns the snapped position and POI name, or the original position. + */ +export function snapToPoi(lat: number, lon: number, pois: Poi[]): SnapResult { + if (pois.length === 0) return { lat, lon, snapped: false }; + + let bestPoi: Poi | null = null; + let bestDist = Infinity; + + for (const poi of pois) { + const dist = haversine(lat, lon, poi.lat, poi.lon); + if (dist < bestDist) { + bestDist = dist; + bestPoi = poi; + } + } + + if (bestPoi && bestDist <= SNAP_DISTANCE_METERS) { + return { + lat: bestPoi.lat, + lon: bestPoi.lon, + name: bestPoi.name, + snapped: true, + }; + } + + return { lat, lon, snapped: false }; +} + +function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}