Merge 3076ee54ad into 759652207f
This commit is contained in:
commit
11089b3ce6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue