Create/edit geocoding used Nominatim alone, so when it failed (e.g. the 429) a new spot published with no city/country and landed in 'Uncategorised'. Now reverseGeocodeRegions() tries Nominatim, then falls back to BigDataCloud's client-side reverse-geocode-client, so a spot is region-tagged by default. Still never throws (an outage can't block a publish); the spot keeps its geohash and the stats' read-time derivation can still place it. derivedRegions reuses the same geocodeBigDataCloud helper (one implementation).
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
/**
|
|
* 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<string, DerivedRegion> | null = null;
|
|
|
|
function readCache(): Record<string, DerivedRegion> {
|
|
if (memoryCache) return memoryCache;
|
|
try {
|
|
memoryCache = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, DerivedRegion>;
|
|
} 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<number> {
|
|
let filled = 0;
|
|
const seen = new Set<string>();
|
|
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;
|
|
}
|