trails/apps/planner/app/lib/overpass.ts
Ullrich Schäfer c2abb64ee0
Add per-waypoint notes and nearby POI snap to Planner
- `note` field on Waypoint type, stored in Yjs Y.Map and exported as
  `<desc>` inside `<wpt>` in GPX
- Inline textarea note editing in WaypointSidebar with auto-resize,
  character counter (500 max), save-on-blur, Escape cancel
- Note indicator dot on map markers; note tooltip on hover
- `useNearbyPois` hook: fetches POIs within 500m of selected waypoint
  via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit
  suppression
- NearbyPoiMarkers component: renders POI markers on map for selected
  waypoint with snap-to-POI on click
- Nearby section in WaypointSidebar: list with snap buttons, spinner,
  empty/rate-limited states, "Show more" toggle (5 → all)
- Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs
  transaction behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:58:21 +02:00

211 lines
5.8 KiB
TypeScript

import type { PoiCategory } from "@trails-cool/map-core";
const OVERPASS_PROXY = "/api/overpass";
// Snap bbox coordinates to this grid before building the query so that two
// users looking at nearly-identical viewports produce byte-identical queries
// and therefore share a server-side cache entry. Expansion is outward
// (south/west floor, north/east ceil) so the user's viewport is always
// covered. ~0.01° ≈ 1 km at mid-latitudes.
const BBOX_GRID_STEP = 0.01;
// Decimals used when formatting the quantized bbox into the query string.
// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP,
// so the string is a stable representation of the quantized cell.
const BBOX_DECIMALS = 3;
export interface Poi {
id: number;
lat: number;
lon: number;
name?: string;
category: string;
tags: Record<string, string>;
}
export interface BBox {
south: number;
west: number;
north: number;
east: number;
}
/**
* Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward
* so the original bbox is fully contained. Returns a new BBox whose
* coordinates are cell-aligned.
*/
export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox {
return {
south: Math.floor(bbox.south / step) * step,
west: Math.floor(bbox.west / step) * step,
north: Math.ceil(bbox.north / step) * step,
east: Math.ceil(bbox.east / step) * step,
};
}
/**
* Build an Overpass QL query combining all enabled categories into a union.
* The bbox is quantized to a fixed grid and formatted with a fixed number of
* decimals so near-identical viewports produce a byte-identical query — which
* the `/api/overpass` server-side cache keys on.
*/
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
const q = quantizeBbox(bbox);
const bboxStr = [q.south, q.west, q.north, q.east]
.map((n) => n.toFixed(BBOX_DECIMALS))
.join(",");
const unions = categories.map((c) => c.query).join("");
return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`;
}
/**
* Parse Overpass JSON response into typed Poi objects.
*/
export function parseResponse(
data: { elements: Array<{
type: string;
id: number;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}> },
categories: PoiCategory[],
): Poi[] {
const pois: Poi[] = [];
for (const el of data.elements) {
const lat = el.lat ?? el.center?.lat;
const lon = el.lon ?? el.center?.lon;
if (lat === undefined || lon === undefined) continue;
const tags = el.tags ?? {};
const category = matchCategory(tags, categories);
if (!category) continue;
pois.push({
id: el.id,
lat,
lon,
name: tags.name,
category: category.id,
tags,
});
}
return deduplicateById(pois);
}
/**
* Match an element's tags to the first matching category.
*/
function matchCategory(tags: Record<string, string>, categories: PoiCategory[]): PoiCategory | null {
for (const cat of categories) {
// Parse query fragments like 'nwr["amenity"="drinking_water"];'
const fragments = cat.query.split(";").filter(Boolean);
for (const frag of fragments) {
const match = frag.match(/\["(\w+)"="([^"]+)"\]/);
if (match && tags[match[1]!] === match[2]) return cat;
}
}
return null;
}
/**
* Deduplicate POIs by OSM node ID (same node may match multiple queries).
*/
export function deduplicateById(pois: Poi[]): Poi[] {
const seen = new Set<number>();
return pois.filter((poi) => {
if (seen.has(poi.id)) return false;
seen.add(poi.id);
return true;
});
}
/**
* Query the Overpass API for POIs within a bounding box.
*
* All queries route through the Planner's own `/api/overpass` proxy, which
* adds a User-Agent identifying trails.cool, rate-limits per IP, and enforces
* same-origin. The client never talks to a public Overpass host directly.
*/
export async function queryPois(
bbox: BBox,
categories: PoiCategory[],
sessionId: string,
signal?: AbortSignal,
): Promise<Poi[]> {
if (categories.length === 0) return [];
const query = buildQuery(bbox, categories);
const response = await fetch(OVERPASS_PROXY, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
// Bind this call to the active planner session so the proxy
// isn't anonymously reachable.
"X-Trails-Session": sessionId,
},
body: `data=${encodeURIComponent(query)}`,
signal,
});
if (response.status === 429) {
throw new OverpassRateLimitError();
}
if (!response.ok) {
throw new Error(`Overpass API error: ${response.status}`);
}
const text = await response.text();
if (text.includes("rate_limited")) {
throw new OverpassRateLimitError();
}
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error("Overpass API returned invalid JSON");
}
return parseResponse(data, categories);
}
export class OverpassRateLimitError extends Error {
constructor() {
super("Overpass API rate limit exceeded");
this.name = "OverpassRateLimitError";
}
}
// Degrees of latitude per meter (approximate, valid globally).
const DEG_PER_METER_LAT = 1 / 111320;
/**
* Fetch POIs within `radiusMeters` of a coordinate.
* Builds a bbox around the point and reuses queryPois + the existing proxy.
*/
export async function fetchNearbyPois(
lat: number,
lon: number,
radiusMeters: number,
categories: import("@trails-cool/map-core").PoiCategory[],
signal?: AbortSignal,
sessionId = "nearby",
): Promise<Poi[]> {
const dLat = radiusMeters * DEG_PER_METER_LAT;
const dLon = dLat / Math.cos((lat * Math.PI) / 180);
const bbox: BBox = {
south: lat - dLat,
north: lat + dLat,
west: lon - dLon,
east: lon + dLon,
};
return queryPois(bbox, categories, sessionId, signal);
}