This commit is contained in:
milu70hub 2026-09-09 18:21:08 +00:00 committed by GitHub
commit 11089b3ce6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 249 additions and 8 deletions

View File

@ -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) 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
still take over whenever a key is configured.
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish

View File

@ -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 |

View File

@ -2143,6 +2143,12 @@ 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=<place>[&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)
`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC:

134
server/keylessGeocode.js Normal file
View File

@ -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=<place>[&bounds=<s>,<w>|<n>,<e>]` 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) });
}
});
}

View File

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

View File

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

View File

@ -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 <anywhere>" 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;

View File

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