Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.
- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
create, shop create, shop edit), so the Nominatim call + Accept-Language + the
parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
'{N} of {M}' footnote let the card reconcile with the location total. Section
retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.
The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
55 lines
2.4 KiB
TypeScript
55 lines
2.4 KiB
TypeScript
/**
|
|
* Reverse geocoding for spot/shop location tags
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Turns a pin into `city` / `country` / `country_code` tags via OpenStreetMap
|
|
* **Nominatim**. Best-effort by design — it never throws, so a geocoder outage
|
|
* can never block a publish.
|
|
*
|
|
* ⚠️ Nominatim sees the caller's IP. That is disclosed in `/privacy` →
|
|
* "What leaves your device" → *Spot geocoding*. The coordinates are the
|
|
* **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 {
|
|
/** 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: '' };
|
|
|
|
/** Best-effort reverse geocode. Resolves to empty strings on any failure. */
|
|
export async function reverseGeocodeRegions(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' } },
|
|
);
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** 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]);
|
|
}
|