From 1067b49576bec0abd01a3045b56268cd75b77c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 01:59:49 +0200 Subject: [PATCH] Add waypoint snapping to nearby POIs (50m threshold) When placing or dragging a waypoint within 50m of a visible POI, the waypoint snaps to the POI's exact coordinates and adopts its name. Snapping only applies to visible POIs (enabled categories, current viewport). Explicit name (from "Add as waypoint" button) is preserved without snapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/components/PlannerMap.tsx | 19 +++++--- apps/planner/app/lib/poi-snap.ts | 51 ++++++++++++++++++++++ 2 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 apps/planner/app/lib/poi-snap.ts 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)); +}