Replace the planner's Overpass proxy with a self-hosted POI index: - map-core POI categories become structured tag selectors (single source of truth) + osmium filter / classification helpers - planner.pois PostGIS table (centroid points, GiST + category indexes) - /api/pois serving route (session + same-origin + rate limit, 100 cap) - lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox) - metrics: poi_api_requests_total + DB-collected index rows/age gauges; drop overpass_* metrics - remove /api/overpass proxy + dead OVERPASS_URLS on the planner service (Journal surface backfill still uses it via code default) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65 lines
1.9 KiB
TypeScript
65 lines
1.9 KiB
TypeScript
import type { Poi } from "./pois.ts";
|
|
import { SNAP_DISTANCE_METERS } from "@trails-cool/map-core";
|
|
|
|
export interface SnapResult {
|
|
lat: number;
|
|
lon: number;
|
|
name?: string;
|
|
snapped: boolean;
|
|
/** OSM node ID if snapped */
|
|
osmId?: number;
|
|
/** Key POI tags if snapped */
|
|
poiTags?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
// Pick key tags worth persisting with the waypoint
|
|
const keepTags = ["phone", "contact:phone", "website", "opening_hours",
|
|
"addr:street", "addr:housenumber", "addr:postcode", "addr:city",
|
|
"description", "amenity", "tourism", "shop"];
|
|
const poiTags: Record<string, string> = {};
|
|
for (const key of keepTags) {
|
|
if (bestPoi.tags[key]) poiTags[key] = bestPoi.tags[key];
|
|
}
|
|
|
|
return {
|
|
lat: bestPoi.lat,
|
|
lon: bestPoi.lon,
|
|
name: bestPoi.name,
|
|
snapped: true,
|
|
osmId: bestPoi.id,
|
|
poiTags: Object.keys(poiTags).length > 0 ? poiTags : undefined,
|
|
};
|
|
}
|
|
|
|
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));
|
|
}
|