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>
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import { fetchNearbyPois, PoiRateLimitError, type Poi } from "./pois.ts";
|
|
import { poiCategories } from "@trails-cool/map-core";
|
|
|
|
const NEARBY_RADIUS_METERS = 500;
|
|
const DEBOUNCE_MS = 500;
|
|
const TIMEOUT_MS = 10_000;
|
|
const RATE_LIMIT_SUPPRESS_MS = 60_000;
|
|
|
|
export type NearbyPoisStatus = "idle" | "loading" | "done" | "rate_limited" | "error";
|
|
|
|
export interface NearbyPoisState {
|
|
pois: Poi[];
|
|
status: NearbyPoisStatus;
|
|
}
|
|
|
|
const rateLimitedUntilRef = { current: 0 };
|
|
|
|
export function useNearbyPois(
|
|
lat: number | undefined,
|
|
lon: number | undefined,
|
|
): NearbyPoisState {
|
|
const [state, setState] = useState<NearbyPoisState>({ pois: [], status: "idle" });
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (lat === undefined || lon === undefined) {
|
|
setState({ pois: [], status: "idle" });
|
|
return;
|
|
}
|
|
|
|
// Clear previous debounce
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
abortRef.current?.abort();
|
|
|
|
timerRef.current = setTimeout(async () => {
|
|
if (Date.now() < rateLimitedUntilRef.current) {
|
|
setState({ pois: [], status: "rate_limited" });
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
setState((prev) => ({ ...prev, status: "loading" }));
|
|
|
|
try {
|
|
const pois = await fetchNearbyPois(lat, lon, NEARBY_RADIUS_METERS, poiCategories, controller.signal);
|
|
if (!controller.signal.aborted) {
|
|
setState({ pois, status: "done" });
|
|
}
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
if (err instanceof PoiRateLimitError) {
|
|
rateLimitedUntilRef.current = Date.now() + RATE_LIMIT_SUPPRESS_MS;
|
|
setState({ pois: [], status: "rate_limited" });
|
|
} else {
|
|
setState({ pois: [], status: "error" });
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}, DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
abortRef.current?.abort();
|
|
};
|
|
}, [lat, lon]);
|
|
|
|
return state;
|
|
}
|