diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts new file mode 100644 index 0000000..48b1507 --- /dev/null +++ b/apps/planner/app/lib/overpass.ts @@ -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; +} + +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; + }> }, + 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, 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(); + 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 { + 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"; + } +} diff --git a/apps/planner/app/lib/poi-cache.ts b/apps/planner/app/lib/poi-cache.ts new file mode 100644 index 0000000..2ee7eb2 --- /dev/null +++ b/apps/planner/app/lib/poi-cache.ts @@ -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(); + +/** + * 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(); + 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(); +} diff --git a/apps/planner/app/lib/poi-categories.ts b/apps/planner/app/lib/poi-categories.ts new file mode 100644 index 0000000..6f016ad --- /dev/null +++ b/apps/planner/app/lib/poi-categories.ts @@ -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 = { + fastbike: ["waymarked-cycling"], + safety: ["waymarked-cycling"], + trekking: ["waymarked-hiking"], +}; diff --git a/apps/planner/app/lib/use-pois.ts b/apps/planner/app/lib/use-pois.ts new file mode 100644 index 0000000..de421b6 --- /dev/null +++ b/apps/planner/app/lib/use-pois.ts @@ -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([]); + const [status, setStatus] = useState("idle"); + const [enabledCategories, setEnabledCategories] = useState([]); + const abortRef = useRef(null); + const debounceRef = useRef>(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 }; +} diff --git a/openspec/changes/osm-overlays/tasks.md b/openspec/changes/osm-overlays/tasks.md index 16bcb6e..722c4fc 100644 --- a/openspec/changes/osm-overlays/tasks.md +++ b/openspec/changes/osm-overlays/tasks.md @@ -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