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).
This commit is contained in:
parent
8fd5bab3f1
commit
fe7e5cfae4
3 changed files with 81 additions and 48 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -866,15 +866,18 @@ session with **no other memory of this project**. So:
|
||||||
tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`;
|
tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`;
|
||||||
the stats can't know about reports without querying them).
|
the stats can't know about reports without querying them).
|
||||||
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
|
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
|
||||||
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `pushRegionTags`) is the
|
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
||||||
**single source of truth**, used by **spot create, spot edit, shop create and
|
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
||||||
shop edit**; it fills `city`/`country`/`country_code` via
|
edit, shop create and shop edit**. `reverseGeocodeRegions()` tries
|
||||||
`nominatim.openstreetmap.org/reverse?lat=…&lon=…` (`Accept-Language: en`) and
|
`nominatim.openstreetmap.org/reverse?lat=…&lon=…` (`Accept-Language: en`) first,
|
||||||
**never throws** (a geocoder outage can't block a publish). Nominatim **sees the
|
then **falls back to BigDataCloud** when Nominatim returns nothing (it 429s —
|
||||||
creator's IP** and was missing from `/privacy`'s "servers that see your IP"
|
see the derived-regions note above), so a new spot is tagged **by default**. It
|
||||||
list — now disclosed as "Spot/shop geocoding". Coordinates are the **pin you
|
**never throws** (a geocoder outage can't block a publish; the spot still gets
|
||||||
placed**, never the device's live GPS. ⚠️ Any new outbound call that receives
|
its geohash and the stats' read-time derivation can place it). Both providers
|
||||||
coordinates must be added to `/privacy` in the same change.
|
**see the creator's IP** and are disclosed on `/privacy` → "Spot/shop
|
||||||
|
geocoding". Coordinates are the **pin you placed**, never the device's live GPS.
|
||||||
|
⚠️ Any new outbound call that receives coordinates must be added to `/privacy`
|
||||||
|
in the same change.
|
||||||
⚠️ **`EditSpotPage` used to DROP region tags (fixed 2026-10-01):** like
|
⚠️ **`EditSpotPage` used to DROP region tags (fixed 2026-10-01):** like
|
||||||
`EditShopPage` it rebuilds `eventTags` from scratch, so any `city`/`country`/
|
`EditShopPage` it rebuilds `eventTags` from scratch, so any `city`/`country`/
|
||||||
`country_code` the spot already had were silently lost on save (the new
|
`country_code` the spot already had were silently lost on save (the new
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
|
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
||||||
|
import { geocodeBigDataCloud } from '@/lib/geocodeRegions';
|
||||||
|
|
||||||
export interface DerivedRegion {
|
export interface DerivedRegion {
|
||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
|
@ -35,7 +37,6 @@ const STORAGE_KEY = 'skatespots:derived-regions-v1';
|
||||||
* ~3 req/s. The whole list is one pass per device, then cached.
|
* ~3 req/s. The whole list is one pass per device, then cached.
|
||||||
*/
|
*/
|
||||||
const GEOCODE_SPACING_MS = 300;
|
const GEOCODE_SPACING_MS = 300;
|
||||||
const REQUEST_TIMEOUT_MS = 8000;
|
|
||||||
|
|
||||||
let memoryCache: Record<string, DerivedRegion> | null = null;
|
let memoryCache: Record<string, DerivedRegion> | null = null;
|
||||||
|
|
||||||
|
|
@ -94,31 +95,6 @@ export function coordsOf(event: { tags: string[][] }): { lat: number; lng: numbe
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort reverse geocode via BigDataCloud. Never throws. */
|
|
||||||
async function geocodeClient(lat: number, lng: number): Promise<DerivedRegion> {
|
|
||||||
const empty: DerivedRegion = { city: '', country: '', countryCode: '' };
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
|
|
||||||
{ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) },
|
|
||||||
);
|
|
||||||
if (!res.ok) return empty;
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
city?: string;
|
|
||||||
locality?: string;
|
|
||||||
countryName?: string;
|
|
||||||
countryCode?: string;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
city: data.city || data.locality || '',
|
|
||||||
country: data.countryName || '',
|
|
||||||
countryCode: String(data.countryCode ?? '').trim().toUpperCase(),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse-geocode a list of coordinates and fill the cache. Sequential and
|
* 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).
|
* lightly spaced. `onProgress` fires after each item (so a UI counter can tick).
|
||||||
|
|
@ -136,9 +112,9 @@ export async function fillDerivedRegions(
|
||||||
// Skip duplicates / already-cached pins, but still advance the progress bar.
|
// Skip duplicates / already-cached pins, but still advance the progress bar.
|
||||||
if (!seen.has(key) && !getDerivedRegion(lat, lng)) {
|
if (!seen.has(key) && !getDerivedRegion(lat, lng)) {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const geo = await geocodeClient(lat, lng);
|
const geo = await geocodeBigDataCloud(lat, lng);
|
||||||
if (geo.city || geo.country || geo.countryCode) {
|
if (geo.city || geo.country || geo.countryCode) {
|
||||||
setDerivedRegion(lat, lng, geo);
|
setDerivedRegion(lat, lng, { city: geo.city, country: geo.country, countryCode: geo.countryCode });
|
||||||
filled += 1;
|
filled += 1;
|
||||||
}
|
}
|
||||||
if (i < coords.length - 1) {
|
if (i < coords.length - 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
/**
|
/**
|
||||||
* Reverse geocoding for spot/shop location tags
|
* Reverse geocoding for spot/shop location tags
|
||||||
* ─────────────────────────────────────────────────────────────────────────────
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
* Turns a pin into `city` / `country` / `country_code` tags via OpenStreetMap
|
* Turns a pin into `city` / `country` / `country_code` tags so the map AND the
|
||||||
* **Nominatim**. Best-effort by design — it never throws, so a geocoder outage
|
* community stats agree on where a spot is. This is the **single source of truth**
|
||||||
* can never block a publish.
|
* for the request, used by spot create/edit and shop create/edit.
|
||||||
*
|
*
|
||||||
* ⚠️ Nominatim sees the caller's IP. That is disclosed in `/privacy` →
|
* Two providers, tried in order, so a new spot is tagged by default:
|
||||||
* "What leaves your device" → *Spot geocoding*. The coordinates are the
|
* 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.
|
* **spot/shop pin**, never the device's live GPS.
|
||||||
*
|
|
||||||
* Single source of truth for the request (spot create, shop create, shop edit),
|
|
||||||
* so the `Accept-Language: en` + parse rules can't drift between callers.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GeocodedRegions {
|
export interface GeocodedRegions {
|
||||||
|
|
@ -26,12 +34,18 @@ export interface GeocodedRegions {
|
||||||
|
|
||||||
const EMPTY: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' };
|
const EMPTY: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' };
|
||||||
|
|
||||||
/** Best-effort reverse geocode. Resolves to empty strings on any failure. */
|
const NOMINATIM_TIMEOUT_MS = 6000;
|
||||||
export async function reverseGeocodeRegions(lat: number, lng: number): Promise<GeocodedRegions> {
|
const BIGDATA_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/** Primary: OpenStreetMap Nominatim. Never throws. */
|
||||||
|
async function geocodeNominatim(lat: number, lng: number): Promise<GeocodedRegions> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
|
||||||
{ headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } },
|
{
|
||||||
|
headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' },
|
||||||
|
signal: AbortSignal.timeout(NOMINATIM_TIMEOUT_MS),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!res.ok) return EMPTY;
|
if (!res.ok) return EMPTY;
|
||||||
const data = (await res.json()) as { address?: Record<string, string> };
|
const data = (await res.json()) as { address?: Record<string, string> };
|
||||||
|
|
@ -47,6 +61,46 @@ export async function reverseGeocodeRegions(lat: number, lng: number): Promise<G
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Push the region tags onto an event tag list, skipping empty values. */
|
||||||
export function pushRegionTags(tags: string[][], geo: GeocodedRegions): void {
|
export function pushRegionTags(tags: string[][], geo: GeocodedRegions): void {
|
||||||
if (geo.city) tags.push(['city', geo.city]);
|
if (geo.city) tags.push(['city', geo.city]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue