From 2198d0bd51b8075bb5af685c690fadbc2c9bda7b Mon Sep 17 00:00:00 2001 From: TD Date: Wed, 9 Sep 2026 19:16:23 +0200 Subject: [PATCH 1/2] feat(nav): keyless forward geocoding via Nominatim proxy so fly_to_location works without a Google key --- CHANGELOG.md | 9 +++ README.md | 2 +- docs/CURRENT-STATE.md | 5 ++ server/keylessGeocode.js | 134 ++++++++++++++++++++++++++++++++++++ src/keylessGeocode.test.mjs | 77 +++++++++++++++++++++ src/locations.js | 16 +++-- vite.config.js | 4 ++ 7 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 server/keylessGeocode.js create mode 100644 src/keylessGeocode.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..f0dd940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Added + +- Keyless forward geocoding: `fly_to_location` (voice, typed, and the + location bar) now resolves any place name without a Google Maps key through + `GET /api/geocode` (Nominatim, β‰₯1.1 s between upstream calls, 10-minute + cache, policy User-Agent). Answers arrive in the Google Geocoding shape, so + `searchAndFlyTo` keeps one code path; Google Geocoding + Places recovery + still take over whenever a key is configured. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/README.md b/README.md index 86f69c5..287c3da 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ Six keys. Four have a free tier, and the two πŸ”΄ ones are metered: | | Key | Why | Get it | |---|-----|-----|--------| | 🟑 | **Cesium ion** | πŸ—ΊοΈ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) β€” use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | -| πŸ”΄ | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) β€” URL-restrict it | +| πŸ”΄ | **Google Maps** | Direct Google Photorealistic 3D + Google place search (place *navigation* also works without a Google key via OpenStreetMap/Nominatim) ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) β€” URL-restrict it | | πŸ”΄ | **OpenAI** | πŸŽ™οΈ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) β€” metered, see costs below | | 🟑 | **AISStream** | 🚒 Live global ships | [aisstream.io](https://aisstream.io) β€” free signup | | 🟑 | **NASA FIRMS** | πŸ”₯ Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) β€” free | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861..4c84ab4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2143,6 +2143,11 @@ silently demoting every later lookup for the session. - Client render cap `VITE_AIS_LIVE_MAX_ROWS` (default 12,000); type-colored ship icons (tanker/cargo/passenger/fishing/tug); screen-space label clustering caps active labels at `VITE_AIS_LIVE_LABEL_MAX_ROWS` (default 900). - Click-to-inspect wired into the voice context store. +### Keyless forward geocoding (September 2026) + +- `server/keylessGeocode.js` installs `GET /api/geocode?q=[&bounds=s,w|n,e]`. It queries Nominatim `/search` (jsonv2, limit 1, optional soft `viewbox` from the viewport bias), serializes upstream calls at β‰₯1.1 s, caches per query for 10 minutes, and returns `{ status:'OK'|'ZERO_RESULTS', results:[{ formatted_address, geometry:{ location, viewport }, types }] }` β€” the subset of the Google Geocoding shape that `searchAndFlyTo` reads. Nominatim `class/type/addresstype` is mapped onto the Google `types` the navigation-mode heuristic understands (country / administrative_area / locality / neighborhood / route / park / natural_feature / establishment). +- `searchAndFlyTo` picks Google when `GOOGLE_MAPS_API_KEY` is present (unchanged behaviour, Places recovery included) and the proxy otherwise; it no longer throws `No Google Maps API key available for geocoding`. The viewport/landmark/swath framing that follows is shared. + ### Voice Control (June 2026) `GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC: diff --git a/server/keylessGeocode.js b/server/keylessGeocode.js new file mode 100644 index 0000000..179205a --- /dev/null +++ b/server/keylessGeocode.js @@ -0,0 +1,134 @@ +/** + * Keyless forward geocoding for fly_to_location (September 2026). + * + * Without a Google Maps key, "take me to Lisbon" used to throw + * ("No Google Maps API key available for geocoding") so voice and typed + * navigation only worked for the eight preset cities. This proxy answers + * `GET /api/geocode?q=[&bounds=,|,]` from Nominatim + * (OpenStreetMap) and returns the same shape the client already parses for + * Google Geocoding (`status`, `results[0].geometry.location/viewport`, + * `formatted_address`, `types`), so `searchAndFlyTo` keeps one code path. + * + * Nominatim usage policy: max 1 request/second, identifying User-Agent, no + * heavy use. The queue below serializes requests at β‰₯1.1 s and the response + * is cached per query for 10 minutes. + */ + +const NOMINATIM_SEARCH = 'https://nominatim.openstreetmap.org/search'; +const USER_AGENT = 'GodsEyeView/0.1 (+https://github.com/bilawalsidhu/gods-eye-view)'; +const MIN_INTERVAL_MS = 1100; +const CACHE_TTL_MS = 10 * 60 * 1000; +const MAX_QUERY_CHARS = 200; + +let queue = Promise.resolve(); +let lastRequestAt = 0; +const cache = new Map(); + +/** Map Nominatim class/type/addresstype to the Google-style types the client's navigation-mode heuristic understands. */ +export function nominatimTypesToGoogle(hit) { + const cls = String(hit?.class || '').toLowerCase(); + const type = String(hit?.type || '').toLowerCase(); + const addressType = String(hit?.addresstype || '').toLowerCase(); + const key = addressType || type; + if (key === 'country') return ['country', 'political']; + if (['state', 'region', 'province'].includes(key)) return ['administrative_area_level_1', 'political']; + if (['county', 'state_district', 'district'].includes(key)) return ['administrative_area_level_2', 'political']; + if (['city', 'town', 'village', 'municipality', 'hamlet', 'borough', 'city_district'].includes(key)) return ['locality', 'political']; + if (['suburb', 'neighbourhood', 'quarter', 'residential', 'postcode'].includes(key)) return ['neighborhood', 'political']; + if (cls === 'highway' || ['road', 'street', 'pedestrian'].includes(key)) return ['route']; + if (['park', 'garden', 'nature_reserve', 'national_park'].includes(key) || cls === 'leisure') return ['park']; + if (['aerodrome', 'airport'].includes(key)) return ['airport']; + if (['university', 'college'].includes(key)) return ['university']; + if (key === 'stadium') return ['stadium']; + if (cls === 'natural' || cls === 'water' || cls === 'waterway' || ['peak', 'mountain_range', 'sea', 'bay', 'beach', 'island'].includes(key)) return ['natural_feature']; + return ['establishment', 'point_of_interest']; +} + +/** Nominatim hit β†’ Google Geocoding-shaped result. */ +export function nominatimHitToResult(hit) { + const lat = Number(hit?.lat); + const lng = Number(hit?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + const box = Array.isArray(hit.boundingbox) ? hit.boundingbox.map(Number) : []; + const viewport = box.length === 4 && box.every(Number.isFinite) + ? { southwest: { lat: box[0], lng: box[2] }, northeast: { lat: box[1], lng: box[3] } } + : { southwest: { lat: lat - 0.01, lng: lng - 0.01 }, northeast: { lat: lat + 0.01, lng: lng + 0.01 } }; + return { + formatted_address: String(hit.display_name || hit.name || '').trim(), + geometry: { location: { lat, lng }, viewport }, + types: nominatimTypesToGoogle(hit), + place_id: hit.place_id != null ? String(hit.place_id) : undefined, + source: 'nominatim', + }; +} + +/** `bounds=s,w|n,e` (Google order) β†’ Nominatim `viewbox=w,n,e,s`. */ +export function boundsToViewbox(bounds) { + const match = /^(-?[\d.]+),(-?[\d.]+)\|(-?[\d.]+),(-?[\d.]+)$/.exec(String(bounds || '').trim()); + if (!match) return null; + const [s, w, n, e] = match.slice(1); + if (![s, w, n, e].every((v) => Number.isFinite(Number(v)))) return null; + return `${w},${n},${e},${s}`; +} + +async function nominatimSearch(query, { viewbox = null, fetchImpl = fetch, lang = 'en' } = {}) { + const cacheKey = `${query}|${viewbox || ''}|${lang}`; + const cached = cache.get(cacheKey); + if (cached && cached.at > Date.now() - CACHE_TTL_MS) return cached.value; + const task = queue.then(async () => { + const waitMs = Math.max(0, MIN_INTERVAL_MS - (Date.now() - lastRequestAt)); + if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs)); + lastRequestAt = Date.now(); + const params = new URLSearchParams({ q: query, format: 'jsonv2', limit: '1', addressdetails: '0', 'accept-language': lang }); + if (viewbox) params.set('viewbox', viewbox); // soft bias, not bounded + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 9000); + try { + const response = await fetchImpl(`${NOMINATIM_SEARCH}?${params}`, { + headers: { 'User-Agent': USER_AGENT, Referer: 'https://github.com/bilawalsidhu/gods-eye-view' }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Nominatim ${response.status}`); + const hits = await response.json(); + const result = Array.isArray(hits) && hits.length ? nominatimHitToResult(hits[0]) : null; + const value = result ? { status: 'OK', results: [result] } : { status: 'ZERO_RESULTS', results: [] }; + cache.set(cacheKey, { at: Date.now(), value }); + return value; + } finally { + clearTimeout(timer); + } + }); + queue = task.catch(() => null); + return task; +} + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(payload)); +} + +/** + * @param {import('connect').Server} middlewares + * @param {{ fetchImpl?: typeof fetch, lang?: string }} [options] + */ +export function installKeylessGeocodeMiddleware(middlewares, { fetchImpl = fetch, lang = 'en' } = {}) { + middlewares.use('/api/geocode', async (req, res) => { + if (req.method !== 'GET') return sendJson(res, 405, { status: 'ERROR', error: 'Method not allowed' }); + let url; + try { + url = new URL(req.url || '/', 'http://localhost'); + } catch { + return sendJson(res, 400, { status: 'ERROR', error: 'Bad URL' }); + } + const query = String(url.searchParams.get('q') || '').trim().slice(0, MAX_QUERY_CHARS); + if (!query) return sendJson(res, 400, { status: 'ERROR', error: 'q required' }); + const viewbox = boundsToViewbox(url.searchParams.get('bounds')); + try { + const payload = await nominatimSearch(query, { viewbox, fetchImpl, lang }); + sendJson(res, 200, payload); + } catch (error) { + sendJson(res, 502, { status: 'ERROR', error: String(error?.message || error).slice(0, 200) }); + } + }); +} diff --git a/src/keylessGeocode.test.mjs b/src/keylessGeocode.test.mjs new file mode 100644 index 0000000..3f24105 --- /dev/null +++ b/src/keylessGeocode.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + boundsToViewbox, + installKeylessGeocodeMiddleware, + nominatimHitToResult, + nominatimTypesToGoogle, +} from '../server/keylessGeocode.js'; + +test('Nominatim types map onto the Google types the navigation-mode heuristic reads', () => { + assert.deepEqual(nominatimTypesToGoogle({ class: 'boundary', type: 'administrative', addresstype: 'country' }), ['country', 'political']); + assert.deepEqual(nominatimTypesToGoogle({ class: 'place', type: 'city', addresstype: 'city' }), ['locality', 'political']); + assert.deepEqual(nominatimTypesToGoogle({ class: 'place', type: 'town' }), ['locality', 'political']); + assert.deepEqual(nominatimTypesToGoogle({ class: 'highway', type: 'residential', addresstype: 'road' }), ['route']); + assert.deepEqual(nominatimTypesToGoogle({ class: 'natural', type: 'peak' }), ['natural_feature']); + assert.deepEqual(nominatimTypesToGoogle({ class: 'tourism', type: 'attraction' }), ['establishment', 'point_of_interest']); +}); + +test('a Nominatim hit becomes a Google-shaped result with location and viewport', () => { + const result = nominatimHitToResult({ + lat: '38.7223', lon: '-9.1393', display_name: 'Lisboa, Portugal', place_id: 42, + boundingbox: ['38.6913', '38.7958', '-9.2298', '-9.0863'], class: 'boundary', type: 'administrative', addresstype: 'city', + }); + assert.equal(result.formatted_address, 'Lisboa, Portugal'); + assert.deepEqual(result.geometry.location, { lat: 38.7223, lng: -9.1393 }); + assert.deepEqual(result.geometry.viewport, { southwest: { lat: 38.6913, lng: -9.2298 }, northeast: { lat: 38.7958, lng: -9.0863 } }); + assert.deepEqual(result.types, ['locality', 'political']); + assert.equal(nominatimHitToResult({ lat: 'x', lon: '1' }), null); +}); + +test('Google-order bounds become a Nominatim viewbox', () => { + assert.equal(boundsToViewbox('38.6,-9.3|38.8,-9.0'), '-9.3,38.8,-9.0,38.6'); + assert.equal(boundsToViewbox('garbage'), null); + assert.equal(boundsToViewbox(''), null); +}); + +function fakeApp() { + const routes = new Map(); + return { use: (path, handler) => routes.set(path, handler), routes }; +} +function fakeRes() { + const res = { statusCode: 200, headers: {}, body: '' }; + res.setHeader = (k, v) => { res.headers[k] = v; }; + res.end = (payload) => { res.body = payload; }; + return res; +} + +test('/api/geocode proxies Nominatim with the policy headers and answers in Google shape', async () => { + const calls = []; + const fetchImpl = async (url, init) => { + calls.push({ url: String(url), headers: init.headers }); + return new Response(JSON.stringify([{ lat: '38.7223', lon: '-9.1393', display_name: 'Lisboa, Portugal', boundingbox: ['38.69', '38.79', '-9.22', '-9.08'], class: 'place', type: 'city' }]), { status: 200 }); + }; + const app = fakeApp(); + installKeylessGeocodeMiddleware(app, { fetchImpl }); + const handler = app.routes.get('/api/geocode'); + const res = fakeRes(); + await handler({ method: 'GET', url: '/?q=Lisboa&bounds=38.6,-9.3|38.8,-9.0' }, res); + const payload = JSON.parse(res.body); + assert.equal(res.statusCode, 200); + assert.equal(payload.status, 'OK'); + assert.equal(payload.results[0].geometry.location.lat, 38.7223); + assert.match(calls[0].url, /nominatim\.openstreetmap\.org\/search\?/); + assert.match(calls[0].url, /q=Lisboa/); + assert.match(calls[0].url, /viewbox=-9\.3%2C38\.8%2C-9\.0%2C38\.6/); + assert.match(calls[0].headers['User-Agent'], /GodsEyeView/); + // cached: a second identical query does not hit upstream + const res2 = fakeRes(); + await handler({ method: 'GET', url: '/?q=Lisboa&bounds=38.6,-9.3|38.8,-9.0' }, res2); + assert.equal(calls.length, 1); + const bad = fakeRes(); + await handler({ method: 'GET', url: '/?q=' }, bad); + assert.equal(bad.statusCode, 400); + const post = fakeRes(); + await handler({ method: 'POST', url: '/?q=x' }, post); + assert.equal(post.statusCode, 405); +}); diff --git a/src/locations.js b/src/locations.js index 36a1226..858194d 100644 --- a/src/locations.js +++ b/src/locations.js @@ -348,7 +348,6 @@ export const CANCELLED_SEARCH = Object.freeze({ cancelled: true }); */ export async function searchAndFlyTo(viewer, query, options = {}) { const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY; - if (!apiKey) throw new Error('No Google Maps API key available for geocoding'); const beforeFly = typeof options.beforeFly === 'function' ? options.beforeFly : null; const mayFly = () => beforeFly === null || beforeFly() !== false; @@ -356,9 +355,17 @@ export async function searchAndFlyTo(viewer, query, options = {}) { // Viewport-biased geocode β€” the same bias annotationResolver's geocodePlace uses: // "Sixth Street" spoken over Austin must prefer the Sixth Street on screen, not a // same-named road in another city (or the wrong end of town β€” the W 6th vs E 6th bug). - let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; + // Without a Google key the keyless `/api/geocode` proxy (Nominatim) answers in + // the same shape, so "take me to " works out of the box instead of throwing. const bias = viewportBias(viewer); - if (bias) url += `&bounds=${bias}`; + let url; + if (apiKey) { + url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; + if (bias) url += `&bounds=${bias}`; + } else { + url = `/api/geocode?q=${encodeURIComponent(query)}`; + if (bias) url += `&bounds=${encodeURIComponent(bias)}`; + } const response = await fetch(url); const data = await response.json(); @@ -372,7 +379,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { // Places-near-view recovery (annotationResolver's twin): a missed geocode, or one // that landed implausibly far from the view centre, snaps back to a view-biased // Places hit within the trust bound β€” "the Capitol" means the one on screen. - const recovered = await placesNearViewRecovery(viewer, query, result ? { lat, lon: lng } : null); + // Places recovery is a Google feature; skipped on the keyless path. + const recovered = apiKey ? await placesNearViewRecovery(viewer, query, result ? { lat, lon: lng } : null) : null; if (recovered) { lat = recovered.lat; lng = recovered.lon; diff --git a/vite.config.js b/vite.config.js index 34dac52..0ce63c1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -47,6 +47,7 @@ import { createRequire } from 'node:module'; import { defineConfig, loadEnv } from 'vite'; import cesium from 'vite-plugin-cesium'; import { normalizeRadioCountryInput } from './src/data/radioCountry.js'; +import { installKeylessGeocodeMiddleware } from './server/keylessGeocode.js'; import { normalizeRegionalArticles, normalizeRegionalPlace, @@ -5149,6 +5150,9 @@ export function openAiRealtimeProxy() { } }); + // Keyless forward geocoding (Nominatim) so fly_to_location works without a Google key. + installKeylessGeocodeMiddleware(middlewares); + middlewares.use('/api/realtime/token', async (req, res) => { if (req.method !== 'GET' && req.method !== 'POST') { res.statusCode = 405; From 3076ee54ad7feb5860e94e31bc6717d40777c2f2 Mon Sep 17 00:00:00 2001 From: TD Date: Wed, 9 Sep 2026 20:21:04 +0200 Subject: [PATCH 2/2] feat(nav): keyless geocoding for annotation targets and route waypoints too --- CHANGELOG.md | 2 +- docs/CURRENT-STATE.md | 1 + src/annotations/annotationResolver.js | 9 ++++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0dd940..3934294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Added - Keyless forward geocoding: `fly_to_location` (voice, typed, and the - location bar) now resolves any place name without a Google Maps key through + location bar) and `annotate_map` targets / route waypoints now resolve any place name without a Google Maps key through `GET /api/geocode` (Nominatim, β‰₯1.1 s between upstream calls, 10-minute cache, policy User-Agent). Answers arrive in the Google Geocoding shape, so `searchAndFlyTo` keeps one code path; Google Geocoding + Places recovery diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c84ab4..aa84e8b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2146,6 +2146,7 @@ silently demoting every later lookup for the session. ### Keyless forward geocoding (September 2026) - `server/keylessGeocode.js` installs `GET /api/geocode?q=[&bounds=s,w|n,e]`. It queries Nominatim `/search` (jsonv2, limit 1, optional soft `viewbox` from the viewport bias), serializes upstream calls at β‰₯1.1 s, caches per query for 10 minutes, and returns `{ status:'OK'|'ZERO_RESULTS', results:[{ formatted_address, geometry:{ location, viewport }, types }] }` β€” the subset of the Google Geocoding shape that `searchAndFlyTo` reads. Nominatim `class/type/addresstype` is mapped onto the Google `types` the navigation-mode heuristic understands (country / administrative_area / locality / neighborhood / route / park / natural_feature / establishment). +- `annotationResolver.geocodePlace` (annotation targets, route waypoints, analyst regions) takes the same keyless branch, so "draw the walking route from A to B" and `fly_route` work without a key. - `searchAndFlyTo` picks Google when `GOOGLE_MAPS_API_KEY` is present (unchanged behaviour, Places recovery included) and the proxy otherwise; it no longer throws `No Google Maps API key available for geocoding`. The viewport/landmark/swath framing that follows is shared. ### Voice Control (June 2026) diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 3bcc143..43144b5 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -604,14 +604,17 @@ function ringAreaM2(ring) { */ async function geocodePlace(query, biasRect, signal) { const apiKey = window.__GOOGLE_MAPS_API_KEY__ || import.meta.env.GOOGLE_MAPS_API_KEY; - if (!apiKey) return null; const cacheKey = `${query.toLowerCase()}|${biasRect || ''}`; const cached = cacheRead(geocodeCache, cacheKey); if (cached !== undefined) return cached; - let url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}`; - if (biasRect) url += `&bounds=${biasRect}`; + // Keyless: the `/api/geocode` proxy (Nominatim) answers in the same shape, so + // annotation targets and route waypoints resolve without a Google key too. + let url = apiKey + ? `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(query)}&key=${apiKey}` + : `/api/geocode?q=${encodeURIComponent(query)}`; + if (biasRect) url += `&bounds=${apiKey ? biasRect : encodeURIComponent(biasRect)}`; try { const response = await fetch(url, { signal });