Add Overpass client, POI categories, cache, and usePois hook

- poi-categories.ts: 9 POI categories with Overpass QL queries, icons,
  colors, and profile-aware defaults
- overpass.ts: Query builder, JSON parser, deduplication, rate limit error
- poi-cache.ts: Tile-based cache (0.1° grid cells, 10min TTL)
- use-pois.ts: React hook with 500ms debounce, AbortController, zoom
  threshold (>=12), exponential backoff on 429

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-11 01:15:34 +02:00
parent 0e399e5174
commit 375ff2fa13
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 423 additions and 11 deletions

View file

@ -0,0 +1,130 @@
import type { PoiCategory } from "./poi-categories.ts";
const OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter";
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;
}
/**
* Build an Overpass QL query combining all enabled categories into a union.
*/
export function buildQuery(bbox: BBox, categories: PoiCategory[]): string {
const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
const unions = categories.map((c) => c.query).join("");
return `[out:json][timeout:15][bbox:${bboxStr}];(${unions});out center 200;`;
}
/**
* 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.
*/
export async function queryPois(
bbox: BBox,
categories: PoiCategory[],
signal?: AbortSignal,
): Promise<Poi[]> {
if (categories.length === 0) return [];
const query = buildQuery(bbox, categories);
const response = await fetch(OVERPASS_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `data=${encodeURIComponent(query)}`,
signal,
});
if (response.status === 429) {
throw new OverpassRateLimitError();
}
if (!response.ok) {
throw new Error(`Overpass API error: ${response.status}`);
}
const data = await response.json();
return parseResponse(data, categories);
}
export class OverpassRateLimitError extends Error {
constructor() {
super("Overpass API rate limit exceeded");
this.name = "OverpassRateLimitError";
}
}

View file

@ -0,0 +1,86 @@
import type { Poi, BBox } from "./overpass.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();
}

View file

@ -0,0 +1,90 @@
export interface PoiCategory {
id: string;
name: string;
icon: string;
color: string;
query: string;
profiles?: string[];
}
export const poiCategories: PoiCategory[] = [
{
id: "drinking_water",
name: "poi.drinkingWater",
icon: "💧",
color: "#2563eb",
query: 'nwr["amenity"="drinking_water"];nwr["amenity"="water_point"];',
},
{
id: "shelter",
name: "poi.shelter",
icon: "🛖",
color: "#8B6D3A",
query: 'nwr["amenity"="shelter"];nwr["tourism"="wilderness_hut"];',
profiles: ["trekking"],
},
{
id: "camping",
name: "poi.camping",
icon: "⛺",
color: "#059669",
query: 'nwr["tourism"="camp_site"];nwr["tourism"="caravan_site"];nwr["tourism"="picnic_site"];',
},
{
id: "food",
name: "poi.food",
icon: "🍽️",
color: "#dc2626",
query: 'nwr["amenity"="restaurant"];nwr["amenity"="cafe"];nwr["amenity"="fast_food"];nwr["amenity"="pub"];nwr["amenity"="biergarten"];',
},
{
id: "groceries",
name: "poi.groceries",
icon: "🛒",
color: "#f97316",
query: 'nwr["shop"="supermarket"];nwr["shop"="convenience"];nwr["shop"="bakery"];',
},
{
id: "bike_infra",
name: "poi.bikeInfra",
icon: "🔧",
color: "#8b5cf6",
query: 'nwr["amenity"="bicycle_parking"];nwr["amenity"="bicycle_repair_station"];nwr["amenity"="bicycle_rental"];',
profiles: ["fastbike", "safety"],
},
{
id: "accommodation",
name: "poi.accommodation",
icon: "🏨",
color: "#0891b2",
query: 'nwr["tourism"="hotel"];nwr["tourism"="hostel"];nwr["tourism"="guest_house"];',
},
{
id: "viewpoints",
name: "poi.viewpoints",
icon: "👁️",
color: "#9333ea",
query: 'nwr["tourism"="viewpoint"];',
profiles: ["trekking"],
},
{
id: "toilets",
name: "poi.toilets",
icon: "🚻",
color: "#6b7280",
query: 'nwr["amenity"="toilets"];',
},
];
export function getCategoriesForProfile(profile: string): string[] {
return poiCategories
.filter((c) => c.profiles?.includes(profile))
.map((c) => c.id);
}
/** Profile → tile overlay mapping */
export const profileOverlayDefaults: Record<string, string[]> = {
fastbike: ["waymarked-cycling"],
safety: ["waymarked-cycling"],
trekking: ["waymarked-hiking"],
};

View file

@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { queryPois, OverpassRateLimitError, type Poi, type BBox } from "./overpass.ts";
import { getCached, setCached } from "./poi-cache.ts";
import { poiCategories, type PoiCategory } from "./poi-categories.ts";
const MIN_ZOOM = 12;
const DEBOUNCE_MS = 500;
const BACKOFF_BASE_MS = 5000;
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: (ids: string[]) => void;
toggleCategory: (id: string) => void;
refresh: (bbox: BBox, zoom: number) => void;
}
export function usePois(): 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 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;
}
debounceRef.current = setTimeout(async () => {
// Cancel previous request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setStatus("loading");
try {
const result = await queryPois(bbox, categories, 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 OverpassRateLimitError) {
setStatus("rate_limited");
backoffRef.current = Math.min(
(backoffRef.current || BACKOFF_BASE_MS) * 2,
MAX_BACKOFF_MS,
);
} else {
setStatus("error");
}
}
}, DEBOUNCE_MS);
},
[enabledCategories],
);
// Cleanup on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return { pois, status, enabledCategories, setEnabledCategories, toggleCategory, refresh };
}

View file

@ -13,23 +13,23 @@
## 3. Overpass Client
- [ ] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function
- [ ] 3.2 Build Overpass QL union queries from enabled POI category configs
- [ ] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags)
- [ ] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries)
- [x] 3.1 Create `apps/planner/app/lib/overpass.ts` with `queryPois(bbox, categories)` function
- [x] 3.2 Build Overpass QL union queries from enabled POI category configs
- [x] 3.3 Parse `[out:json]` response into typed `Poi` objects (id, lat, lon, name, category, tags)
- [x] 3.4 Deduplicate results by OSM node ID (same node may match multiple tag queries)
## 4. POI Caching & Rate Limiting
- [ ] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL
- [ ] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query
- [ ] 4.3 Use AbortController to cancel in-flight requests when viewport changes
- [ ] 4.4 Handle 429 responses with exponential backoff and user-visible message
- [ ] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below
- [x] 4.1 Implement tile-based cache: quantize viewport to 0.1° grid cells, cache per cell with 10-minute TTL
- [x] 4.2 Add 500ms debounce on map `moveend` before triggering Overpass query
- [x] 4.3 Use AbortController to cancel in-flight requests when viewport changes
- [x] 4.4 Handle 429 responses with exponential backoff and user-visible message
- [x] 4.5 Enforce zoom >= 12 threshold — show "Zoom in to see POIs" message below
## 5. POI Category Configuration
- [ ] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets)
- [ ] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles
- [x] 5.1 Define `PoiCategory` type and initial category configs (water, shelter, camping, food, groceries, bike infra, accommodation, viewpoints, toilets)
- [x] 5.2 Map each category to Overpass QL tag queries, icon, color, and applicable routing profiles
## 6. POI Overlay Panel