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).
109 lines
4.4 KiB
TypeScript
109 lines
4.4 KiB
TypeScript
/**
|
|
* Reverse geocoding for spot/shop location tags
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Turns a pin into `city` / `country` / `country_code` tags so the map AND the
|
|
* community stats agree on where a spot is. This is the **single source of truth**
|
|
* for the request, used by spot create/edit and shop create/edit.
|
|
*
|
|
* Two providers, tried in order, so a new spot is tagged by default:
|
|
* 1. OpenStreetMap **Nominatim** (primary — matches existing data).
|
|
* 2. **BigDataCloud** `reverse-geocode-client` (fallback — client-friendly, no
|
|
* key). Nominatim's usage policy forbids per-viewer volume and can 429; a
|
|
* single create/edit call is fine, but we don't want a failure to mean an
|
|
* untagged spot, so we fall back.
|
|
*
|
|
* Best-effort by design — it **never throws**, so a geocoder outage can never
|
|
* block a publish. If both providers fail the spot still publishes (with its
|
|
* geohash); the stats' read-time derivation can still place it.
|
|
*
|
|
* ⚠️ Both providers see the caller's IP. Disclosed in `/privacy` →
|
|
* "What leaves your device" → *Spot/shop geocoding*. The coordinates are the
|
|
* **spot/shop pin**, never the device's live GPS.
|
|
*/
|
|
|
|
export interface GeocodedRegions {
|
|
/** Best available human city/town/village/county name. */
|
|
city: string;
|
|
/** Nominatim's display country name (e.g. "United Kingdom"). */
|
|
country: string;
|
|
/** Region/state, used to build a readable location string. */
|
|
state: string;
|
|
/** ISO-3166-1 alpha-2, uppercased (e.g. "GB"). The stable stats grouping key. */
|
|
countryCode: string;
|
|
}
|
|
|
|
const EMPTY: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' };
|
|
|
|
const NOMINATIM_TIMEOUT_MS = 6000;
|
|
const BIGDATA_TIMEOUT_MS = 8000;
|
|
|
|
/** Primary: OpenStreetMap Nominatim. Never throws. */
|
|
async function geocodeNominatim(lat: number, lng: number): Promise<GeocodedRegions> {
|
|
try {
|
|
const res = await fetch(
|
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
|
|
{
|
|
headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' },
|
|
signal: AbortSignal.timeout(NOMINATIM_TIMEOUT_MS),
|
|
},
|
|
);
|
|
if (!res.ok) return EMPTY;
|
|
const data = (await res.json()) as { address?: Record<string, string> };
|
|
const addr = data.address ?? {};
|
|
return {
|
|
city: addr.city ?? addr.town ?? addr.village ?? addr.county ?? '',
|
|
country: addr.country ?? '',
|
|
state: addr.state ?? '',
|
|
countryCode: String(addr.country_code ?? '').trim().toUpperCase(),
|
|
};
|
|
} catch {
|
|
return EMPTY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback: BigDataCloud client-side reverse geocoding (no key, CORS `*`).
|
|
* Also used directly by the stats' read-time derivation (`lib/derivedRegions`),
|
|
* where Nominatim must NOT be used. Never throws.
|
|
*/
|
|
export async function geocodeBigDataCloud(lat: number, lng: number): Promise<GeocodedRegions> {
|
|
try {
|
|
const res = await fetch(
|
|
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
|
|
{ signal: AbortSignal.timeout(BIGDATA_TIMEOUT_MS) },
|
|
);
|
|
if (!res.ok) return EMPTY;
|
|
const data = (await res.json()) as {
|
|
city?: string;
|
|
locality?: string;
|
|
countryName?: string;
|
|
countryCode?: string;
|
|
principalSubdivision?: string;
|
|
};
|
|
return {
|
|
city: data.city || data.locality || '',
|
|
country: data.countryName || '',
|
|
state: data.principalSubdivision || '',
|
|
countryCode: String(data.countryCode ?? '').trim().toUpperCase(),
|
|
};
|
|
} catch {
|
|
return EMPTY;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reverse geocode a pin. Nominatim first, then BigDataCloud. Resolves to empty
|
|
* strings only when BOTH providers fail.
|
|
*/
|
|
export async function reverseGeocodeRegions(lat: number, lng: number): Promise<GeocodedRegions> {
|
|
const primary = await geocodeNominatim(lat, lng);
|
|
if (primary.city || primary.country || primary.countryCode) return primary;
|
|
return geocodeBigDataCloud(lat, lng);
|
|
}
|
|
|
|
/** Push the region tags onto an event tag list, skipping empty values. */
|
|
export function pushRegionTags(tags: string[][], geo: GeocodedRegions): void {
|
|
if (geo.city) tags.push(['city', geo.city]);
|
|
if (geo.country) tags.push(['country', geo.country]);
|
|
if (geo.countryCode) tags.push(['country_code', geo.countryCode]);
|
|
}
|