/** * Derived regions — a read-time fallback for pins with no city/country tag * ───────────────────────────────────────────────────────────────────────────── * The map plots a spot by its **coordinates** (geohash); the region card can only * place a spot that carries a `city`/`country` tag. Spots made before reverse * geocoding existed therefore appear on the map but land in "Uncategorised" — the * map and the stats legitimately disagree. * * This module lets the stats DERIVE the region from the pin's own coordinates * instead, so the card matches the map with no action from the user. Results are * cached in localStorage (keyed by ~110 m-rounded coordinates), so each pin is * reverse-geocoded at most once per device. * * ⚠️ Uses **BigDataCloud** (`reverse-geocode-client`, no key, CORS, designed for * browsers) — NOT Nominatim. Nominatim's usage policy forbids this kind of * per-viewer volume and returned 429 (see AGENTS.md). Nominatim stays for * create/edit geocoding (author-initiated, one call per spot). * * ⚠️ Privacy: unlike create/edit geocoding (the author's own pin), this runs when * the STATS PAGE IS VIEWED, so BigDataCloud sees the viewer's IP and the (public) * coordinates of untagged pins. Disclosed on /privacy. */ import ngeohash from 'ngeohash'; import { geocodeBigDataCloud } from '@/lib/geocodeRegions'; export interface DerivedRegion { city: string; country: string; countryCode: string; } const STORAGE_KEY = 'skatespots:derived-regions-v1'; /** * BigDataCloud tolerates far more than Nominatim's 1 req/s, but keep it polite: * ~3 req/s. The whole list is one pass per device, then cached. */ const GEOCODE_SPACING_MS = 300; let memoryCache: Record | null = null; function readCache(): Record { if (memoryCache) return memoryCache; try { memoryCache = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; } catch { memoryCache = {}; } return memoryCache; } /** ~110 m bucket — matches the 7-char geohash precision used for pins. */ export function regionCoordKey(lat: number, lng: number): string { return `${lat.toFixed(3)},${lng.toFixed(3)}`; } export function getDerivedRegion(lat: number, lng: number): DerivedRegion | undefined { return readCache()[regionCoordKey(lat, lng)]; } export function setDerivedRegion(lat: number, lng: number, region: DerivedRegion): void { const cache = readCache(); cache[regionCoordKey(lat, lng)] = region; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); } catch { /* quota or storage unavailable — keep the in-memory copy */ } } /** Coordinates from explicit lat/lon tags, else decoded from the geohash. */ export function coordsOf(event: { tags: string[][] }): { lat: number; lng: number } | null { const latTag = event.tags.find(([name]) => name === 'lat')?.[1]; const lonTag = event.tags.find(([name]) => name === 'lon')?.[1]; if (latTag && lonTag) { const lat = parseFloat(latTag); const lng = parseFloat(lonTag); if (Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180) { return { lat, lng }; } } const geohash = event.tags.find(([name]) => name === 'g')?.[1]; if (geohash) { try { const { latitude, longitude } = ngeohash.decode(geohash); if (Number.isFinite(latitude) && Number.isFinite(longitude)) { return { lat: latitude, lng: longitude }; } } catch { /* not a decodable geohash */ } } return null; } /** * Reverse-geocode a list of coordinates and fill the cache. Sequential and * lightly spaced. `onProgress` fires after each item (so a UI counter can tick). * Returns how many NEW regions were added. */ export async function fillDerivedRegions( coords: Array<{ lat: number; lng: number }>, onProgress?: (done: number, total: number) => void, ): Promise { let filled = 0; const seen = new Set(); for (let i = 0; i < coords.length; i += 1) { const { lat, lng } = coords[i]; const key = regionCoordKey(lat, lng); // Skip duplicates / already-cached pins, but still advance the progress bar. if (!seen.has(key) && !getDerivedRegion(lat, lng)) { seen.add(key); const geo = await geocodeBigDataCloud(lat, lng); if (geo.city || geo.country || geo.countryCode) { setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode }); filled += 1; } if (i < coords.length - 1) { await new Promise((resolve) => setTimeout(resolve, GEOCODE_SPACING_MS)); } } onProgress?.(i + 1, coords.length); } return filled; }