SkateSpots/src/lib/derivedRegions.ts
SkateSpots fe7e5cfae4 feat(spots): tag new spots/shops by default with a BigDataCloud fallback
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).
2026-10-01 14:37:29 +07:00

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;
}