From ffe24b3dd7b1f3d8c36134dbbf0700d26b83f842 Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 25 Aug 2026 13:07:11 +1000 Subject: [PATCH] =?UTF-8?q?wave4.1:=20key-gated=20extras=20=E2=80=94=20Goo?= =?UTF-8?q?gle=203D=20/=20Bing=20/=20TomTom=20traffic=20slots=20(assets/ke?= =?UTF-8?q?ys.json)=20+=20BYOK=20OpenAI=20Realtime=20voice=20agent,=20all?= =?UTF-8?q?=20dormant=20without=20keys=20(fable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + README.md | 21 ++++ assets/keys.json.example | 5 + css/style.css | 4 + index.html | 6 + js/keyed.js | 90 +++++++++++++ js/main.js | 50 ++++++-- js/ui.js | 10 ++ js/voice.js | 265 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 assets/keys.json.example create mode 100644 js/keyed.js create mode 100644 js/voice.js diff --git a/.gitignore b/.gitignore index 331c67f..8c1b987 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ openskycredentials.json ais_snapshot.json # Vendored upstream open-source release (reference only, 88MB — never commit) gods-eye-view-main/ +# Live keys for the key-gated extras (Wave 4.1) — copy keys.json.example, fill, +# never commit. The OpenAI voice key is browser-localStorage only, never a file. +assets/keys.json diff --git a/README.md b/README.md index 11bc5a9..ce5541b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,27 @@ Select a satellite and its **sub-satellite ground track** draws under it. In **D > OpenSky's anonymous API has a small daily quota (~400 credits; a global snapshot costs 4). The aircraft layer polls every 90 s only while the tab is focused and the clock is live, and shows the remaining quota in its status line. If you hit the limit it backs off and says so. +## Key-gated extras (built in, dormant without keys) + +The upstream God's Eye View features that need paid/registered keys are **fully +wired in but disabled** until a working key activates them. No key → no button, +or a row that explains itself. To activate: copy +[`assets/keys.json.example`](assets/keys.json.example) to `assets/keys.json` +(gitignored) and fill in what you have — locally for dev, or drop the file into +the web root on the VPS for prod. No redeploy needed; reload the page. + +| Key slot | Unlocks | Cost | ⚠️ | +|---|---|---|---| +| `google` | **3D** basemap button — Google Photorealistic 3D Tiles replace the globe | 🔴 metered per session; set quotas + budget alerts | Client-exposed by design — **URL-restrict the key** to your domains in Google Cloud Console | +| `cesiumIon` | **Bing** aerial basemap button | 🟡 free tier (non-commercial) | Use a public `assets:read` scoped token | +| `tomtom` | **Traffic flow (TomTom)** overlay layer (HUD → Context) | 🟡 free developer tier | Restrict by domain in the TomTom portal | +| *(browser only)* | **🎙 VOICE** — OpenAI Realtime voice control: "fly to Tokyo", "turn on military", "FLIR", "scrub back two hours", "situation report" | 🔴 metered (~$0.10–0.30/min) | **Never** goes in keys.json: the button prompts once and stores the key ONLY in your browser's localStorage, sent only to api.openai.com. Set a spend cap on your OpenAI account. | + +The three `keys.json` keys ship to every visitor's browser (that's how these +providers work) — restriction at the provider is the security model, same as +upstream. The voice agent gives the model 7 tools (fly/layers/style/basemap/ +time/reset/report) and answers questions from live layer state. + ## Optional: live AIS ship tracking The demo fleet is the default. To show **real** ship traffic, get a free key from [aisstream.io](https://aisstream.io) and set it in [`js/config.js`](js/config.js): diff --git a/assets/keys.json.example b/assets/keys.json.example new file mode 100644 index 0000000..b95488d --- /dev/null +++ b/assets/keys.json.example @@ -0,0 +1,5 @@ +{ + "google": "", + "cesiumIon": "", + "tomtom": "" +} diff --git a/css/style.css b/css/style.css index d32a0a0..ea39629 100644 --- a/css/style.css +++ b/css/style.css @@ -134,6 +134,10 @@ body.hud-hidden #hud-toggle { } .modes.styles { margin-top: 6px; } .modes.styles button { padding: 4px 6px; font-size: 10px; } +.modes.voice-row { margin-top: 6px; } +.modes.voice-row button { padding: 4px 6px; font-size: 10px; letter-spacing: 0.06em; } +#voice-status { margin: 4px 2px 0; font-size: 10px; color: var(--dim); } +#voice-status[data-state="err"] { color: #ff9f40; } .modes button:hover { color: var(--text); } .modes button.active { color: #04121a; diff --git a/index.html b/index.html index 839c7b5..1e2ac69 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,8 @@
+ +
@@ -29,6 +31,10 @@
+
+ +
+
🪐 SOLARGOD diff --git a/js/keyed.js b/js/keyed.js new file mode 100644 index 0000000..f0a82be --- /dev/null +++ b/js/keyed.js @@ -0,0 +1,90 @@ +// Key-gated features (Wave 4.1) — the upstream God's Eye View extras, built in +// but dormant until a working key activates them. Keys live in assets/keys.json +// (gitignored — copy assets/keys.json.example) and are CLIENT-EXPOSED by +// design: restrict each key at its provider (see README §Key-gated extras). +// No file / empty key = the feature stays hidden or explains itself. +// +// google → "3D" basemap mode: Google Photorealistic 3D Tiles (metered 🔴) +// cesiumIon → "Bing" basemap mode: Bing aerial via Cesium ion (free 🟡) +// tomtom → Traffic flow overlay layer (free 🟡) +// +// OpenAI voice is NOT here — its key must never ship to a server; see voice.js. + +export async function loadKeys() { + try { + const r = await fetch('assets/keys.json', { cache: 'no-store' }); + if (r.ok) return await r.json(); + } catch { /* absent file = no keys, by design */ } + return {}; +} + +// hooks: { onBasemapReady(kind, resource), mode3dActive(), solarActive() } +export async function initKeyed(ctx, hooks) { + const { viewer, Cesium } = ctx; + const keys = await loadKeys(); + + // ---- Bing aerial (Cesium ion asset 2) → "Bing" basemap mode ---- + if (keys.cesiumIon) { + try { + Cesium.Ion.defaultAccessToken = keys.cesiumIon; + const prov = await Cesium.IonImageryProvider.fromAssetId(2); + const layer = viewer.imageryLayers.addImageryProvider(prov); + layer.show = false; + hooks.onBasemapReady('bing', layer); + } catch (err) { + console.warn('[godsigh] Cesium ion key configured but Bing failed:', err); + } + } + + // ---- Google Photorealistic 3D Tiles → "3D" basemap mode ---- + if (keys.google) { + try { + const tiles = await Cesium.Cesium3DTileset.fromUrl( + `https://tile.googleapis.com/v1/3dtiles/root.json?key=${encodeURIComponent(keys.google)}`, + { showCreditsOnScreen: true }, // Google attribution is a licence condition + ); + tiles.show = false; + viewer.scene.primitives.add(tiles); + // keyed.js owns tiles.show: on only in 3D mode and never inside Solar + // System mode (which hides the globe but not this primitive — without + // this, tiles would render under the orrery and stream paid quota). + viewer.scene.preUpdate.addEventListener(() => { + const want = hooks.mode3dActive() && !hooks.solarActive(); + if (tiles.show !== want) tiles.show = want; + }); + hooks.onBasemapReady('3d', tiles); + } catch (err) { + console.warn('[godsigh] Google key configured but 3D tiles failed:', err); + } + } + + // ---- TomTom live traffic flow — row always present so the capability is + // discoverable; keyless it just says how to get the (free) key. ---- + initTraffic(ctx, keys.tomtom); + + return keys; +} + +function initTraffic({ viewer, ui, Cesium }, key) { + let layer = null; + if (key) { + const prov = new Cesium.UrlTemplateImageryProvider({ + url: `https://api.tomtom.com/traffic/map/4/tile/flow/relative0/{z}/{x}/{y}.png?key=${encodeURIComponent(key)}`, + maximumLevel: 20, + credit: 'Traffic © TomTom', + }); + layer = viewer.imageryLayers.addImageryProvider(prov); + layer.alpha = 0.75; + layer.show = false; + } + ui.addLayer('tomtom', 'Traffic flow (TomTom)', false, (on) => { + if (!layer) { + // ponytail: key is not validated up front; a bad key just renders no tiles + ui.setStatus('tomtom', on ? 'needs key — assets/keys.json (free: developer.tomtom.com)' : '', on ? 'warn' : 'off'); + return; + } + layer.show = on; + ui.setStatus('tomtom', on ? 'live congestion tiles · © TomTom' : '', on ? 'ok' : 'off'); + }, 'Context'); + ui.setStatus('tomtom', key ? 'key loaded — toggle on' : 'off — free key unlocks', 'off'); +} diff --git a/js/main.js b/js/main.js index e3a4c33..fcd655e 100644 --- a/js/main.js +++ b/js/main.js @@ -24,6 +24,8 @@ import { ADSB_LAYERS } from './adsb-registry.js'; import { createAdsbLayer } from './layers/adsb-layer.js'; import initSolarSystem from './solarsystem.js'; import * as styles from './styles.js'; +import * as keyed from './keyed.js'; +import { initVoice } from './voice.js'; const Cesium = window.Cesium; @@ -62,18 +64,28 @@ const photoLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvi })); photoLayer.show = false; +// Four basemap modes; bing/3d exist only once keyed.js activates them (Wave 4.1). let currentMode = 'data'; +let bingLayer = null; // Cesium ion Bing imagery layer, set by keyed.js +let google3d = false; // Google 3D tileset ready (keyed.js owns its .show) function setMode(mode) { + if (mode === 'bing' && !bingLayer) mode = 'data'; // keyless → graceful fallback + if (mode === '3d' && !google3d) mode = 'data'; + currentMode = mode; const photo = mode === 'photo'; - currentMode = photo ? 'photo' : 'data'; + const bing = mode === 'bing'; photoLayer.show = photo; - darkLayer.show = !photo; + darkLayer.show = mode === 'data'; + if (bingLayer) bingLayer.show = bing; // Data Mode = flat high-contrast asset map (always readable). - // Photo Mode = realistic imagery with a live day/night terminator. - viewer.scene.globe.enableLighting = photo; - viewer.scene.globe.showGroundAtmosphere = photo; - document.getElementById('mode-data').classList.toggle('active', !photo); - document.getElementById('mode-photo').classList.toggle('active', photo); + // Photo/Bing = realistic imagery with a live day/night terminator. + // 3D = Google Photorealistic tiles replace the globe entirely. + viewer.scene.globe.show = mode !== '3d'; + viewer.scene.globe.enableLighting = photo || bing; + viewer.scene.globe.showGroundAtmosphere = photo || bing; + for (const [id, m] of [['mode-data', 'data'], ['mode-photo', 'photo'], ['mode-bing', 'bing'], ['mode-3d', '3d']]) { + document.getElementById(id)?.classList.toggle('active', mode === m); + } } document.getElementById('mode-data').addEventListener('click', () => setMode('data')); document.getElementById('mode-photo').addEventListener('click', () => setMode('photo')); @@ -140,6 +152,10 @@ function applyHashState() { const m = params.get('m'); if (m === 'p') { setMode('photo'); state.modeApplied = true; } else if (m === 'd') { setMode('data'); state.modeApplied = true; } + // Keyed basemaps aren't loaded yet at boot — remember the wish, applied by + // keyed.js's onBasemapReady (keyless viewers just stay on the default). + else if (m === 'b') state.keyedMode = 'bing'; + else if (m === 'g') state.keyedMode = '3d'; const s = params.get('s'); if (s) styles.setStyle(s); // unknown ids are ignored by setStyle @@ -173,7 +189,7 @@ function serializeState() { const height = Math.round(carto.height); const heading = (((Cesium.Math.toDegrees(cam.heading) % 360) + 360) % 360).toFixed(3); const pitch = Cesium.Math.toDegrees(cam.pitch).toFixed(3); - const mode = currentMode === 'photo' ? 'p' : 'd'; + const mode = { data: 'd', photo: 'p', bing: 'b', '3d': 'g' }[currentMode] || 'd'; const onLayers = ui.getLayerIds().filter((id) => ui.getLayerChecked(id)); const curMs = Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime(); const offset = Math.round((curMs - Date.now()) / 1000); @@ -294,4 +310,22 @@ loadLayers().then(() => { // Solar System mode ("Travel to…") — a separate mode, not a layer. const solarSystem = initSolarSystem(ctx); +// ---- key-gated extras (Wave 4.1): Google 3D, Bing aerial, TomTom, voice ---- +keyed.initKeyed(ctx, { + onBasemapReady: (kind, resource) => { + if (kind === 'bing') bingLayer = resource; + if (kind === '3d') google3d = true; + const btn = document.getElementById(kind === 'bing' ? 'mode-bing' : 'mode-3d'); + if (btn) { + btn.hidden = false; + btn.addEventListener('click', () => setMode(kind)); + } + if (hashState.keyedMode === kind) setMode(kind); // honor a shared link's mode + }, + mode3dActive: () => currentMode === '3d', + solarActive: () => solarSystem.isActive(), +}).catch((err) => console.warn('[godsigh] keyed init failed:', err)); + +initVoice({ viewer, ui, Cesium, CONFIG, styles, setMode, getMode: () => currentMode }); + window.__godsigh = { viewer, ctx, activeLayers, solarSystem }; // debug handle diff --git a/js/ui.js b/js/ui.js index 3b5bef2..e2efd80 100644 --- a/js/ui.js +++ b/js/ui.js @@ -79,6 +79,16 @@ export function getLayerIds() { return [...rows.keys()]; } +// Full layer snapshot for the voice agent: id, display name, on/off, status. +export function layerReport() { + return [...rows.entries()].map(([id, row]) => ({ + id, + name: row.querySelector('.lname').textContent, + on: row.querySelector('input').checked, + status: row.querySelector('.lstatus').textContent, + })); +} + // Is a layer's checkbox currently checked? export function getLayerChecked(id) { const row = rows.get(id); diff --git a/js/voice.js b/js/voice.js new file mode 100644 index 0000000..ee3f0b6 --- /dev/null +++ b/js/voice.js @@ -0,0 +1,265 @@ +// Voice control (Wave 4.1) — hands-free GODSIGH via the OpenAI Realtime API +// over WebSocket, in the spirit of the open-sourced God's Eye View voice agent +// (28 tools there; the ponytail cut here is 7 that cover fly/layers/styles/ +// basemap/time/report). +// +// BYOK, strictly: the key is asked for once in a browser prompt and lives ONLY +// in this browser's localStorage ('godsigh_openai_key'). It is never in the +// repo, never on the server, never in assets/keys.json, and is sent only to +// api.openai.com. No key → the button explains itself and nothing else changes. +// +// Costs are the user's own: Realtime audio is metered (~$0.10–0.30/min). + +const MODEL = 'gpt-realtime'; +const KEY_STORE = 'godsigh_openai_key'; + +// host: { viewer, ui, Cesium, CONFIG, styles, setMode, getMode } +export function initVoice(host) { + const btn = document.getElementById('voice-btn'); + const statusEl = document.getElementById('voice-status'); + if (!btn) return; + + let session = null; + const setStatus = (text, state) => { + if (!statusEl) return; + statusEl.hidden = !text; + statusEl.textContent = text || ''; + statusEl.dataset.state = state || 'ok'; + }; + + btn.addEventListener('click', async () => { + if (session) { session.stop('voice off'); return; } + let key = localStorage.getItem(KEY_STORE); + if (!key) { + key = window.prompt( + 'OpenAI API key for voice control.\nStored ONLY in this browser (localStorage); sent only to api.openai.com. Realtime audio is metered — set a usage cap on your OpenAI account.'); + if (!key || !key.trim()) return; + key = key.trim(); + localStorage.setItem(KEY_STORE, key); + } + btn.disabled = true; + session = await startSession(host, key, setStatus, () => { + session = null; + btn.classList.remove('active'); + }); + btn.disabled = false; + if (session) btn.classList.add('active'); + }); +} + +async function startSession(host, key, setStatus, onEnd) { + const { Cesium, viewer } = host; + + // ---- microphone ---- + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + setStatus('microphone denied — voice needs mic access', 'err'); + onEnd(); + return null; + } + + const ac = new AudioContext({ sampleRate: 24000 }); + let ws = null; + let opened = false; + let gotSession = false; + let stopped = false; + let playTime = 0; // next scheduled playback time + let playing = []; // scheduled AudioBufferSourceNodes (for barge-in) + + function stop(msg, bad) { + if (stopped) return; + stopped = true; + try { ws && ws.close(); } catch { /* already closed */ } + stream.getTracks().forEach((t) => t.stop()); + try { ac.close(); } catch { /* already closed */ } + setStatus(msg || '', bad ? 'err' : 'ok'); + onEnd(); + } + + // ---- websocket (browser auth via subprotocol) ---- + const startedAt = Date.now(); + try { + ws = new WebSocket( + `wss://api.openai.com/v1/realtime?model=${MODEL}`, + ['realtime', `openai-insecure-api-key.${key}`, 'openai-beta.realtime-v1'], + ); + } catch { + stop('connection failed', true); + return null; + } + + ws.onopen = () => { + opened = true; + ws.send(JSON.stringify({ + type: 'session.update', + session: { + type: 'realtime', + instructions: + 'You are the voice of GODSIGH, a live OSINT world globe. Speak like a terse mission operator: confirmations under 8 words. ' + + 'Use the tools for every action and for any question about what is on screen (situation_report). ' + + 'Never invent data — if a tool result is empty, say so.', + tools: toolDefs(host), + tool_choice: 'auto', + }, + })); + setStatus('listening…', 'ok'); + }; + + ws.onmessage = async (m) => { + let ev; + try { ev = JSON.parse(m.data); } catch { return; } + if (ev.type === 'session.created' || ev.type === 'session.updated') gotSession = true; + else if (ev.type === 'response.output_audio.delta' || ev.type === 'response.audio.delta') play(ev.delta); + else if (ev.type === 'input_audio_buffer.speech_started') bargeIn(); + else if (ev.type === 'response.function_call_arguments.done') { + let args = {}; + try { args = JSON.parse(ev.arguments || '{}'); } catch { /* leave {} */ } + const output = await dispatch(host, ev.name, args); + ws.send(JSON.stringify({ type: 'conversation.item.create', item: { type: 'function_call_output', call_id: ev.call_id, output } })); + ws.send(JSON.stringify({ type: 'response.create' })); + } else if (ev.type === 'error') { + // Session-fatal errors surface here (bad session config, bad key scope…) + setStatus(`voice error: ${(ev.error && ev.error.message) || 'unknown'}`, 'err'); + } + }; + + ws.onclose = () => { + // Rejected key: closes almost immediately, before any session event. + if (!gotSession && Date.now() - startedAt < 5000) { + localStorage.removeItem(KEY_STORE); + stop('key rejected — stored key cleared, click to retry', true); + } else { + stop(stopped ? '' : 'voice disconnected', !stopped); + } + }; + ws.onerror = () => { /* onclose always follows and handles it */ }; + + // ---- mic → pcm16 base64 → input_audio_buffer.append ---- + const src = ac.createMediaStreamSource(stream); + // ponytail: ScriptProcessor is deprecated but universal; AudioWorklet when it breaks + const proc = ac.createScriptProcessor(4096, 1, 1); + proc.onaudioprocess = (e) => { + if (!ws || ws.readyState !== 1) return; + ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: pcm16b64(e.inputBuffer.getChannelData(0)) })); + }; + src.connect(proc); + proc.connect(ac.destination); // required sink; we never write output = silence + + // ---- model audio out (pcm16 24k base64 deltas) ---- + function play(b64) { + const bin = atob(b64); + const n = bin.length / 2; + if (!n) return; + const buf = ac.createBuffer(1, n, 24000); + const ch = buf.getChannelData(0); + for (let i = 0; i < n; i++) { + const v = (bin.charCodeAt(2 * i) | (bin.charCodeAt(2 * i + 1) << 8)) << 16 >> 16; + ch[i] = v / 32768; + } + const node = ac.createBufferSource(); + node.buffer = buf; + node.connect(ac.destination); + playTime = Math.max(playTime, ac.currentTime); + node.start(playTime); + playTime += buf.duration; + playing.push(node); + node.onended = () => { playing = playing.filter((x) => x !== node); }; + } + + function bargeIn() { // user spoke over the model — cut playback + for (const node of playing) { try { node.stop(); } catch { /* raced */ } } + playing = []; + playTime = 0; + } + + return { stop }; +} + +function pcm16b64(f32) { + const out = new Uint8Array(f32.length * 2); + for (let i = 0; i < f32.length; i++) { + const s = Math.max(-1, Math.min(1, f32[i])); + const v = s < 0 ? s * 0x8000 : s * 0x7fff; + out[2 * i] = v & 0xff; + out[2 * i + 1] = (v >> 8) & 0xff; + } + let bin = ''; + for (let i = 0; i < out.length; i += 8192) bin += String.fromCharCode.apply(null, out.subarray(i, i + 8192)); + return btoa(bin); +} + +// ---- tools ------------------------------------------------------------------ + +function toolDefs(host) { + const layerNames = host.ui.layerReport().map((l) => l.name).join(' · '); + const obj = (props, required) => ({ type: 'object', properties: props, required }); + return [ + { type: 'function', name: 'fly_to', description: 'Fly the camera to a named place on Earth (city, landmark, strait, airport…).', + parameters: obj({ place: { type: 'string' }, height_km: { type: 'number', description: 'eye height in km, default 40' } }, ['place']) }, + { type: 'function', name: 'set_layer', description: `Turn a data layer on or off. Layers: ${layerNames}`, + parameters: obj({ name: { type: 'string' }, on: { type: 'boolean' } }, ['name', 'on']) }, + { type: 'function', name: 'set_style', description: 'Set the full-screen sensor style.', + parameters: obj({ style: { type: 'string', enum: ['none', 'nvg', 'flir', 'crt'] } }, ['style']) }, + { type: 'function', name: 'set_basemap', description: 'Switch basemap: data (dark), photo (Sentinel-2), bing (aerial, needs key), 3d (Google photoreal, needs key).', + parameters: obj({ mode: { type: 'string', enum: ['data', 'photo', 'bing', '3d'] } }, ['mode']) }, + { type: 'function', name: 'set_time', description: 'Scrub the timeline. Offset in minutes from now: negative = past, 0 = back to live. Window is ±6 hours.', + parameters: obj({ minutes: { type: 'number' } }, ['minutes']) }, + { type: 'function', name: 'reset_view', description: 'Reset to the default Persian Gulf overview, dark basemap.', parameters: obj({}, []) }, + { type: 'function', name: 'situation_report', description: 'Read the current console state: every layer with its live status, basemap, style, camera position.', parameters: obj({}, []) }, + ]; +} + +async function dispatch(host, name, args) { + const { viewer, ui, Cesium, CONFIG, styles, setMode, getMode } = host; + try { + if (name === 'fly_to') { + const r = await fetch(`https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(args.place)}`); + const j = await r.json(); + if (!j[0]) return `No result for "${args.place}"`; + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(+j[0].lon, +j[0].lat, (args.height_km || 40) * 1000), + duration: 3, + }); + return `Flying to ${j[0].display_name}`; + } + if (name === 'set_layer') { + const q = String(args.name || '').toLowerCase(); + const hit = ui.layerReport().find((l) => l.id === q || l.name.toLowerCase().includes(q)); + if (!hit) return `No layer matching "${args.name}"`; + ui.setLayerChecked(hit.id, !!args.on); + return `${hit.name} ${args.on ? 'on' : 'off'}`; + } + if (name === 'set_style') { styles.setStyle(args.style); return `Style: ${args.style}`; } + if (name === 'set_basemap') { + setMode(args.mode); + const got = getMode(); + return got === args.mode ? `Basemap: ${got}` : `"${args.mode}" needs a key that isn't configured — staying on ${got}`; + } + if (name === 'set_time') { + let jd = Cesium.JulianDate.addSeconds(Cesium.JulianDate.now(), (args.minutes || 0) * 60, new Cesium.JulianDate()); + if (Cesium.JulianDate.lessThan(jd, viewer.clock.startTime)) jd = viewer.clock.startTime.clone(); + if (Cesium.JulianDate.greaterThan(jd, viewer.clock.stopTime)) jd = viewer.clock.stopTime.clone(); + viewer.clock.currentTime = jd; + return args.minutes === 0 ? 'Back to live' : `Clock at now ${args.minutes > 0 ? '+' : ''}${args.minutes} min`; + } + if (name === 'reset_view') { + setMode('data'); + viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(CONFIG.camera.lon, CONFIG.camera.lat, CONFIG.camera.height), duration: 2 }); + return 'Reset to Gulf overview'; + } + if (name === 'situation_report') { + const carto = viewer.camera.positionCartographic; + return JSON.stringify({ + basemap: getMode(), + style: styles.getStyle(), + camera: { lat: +Cesium.Math.toDegrees(carto.latitude).toFixed(2), lon: +Cesium.Math.toDegrees(carto.longitude).toFixed(2), height_km: Math.round(carto.height / 1000) }, + layers: ui.layerReport().filter((l) => l.on).map((l) => `${l.name}: ${l.status || 'on'}`), + }); + } + return `Unknown tool ${name}`; + } catch (err) { + return `Tool failed: ${err && err.message}`; + } +}