trails/apps/planner/app/lib/poi-cache.ts
Ullrich Schäfer b45e69885d
poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics
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>
2026-07-12 22:38:03 +02:00

86 lines
2.3 KiB
TypeScript

import type { Poi, BBox } from "./pois.ts";
const CELL_SIZE = 0.1; // degrees
const TTL = 10 * 60 * 1000; // 10 minutes
interface CacheEntry {
pois: Poi[];
timestamp: number;
}
const cache = new Map<string, CacheEntry>();
/**
* Quantize a coordinate to the nearest grid cell boundary.
*/
export function quantize(value: number): number {
return Math.floor(value / CELL_SIZE) * CELL_SIZE;
}
/**
* Generate cache keys for grid cells covering a bounding box.
*/
export function getCellKeys(bbox: BBox): string[] {
const keys: string[] = [];
const minLat = quantize(bbox.south);
const maxLat = quantize(bbox.north) + CELL_SIZE;
const minLon = quantize(bbox.west);
const maxLon = quantize(bbox.east) + CELL_SIZE;
for (let lat = minLat; lat < maxLat; lat += CELL_SIZE) {
for (let lon = minLon; lon < maxLon; lon += CELL_SIZE) {
keys.push(`${lat.toFixed(1)},${lon.toFixed(1)}`);
}
}
return keys;
}
/**
* Get cached POIs for a bounding box. Returns null if any cell is missing/expired.
*/
export function getCached(bbox: BBox, categoriesKey: string): Poi[] | null {
const keys = getCellKeys(bbox);
const now = Date.now();
const allPois: Poi[] = [];
for (const cellKey of keys) {
const fullKey = `${categoriesKey}:${cellKey}`;
const entry = cache.get(fullKey);
if (!entry || now - entry.timestamp > TTL) return null;
allPois.push(...entry.pois);
}
// Filter to only POIs actually within the requested bbox
return allPois.filter(
(p) => p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east,
);
}
/**
* Store POIs in the cache, split by grid cell.
*/
export function setCached(bbox: BBox, categoriesKey: string, pois: Poi[]): void {
const now = Date.now();
const keys = getCellKeys(bbox);
// Assign each POI to its grid cell
const cellPois = new Map<string, Poi[]>();
for (const key of keys) cellPois.set(key, []);
for (const poi of pois) {
const cellKey = `${quantize(poi.lat).toFixed(1)},${quantize(poi.lon).toFixed(1)}`;
const bucket = cellPois.get(cellKey);
if (bucket) bucket.push(poi);
}
for (const [cellKey, cellData] of cellPois) {
cache.set(`${categoriesKey}:${cellKey}`, { pois: cellData, timestamp: now });
}
}
/**
* Clear all cached data.
*/
export function clearCache(): void {
cache.clear();
}