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:
parent
0e399e5174
commit
375ff2fa13
5 changed files with 423 additions and 11 deletions
130
apps/planner/app/lib/overpass.ts
Normal file
130
apps/planner/app/lib/overpass.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
86
apps/planner/app/lib/poi-cache.ts
Normal file
86
apps/planner/app/lib/poi-cache.ts
Normal 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();
|
||||
}
|
||||
90
apps/planner/app/lib/poi-categories.ts
Normal file
90
apps/planner/app/lib/poi-categories.ts
Normal 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"],
|
||||
};
|
||||
106
apps/planner/app/lib/use-pois.ts
Normal file
106
apps/planner/app/lib/use-pois.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue