trails/apps/planner/app/lib/use-pois.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

113 lines
3.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { queryPois, PoiRateLimitError, type Poi, type BBox } from "./pois.ts";
import { getCached, setCached } from "./poi-cache.ts";
import { poiCategories } from "@trails-cool/map-core";
const MIN_ZOOM = 10;
const DEBOUNCE_MS = 800;
const MIN_REQUEST_INTERVAL_MS = 2000;
const BACKOFF_BASE_MS = 10000;
const MAX_BACKOFF_MS = 60000;
export type PoiStatus = "idle" | "loading" | "loaded" | "zoom_too_low" | "rate_limited" | "error";
export interface PoiState {
pois: Poi[];
status: PoiStatus;
enabledCategories: string[];
setEnabledCategories: React.Dispatch<React.SetStateAction<string[]>>;
toggleCategory: (id: string) => void;
refresh: (bbox: BBox, zoom: number) => void;
}
export function usePois(sessionId: string): PoiState {
const [pois, setPois] = useState<Poi[]>([]);
const [status, setStatus] = useState<PoiStatus>("idle");
const [enabledCategories, setEnabledCategories] = useState<string[]>([]);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const backoffRef = useRef(0);
const lastRequestRef = useRef(0);
const toggleCategory = useCallback((id: string) => {
setEnabledCategories((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
);
}, []);
const refresh = useCallback(
(bbox: BBox, zoom: number) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (enabledCategories.length === 0) {
setPois([]);
setStatus("idle");
return;
}
if (zoom < MIN_ZOOM) {
setPois([]);
setStatus("zoom_too_low");
return;
}
const categories = poiCategories.filter((c) => enabledCategories.includes(c.id));
const categoriesKey = [...enabledCategories].sort().join(",");
// Check cache first
const cached = getCached(bbox, categoriesKey);
if (cached) {
setPois(cached);
setStatus("loaded");
return;
}
setStatus("loading");
// Calculate delay: debounce + respect minimum interval
const sinceLastRequest = Date.now() - lastRequestRef.current;
const delay = Math.max(DEBOUNCE_MS, MIN_REQUEST_INTERVAL_MS - sinceLastRequest);
debounceRef.current = setTimeout(async () => {
// Cancel previous request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
lastRequestRef.current = Date.now();
try {
const result = await queryPois(bbox, categories, sessionId, controller.signal);
if (controller.signal.aborted) return;
setCached(bbox, categoriesKey, result);
setPois(result);
setStatus("loaded");
backoffRef.current = 0;
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof PoiRateLimitError) {
setStatus("rate_limited");
backoffRef.current = Math.min(
(backoffRef.current || BACKOFF_BASE_MS) * 2,
MAX_BACKOFF_MS,
);
} else {
setStatus("error");
}
}
}, delay);
},
[enabledCategories, sessionId],
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return { pois, status, enabledCategories, setEnabledCategories, toggleCategory, refresh };
}