From 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 001/128] fix(voice): show FIRMS and ALPR entity labels translated on the voice card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pointer chip, its live-region line and the get_entity_context card title now translate the English entity labels the FIRMS and ALPR layers register ("Fire · FRP {frp} MW", "ALPR camera"). Other names, such as callsigns and place names, show as they came. --- src/voice/speech.js | 14 +++++++- src/voice/voiceCardLocale.test.mjs | 58 ++++++++++++++++++++++++++++-- src/voice/voiceCardPresentation.js | 11 ++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/voice/speech.js b/src/voice/speech.js index ab129aad3..711805502 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -115,6 +115,18 @@ function shownSources(source) { return source ? [{ label: sourceLabel(source) }] : []; } +// Entity labels that layers register as English data (firms/, alpr/); keep in +// step with the producers. Other names (callsigns, places) show as they came. +const ENTITY_LABELS = new Set([msg('ALPR camera')]); +const FIRE_LABEL = /^Fire · FRP (\S+) MW$/; + +/** An entity label as the card shows it, in the active language. */ +export function shownEntityLabel(label) { + if (ENTITY_LABELS.has(label)) return t(label); + const fire = FIRE_LABEL.exec(label ?? ''); + return fire ? t('Fire · FRP {frp} MW', { frp: fire[1] }) : label; +} + /* * Tool errors stay English in the result (the model reads them). The card * translates the known ones; the English must match gevActions exactly. @@ -592,7 +604,7 @@ function getEntityContext(result) { return { say: null, display: { - title, + title: spokenLabel(shownEntityLabel(title)), lines: [ selected.layerName ? shownLayerName(selected.layerName, selected.layerId) diff --git a/src/voice/voiceCardLocale.test.mjs b/src/voice/voiceCardLocale.test.mjs index 08274d21b..9c70d91f9 100644 --- a/src/voice/voiceCardLocale.test.mjs +++ b/src/voice/voiceCardLocale.test.mjs @@ -3,8 +3,16 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../i18n/index.js'; -import { attachVoiceResult, planStepLabel } from './speech.js'; -import { voiceCardView } from './voiceCardPresentation.js'; +import { + attachVoiceResult, + planStepLabel, + shownEntityLabel, +} from './speech.js'; +import { + initialVoiceCardState, + reduceVoiceCard, + voiceCardView, +} from './voiceCardPresentation.js'; for (const locale of ['ru', 'es']) { test(`voice card is in ${locale} for a layer switch`, (t) => { @@ -86,3 +94,49 @@ test('en: layer switch, distance and plan labels in English', () => { 'Earthquakes (24h) on', ); }); + +// FIRMS and ALPR register English entity labels: the model reads them as data +// (get_entity_context, pointer_context), the card shows them translated. +const ENTITY_LABELS = { + ru: { fire: 'Пожар · FRP 12 МВт', alpr: 'Камера ALPR' }, + es: { fire: 'Incendio · FRP 12 MW', alpr: 'Cámara ALPR' }, +}; + +for (const [locale, shown] of Object.entries(ENTITY_LABELS)) { + test(`entity labels stay English in the result and show in ${locale}`, (t) => { + t.after(() => setLocale('en')); + setLocale(locale); + const out = attachVoiceResult('get_entity_context', { + ok: true, + selected: { + id: 'fire-1', + name: 'Fire · FRP 12 MW', + layerId: 'local-firms', + layerName: 'NASA FIRMS', + }, + }); + assert.equal(out.selected.name, 'Fire · FRP 12 MW'); + assert.equal(out.display.title, shown.fire); + + const state = reduceVoiceCard(initialVoiceCardState(), { + type: 'pointer', + pointer: { kind: 'this', label: 'ALPR camera' }, + }); + assert.equal(state.pointer.label, 'ALPR camera'); + assert.equal(voiceCardView(state).pointer.text, shown.alpr); + assert.equal(state.announce, `This: ${shown.alpr}`); + }); +} + +test('only the registered entity labels are translated', () => { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Fire · FRP 4.9 MW'); + assert.equal(shownEntityLabel('ALPR camera'), 'ALPR camera'); + setLocale('ru'); + try { + assert.equal(shownEntityLabel('Fire · FRP 4.9 MW'), 'Пожар · FRP 4.9 МВт'); + assert.equal(shownEntityLabel('UPS793'), 'UPS793'); + assert.equal(shownEntityLabel('Fire Station 5'), 'Fire Station 5'); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/voiceCardPresentation.js b/src/voice/voiceCardPresentation.js index f8bf8ff7d..218241488 100644 --- a/src/voice/voiceCardPresentation.js +++ b/src/voice/voiceCardPresentation.js @@ -1,4 +1,9 @@ -import { planStepLabel, progressStepLabel, spokenLabel } from './speech.js'; +import { + planStepLabel, + progressStepLabel, + shownEntityLabel, + spokenLabel, +} from './speech.js'; import { presentResult } from './resultDisplay.js'; import { MAX_DISPLAYED_REFERENTS, normalizeReferents } from './referents.js'; @@ -132,7 +137,7 @@ export function reduceVoiceCard(state, event) { return reveal({ ...state, pointer, - announce: `${kind}: ${spokenLabel(pointer.label, 32)}`, + announce: `${kind}: ${spokenLabel(shownEntityLabel(pointer.label), 32)}`, }); } case 'transcript': { @@ -288,7 +293,7 @@ export function voiceCardView(state) { pointer: { visible: Boolean(state.pointer), kind: state.pointer?.kind === 'here' ? t('HERE') : t('THIS'), - text: spokenLabel(state.pointer?.label || '', 32), + text: spokenLabel(shownEntityLabel(state.pointer?.label || ''), 32), }, assistant: { visible: Boolean(state.assistant), From cec5d99e13e1564813d05a6ed5694e428dd831e8 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 002/128] fix(voice): keep FIRMS and ALPR entity labels English in voice data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire and camera labels in the context store, getStrongestFire() and getSelectedInfo() were translated at the source, so the voice model got "Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity, get_entity_context and pointer_context while every other entity name in those payloads is English. Keep them English, as before the interface was translated; the voice card translates them where it shows them. --- src/data/alprCameras.test.mjs | 20 ++++++++++++++++++++ src/data/firmsInteraction.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/presentation.js | 4 ++-- src/layers/firms/queries.js | 10 ++++------ src/layers/firms/selection.js | 5 ++--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..71ee6d675 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -9,6 +9,7 @@ import alprCamerasLayer, { } from './alprCameras.js'; import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; +import { setLocale } from '../i18n/index.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -473,6 +474,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a } }); +// The voice model reads the label as data; the voice card translates it. +test('the camera label stays English data in a translated interface', async () => { + const h = cameraHarness(); + setLocale('ru'); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), + ]), + ); + await alprCamerasLayer.update(); + h.click(h.source.entities.values[0].id); + assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + } finally { + setLocale('en'); + h.restore(); + } +}); + test('invalid OSM ids and out-of-range coordinates never become camera entities', () => { for (const id of [undefined, null, 0, -1, 1.5, '42', NaN]) assert.equal(normalizeAlprNode(cameraNode(id, { id })), null); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 442af60b2..cde867f87 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -8,6 +8,8 @@ import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js'; import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; +import { getSelectedEntityContext } from './contextStore.js'; +import { setLocale } from '../i18n/index.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -234,6 +236,22 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { } }); +// The voice model reads these labels as data (track_entity, get_entity_context, +// pointer_context); the voice card translates them where it shows them. +test('fire labels stay English data in a translated interface', () => { + setLocale('ru'); + const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); + try { + h.click(); + assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); + } finally { + h.cleanup(); + setLocale('en'); + } +}); + test('a click on neither sprite nor card clears without moving the camera', () => { const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } }); try { diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 866f15a6d..c7343d573 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -13,7 +13,7 @@ import { MARKER_SCALE_BY_DISTANCE, } from './policy.js'; import { boxContains } from './model.js'; -import { t } from '../../i18n/index.js'; +import { msg } from '../../i18n/index.js'; import { createAlprFloorResolver } from './floors.js'; import { createAlprOverlay } from './overlay.js'; import { @@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) { layerName: 'ALPR Cameras', source: source.attribution?.description || source.label || 'Camera source', - label: t('ALPR camera'), + label: msg('ALPR camera'), latitude: record.latitude, longitude: record.longitude, properties: { diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..16ca01166 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -18,9 +18,8 @@ export function createQueries({ if (!fire) return null; return { id: fireDetectionKey(fire), - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, frp: fire.frp, @@ -117,9 +116,8 @@ export function createQueries({ latitude: strongest.lat, longitude: strongest.lon, frp: strongest.frp, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(strongest.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, }; }, diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index fc05eea2a..d5cfc4d26 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -193,9 +193,8 @@ export function createSelection({ layerName: name, source: 'NASA FIRMS', dataSource: layerState._dataSource, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, properties: { From 9011918731b4a3d2444c7c11e1a472195dce1035 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 003/128] docs(changelog): add the #1207 entry --- changelog.d/1207-voice-entity-labels-english.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1207-voice-entity-labels-english.md diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md new file mode 100644 index 000000000..b69cdc7ed --- /dev/null +++ b/changelog.d/1207-voice-entity-labels-english.md @@ -0,0 +1,3 @@ +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). From 843c8b1a6bba03073b58e9a00e60467891827a4b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:31 +0700 Subject: [PATCH 004/128] test(i18n): feed real FIRMS and ALPR labels through the voice card The card's fire pattern is written by hand against the producers' template, so a format change upstream would silently show English. Both locale tests now pass the label the layer actually produced through shownEntityLabel() under ru, and set the locale inside the try that resets it. --- src/data/alprCameras.test.mjs | 7 ++++++- src/data/firmsInteraction.test.mjs | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 71ee6d675..549adde96 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -10,6 +10,7 @@ import alprCamerasLayer, { import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -477,8 +478,8 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a // The voice model reads the label as data; the voice card translates it. test('the camera label stays English data in a translated interface', async () => { const h = cameraHarness(); - setLocale('ru'); try { + setLocale('ru'); h.setFetch(async () => cameraResponse([ cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), @@ -487,6 +488,10 @@ test('the camera label stays English data in a translated interface', async () = await alprCamerasLayer.update(); h.click(h.source.entities.values[0].id); assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Камера ALPR', + ); } finally { setLocale('en'); h.restore(); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cde867f87..d735df3f8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -10,6 +10,7 @@ import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { getSelectedEntityContext } from './contextStore.js'; import { setLocale } from '../i18n/index.js'; +import { shownEntityLabel } from '../voice/speech.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -239,11 +240,16 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { // The voice model reads these labels as data (track_entity, get_entity_context, // pointer_context); the voice card translates them where it shows them. test('fire labels stay English data in a translated interface', () => { - setLocale('ru'); const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); try { + setLocale('ru'); h.click(); assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + // The card's pattern must match what the producer actually writes. + assert.equal( + shownEntityLabel(getSelectedEntityContext().label), + 'Пожар · FRP 1520 МВт', + ); assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); } finally { From 2379d20616a988924924e0500079de7b3dc9966b Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:01:48 -0500 Subject: [PATCH 005/128] Harden Vite fs deny for local logs and cache --- build/vite.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 4f2703e88..00479572d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -94,7 +94,16 @@ export function createBrowserViteConfig({ // build/allowedHosts.js); IP addresses are always accepted. allowedHosts: [...allowedHosts], fs: { - deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/ENVIRONMENT'], + deny: [ + '.env', + '.env.*', + '*.{crt,pem}', + '**/.git/**', + '**/ENVIRONMENT', + '**/.gev-logs/**', + '**/.gev-cache/**', + '**/dist/**', + ], }, // These headers protect the document containing Provider Settings and // give the whole page a real Content-Security-Policy (BROWSER_CSP). From ccb7b7333d3e13f0bda8fdea45706e49a37ca705 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:55:37 -0500 Subject: [PATCH 006/128] fix: scope Vite dist deny to repository root --- build/vite.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index 00479572d..f390e359e 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,9 +1,23 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +/** + * The repository root (this file lives at `/build/vite.js`), as a + * forward-slash path for glob matching. Vite's `fs.deny` patterns are + * matched against the full absolute file path, and a bare `**\/dist/**` + * matches a `dist` directory at *any* depth — including dependencies that + * ship their own, such as `node_modules/vite/dist/client/client.mjs` (the + * dev client itself). Anchoring to this repository's root denies only the + * project's own build output. + */ +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the @@ -102,7 +116,10 @@ export function createBrowserViteConfig({ '**/ENVIRONMENT', '**/.gev-logs/**', '**/.gev-cache/**', - '**/dist/**', + // Root-anchored: a bare '**/dist/**' would also deny every + // dependency's own dist/ (e.g. node_modules/vite/dist/client), + // breaking /@vite/client and other dev-client assets. + `${REPO_ROOT}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 509ab57e4b1136232198e7bbac8f43cb09194061 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:27 -0500 Subject: [PATCH 007/128] fix: escape repo root in Vite deny glob --- build/vite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/vite.js b/build/vite.js index f390e359e..05d8e5fa6 100644 --- a/build/vite.js +++ b/build/vite.js @@ -119,7 +119,7 @@ export function createBrowserViteConfig({ // Root-anchored: a bare '**/dist/**' would also deny every // dependency's own dist/ (e.g. node_modules/vite/dist/client), // breaking /@vite/client and other dev-client assets. - `${REPO_ROOT}/dist/**`, + `${REPO_ROOT_GLOB}/dist/**`, ], }, // These headers protect the document containing Provider Settings and From 0c1bb5a71b680fca09ff19fd133c81e18f4a51c1 Mon Sep 17 00:00:00 2001 From: Blowing Raspberries <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:52:35 -0500 Subject: [PATCH 008/128] docs: note Vite fs deny hardening ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ .git/COMMIT_EDITMSG [unix] (16:23 16/10/2026) 1,1 All"/d/AI-Lab/Repos/gods-eye-view/.git/COMMIT_EDITMSG" [unix] 19L, 662B docs: note Vite fs deny hardening --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d32365f..2c7445ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,6 +445,7 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency `dist/` directories, and checkout paths containing glob metacharacters are escaped before building the root-anchored pattern. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its From 1b641fde9f34462d508fc4520d4de21c642f8516 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:55:48 -0500 Subject: [PATCH 009/128] fix: restore escaped repo root after rebase --- build/vite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/vite.js b/build/vite.js index 05d8e5fa6..042b8e98d 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,6 +18,8 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); +const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); + /** * Content-Security-Policy for every document the dev/preview server serves. * No inline script is permitted, and the only foreign script origins are the From e3e4fc7284fcc59716f888bee6cc64dd07089ac5 Mon Sep 17 00:00:00 2001 From: blowing-a-raspberry <120271497+blowing-a-raspberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:55:56 -0500 Subject: [PATCH 010/128] test: cover Vite fs deny hardening --- CHANGELOG.md | 1 - build/vite.js | 6 +++++- changelog.d/948-vite-fs-deny.md | 4 ++++ src/tooling/viteBuild.test.mjs | 15 ++++++++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 changelog.d/948-vite-fs-deny.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7445ce6..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -445,7 +445,6 @@ `ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask, and accepts Entur's extensionless feed paths. Attributed under NLOD and Licence Ouverte. `get_bike_share` covers the six systems too. -- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency `dist/` directories, and checkout paths containing glob metacharacters are escaped before building the root-anchored pattern. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its diff --git a/build/vite.js b/build/vite.js index 042b8e98d..a541eb9aa 100644 --- a/build/vite.js +++ b/build/vite.js @@ -18,7 +18,11 @@ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)) .replace(/\\/g, '/') .replace(/\/$/, ''); -const REPO_ROOT_GLOB = REPO_ROOT.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +export function escapeFsDenyPath(path) { + return path.replace(/[()[\]{}*?!+@|]/g, '\\$&'); +} + +const REPO_ROOT_GLOB = escapeFsDenyPath(REPO_ROOT); /** * Content-Security-Policy for every document the dev/preview server serves. diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md new file mode 100644 index 000000000..642bafb1f --- /dev/null +++ b/changelog.d/948-vite-fs-deny.md @@ -0,0 +1,4 @@ +- Harden Vite's filesystem deny list for local-only artifacts: repo `dist/`, + `.gev-logs/`, and `.gev-cache/` are denied without blocking dependency + `dist/` directories, and checkout paths containing glob metacharacters are + escaped before building the root-anchored pattern (blowing-a-raspberry, #948). diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 9e38a0295..9f309c48d 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js'; +import { + BROWSER_CSP, + createBrowserViteConfig, + escapeFsDenyPath, +} from '../../build/vite.js'; import standaloneConfig, * as compatibility from '../../vite.config.js'; import * as providers from '../../server/providers/local.js'; @@ -21,6 +25,15 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']); assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT')); assert.ok(config.server.fs.deny.includes('.env.*')); + assert.ok(config.server.fs.deny.includes('**/.gev-logs/**')); + assert.ok(config.server.fs.deny.includes('**/.gev-cache/**')); + assert.ok( + config.server.fs.deny.some((pattern) => pattern.endsWith('/dist/**')), + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/gods-eye-view (1)'), + 'C:/Users/a/gods-eye-view \\(1\\)', + ); assert.equal(config.server.headers['X-Frame-Options'], 'DENY'); assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff'); const csp = config.server.headers['Content-Security-Policy']; From d2c80cffdb6d8a25d6c271e6548df57d0c957c8e Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:57:29 -0400 Subject: [PATCH 011/128] fix(gbfs): exclude stations with invalid coordinates Reject non-numeric and out-of-range feed coordinates before they reach the map or bike-share queries. Preserve numeric strings, aliases and zero. Cover malformed records at the parser and public query boundaries. Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- changelog.d/0-gbfs-invalid-coordinates.md | 3 + docs/CURRENT-STATE.md | 6 +- src/sources/gbfsStations.js | 20 ++++++- src/sources/gbfsStations.test.mjs | 67 +++++++++++++++++++++++ src/tools/queries/mobility.test.mjs | 40 ++++++++++++++ 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 changelog.d/0-gbfs-invalid-coordinates.md diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/0-gbfs-invalid-coordinates.md new file mode 100644 index 000000000..ed1f0824e --- /dev/null +++ b/changelog.d/0-gbfs-invalid-coordinates.md @@ -0,0 +1,3 @@ +- Skip bike-share stations with missing, blank or invalid coordinates instead + of placing them at coerced zero coordinates or accepting out-of-range values. + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9562dc9f5..8aedf558a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1265,7 +1265,11 @@ camera is refused there with a JSON 404 and is served solely through ## GBFS upstream bounds Station information is normalized in `src/sources/gbfsStations.js` for both the -globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized +globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or +out-of-range coordinates (latitude outside ±90, longitude outside ±180) are +skipped. Numbers and nonblank numeric strings are accepted, including genuine +zero and the `latitude`/`longitude` aliases; other JSON types are rejected. +Names accept legacy strings or GBFS v3 Localized String arrays regardless of the reported feed version. Nonempty English text (including regional `en-*` tags, case-insensitive) takes precedence, then the first usable translation. An unusable `name` falls back to `short_name` with the same diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index c37c2fae4..c942579db 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -98,6 +98,13 @@ function dockCapacity(raw) { return capacity - geofenced; } +/** Read a numeric coordinate without treating blank text or other JSON types as zero. */ +function coordinateNumber(value) { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) + return NaN; + return Number(value); +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -113,9 +120,16 @@ export function parseStationInformation(payload) { for (const raw of stations) { // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); - const lat = Number(raw?.lat ?? raw?.latitude); - const lon = Number(raw?.lon ?? raw?.longitude); - if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const lat = coordinateNumber(raw?.lat ?? raw?.latitude); + const lon = coordinateNumber(raw?.lon ?? raw?.longitude); + if ( + !stationId || + !Number.isFinite(lat) || + !Number.isFinite(lon) || + Math.abs(lat) > 90 || + Math.abs(lon) > 180 + ) + continue; stationMap.set(stationId, { stationId, diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 91983df98..1ab273556 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -19,6 +19,73 @@ const parseCapacity = (fields) => parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') .capacity; +test('station information skips missing, blank and malformed coordinates', () => { + for (const field of ['lat', 'lon', 'latitude', 'longitude']) { + for (const value of [ + undefined, + null, + '', + ' \t ', + false, + true, + [], + [0], + {}, + 'bad', + NaN, + Infinity, + ]) { + const raw = { station_id: 'bad', lat: 0, lon: 0 }; + if (field === 'latitude') delete raw.lat; + if (field === 'longitude') delete raw.lon; + raw[field] = value; + const parsed = parseStationInformation({ + data: { stations: [raw, station({ station_id: 'good' })] }, + }); + assert.deepEqual( + [...parsed.keys()], + ['good'], + `${field} = ${JSON.stringify(value)}`, + ); + } + } +}); + +test('station information skips coordinates outside latitude and longitude bounds', () => { + for (const fields of [ + { lat: 90.001 }, + { lat: -90.001 }, + { lon: 180.001 }, + { lon: -180.001 }, + { lat: undefined, latitude: '91' }, + { lon: undefined, longitude: '-181' }, + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }); + assert.equal(parsed.size, 0, JSON.stringify(fields)); + } +}); + +test('station information preserves zero, boundary coordinates and numeric aliases', () => { + for (const [fields, expected] of [ + [{ lat: 0, lon: 0 }, [0, 0]], + [{ lat: ' 0 ', lon: '0' }, [0, 0]], + [{ lat: 90, lon: 180 }, [90, 180]], + [{ lat: '-90', lon: '-180' }, [-90, -180]], + [ + { lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' }, + [30.2681, -97.7469], + ], + ]) { + const parsed = parseStationInformation({ + data: { stations: [station(fields)] }, + }).get('s1'); + assert.ok(parsed, JSON.stringify(fields)); + assert.deepEqual([parsed.lat, parsed.lon], expected); + } +}); + test('capacity counts docks, not the geofenced area PBSC adds to it', () => { // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 011e431fd..323173f03 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async () ); }); +test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => { + const stations = [ + { station_id: 'blank', lat: 51.5, lon: ' ' }, + { station_id: 'zero', lat: 51.5, lon: 0 }, + ]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + systems: [system('fixture', 'Fixture Bikes', 51.5, 0)], + async getStations(url) { + return { + data: { + stations: url.endsWith('station_information.json') + ? stations + : stations.map(({ station_id }) => ({ + station_id, + num_bikes_available: 4, + })), + }, + }; + }, + }, + }, + }); + const result = await catalog.call('get_bike_share', { + area: { lat: 51.5, lon: 0, radius_km: 2 }, + }); + assert.deepEqual( + result.data.rows.map((row) => row.station_id), + ['zero'], + ); + assert.equal(result.data.total, 1); + assert.equal(result.data.rows[0].lon, 0); + assert.equal( + result.summary, + '1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.', + ); +}); + const feed = { id: 'capmetro', name: 'CapMetro', From dd6e01353ab7897edc6d840177661ec481f5b394 Mon Sep 17 00:00:00 2001 From: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:58:01 -0400 Subject: [PATCH 012/128] docs(gbfs): assign changelog fragment to PR 1232 Signed-off-by: Bortlesboat <169967362+Bortlesboat@users.noreply.github.com> --- ...-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-gbfs-invalid-coordinates.md => 1232-gbfs-invalid-coordinates.md} (90%) diff --git a/changelog.d/0-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md similarity index 90% rename from changelog.d/0-gbfs-invalid-coordinates.md rename to changelog.d/1232-gbfs-invalid-coordinates.md index ed1f0824e..4f3da2a97 100644 --- a/changelog.d/0-gbfs-invalid-coordinates.md +++ b/changelog.d/1232-gbfs-invalid-coordinates.md @@ -1,3 +1,3 @@ - Skip bike-share stations with missing, blank or invalid coordinates instead of placing them at coerced zero coordinates or accepting out-of-range values. - Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #0). + Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232). From 657fd589d7c30c41b111b1e46351b20c92a50c49 Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:17 -0600 Subject: [PATCH 013/128] test(terrain): give every terrain-heights proxy in the bounds tests its own cache Seven tests built terrainHeightsProxy() with no cachePath, so the proxy fell back to this checkout's .gev-cache/terrain-heights.json. A point the app had already cached there answered a request the test expected to reach the stubbed upstream: after running the dev server near Austin, the cross-site test failed with 1 !== 2. isolatedCachePath(t) gives each test a mkdtemp cache file removed in t.after, as the over-large cache test already did; that test now uses it too. Fixes #1242. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4 --- src/terrainHeightsBounds.test.mjs | 49 +++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index cd1a9ad36..88b26b694 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -99,6 +99,18 @@ function stubTerrainUpstream(t) { return asked; } +/** + * A cache file of the test's own, removed when the test ends. Without one the + * proxy falls back to this checkout's `.gev-cache/terrain-heights.json`, and a + * point the app has already cached there would answer a request the test + * expects to reach the stubbed upstream (#1242). + */ +async function isolatedCachePath(t) { + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return path.join(dir, 'terrain-heights.json'); +} + test('a coordinate off the globe is not a cache key', () => { // Finite but not on Earth — the old guard accepted every one of these. for (const raw of [ @@ -122,7 +134,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', upstreamCalls += 1; return new Response('[]', { status: 200 }); }); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const refused = await request(route, { url: '/?points=0,91' }); assert.equal(refused.statusCode, 400); @@ -135,7 +149,9 @@ test('an out-of-range point is refused before it reaches upstream or the cache', test('one out-of-range point rejects the batch it is hidden in', async (t) => { stubFetch(t, async () => new Response('[]', { status: 200 })); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const mixed = await request(route, { url: '/?points=-97.74,30.26;0,999;-97.75,30.27', }); @@ -145,9 +161,12 @@ test('one out-of-range point rejects the batch it is hidden in', async (t) => { test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { const asked = stubTerrainUpstream(t); // A ceiling of 3 exercises the same code path 50,000 does, in four requests. - const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( - '/api/terrain/heights', - ); + const route = install( + terrainHeightsProxy({ + cachePath: await isolatedCachePath(t), + cacheMaxEntries: 3, + }), + ).get('/api/terrain/heights'); const point = (i) => `-97.7${i}000,30.26000`; for (let i = 1; i <= 4; i += 1) @@ -168,9 +187,7 @@ test('the point cache stops growing, and an evicted point is simply refetched', test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; - const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); - const cachePath = path.join(dir, 'terrain-heights.json'); - t.after(() => rm(dir, { recursive: true, force: true })); + const cachePath = await isolatedCachePath(t); // A cache file holding the answer, but larger than the ceiling allows. const padded = { @@ -201,7 +218,9 @@ test('an over-large cache file is not read back into memory', async (t) => { test('the route refuses a flood of requests from one client', async (t) => { stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); let refusedAt = 0; for (let i = 1; i <= 95; i += 1) { @@ -218,7 +237,9 @@ test('the route refuses a flood of requests from one client', async (t) => { test('a cross-site page cannot fill the cache or spend the visitor quota', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'localhost:5173'; const refused = [ { host, 'sec-fetch-site': 'cross-site' }, // from any web page @@ -253,7 +274,9 @@ test('a cross-site page cannot fill the cache or spend the visitor quota', async test('the app served behind a reverse proxy still gets terrain', async (t) => { const asked = stubTerrainUpstream(t); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); const host = 'globe.example.lan'; // What nginx/Caddy/Cloudflare add in front of a same-origin app fetch. The // cost-bearing routes refuse these; terrain is keyless and needed to render, @@ -347,7 +370,9 @@ test('a second client keeps its own quota', async (t) => { stubFetch(t, async () => Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), ); - const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const route = install( + terrainHeightsProxy({ cachePath: await isolatedCachePath(t) }), + ).get('/api/terrain/heights'); for (let i = 0; i < 90; i += 1) await request(route, { From 6bb2856403b382eb0befacd5568565589967dede Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:41 -0600 Subject: [PATCH 014/128] docs(changelog): add the terrain test cache isolation fragment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BNTtHAa65nobzJ7jp2Ltr4 --- changelog.d/1244-terrain-test-cache-isolation.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1244-terrain-test-cache-isolation.md diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md new file mode 100644 index 000000000..5efacd3bb --- /dev/null +++ b/changelog.d/1244-terrain-test-cache-isolation.md @@ -0,0 +1,4 @@ +- The terrain-heights bounds tests no longer read the checkout's + `.gev-cache/terrain-heights.json`. Each test gets a cache file of its own, so + `npm test` no longer fails after the dev server has cached terrain near + Austin (KnottyDyes, #1244). From 5980a085337907e20444887226f649168749b3ee Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:36 +0100 Subject: [PATCH 015/128] fix(overpass): reserve singleflight before disk cache reads --- docs/CURRENT-STATE.md | 6 ++ server/providers/overpass.js | 93 +++++++++++------------------- server/providers/overpass/cache.js | 27 ++++++--- src/data/overpassProxy.test.mjs | 41 ++++++++++++- 4 files changed, 96 insertions(+), 71 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f59f6c5d9..92f7ecd18 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Overpass reserves each normalized query before its disk-cache lookup. The same +reservation covers upstream quota admission, concurrency, fetch and cache +publication; duplicate callers join the complete transaction and share stale +fallback on failure. Settled and rejected reservations are removed only while +they still own their map entry. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/server/providers/overpass.js b/server/providers/overpass.js index 3b5a2e575..a9726f22b 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -164,6 +164,30 @@ function overpassProxy({ routing = {} } = {}) { readDisk: () => readOverpassDisk(cacheKey, overpassDiskTtlMs(cacheKey)), allowUpstream: () => _overpassRateLimiter(clientKey(req)), + loadUpstream: async () => { + if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) + return { + status: 503, + contentType: 'application/json', + retryAfterMs: 2000, + body: JSON.stringify({ + error: 'Overpass proxy busy — try again shortly', + }), + }; + _overpassConcurrent++; + try { + const payload = await fetchOverpassPayload(safeBody); + if (overpassPayloadIsData(payload)) { + const entry = { ...payload, cachedAt: Date.now() }; + _overpassCache.set(cacheKey, entry); + trimOverpassCache(); + writeOverpassDisk(cacheKey, entry); + } + return payload; + } finally { + _overpassConcurrent--; + } + }, }); if (preflight.source === 'RATE_LIMITED') { res.writeHead(429, { @@ -173,71 +197,22 @@ function overpassProxy({ routing = {} } = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - if (preflight.source !== 'UPSTREAM') { - // A coalesced caller sees the same failure as the original request - // and must get the same last-good fallback, not the raw refusal. - if (!overpassPayloadIsData(preflight.payload)) { - const stale = await readStaleOverpass(cacheKey); - if (stale) { - sendOverpassResponse(res, stale, 'STALE'); - return; - } - } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } - sendOverpassResponse(res, preflight.payload, preflight.source); - return; - } - - // From here onward the request is genuinely upstream-bound and has - // consumed one local limiter slot. Cache and dedupe hits above do not. - if (_overpassConcurrent >= OVERPASS_MAX_CONCURRENT) { - res.writeHead(503, { - 'Content-Type': 'application/json', - 'Retry-After': '2', - }); - res.end( - JSON.stringify({ - error: 'Overpass proxy busy — try again shortly', - }), - ); - return; - } - _overpassConcurrent += 1; - const requestPromise = fetchOverpassPayload(safeBody) - .then((payload) => { - // Only a 2xx is data. `< 500` cached every 4xx, so one mirror's - // refusal was written to memory AND disk — and boundary-class - // queries hold a month-long TTL, so a single 406 outlived the - // outage that caused it. - if (overpassPayloadIsData(payload)) { - const entry = { ...payload, cachedAt: Date.now() }; - _overpassCache.set(cacheKey, entry); - trimOverpassCache(); - writeOverpassDisk(cacheKey, entry); - } - return payload; - }) - .finally(() => { - _overpassConcurrent -= 1; - _overpassInFlight.delete(cacheKey); - }); - - _overpassInFlight.set(cacheKey, requestPromise); - const payload = await requestPromise; - // Degraded upstream (rate-limited on every mirror / 5xx / runtime - // error): last-good roads beat an empty layer — serve stale from - // memory or disk at ANY age before surfacing the failure. - if (!overpassPayloadIsData(payload)) { + if (!overpassPayloadIsData(preflight.payload)) { const stale = await readStaleOverpass(cacheKey); if (stale) { sendOverpassResponse(res, stale, 'STALE'); return; } } - sendOverpassResponse(res, payload, 'MISS'); + if (preflight.source === 'DISK') { + _overpassCache.set(cacheKey, preflight.payload); + trimOverpassCache(); + } + sendOverpassResponse( + res, + preflight.payload, + preflight.source === 'UPSTREAM' ? 'MISS' : preflight.source, + ); } catch (e) { // Every mirror threw (network-level). Same serve-stale rule. const stale = cacheKey ? await readStaleOverpass(cacheKey) : null; diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 442f599a6..5e88c3f19 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -1,3 +1,4 @@ +import { coalesceProxyRequest } from '../common/http.js'; import { isOverpassBoundaryQuery } from './query.js'; import { OVERPASS_BOUNDARY_DISK_TTL_MS, @@ -87,6 +88,7 @@ function writeOverpassDisk(cacheKey, payload) { * @param {Map>} options.inFlight * @param {()=>Promise} options.readDisk * @param {()=>boolean} options.allowUpstream + * @param {()=>Promise} options.loadUpstream Fetch and publish the admitted result. * @param {number} [options.now] * @param {number} [options.cacheMs] * @returns {Promise<{source:'HIT'|'INFLIGHT'|'DISK'|'UPSTREAM'|'RATE_LIMITED', payload:object|null}>} @@ -97,6 +99,7 @@ async function resolveOverpassPreflight({ inFlight, readDisk, allowUpstream, + loadUpstream = async () => null, now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { @@ -104,15 +107,21 @@ async function resolveOverpassPreflight({ if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) return { source: 'HIT', payload: cached }; - const pending = inFlight.get(cacheKey); - if (pending) return { source: 'INFLIGHT', payload: await pending }; - - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) return { source: 'DISK', payload: disk }; - - return allowUpstream() - ? { source: 'UPSTREAM', payload: null } - : { source: 'RATE_LIMITED', payload: null }; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + return request.shared && result.source !== 'RATE_LIMITED' + ? { ...result, source: 'INFLIGHT' } + : result; } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index f161eb72e..7f9b117ae 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -26,7 +26,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const memoryHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, fresh]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -40,7 +40,7 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo const inFlightHit = await resolveOverpassPreflight({ cacheKey: key, memoryCache: new Map([[key, { id: 'stale', status: 200, cachedAt: 0 }]]), - inFlight: new Map([[key, Promise.resolve(joined)]]), + inFlight: new Map([[key, Promise.resolve({ source: 'UPSTREAM', payload: joined })]]), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, now: 1000, @@ -51,9 +51,10 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskReads, 0, 'in-flight join must short-circuit before disk'); assert.equal(limiterCalls, 0, 'in-flight join must not consume limiter quota'); + const diskMemory = new Map(); const diskHit = await resolveOverpassPreflight({ cacheKey: key, - memoryCache: new Map(), + memoryCache: diskMemory, inFlight: new Map(), readDisk: async () => { diskReads += 1; return disk; }, allowUpstream, @@ -62,6 +63,8 @@ test('preflight checks memory, in-flight, then disk before consuming limiter quo assert.equal(diskHit.payload, disk); assert.equal(diskReads, 1); assert.equal(limiterCalls, 0, 'disk hit must not consume limiter quota'); + const publishedDisk = await resolveOverpassPreflight({ cacheKey: key, memoryCache: diskMemory, inFlight: new Map(), now: 1000, readDisk: async () => { throw new Error('disk result was not published'); }, allowUpstream }); + assert.equal(publishedDisk.source, 'HIT'); const upstreamMiss = await resolveOverpassPreflight({ cacheKey: key, @@ -214,3 +217,35 @@ function pointSegDistDeg(p, a, b) { const t = c1 / c2; return Math.hypot(wx - t * vx, wy - t * vy); } + + +test('cold disk lookup is single-flight through upstream publication and quota admission', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, admissions = 0, upstreams = 0; + const payload = { status: 200, body: '{"elements":[]}' }; + const options = { + cacheKey: 'cold', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return disk; }, + allowUpstream: () => { admissions++; return true; }, + loadUpstream: async () => { upstreams++; return payload; }, + }; + const pending = Array.from({ length: 4 }, () => resolveOverpassPreflight(options)); + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const results = await Promise.all(pending); + assert.equal(reads, 1); + assert.equal(admissions, 1); + assert.equal(upstreams, 1); + assert.ok(results.every((result) => result.payload === payload)); + assert.deepEqual(results.map((result) => result.source), ['UPSTREAM', 'INFLIGHT', 'INFLIGHT', 'INFLIGHT']); + assert.equal(inFlight.size, 0); + const failed = { ...options, readDisk: async () => { throw new Error('disk failed'); } }; + const rejected = await Promise.allSettled([resolveOverpassPreflight(failed), resolveOverpassPreflight(failed)]); + assert.ok(rejected.every((result) => result.status === 'rejected')); + assert.equal(inFlight.size, 0); + await resolveOverpassPreflight({ ...options, readDisk: async () => null }); + assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); +}); From 133d82621eb31aa1d939cf008cb992dcfb0dc48d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:53 +0100 Subject: [PATCH 016/128] docs: record overpass fix --- changelog.d/1278-overpass-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1278-overpass-issue-fix.md diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md new file mode 100644 index 000000000..4b028f046 --- /dev/null +++ b/changelog.d/1278-overpass-issue-fix.md @@ -0,0 +1 @@ +- Reserve singleflight before disk cache reads (asturksever, #1278). From 08e90e818261326b1c52ba33fba8c5b5f006f3ad Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:19 +0100 Subject: [PATCH 017/128] fix(missions): throttle hover picks and clear stale cursors --- docs/CURRENT-STATE.md | 2 + src/layers/launches/hover.test.mjs | 91 ++++++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 90 ++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/layers/launches/hover.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..135543f3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs new file mode 100644 index 000000000..83a370f26 --- /dev/null +++ b/src/layers/launches/hover.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Event } from 'cesium'; +import { createLaunchHover } from './lifecycle.js'; + +test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { + let at = 0, + picks = 0, + enabled = true; + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_n, fn) => listeners.add(fn), + removeEventListener: (_n, fn) => listeners.delete(fn), + }; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: {} }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: doc, + pointerFree: () => true, + }); + for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + viewer.camera.moveStart.raiseEvent(); + at = 500; + hover.move({ endPosition: {} }); + assert.equal(picks, 2); + assert.equal(viewer.scene.canvas.style.cursor, ''); + viewer.camera.moveEnd.raiseEvent(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + doc.hidden = true; + for (const fn of listeners) fn(); + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + assert.equal(viewer.scene.canvas.style.cursor, ''); + doc.hidden = false; + enabled = false; + hover.move({ endPosition: {} }); + assert.equal(picks, 3); + hover.destroy(); + assert.equal(listeners.size, 0); + assert.equal(viewer.camera.moveStart.numberOfListeners, 0); + assert.equal(viewer.camera.moveEnd.numberOfListeners, 0); +}); + +test('mission hover preserves the cursor of a pointer-owning tool', () => { + let free = true, + picks = 0; + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick() { + picks++; + return [{ id: 'launch' }]; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => true, + entityLaunchId: () => true, + documentRef: null, + pointerFree: () => free, + }); + hover.move({ endPosition: {} }); + assert.equal(viewer.scene.canvas.style.cursor, 'pointer'); + free = false; + viewer.scene.canvas.style.cursor = 'crosshair'; + hover.move({ endPosition: {} }); + viewer.camera.moveStart.raiseEvent(); + hover.destroy(); + assert.equal(picks, 1); + assert.equal(viewer.scene.canvas.style.cursor, 'crosshair'); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 75b0276a3..4d4962242 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,6 +1,75 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +/** Bound hover picking independently of precise click selection. */ +export function createLaunchHover({ + viewer, + enabled, + entityLaunchId, + now = () => performance.now(), + documentRef = globalThis.document, + pointerFree = isPointerFree, +}) { + let moving = false; + let lastPick = -Infinity; + let ownsCursor = false; + const clearCursor = () => { + if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') + viewer.scene.canvas.style.cursor = ''; + ownsCursor = false; + }; + const reset = () => { + clearCursor(); + lastPick = -Infinity; + }; + const start = () => { + moving = true; + reset(); + }; + const end = () => { + moving = false; + reset(); + }; + const visibility = () => { + if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') + reset(); + }; + const removeStart = viewer.camera?.moveStart?.addEventListener(start); + const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); + documentRef?.addEventListener?.('visibilitychange', visibility); + return { + move(movement) { + if ( + !enabled() || + moving || + documentRef?.hidden || + documentRef?.visibilityState === 'hidden' || + !pointerFree() + ) { + reset(); + return; + } + const at = now(); + if (at - lastPick < 120) return; + lastPick = at; + const found = viewer.scene + .drillPick(movement.endPosition, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }, + reset, + destroy() { + reset(); + removeStart?.(); + removeEnd?.(); + documentRef?.removeEventListener?.('visibilitychange', visibility); + }, + }; +} + export function createLifecycle({ state: layerState, services, @@ -8,6 +77,7 @@ export function createLifecycle({ source, }) { const { holdContinuousRender, releaseContinuousRender } = services.render; + let hover = null; const methods = { init(viewer) { @@ -45,14 +115,15 @@ export function createLifecycle({ layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( viewer.scene.canvas, ); - layerState._moveHandler.setInputAction((movement) => { - if (!layerState._enabled || !layerState._dataSource?.show) return; - const missionEntity = viewer.scene - .drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => parts.selection.entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + hover = createLaunchHover({ + viewer, + enabled: () => layerState._enabled && layerState._dataSource?.show, + entityLaunchId: parts.selection.entityLaunchId, + }); + layerState._moveHandler.setInputAction( + hover.move, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); // Apply horizon visibility before Cesium traverses and picks the scene. // A postRender write is one frame late and can remain visibly stale when // request-on-demand rendering stops after a globe camera move. @@ -100,6 +171,7 @@ export function createLifecycle({ }, async disable() { + hover?.reset(); layerState._sourceController.abort(); layerState._enabled = false; releaseContinuousRender('rocket-launches'); @@ -122,6 +194,8 @@ export function createLifecycle({ }, async destroy(viewer) { + hover?.destroy(); + hover = null; layerState._sourceController.abort(); releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; From bc0c4b84897d7d1fd1dc77c4bf3d843a4e5cea62 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:15:33 +0100 Subject: [PATCH 018/128] docs: record launch fix --- changelog.d/1279-launch-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1279-launch-issue-fix.md diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md new file mode 100644 index 000000000..7005c53cd --- /dev/null +++ b/changelog.d/1279-launch-issue-fix.md @@ -0,0 +1 @@ +- Throttle hover picks and clear stale cursors (asturksever, #1279). From ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH 019/128] fix(layers): pause periodic network updates in hidden tabs --- docs/CURRENT-STATE.md | 2 ++ src/app/data.js | 1 + src/data/lifecycle.js | 57 +++++++++++++++++++++++---------- src/data/manager.js | 2 +- src/data/manager.test.mjs | 31 ++++++++++++++++++ src/layers/transit/lifecycle.js | 1 + src/layers/transit/viewport.js | 5 +++ 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..508b6264d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. + Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is canonical; `greatCircleMeters` is its exact metre conversion. Missing, diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..e6c7d25e0 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -13,6 +13,7 @@ export function createApplicationData({ // Initialize data layer manager const dataManager = new LayerLifecycle(viewer, { allowQaRegistration, + visibilityTarget: document, getSourceAvailability: catalog?.getSourceAvailability, }); defer(async () => { diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..01aaf6124 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -72,13 +72,20 @@ function refreshFailureFromStats(stats, label) { /** * LayerLifecycle — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. + * Hosts may inject a visibilityTarget with hidden/visibilityState and event + * listener methods. Without one, periodic work retains its headless behavior. */ export class LayerLifecycle { constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { + allowQaRegistration = false, + getSourceAvailability, + visibilityTarget = null, + } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; + this._visibilityTarget = visibilityTarget; this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } @@ -528,6 +535,13 @@ export class LayerLifecycle { } } + _stopUpdateLoop(entry) { + clearInterval(entry.intervalId); + entry.intervalId = null; + entry.removeVisibilityListener?.(); + entry.removeVisibilityListener = null; + } + _armUpdateLoop(layerId, entry) { const configuredRefreshInterval = Number(entry.module.refreshInterval); const updateInterval = Number(entry.module.updateInterval); @@ -538,9 +552,25 @@ export class LayerLifecycle { ? updateInterval : 0; if (refreshInterval > 0) { - entry.intervalId = setInterval(() => { + const visibilityDocument = this._visibilityTarget; + let dueAt = Date.now() + refreshInterval; + const tick = () => { + if ( + visibilityDocument?.hidden || + visibilityDocument?.visibilityState === 'hidden' || + !entry.enabled || + entry.destroying || + entry.refreshing || + Date.now() < dueAt + ) + return; + dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); - }, refreshInterval); + }; + entry.intervalId = setInterval(tick, refreshInterval); + visibilityDocument?.addEventListener?.('visibilitychange', tick); + entry.removeVisibilityListener = () => + visibilityDocument?.removeEventListener?.('visibilitychange', tick); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; @@ -772,8 +802,7 @@ export class LayerLifecycle { entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } this._publishActivity({ type: 'status' }); if (!compensated) { @@ -833,8 +862,7 @@ export class LayerLifecycle { } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; entry.lifecycleUncertain = false; @@ -845,8 +873,7 @@ export class LayerLifecycle { let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second @@ -876,8 +903,7 @@ export class LayerLifecycle { // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } await abortCleanup; let cleanupConfirmed = false; @@ -918,8 +944,7 @@ export class LayerLifecycle { }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } let cleanupConfirmed = false; try { @@ -1029,8 +1054,7 @@ export class LayerLifecycle { // Always clear any stale interval before assigning a new one, so we never // orphan a running timer and end up double-polling. if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } // Manager-owned periodic refresh work has one normalized loading/error @@ -2158,8 +2182,7 @@ export class LayerLifecycle { return false; } if (entry.intervalId) { - clearInterval(entry.intervalId); - entry.intervalId = null; + this._stopUpdateLoop(entry); } entry.enabled = false; } diff --git a/src/data/manager.js b/src/data/manager.js index b719b5934..79bb7d9ae 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../ui/layers.js'; /** Compatibility facade for callers that construct a manager with its panel. */ export class DataLayerManager extends LayerLifecycle { constructor(viewer, options) { - super(viewer, options); + super(viewer, { visibilityTarget: globalThis.document, ...options }); this._presentation = new LayerPresentation(this); } buildTogglePanel(container) { diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 23641c962..68a0644f1 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3492,3 +3492,34 @@ test('refreshLayerStats reaches presentation through the bare lifecycle', async lifecycle.refreshLayerStats(); assert.deepEqual(changes, ['status']); }); + +test('periodic polls pause hidden, catch up once when due, and dispose visibility listeners', async (t) => { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('hidden-poll'); + manager.register(module); + await manager.setEnabled('hidden-poll', true); + assert.equal(calls.update, 1); + doc.hidden = true; + t.mock.timers.tick(10000); + assert.equal(calls.update, 1); + assert.equal(await manager.refreshLayer('hidden-poll'), true); + assert.equal(calls.update, 2, 'explicit refresh remains allowed'); + doc.hidden = false; + for (const fn of listeners) fn(); + for (const fn of listeners) fn(); + assert.equal(calls.update, 3); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(10000); + assert.equal(calls.update, 3); +}); diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 11b670519..90210063a 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -97,6 +97,7 @@ export function createLifecycle({ state, services, parts }) { else if (hidden) { hidden = false; parts.rendering.resumePlayback(); + parts.viewport.runProximityCheck(); } } function bindVisibility() { diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 641fab5a2..730126f10 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -75,6 +75,11 @@ export function createViewport({ state, services, parts }) { } function runProximityCheck() { + if ( + globalThis.document?.hidden || + globalThis.document?.visibilityState === 'hidden' + ) + return; if (!state._enabled || !state._viewer) return; refreshViewBounds(); const altitude = getCameraAltitude(); From eabff9005d894454b22a66144a0582fe195aa3e7 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:26:21 +0100 Subject: [PATCH 020/128] docs: record polling fix --- changelog.d/1282-polling-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1282-polling-issue-fix.md diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..82f9a0fba --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1 @@ +- Pause periodic network updates in hidden tabs (asturksever, #1282). From e4955f351ff8c7068748b22e9673e7b61edd7d92 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:07:13 +0100 Subject: [PATCH 021/128] test: qualify Transit visibility and city feed ownership --- changelog.d/1282-polling-issue-fix.md | 2 ++ scripts/qa-transit-browser.mjs | 5 ++++ scripts/qa-transit-controls.mjs | 5 ++++ scripts/qa-transit.mjs | 42 ++++++++++++++++++++------- src/tooling/transitQa.test.mjs | 13 +++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 82f9a0fba..c7b0dec0a 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1 +1,3 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0c66098 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -266,6 +266,7 @@ const browser = await puppeteer.launch({ executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -391,22 +392,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -428,6 +449,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 51e4d5c44..634538c2a 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { getTransitFeed } from '../data/transitFeeds.js'; import { chooseLiveCities, selectStyle, @@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', () const cities = chooseLiveCities(now); assert.ok(cities.length >= 1); for (const city of cities) { + const feed = getTransitFeed(city.feedId); + assert.ok(feed, `${city.id} has a registered feed`); + const distanceKm = + 111.32 * + Math.hypot( + feed.center.lat - city.lat, + (feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180), + ); + assert.ok( + distanceKm < feed.loadRadiusKm, + `${city.id} is inside its feed's admission region`, + ); const local = Number( new Intl.DateTimeFormat('en', { timeZone: city.zone, From 2c8b3593d1af5ae2291c178f9105da0a01060223 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:19:11 +0100 Subject: [PATCH 022/128] test: observe a poll transition in the Transit anchor probe --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-transit.mjs | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index c7b0dec0a..f3aede4e4 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,3 +1,4 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index fb0c66098..628983e74 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -40,6 +40,7 @@ import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -1631,6 +1632,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1647,6 +1649,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1692,7 +1701,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1700,6 +1717,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1714,7 +1732,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1735,8 +1753,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( From 5b9c726f33e7eb6e9af400f981f42ae2ef243628 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:57 +0100 Subject: [PATCH 023/128] test: prove hidden-tab polling in a real browser --- changelog.d/1282-polling-issue-fix.md | 2 + docs/CURRENT-STATE.md | 2 + scripts/format-scope.json | 3 +- scripts/qa-layer-visibility.mjs | 124 ++++++++++++++++++++++++++ scripts/qa-transit.mjs | 4 + 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-layer-visibility.mjs diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index f3aede4e4..4af89eeee 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -2,3 +2,5 @@ - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). +- Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). +- Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 508b6264d..3eacee1c6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4002,6 +4002,8 @@ animation. Polls, surface completions and trail fades request individual renders Disable clears vehicles, detection references, requests, timers and holds; destruction also releases collections and registrations. +The `scripts/qa-layer-visibility.mjs` gate uses the development-only registration seam and real browser tab visibility to prove periodic work pauses while hidden, explicit refresh still works, resume performs one overdue refresh, and disabled layers stay stopped. + **Acceptance harness.** Run against an already running development server: ```sh diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..b894cbe9a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1070,5 +1070,6 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "scripts/qa-layer-visibility.mjs" ] diff --git a/scripts/qa-layer-visibility.mjs b/scripts/qa-layer-visibility.mjs new file mode 100644 index 000000000..8a903918e --- /dev/null +++ b/scripts/qa-layer-visibility.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * Real-browser proof for manager-owned periodic work and document visibility. + * Uses the app's development-only layer registration seam; no provider keys. + * Run: node scripts/qa-layer-visibility.mjs --url http://localhost:4173 + * Hiding uses a real background tab, never a patched document.hidden getter. + */ +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer'; +import { chromeExecutablePath } from './qa-chrome.mjs'; + +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const url = new URL( + (urlIndex >= 0 ? args[urlIndex + 1] : undefined) || + process.env.QA_BASE_URL || + 'http://localhost:4173', +); +url.searchParams.set('welcome', '0'); +const browser = await puppeteer.launch({ + executablePath: chromeExecutablePath(), + headless: true, + args: [ + '--no-sandbox', + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + ], +}); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const check = (name, actual, expected) => { + assert.equal(actual, expected, name); + console.log(`PASS ${name}: ${actual}`); +}; +try { + const page = await browser.newPage(); + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__gevQaRegisterLayer && window.__godsEyeView?.dataManager, + ); + await page.evaluate(async () => { + const manager = window.__godsEyeView.dataManager; + window.__qaVisibilityUpdates = 0; + window.__gevQaRegisterLayer(manager, { + id: 'qa-document-visibility', + name: 'Visibility QA', + updateInterval: 750, + init() {}, + enable() {}, + disable() {}, + destroy() {}, + async update() { + window.__qaVisibilityUpdates++; + return true; + }, + getStats() { + return { count: window.__qaVisibilityUpdates }; + }, + }); + await manager.setEnabled('qa-document-visibility', true, { origin: 'qa' }); + }); + const count = () => page.evaluate(() => window.__qaVisibilityUpdates); + await page.waitForFunction(() => window.__qaVisibilityUpdates >= 3); + check('periodic updates run while visible', (await count()) >= 3, true); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + const cover = await browser.newPage(); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + const hiddenCount = await count(); + await pause(2200); + check('hidden tab makes no periodic updates', await count(), hiddenCount); + check( + 'explicit refresh remains available while hidden', + await page.evaluate(() => + window.__godsEyeView.dataManager.refreshLayer('qa-document-visibility'), + ), + true, + ); + check('explicit hidden refresh runs once', await count(), hiddenCount + 1); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await page.waitForFunction( + () => + document.visibilityState === 'visible' && + window.__qaVisibilityUpdates > 0, + ); + await pause(200); + check( + 'resume performs one overdue refresh without a catch-up burst', + await count(), + hiddenCount + 2, + ); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'qa-document-visibility', + false, + { origin: 'qa' }, + ), + ); + const stopped = await count(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: false }); + await cover.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'hidden'); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + await pause(1100); + check( + 'disabled layer does not resume on visibility change', + await count(), + stopped, + ); + check( + 'QA layer destroys cleanly', + await page.evaluate(() => + window.__gevQaUnregisterLayer( + window.__godsEyeView.dataManager, + 'qa-document-visibility', + ), + ), + true, + ); + console.log('RESULT: 7 passed'); +} finally { + await browser.close(); +} diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 628983e74..47eafc94d 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -275,6 +275,10 @@ const browser = await puppeteer.launch({ ], }); const page = await browser.newPage(); +// This gate measures visible-page motion. OS minimization must not silently +// switch it into the app's intentional hidden-tab polling pause. +const focus = await page.createCDPSession(); +await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); boundPageEvaluations(page); await page.setViewport({ width: 1920, height: 1080 }); const pageErrors = []; From 72f1625257d14702e5a911958ab72c70264ee3e5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:43:01 +0100 Subject: [PATCH 024/128] test: avoid concurrent format-scope append conflicts --- scripts/format-scope.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b894cbe9a..92bd228ba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -4,6 +4,7 @@ "scripts/format-scope.json", "scripts/package-boundaries.json", "scripts/format.mjs", + "scripts/qa-layer-visibility.mjs", "scripts/next-layer-state-token.mjs", "scripts/check-layer-state-tokens.mjs", "scripts/qa-layer-token-twochar.mjs", @@ -1070,6 +1071,5 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs", - "scripts/qa-layer-visibility.mjs" + "src/terrainHeightsBounds.test.mjs" ] From 56be69d065c4e77d46ec034075327155eb520940 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:33 +0100 Subject: [PATCH 025/128] test: restore minimized Transit windows before screenshots --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-chrome.mjs | 27 +++++++++++++ scripts/qa-transit-recovery.mjs | 4 +- scripts/qa-transit-scenes.mjs | 13 +++--- scripts/qa-transit.mjs | 14 ++++--- src/qaChrome.test.mjs | 57 ++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 4af89eeee..9931385bf 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -4,3 +4,4 @@ - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). - Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). - Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 47eafc94d..943e39df5 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,7 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -294,11 +294,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); From 1f77d2290cb8d4d2ef194f6819466f9c98c27d77 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:10:49 -0400 Subject: [PATCH 026/128] feat: add S2 GhostMaps live intel layer Adds an "S2 GhostMaps" layer (Events group) that renders S2 Underground's GhostMaps KMZ exports (Common Intelligence Picture, Kinetic 30d, Border Crisis, Iran CIP/Kinetic, Yemen FLOT, latest incident drop). - server/providers/ghostMaps.js: watcher polls the repo's git smart-HTTP ref advertisement (default every 15s, GHOSTMAPS_POLL_SECONDS), fetches the tree only on a new head, caches commit-pinned files by blob sha and pushes catalog updates to browsers over SSE (/api/ghostmaps/events). - src/layers/ghostMaps: KML reader, ArcGIS popup-table parsing, per-product data sources, product chips, horizon-culled pins, report cards. - Unit tests, QA gate (scripts/qa-ghostmaps.mjs), credits (CC BY-NC-SA 4.0), i18n, voice manifest, layer-state token 5. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 1 + changelog.d/0000-s2-ghostmaps-layer.md | 7 + docs/CURRENT-STATE.md | 20 + package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 49 +- scripts/qa-ghostmaps.mjs | 645 ++++++++++++++++++++ server/providers/ghostMaps.js | 431 +++++++++++++ server/providers/ghostMaps.test.mjs | 227 +++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/ghostMaps.js | 27 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 8 + src/data/geoFileImport.js | 11 +- src/data/layerState.js | 5 + src/data/layerState.test.mjs | 26 +- src/data/layerStateTokenLedger.test.mjs | 57 +- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/i18n/locales/es.json | 30 +- src/i18n/locales/ru.json | 32 +- src/i18n/same-as-english.json | 4 + src/layers/ghostMaps/catalog.js | 245 ++++++++ src/layers/ghostMaps/catalog.test.mjs | 165 +++++ src/layers/ghostMaps/index.js | 708 ++++++++++++++++++++++ src/layers/ghostMaps/kml.js | 198 ++++++ src/layers/ghostMaps/records.js | 165 +++++ src/layers/ghostMaps/records.test.mjs | 106 ++++ src/layers/ghostMaps/source.js | 81 +++ src/tooling/previewServing.test.mjs | 2 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/voice/actionSchemas.test.mjs | 3 +- src/voice/layerManifest.js | 14 + 36 files changed, 3247 insertions(+), 52 deletions(-) create mode 100644 changelog.d/0000-s2-ghostmaps-layer.md create mode 100644 scripts/qa-ghostmaps.mjs create mode 100644 server/providers/ghostMaps.js create mode 100644 server/providers/ghostMaps.test.mjs create mode 100644 src/app/layers/ghostMaps.js create mode 100644 src/layers/ghostMaps/catalog.js create mode 100644 src/layers/ghostMaps/catalog.test.mjs create mode 100644 src/layers/ghostMaps/index.js create mode 100644 src/layers/ghostMaps/kml.js create mode 100644 src/layers/ghostMaps/records.js create mode 100644 src/layers/ghostMaps/records.test.mjs create mode 100644 src/layers/ghostMaps/source.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 61b971b71..ca774b47c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -38,6 +38,7 @@ How to read this: | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/0000-s2-ghostmaps-layer.md new file mode 100644 index 000000000..b78d6a042 --- /dev/null +++ b/changelog.d/0000-s2-ghostmaps-layer.md @@ -0,0 +1,7 @@ +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..1e2fabc51 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3507,6 +3507,7 @@ its criteria cannot be silently ignored. | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen @@ -4994,6 +4995,25 @@ are omitted rather than framing the wrong part of the globe. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### S2 GhostMaps (October 2026) + +- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. +- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. +- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. + - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. + - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. + - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. + - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. +- **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. + - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. + - Lines and polygon outlines are ground-clamped polylines. + - Styles, StyleMaps and inline styles are honoured. Icons come only from images packed in the same KMZ and at most 512 KB. Anything else uses a per-product round marker. + - Network links, overlays, models and remote hrefs are never followed. Hidden placemarks are skipped. Bounds are 20,000 placemarks and 250,000 positions per product. +- **Reports.** Clicking a marker opens a card with the product, folder, edition date and the placemark's report fields. `src/layers/ghostMaps/records.js` reads ArcGIS popup tables as text only: `` and internal fields are dropped and values are capped. Description markup is never inserted into the page. The first `http(s)` research link opens from the card. The card is `stateless`, so it comes and goes with the click, as CCTV cards do. It is also `horizonCull`ed, so it hides when its marker turns behind the planet. Picks are registered under `s2-ghostmaps`. +- **Validation.** Unit tests: `src/layers/ghostMaps/*.test.mjs` cover editions, the catalog, ref parsing, description tables and wrapping; `server/providers/ghostMaps.test.mjs` covers commit detection, events, pinned downloads, cache pruning, restart and routing. `node scripts/qa-ghostmaps.mjs --url http://localhost:4173` covers the live load, the open stream, picks and cards (asserted on the overlay host's diagnostics, since the `desynchronized` overlay canvas can read back a stale buffer headless), far-side culling, a synthetic push, chip persistence, disable and phone width. + ### Import Your Own Files (October 2026) - DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. diff --git a/package.json b/package.json index 241c59e59..9256107e4 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/gnss": "./src/layers/gnss/index.js", + "./layers/ghostmaps": "./src/layers/ghostMaps/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -244,6 +245,7 @@ "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/gnss/source": "./src/layers/gnss/source.js", + "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83e961d8b..46d4fa3c4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -865,6 +865,17 @@ "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", "scripts/qa-gnss.mjs", + "server/providers/ghostMaps.js", + "server/providers/ghostMaps.test.mjs", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.test.mjs", + "src/layers/ghostMaps/records.test.mjs", + "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf33bebc7..ddd3f6bdf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2285,6 +2285,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2916,6 +2922,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3067,7 +3079,10 @@ "src/layers/airQuality/source.js", "src/ui/statusText.js", "src/ui/layerNames.js", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/data/geoFileImport.js" ], "external": [ "hls.js", @@ -3759,6 +3774,38 @@ ], "external": [] }, + "ghostmaps": { + "exports": ["./layers/ghostmaps"], + "modules": [ + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", + "src/sources/httpBody.js", + "src/data/geoFileImport.js", + "src/i18n/marker.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/layers/streetLevel/tileMath.js", + "src/layers/streetLevel/view.js" + ], + "external": ["cesium"] + }, + "ghostmaps-source": { + "exports": ["./layers/ghostmaps/source"], + "modules": [ + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.js", + "src/sources/httpBody.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs new file mode 100644 index 000000000..83dc8ed97 --- /dev/null +++ b/scripts/qa-ghostmaps.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +/** + * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer + * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from + * github.com/s2underground/GhostMaps. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * + * (i) LIVE — enabling the layer loads the default products (Common + * Intelligence Picture and the 30-day kinetic pull) from the live + * repository through /api/ghostmaps, one data source per product, + * matching the proxy's catalog commit; the row shows one chip per + * catalog product with the defaults active, and the page holds an + * open event stream to /api/ghostmaps/events. + * (ii) PICK — a Common Intelligence Picture marker at the screen centre + * picks as this layer's entity; clicking it paints its report card + * and clicking empty ground clears it. + * (iii) PUSH (synthetic) — the event stream is replaced in the page and a + * catalog naming a new edition of one product is pushed: only that + * product reloads (a new data source replaces the old one, no + * duplicates), the other keeps its data source. A catalog without a + * product removes it. + * (iv) CHIPS — clicking an active product chip removes that product, + * clicking it again restores it; the choice survives a reload. + * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips + * fit the panel width with no horizontal overflow. + * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: + * seen from over the Indian Ocean no pin clearly behind the planet is + * shown; back over North America the near-side pins show again. + * + * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). + * + * Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot, --shots . + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000')); +const SHOTS_DIR = path.resolve( + getOpt('--shots', path.join(__dirname, '..', 'qa-shots')), +); +const LAYER_ID = 's2-ghostmaps'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function boot(page) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); +} + +/** GhostMaps data sources on the viewer: name → entity count. */ +function ghostSources(prefix) { + const { dataSources } = window.__godsEyeView.viewer; + const out = []; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (ds.name?.startsWith(prefix)) + out.push({ name: ds.name, count: ds.entities.values.length }); + } + return out; +} + +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function enableAndSettle(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel?.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]')?.click(); + return window.__godsEyeView.dataManager.setEnabled(id, true); + }, LAYER_ID); + await page.waitForFunction( + (id) => { + const stats = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return !stats.loading && stats.count > 0; + }, + { timeout: TIMEOUT_MS, polling: 500 }, + LAYER_ID, + ); +} + +const stats = (page) => + page.evaluate( + (id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(), + LAYER_ID, + ); +const sources = (page) => + page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); + +async function showRow(page) { + await page.evaluate((id) => { + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(400); +} + +async function main() { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, + }); + try { + // ---- (i) LIVE --------------------------------------------------------- + const page = await browser.newPage(); + // Start clean once; a reload must keep what the page stored. + await page.evaluateOnNewDocument(() => { + try { + if (sessionStorage.getItem('qa-ghostmaps-clean')) return; + localStorage.clear(); + sessionStorage.setItem('qa-ghostmaps-clean', '1'); + } catch { + /* sandboxed frames have no storage */ + } + }); + page.on('pageerror', (error) => + console.log(` [page error] ${error.message}`), + ); + await boot(page); + const catalog = await page.evaluate(() => + fetch('/api/ghostmaps/catalog').then((r) => r.json()), + ); + record( + 'LIVE: proxy catalog names a commit and the default products', + /^[0-9a-f]{40}$/.test(catalog.commit || '') && + ['cip', 'kinetic-30d'].every((id) => + catalog.products.some((p) => p.id === id && p.defaultOn), + ), + `${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`, + ); + const t0 = Date.now(); + await enableAndSettle(page); + const live = await stats(page); + const liveSources = await sources(page); + record( + 'LIVE: default products load as one data source each', + liveSources.length === 2 && + liveSources.every((s) => s.count > 0) && + live.products.sort().join() === 'cip,kinetic-30d', + `${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`, + ); + record( + 'LIVE: layer reports the proxy commit and no error', + live.commit === catalog.commit && !live.error, + `commit ${live.commit?.slice(0, 7)}, error ${live.error}`, + ); + await showRow(page); + const chips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] .data-toggle-chip`, + ), + ].map((chip) => ({ + id: chip.dataset.chipId, + active: chip.getAttribute('aria-pressed') === 'true', + })), + LAYER_ID, + ); + record( + 'LIVE: one chip per product, defaults pressed', + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), + chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), + ); + const stream = await page + .waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers.get(id).module.getStats() + .stream === 'open', + { timeout: 15000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record('LIVE: the /api/ghostmaps/events stream is open', stream); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + + // ---- (ii) PICK ---------------------------------------------------------- + const target = await page.evaluate((id) => { + const gev = window.__godsEyeView; + const ds = [...Array(gev.viewer.dataSources.length).keys()] + .map((i) => gev.viewer.dataSources.get(i)) + .find((item) => item.name === `${id}:cip`); + const entity = ds.entities.values.find((e) => e.billboard); + const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( + entity.position.getValue(gev.viewer.clock.currentTime), + ); + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + return entity.id; + }, LAYER_ID); + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + // The horizon cull reveals the pin on the frame after the camera moves. + await page + .waitForFunction( + () => { + const v = window.__godsEyeView.viewer; + v.scene.requestRender?.(); + const c = v.scene.canvas; + return v.scene + .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) + .some((p) => + String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), + ); + }, + { timeout: 15000, polling: 500 }, + ) + .catch(() => {}); + const probe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const c = v.scene.canvas; + const box = c.getBoundingClientRect(); + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const picked = v.scene.drillPick(centre, 8); + // Empty ground: the first point below the marker (the card is drawn + // above it) that picks nothing at all; markers can be dense in a city. + let empty = null; + for (let r = 120; r < 420 && !empty; r += 60) + for (const a of [2, 1, 3, 0, 4]) { + const p = { + x: centre.x + r * Math.cos((a * Math.PI) / 4), + y: centre.y + r * Math.sin((a * Math.PI) / 4), + }; + if (!v.scene.pick(p)) { + empty = p; + break; + } + } + empty ||= { x: centre.x + 400, y: centre.y + 300 }; + return { + ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + }; + }); + record( + 'PICK: the centred marker picks as a GhostMaps entity', + probe.ids.some((id) => id.startsWith('ghostmaps:cip:')), + `${target} → [${probe.ids.slice(0, 3).join(', ')}]`, + ); + const before = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(probe.centre.x, probe.centre.y); + const painted = await page + .waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, { + timeout: 10000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const after = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: clicking the marker paints its report card', + painted, + `${before} → ${after} pixels`, + ); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') }); + const cardState = () => + page.evaluate((id) => { + const d = window.__gevWorldOverlay?.getDiagnostics?.(); + return { + entries: d?.entriesBySource?.[id] ?? null, + painted: d?.paintedBySource?.[id] ?? null, + }; + }, LAYER_ID); + const selectedState = await cardState(); + record( + 'PICK: the overlay host holds and paints exactly one card', + selectedState.entries === 1 && selectedState.painted === 1, + JSON.stringify(selectedState), + ); + await page.mouse.click(probe.empty.x, probe.empty.y); + // The overlay canvas is `desynchronized`, so headless read-back can + // return a stale buffer; the host's own model is the authority here. + const cleared = await page + .waitForFunction( + (id) => { + const d = window.__gevWorldOverlay.getDiagnostics(); + return ( + d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0) + ); + }, + { timeout: 10000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record( + 'PICK: clicking empty ground clears the card', + cleared, + JSON.stringify(await cardState()), + ); + + // ---- (vii) FAR SIDE --------------------------------------------------- + // Pins skip the depth test; with Google 3D tiles nothing else hides the + // far side. A pin clearly behind the planet (sphere test with margin) must + // be hidden, and pins facing the camera must still show. + const horizonCounts = (lon, lat) => + page.evaluate( + async (id, lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: + gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1200)); + const c = gev.viewer.camera.positionWC; + const norm = (v) => Math.hypot(v.x, v.y, v.z); + let far = 0; + let nearShown = 0; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + const ds = gev.viewer.dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + for (const e of ds.entities.values) { + if (!e.billboard) continue; + const p = e.position.getValue(gev.viewer.clock.currentTime); + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; + } + } + return { far, nearShown }; + }, + LAYER_ID, + lon, + lat, + ); + const fromIndianOcean = await horizonCounts(80, -30); + record( + 'FAR SIDE: pins behind the planet are hidden', + fromIndianOcean.far === 0, + JSON.stringify(fromIndianOcean), + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'), + }); + const fromAmerica = await horizonCounts(-98, 39); + record( + 'FAR SIDE: pins facing the camera show again', + fromAmerica.far === 0 && fromAmerica.nearShown > 100, + JSON.stringify(fromAmerica), + ); + + // ---- (iv) CHIPS ------------------------------------------------------- + const clickChip = async (id) => { + // Row controls render on the next frame after the layer settles. + await page.waitForSelector( + `[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`, + { timeout: 15000 }, + ); + return page.evaluate( + (layer, chip) => + document + .querySelector( + `[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`, + ) + ?.click(), + LAYER_ID, + id, + ); + }; + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + !window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 15000 }, + LAYER_ID, + ); + const without = await sources(page); + record( + 'CHIPS: turning a product off removes only its data source', + without.length === 1 && without[0].name === `${LAYER_ID}:cip`, + without.map((s) => s.name).join(', '), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await sleep(3000); + await enableAndSettle(page); + const kept = await stats(page); + record( + 'CHIPS: the product choice survives a reload', + kept.products.join() === 'cip', + kept.products.join(', '), + ); + await showRow(page); + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 30000 }, + LAYER_ID, + ); + record( + 'CHIPS: turning it back on restores it', + (await sources(page)).length === 2, + ); + + // ---- (v) DISABLE ------------------------------------------------------ + await page.evaluate( + (id) => window.__godsEyeView.dataManager.setEnabled(id, false), + LAYER_ID, + ); + await sleep(500); + record( + 'DISABLE: every GhostMaps data source leaves the viewer', + (await sources(page)).length === 0, + ); + await page.close(); + + // ---- (iii) PUSH (synthetic) ---------------------------------------------- + const push = await browser.newPage(); + await push.evaluateOnNewDocument(() => { + localStorage.clear(); + // A controllable stand-in for the real stream. + class QaEventSource { + constructor(url) { + this.url = url; + this.listeners = new Set(); + (window.__qaStreams ||= []).push(this); + } + addEventListener(type, listener) { + if (type === 'catalog') this.listeners.add(listener); + } + removeEventListener(type, listener) { + this.listeners.delete(listener); + } + close() { + this.closed = true; + } + emit(catalog) { + for (const listener of this.listeners) + listener({ data: JSON.stringify(catalog) }); + } + } + window.EventSource = QaEventSource; + }); + await boot(push); + await enableAndSettle(push); + const identity = () => + push.evaluate((id) => { + const { dataSources } = window.__godsEyeView.viewer; + window.__qaIds ||= new WeakMap(); + window.__qaNext ||= 1; + const out = {}; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + if (!window.__qaIds.has(ds)) + window.__qaIds.set(ds, window.__qaNext++); + out[ds.name] = window.__qaIds.get(ds); + } + return out; + }, LAYER_ID); + const first = await identity(); + const pushed = Date.now(); + await push.evaluate(async (id) => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d'); + kinetic.version = 'f'.repeat(40); + catalog.message = 'QA push'; + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }, LAYER_ID); + await push.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id).module; + return ( + !layer.getStats().loading && + layer.getRowControls().info.includes('QA push') + ); + }, + { timeout: 20000, polling: 100 }, + LAYER_ID, + ); + await sleep(500); + const second = await identity(); + record( + 'PUSH: a new edition reloads only that product', + Object.keys(second).length === 2 && + second[`${LAYER_ID}:kinetic-30d`] !== + first[`${LAYER_ID}:kinetic-30d`] && + second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`], + `${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`, + ); + await push.evaluate(async () => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d'); + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }); + await sleep(1000); + const third = await identity(); + record( + 'PUSH: a product leaving the catalog leaves the globe', + Object.keys(third).join() === `${LAYER_ID}:cip`, + Object.keys(third).join(', '), + ); + await push.close(); + + // ---- (vi) VIEWPORT ---------------------------------------------------- + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + await phone.evaluateOnNewDocument(() => localStorage.clear()); + await boot(phone); + await enableAndSettle(phone); + await showRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const panel = document.getElementById('data-panel'); + const controls = row?.querySelector('.data-toggle-controls'); + return { + row: Boolean(row), + overflow: controls ? controls.scrollWidth - controls.clientWidth : -1, + pageOverflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + panelRight: panel?.getBoundingClientRect().right, + width: window.innerWidth, + }; + }, LAYER_ID); + record( + 'VIEWPORT: chips fit the phone-width panel', + fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0, + JSON.stringify(fit), + ); + await phone.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'), + }); + await phone.close(); + } finally { + await browser.close(); + } + const failed = results.filter((r) => !r.ok); + console.log( + `\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js new file mode 100644 index 000000000..dd30fd986 --- /dev/null +++ b/server/providers/ghostMaps.js @@ -0,0 +1,431 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + GHOSTMAPS_MAX_FILE_BYTES, + GHOSTMAPS_REPO, + buildGhostMapsCatalog, + ghostMapsRawUrl, + parseGitRefAdvertisement, +} from '../../src/layers/ghostMaps/catalog.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from './common/http.js'; + +// S2 Underground GhostMaps (public GitHub repository of KMZ intel products). +// The repository is not ours, so there is no webhook: the watcher reads the +// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the +// REST API rate limit) every few seconds, and only a new commit costs REST +// calls (tree + commit). Browsers hear about a new commit over server-sent +// events and fetch only the products whose blob changed. Files are fetched by +// commit-pinned raw URLs and kept on disk by blob sha. + +const DEFAULT_POLL_MS = 15_000; +const MIN_POLL_MS = 5_000; +const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000]; +const TIMEOUT_MS = 15_000; +const FILE_TIMEOUT_MS = 120_000; +const HEARTBEAT_MS = 25_000; +const MAX_SUBSCRIBERS = 64; +const MAX_REF_BYTES = 64 * 1024; +const MAX_TREE_BYTES = 16 * 1024 * 1024; +const MAX_COMMIT_BYTES = 2 * 1024 * 1024; +const USER_AGENT = 'gods-eye-view-ghostmaps/1.0'; +const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`; + +/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */ +export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) { + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS; + return Math.max(MIN_POLL_MS, Math.round(seconds * 1000)); +} + +/** + * The repository watcher and file cache, independent of HTTP routing. + * @returns {{start: Function, stop: Function, catalog: Function, + * checkNow: Function, file: Function, subscribe: Function}} + */ +export function createGhostMapsWatcher({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + pollMs = ghostMapsPollMs(), + cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'), + token = () => String(process.env.GITHUB_TOKEN || '').trim(), + log = (...args) => console.log(...args), + warn = (...args) => console.warn(...args), +} = {}) { + let state = { + commit: null, + committedAt: null, + message: null, + products: [], + checkedAt: null, + changedAt: null, + error: null, + }; + let timer = null; + let running = false; + let checking = null; + let failures = 0; + let diskLoaded = false; + const listeners = new Set(); + const downloads = new Map(); + + const apiHeaders = () => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + }); + + async function get(url, headers, timeoutMs = TIMEOUT_MS) { + const response = await fetchImpl(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), { + status: response.status, + }); + } + return response; + } + + async function readHead() { + const response = await get( + `${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`, + { 'User-Agent': USER_AGENT }, + ); + const sha = parseGitRefAdvertisement( + await readResponseTextCapped(response, MAX_REF_BYTES), + ); + if (!sha) throw new Error('GhostMaps branch head not advertised'); + return sha; + } + + async function readCommit(sha) { + const api = `https://api.github.com/repos/${REPO_PATH}`; + const tree = await readResponseJsonCapped( + await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()), + MAX_TREE_BYTES, + ); + if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed'); + if (tree.truncated) warn('[ghostmaps] repository tree listing truncated'); + let committedAt = null; + let message = null; + try { + const commit = await readResponseJsonCapped( + await get(`${api}/commits/${sha}`, apiHeaders()), + MAX_COMMIT_BYTES, + ); + committedAt = Date.parse(commit?.commit?.committer?.date) || null; + message = String(commit?.commit?.message || '') + .split('\n')[0] + .slice(0, 200); + } catch { + // The commit date is decoration; the tree is what matters. + } + return { products: buildGhostMapsCatalog(tree.tree), committedAt, message }; + } + + async function loadDisk() { + if (diskLoaded) return; + diskLoaded = true; + try { + const saved = JSON.parse( + await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'), + ); + if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products)) + state = { ...state, ...saved, error: null }; + } catch { + /* first run */ + } + } + + async function saveDisk() { + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const { commit, committedAt, message, products, changedAt } = state; + await fsp.writeFile( + path.join(cacheDir, 'catalog.json'), + JSON.stringify({ commit, committedAt, message, products, changedAt }), + ); + } catch (error) { + warn('[ghostmaps] could not save catalog:', error.message); + } + } + + function emit() { + const event = publicCatalog(); + for (const listener of listeners) { + try { + listener(event); + } catch { + /* a broken subscriber must not stop the rest */ + } + } + } + + async function check() { + await loadDisk(); + const head = await readHead(); + state.checkedAt = now(); + if (head === state.commit) return false; + const next = await readCommit(head); + const previous = new Map(state.products.map((p) => [p.id, p.version])); + const changed = next.products + .filter((p) => previous.get(p.id) !== p.version) + .map((p) => p.id); + state = { + ...state, + ...next, + commit: head, + changedAt: now(), + error: null, + }; + log( + `[ghostmaps] commit ${head.slice(0, 8)}${ + next.message ? ` "${next.message}"` : '' + }; updated: ${changed.join(', ') || 'none'}`, + ); + await saveDisk(); + emit(); + // Warm the default products so the first browser load is local. + for (const product of state.products) + if (product.defaultOn) file(product.id).catch(() => {}); + return true; + } + + /** Run one check now, coalescing with one already in flight. */ + function checkNow() { + if (!checking) + checking = check() + .then( + (changed) => { + failures = 0; + return changed; + }, + (error) => { + failures += 1; + state.error = error.message; + state.checkedAt = now(); + warn('[ghostmaps] check failed:', error.message); + emit(); + return false; + }, + ) + .finally(() => { + checking = null; + }); + return checking; + } + + function schedule() { + clearTimeout(timer); + if (!running) return; + const delay = failures + ? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1] + : pollMs; + timer = setTimeout(async () => { + await checkNow(); + schedule(); + }, delay); + timer.unref?.(); + } + + /** Start watching (idempotent); the first check runs immediately. */ + function start() { + if (running) return checking || Promise.resolve(false); + running = true; + const first = checkNow(); + first.then(schedule); + return first; + } + + function stop() { + running = false; + clearTimeout(timer); + timer = null; + } + + function publicCatalog() { + return { + repo: GHOSTMAPS_REPO.url, + branch: GHOSTMAPS_REPO.branch, + commit: state.commit, + committedAt: state.committedAt, + message: state.message, + checkedAt: state.checkedAt, + changedAt: state.changedAt, + pollMs, + error: state.error, + products: state.products.map( + ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }) => ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }), + ), + }; + } + + /** + * The bytes of one product's current edition, from disk or a commit-pinned + * download. Only catalog paths are ever fetched. + */ + async function file(id) { + const product = state.products.find((p) => p.id === id); + if (!product) return null; + const target = path.join(cacheDir, 'files', `${product.version}.kmz`); + try { + return { product, bytes: await fsp.readFile(target) }; + } catch { + /* not cached yet */ + } + const commit = state.commit; + let pending = downloads.get(product.version); + if (!pending) { + pending = (async () => { + const response = await get( + ghostMapsRawUrl(commit, product.path), + { 'User-Agent': USER_AGENT }, + FILE_TIMEOUT_MS, + ); + const bytes = Buffer.from( + await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES), + ); + await fsp.mkdir(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.tmp`; + await fsp.writeFile(temp, bytes); + await fsp.rename(temp, target); + await pruneFiles(); + return bytes; + })().finally(() => downloads.delete(product.version)); + downloads.set(product.version, pending); + } + return { product, bytes: await pending }; + } + + /** Keep only files that a current product still points to. */ + async function pruneFiles() { + const keep = new Set(state.products.map((p) => `${p.version}.kmz`)); + const dir = path.join(cacheDir, 'files'); + for (const name of await fsp.readdir(dir).catch(() => [])) { + if (!keep.has(name) && !name.endsWith('.tmp')) + await fsp.rm(path.join(dir, name), { force: true }).catch(() => {}); + } + } + + function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + start, + stop, + checkNow, + catalog: publicCatalog, + file, + subscribe, + subscribers: () => listeners.size, + }; +} + +/** Same-origin GhostMaps routes for dev and preview. */ +export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(JSON.stringify(value)); + }; + + function events(req, res) { + if (watcher.subscribers() >= MAX_SUBSCRIBERS) + return json(res, 429, { error: 'too_many_subscribers' }); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (catalog) => + res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`); + res.write('retry: 5000\n\n'); + send(watcher.catalog()); + const unsubscribe = watcher.subscribe(send); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + heartbeat.unref?.(); + const close = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + req.on('close', close); + res.on('error', close); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const url = new URL(req.url || '/', 'http://local'); + const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname); + if (!['/catalog', '/events'].includes(url.pathname) && !match) + return json(res, 404, { error: 'unknown_route' }); + // Watching starts with the first request, so an install that never turns + // the layer on never contacts GitHub. + const first = watcher.start(); + if (url.pathname === '/catalog') { + if (!watcher.catalog().commit) await first; + return json(res, 200, watcher.catalog()); + } + if (url.pathname === '/events') return events(req, res); + try { + if (!watcher.catalog().commit) await first; + const found = await watcher.file(match[1]); + if (!found) return json(res, 404, { error: 'unknown_product' }); + const pinned = url.searchParams.get('v') === found.product.version; + res.writeHead(200, { + 'Content-Type': 'application/vnd.google-earth.kmz', + 'Content-Length': found.bytes.length, + 'X-GhostMaps-Version': found.product.version, + // A versioned URL names one immutable blob. + 'Cache-Control': pinned + ? 'private, max-age=31536000, immutable' + : 'no-store', + }); + return res.end(found.bytes); + } catch { + return json(res, 502, { error: 'ghostmaps_unavailable' }); + } + } + + return { + name: 'ghostmaps', + configureServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + }; +} diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs new file mode 100644 index 000000000..6251bf4de --- /dev/null +++ b/server/providers/ghostMaps.test.mjs @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createGhostMapsWatcher, + ghostMapsPollMs, + ghostMapsProxy, +} from './ghostMaps.js'; + +const MASTER = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/'; +const KINETIC = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz'; +const sha = (c) => c.repeat(40); + +/** A fake GitHub: ref advertisement, tree, commit and raw files. */ +function fakeGitHub() { + const repo = { head: sha('1'), trees: new Map(), calls: [] }; + repo.trees.set(sha('1'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('a'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + const body = (text, status = 200) => + new Response(text, { + status, + headers: { 'content-length': String(Buffer.byteLength(text)) }, + }); + const fetchImpl = async (url) => { + repo.calls.push(url); + if (url.endsWith('/info/refs?service=git-upload-pack')) + return body( + `001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`, + ); + const tree = /git\/trees\/([0-9a-f]{40})/.exec(url); + if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) })); + if (/\/commits\//.test(url)) + return body( + JSON.stringify({ + commit: { + message: 'Oct 18 Update\nmore', + committer: { date: '2026-10-18T00:00:00Z' }, + }, + }), + ); + if (url.startsWith('https://raw.githubusercontent.com/')) + return body('KMZ!'); + return body('nope', 404); + }; + return { repo, fetchImpl }; +} + +async function withCache(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('a new commit is announced once, naming only the products that changed', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + + assert.equal(await watcher.checkNow(), true); + const first = watcher.catalog(); + assert.equal(first.commit, sha('1')); + assert.equal(first.message, 'Oct 18 Update'); + assert.deepEqual( + first.products.map((p) => p.id), + ['cip', 'kinetic-30d'], + ); + assert.equal(events.length, 1); + + // Same head: one ref read, no REST calls, no event. + repo.calls.length = 0; + assert.equal(await watcher.checkNow(), false); + assert.equal(repo.calls.length, 1); + assert.equal(events.length, 1); + + // A new upload: the CIP gets a newer edition, the 30-day pull is untouched. + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + ...repo.trees.get(sha('1')), + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + ]); + assert.equal(await watcher.checkNow(), true); + assert.equal(events.length, 2); + const cip = events[1].products.find((p) => p.id === 'cip'); + assert.equal(cip.version, sha('c')); + assert.match(cip.fileName, /October_18/); + assert.equal( + events[1].products.find((p) => p.id === 'kinetic-30d').version, + sha('b'), + ); + // Repository paths stay on the server. + assert.equal('path' in cip, false); + watcher.stop(); + })); + +test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + await watcher.checkNow(); + const found = await watcher.file('kinetic-30d'); + assert.equal(found.bytes.toString(), 'KMZ!'); + const raw = repo.calls.filter((url) => + url.includes('raw.githubusercontent'), + ); + assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`))); + assert.ok( + raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')), + ); + const before = repo.calls.length; + await watcher.file('kinetic-30d'); + assert.equal(repo.calls.length, before, 'second read comes from disk'); + assert.equal(await watcher.file('not-a-product'), null); + + // When the edition changes, the old blob leaves the disk cache. + await watcher.file('cip'); + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + await watcher.checkNow(); + await watcher.file('cip'); + const files = (await readdir(path.join(cacheDir, 'files'))).sort(); + assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]); + watcher.stop(); + })); + +test('the catalog survives a restart from disk, and upstream failures are reported', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + const quiet = { log: () => {}, warn: () => {} }; + await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const offline = createGhostMapsWatcher({ + fetchImpl: async () => new Response('down', { status: 503 }), + cacheDir, + ...quiet, + }); + assert.equal(await offline.checkNow(), false); + const catalog = offline.catalog(); + assert.equal(catalog.commit, sha('1')); + assert.equal(catalog.products.length, 2); + assert.match(catalog.error, /HTTP 503/); + })); + +test('poll interval is configurable with a 5 s floor', () => { + assert.equal(ghostMapsPollMs(undefined), 15000); + assert.equal(ghostMapsPollMs('30'), 30000); + assert.equal(ghostMapsPollMs('1'), 5000); + assert.equal(ghostMapsPollMs('soon'), 15000); +}); + +test('routes: unknown paths and methods are refused without contacting GitHub', async () => { + let started = 0; + const watcher = { + start: () => { + started += 1; + return Promise.resolve(false); + }, + catalog: () => ({ commit: sha('1'), products: [] }), + file: async () => null, + subscribe: () => () => {}, + subscribers: () => 0, + }; + let handler; + ghostMapsProxy({ watcher }).configureServer({ + middlewares: { use: (_route, fn) => (handler = fn) }, + }); + const call = async (method, url) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = String(body ?? ''); + }, + }; + await handler({ method, url }, res); + return res; + }; + assert.equal((await call('GET', '/nope')).status, 404); + assert.equal((await call('POST', '/catalog')).status, 405); + assert.equal((await call('GET', '/file/../../etc')).status, 404); + assert.equal(started, 0); + assert.equal((await call('GET', '/file/cip')).status, 404); + const catalog = await call('GET', '/catalog'); + assert.equal(catalog.status, 200); + assert.equal(JSON.parse(catalog.body).commit, sha('1')); + assert.equal(started, 2); +}); diff --git a/server/providers/local.js b/server/providers/local.js index 5d39c3a01..d9e7b7919 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -26,6 +26,7 @@ import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; +import { ghostMapsProxy } from './ghostMaps.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) { mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + ghostMapsProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832c174bc..7c149ab59 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; +import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -126,6 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), + createApplicationGhostMaps(), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9e1059c33..eb9e88d31 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 32); + assert.equal(first.layers.length, 33); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied ); assert.ok(first.get('fire-perimeters')); assert.ok(first.get('gnss-integrity')); + assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 32); + assert.equal(catalog.layers.length, 33); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js new file mode 100644 index 000000000..5e373c3d8 --- /dev/null +++ b/src/app/layers/ghostMaps.js @@ -0,0 +1,27 @@ +import * as Cesium from 'cesium'; +import { + createGhostMapsLayer, + createGhostMapsSource, +} from '../../layers/ghostMaps/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +export function createApplicationGhostMaps(options) { + return createGhostMapsLayer({ + source: createGhostMapsSource(), + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 422308f1e..bfb031026 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 32); + assert.equal(actual.layers.length, 33); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a86f43465..e7a523766 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -441,6 +441,14 @@ export const DATA_CREDITS = [ '(earthdata.nasa.gov/firms), ' + 'part of NASA’s Earth Observing System Data and Information System (EOSDIS)', }, + { + key: 's2-ghostmaps', + html: + 'S2 GhostMaps intel products: S2 Underground © by S2A1, ' + + 'github.com/s2underground/GhostMaps, ' + + 'licensed under CC BY-NC-SA 4.0. ' + + 'Shown as published; reports are S2 assessments, not verified fact.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..86c60cb94 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1040,7 +1040,13 @@ export function detachImportedTime(dataSource) { return dataSource; } -function parseXml(text, what) { +/** + * Parse KML or GPX text with the namespace repairs Cesium applies. + * @param {string} text + * @param {string} what Format name for the error message. + * @returns {Document} + */ +export function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. if (what !== 'GPX') text = String(text).replace(/]*>/i, (root) => { @@ -1073,7 +1079,8 @@ function parseXml(text, what) { return doc; } -function decodeXml(bytes) { +/** Decode XML bytes, honouring a UTF-16 byte-order mark. */ +export function decodeXml(bytes) { const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..aa366a0e6 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -675,6 +675,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'x', disposition: 'enabled-only', }), + Object.freeze({ + id: 's2-ghostmaps', + token: '5', + disposition: 'enabled-only', + }), Object.freeze({ id: 'satellites', token: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 77b8f902f..ba5496227 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 31); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31); + assert.equal(REGISTERED_LAYER_IDS.length, 32); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '5'); + assert.equal(nextLayerStateToken(), '6'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '5', - bravo: '6', + alpha: '6', + bravo: '7', }), - '7', + '8', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'56789'].map((digit) => [`prior-${digit}`, digit]), + [...'6789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '5', - next: '6', + future: '6', + next: '7', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 5/, + /next free token 6/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' }, ), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7bcfd96cb..47b55b067 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'street-level': '0', 'gnss-integrity': '3', 'air-quality': '4', + 's2-ghostmaps': '5', }, ); assert.throws( @@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '5']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '6']]) .future, - '5', + '6', ); assert.throws( () => @@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '5']] }, + { rows: [...reservationRows, ['retired-layer', '6']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); + assert.equal(nextLayerStateToken(published), '7'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '6', + newcomer: '7', }), true, ); @@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '5', + competing: '6', }), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '5', + newcomer: '6', }), /changed or removed: retired-layer/, ); @@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => { +test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '5']], - [{ id: 'pr-b', token: '5' }], + [...reservationRows, ['pr-b', '6']], + [{ id: 'pr-b', token: '6' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '5']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '6']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '5' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '6' }, ], ); const staleRebase = runCheck(); @@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); - const validBaselineRows = [...reservationRows, ['pr-a', '5']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]); + assert.equal(nextLayerStateToken(published), '7'); + const validBaselineRows = [...reservationRows, ['pr-a', '6']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 6/); + assert.match(helper.stdout, /pr-b: 7/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '6']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '7']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '6' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '7' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /32 published, 1 new/); + assert.match(corrected.stdout, /33 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'56789'].map((digit) => [ + const priorDigits = [...'6789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 73); + assert.equal(params.get('l').length, 75); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 32); + assert.equal(Object.keys(published).length, 33); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index dd8dcd7c4..7593425ae 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], + ["s2-ghostmaps", "5"], ["satellites", "s"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..acf4d4bfe 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // S2 GhostMaps extends the manifest. + assert.equal(block.length, 30723, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '5937f0e90d38bf4109136c71bb9561298dd3fca11464ff980a1f982b6c154e5b', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..90da6d197 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2937,5 +2937,33 @@ "Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.", "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Token request is too large": "La solicitud de credencial es demasiado grande", - "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío" + "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Panorama de inteligencia", + "Kinetic 30d": "Cinético 30 d", + "Border Crisis": "Crisis fronteriza", + "Iran Picture": "Panorama de Irán", + "Iran Kinetic": "Irán cinético", + "Yemen FLOT": "FLOT de Yemen", + "Latest Drop": "Última publicación", + "GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps", + "GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible", + "Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado", + "{count} source links ↗ · click card to open the first": { + "one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo", + "many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero", + "other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Informe", + "Open the source for {title}": "Abrir la fuente de {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo", + "Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} marcador", + "many": "{count} de marcadores", + "other": "{count} marcadores" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..25bcead93 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3004,5 +3004,35 @@ "Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.", "Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.", "Token request is too large": "Запрос токена слишком велик", - "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON" + "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Разведкартина", + "Kinetic 30d": "Боевые 30 дн.", + "Border Crisis": "Кризис на границе", + "Iran Picture": "Иран: картина", + "Iran Kinetic": "Иран: боевые", + "Yemen FLOT": "Йемен: линия фронта", + "Latest Drop": "Новый выпуск", + "GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps", + "GhostMaps catalog unavailable": "Каталог GhostMaps недоступен", + "Malformed GhostMaps catalog": "Повреждённый каталог GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} ссылка на источник ↗ · нажмите на карточку, чтобы открыть", + "few": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую", + "many": "{count} ссылок на источники ↗ · нажмите на карточку, чтобы открыть первую", + "other": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Сводка", + "Open the source for {title}": "Открыть источник: {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени", + "Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} метка", + "few": "{count} метки", + "many": "{count} меток", + "other": "{count} метки" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку." } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..722f1c942 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -74,6 +74,8 @@ "Radio", "Reggae", "Rock", + "S2 GhostMaps", + "S2 · {name}", "SAR", "SE", "SECTOR {lat} {lon}", @@ -131,6 +133,8 @@ "NVG", "R&B", "REC", + "S2 GhostMaps", + "S2 · {name}", "VS", "WHOT/BHOT" ] diff --git a/src/layers/ghostMaps/catalog.js b/src/layers/ghostMaps/catalog.js new file mode 100644 index 000000000..4fb68c948 --- /dev/null +++ b/src/layers/ghostMaps/catalog.js @@ -0,0 +1,245 @@ +// S2 Underground GhostMaps catalog: which KMZ in the public repository is the +// current edition of each product. Pure and portable (the server builds the +// catalog, tests pin the rules); no i18n, no DOM, no Cesium. + +export const GHOSTMAPS_REPO = Object.freeze({ + owner: 's2underground', + name: 'GhostMaps', + branch: 'main', + url: 'https://github.com/s2underground/GhostMaps', +}); + +/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */ +export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; + +/** + * Curated products, in panel order. Each names a series of dated uploads; the + * newest edition wins. Patterns match whole repository paths, so a renamed + * folder fails closed (the product drops out) instead of matching elsewhere. + */ +export const GHOSTMAPS_PRODUCTS = Object.freeze([ + Object.freeze({ + id: 'cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Common Intelligence Picture', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'kinetic-30d', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Kinetic Activity (30 days)', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'border-crisis', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Border Crisis Map', + defaultOn: false, + pattern: new RegExp( + `^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Common Intelligence Picture', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-kinetic', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Kinetic Events', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'yemen-flot', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Yemen FLOT', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`, + 'i', + ), + }), +]); + +/** The newest incident product outside the curated series and the archive. */ +export const GHOSTMAPS_LATEST_DROP = Object.freeze({ + id: 'latest-drop', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Latest incident drop', + defaultOn: false, +}); + +const LATEST_DROP_EXCLUDED = + /\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i; + +function escape(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; +const DATE_IN_NAME = + /(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i; +const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i; + +/** + * The date and edition written into an S2 file name, e.g. + * `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names + * report `dateMs: null` and sort below every dated edition. + * @param {string} fileName + * @returns {{dateMs: ?number, edition: number}} + */ +export function parseGhostMapsEdition(fileName) { + const stem = String(fileName || '').replace(/\.km[lz]$/i, ''); + const match = DATE_IN_NAME.exec(stem); + let dateMs = null; + if (match) { + const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()); + const day = Number(match[2]); + const year = Number(match[3]); + const at = Date.UTC(year, month, day); + // Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts. + if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at; + } + const tail = match ? stem.slice(match.index + match[0].length) : stem; + const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0); + return { dateMs, edition }; +} + +function newer(a, b) { + if (!b) return true; + const ad = a.dateMs ?? -Infinity; + const bd = b.dateMs ?? -Infinity; + if (ad !== bd) return ad > bd; + if (a.edition !== b.edition) return a.edition > b.edition; + return a.path > b.path; +} + +/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */ +export function ghostMapsFileTitle(fileName) { + return String(fileName || '') + .replace(/\.km[lz]$/i, '') + .replace(/^S2Underground_/i, '') + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Build the product catalog from a recursive git tree listing. + * @param {Array<{path: string, type: string, sha: string, size?: number}>} tree + * @returns {Array<{id: string, title: string, defaultOn: boolean, + * path: string, fileName: string, fileTitle: string, version: string, + * size: number, dateMs: ?number, edition: number}>} + */ +export function buildGhostMapsCatalog(tree) { + const best = new Map(); + let drop = null; + for (const entry of Array.isArray(tree) ? tree : []) { + if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue; + if (!/\.kmz$/i.test(entry.path)) continue; + if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue; + const size = Number(entry.size); + if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES) + continue; + const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1); + const candidate = { + path: entry.path, + fileName, + version: entry.sha, + size, + ...parseGhostMapsEdition(fileName), + }; + const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) => + pattern.test(entry.path), + ); + if (product) { + if (newer(candidate, best.get(product.id))) + best.set(product.id, candidate); + } else if ( + entry.path.startsWith(INCIDENTS) && + !LATEST_DROP_EXCLUDED.test(`/${entry.path}`) && + candidate.dateMs != null && + newer(candidate, drop) + ) { + drop = candidate; + } + } + const rows = []; + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + const file = + product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id); + if (!file) continue; + rows.push({ + id: product.id, + title: product.title, + defaultOn: product.defaultOn, + ...file, + fileTitle: ghostMapsFileTitle(file.fileName), + }); + } + return rows; +} + +/** + * Raw download URL pinned to a commit, so a CDN can never hand back an older + * edition for a newer catalog. + * @param {string} commit 40-hex commit sha. + * @param {string} path Repository path. + */ +export function ghostMapsRawUrl(commit, path) { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`; +} + +/** + * The branch head from a git smart-HTTP ref advertisement + * (`/info/refs?service=git-upload-pack`), or null. + * @param {string} text + * @param {string} [branch] + * @returns {?string} + */ +export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) { + const ref = `refs/heads/${branch}`; + for (const line of String(text || '').split('\n')) { + // pkt-line: 4 hex length digits, then " " (the first ref line + // also carries a NUL and the capability list). + const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line); + if (match && match[2] === ref) return match[1]; + } + return null; +} diff --git a/src/layers/ghostMaps/catalog.test.mjs b/src/layers/ghostMaps/catalog.test.mjs new file mode 100644 index 000000000..74735381d --- /dev/null +++ b/src/layers/ghostMaps/catalog.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildGhostMapsCatalog, + ghostMapsFileTitle, + ghostMapsRawUrl, + parseGhostMapsEdition, + parseGitRefAdvertisement, +} from './catalog.js'; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; +let n = 0; +const blob = (path, size = 1000) => ({ + path, + type: 'blob', + size, + sha: (++n).toString(16).padStart(40, '0'), +}); + +test('editions read the date and export/version number written in the name', () => { + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz', + ), + { dateMs: Date.UTC(2026, 9, 16), edition: 1 }, + ); + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz', + ), + { dateMs: Date.UTC(2026, 8, 21), edition: 2 }, + ); + assert.deepEqual( + parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'), + { + dateMs: Date.UTC(2026, 7, 24), + edition: 0, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'), + { + dateMs: Date.UTC(2025, 8, 5), + edition: 2, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'), + { + dateMs: null, + edition: 0, + }, + ); + // An impossible date is not a date. + assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null); +}); + +test('each product keeps its newest edition by date, then edition number', () => { + const master = `${ARCGIS}Common Intelligence Picture/Master Database/`; + const tree = [ + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`, + ), + blob( + `${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`, + ), + // A European-only subset in a subfolder is not the border map. + blob( + `${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`), + { path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) }, + ]; + const rows = buildGhostMapsCatalog(tree); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + assert.deepEqual( + rows.map(({ id }) => id), + ['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'], + ); + assert.match(byId.cip.fileName, /October_16_2026/); + assert.equal(byId.cip.defaultOn, true); + assert.equal(byId['kinetic-30d'].defaultOn, true); + assert.match(byId['border-crisis'].fileName, /Export_v2/); + assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz'); + assert.equal(byId.cip.version, tree[1].sha); + assert.equal( + byId.cip.fileTitle, + 'Common Intelligence Picture October 16 2026 Export 1', + ); +}); + +test('latest drop is the newest dated incident file outside curated, archived and base data', () => { + const rows = buildGhostMapsCatalog([ + blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`), + blob( + `${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`, + ), + blob( + `${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`), + blob( + `${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Undated Product.kmz`), + ]); + const drop = rows.find(({ id }) => id === 'latest-drop'); + assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz'); + assert.ok(rows.some(({ id }) => id === 'iran-kinetic')); +}); + +test('oversized, empty, non-KMZ and malformed entries are skipped', () => { + const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`; + assert.deepEqual( + buildGhostMapsCatalog([ + { ...blob(path), size: 51 * 1024 * 1024 }, + { ...blob(path), size: 0 }, + { ...blob(path), sha: 'not-a-sha' }, + blob(path.replace(/\.kmz$/, '.zip')), + null, + ]), + [], + ); + assert.deepEqual(buildGhostMapsCatalog(undefined), []); +}); + +test('raw URLs are pinned to the commit and encode every path segment', () => { + assert.equal( + ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'), + `https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`, + ); + assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B'); +}); + +test('the branch head comes from the smart-HTTP ref advertisement', () => { + const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d'; + const other = 'b'.repeat(40); + const text = [ + '001e# service=git-upload-pack', + `0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`, + `003f${other} refs/heads/main-old`, + `003d${head} refs/heads/main`, + '0000', + ].join('\n'); + assert.equal(parseGitRefAdvertisement(text), head); + assert.equal(parseGitRefAdvertisement(text, 'dev'), null); + assert.equal(parseGitRefAdvertisement(''), null); +}); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js new file mode 100644 index 000000000..b0aa7c88b --- /dev/null +++ b/src/layers/ghostMaps/index.js @@ -0,0 +1,708 @@ +import * as Cesium from 'cesium'; +import { + decodeXml, + kmzImageResolver, + kmzMainDocument, + parseXml, + readZipEntries, + resolveKmzPath, +} from '../../data/geoFileImport.js'; +import { + formatNumber, + formatUtcDayMonth, + msg, + t, + tn, +} from '../../i18n/index.js'; +import { createHorizonCull } from '../streetLevel/view.js'; +import { readGhostMapsKml } from './kml.js'; +import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +export * from './catalog.js'; +export * from './records.js'; +export { readGhostMapsKml } from './kml.js'; +export { createGhostMapsSource } from './source.js'; + +export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps'; +export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps'; +const PICK_PREFIX = 'ghostmaps:'; +const STORAGE_KEY = 'gev.ghostmaps.products'; +const ACCENT = '#c8ff00'; +const MAX_ICON_BYTES = 512 * 1024; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); +const MAX_CARD_LINES = 12; + +/** Panel names for the catalog's product ids (the catalog sends English). */ +const PRODUCT_LABELS = Object.freeze({ + cip: msg('Intel Picture'), + 'kinetic-30d': msg('Kinetic 30d'), + 'border-crisis': msg('Border Crisis'), + 'iran-cip': msg('Iran Picture'), + 'iran-kinetic': msg('Iran Kinetic'), + 'yemen-flot': msg('Yemen FLOT'), + 'latest-drop': msg('Latest Drop'), +}); + +/** One distinct fallback marker color per product. */ +const PRODUCT_COLORS = Object.freeze({ + cip: '#c8ff00', + 'kinetic-30d': '#ff5a36', + 'border-crisis': '#ffb547', + 'iran-cip': '#39d0ff', + 'iran-kinetic': '#ff3b8d', + 'yemen-flot': '#5dff9f', + 'latest-drop': '#b48cff', +}); + +function defaultStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +function readStoredSelection(storage) { + try { + const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null'); + return Array.isArray(value) ? new Set(value.map(String)) : null; + } catch { + return null; + } +} + +function writeStoredSelection(storage, selected) { + try { + storage?.setItem(STORAGE_KEY, JSON.stringify([...selected])); + } catch { + /* private window or blocked storage: the selection lasts this session */ + } +} + +/** A round marker for placemarks whose icon is missing or remote. */ +const markerCache = new Map(); +function markerImage(color) { + if (markerCache.has(color)) return markerCache.get(color); + let url = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 20; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(10, 10, 7, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.stroke(); + url = canvas.toDataURL(); + } catch { + url = null; + } + markerCache.set(color, url); + return url; +} + +const cesiumColor = (rgba, fallback, alpha) => + rgba + ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) + : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); + +function anchorOf(geometry) { + if (geometry.type === 'point') return geometry.position; + const ring = + geometry.type === 'polygon' ? geometry.outer : geometry.positions; + let lon = 0; + let lat = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lon / ring.length, lat / ring.length]; +} + +/** + * S2 Underground GhostMaps: the current editions of S2's public KMZ intel + * products, kept in step with the GitHub repository. The proxy watches the + * branch head and pushes each new commit; only changed products reload. + */ +export function createGhostMapsLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, + openExternal = null, + storage = defaultStorage(), +} = {}) { + if (typeof source?.getCatalog !== 'function') + throw new TypeError('GhostMaps layer requires a catalog source'); + let _viewer = null; + let _enabled = false; + let _catalog = null; + let _lastError = null; + let _unsubscribe = null; + let _stream = 'closed'; + let _queue = Promise.resolve(); + let _clickHandler = null; + let _rowListener = null; + let _selectedKey = null; + let _selectedCardId = null; + let _selectedLink = null; + let _lifetime = new AbortController(); + let _selection = readStoredSelection(storage); + /** productId → {version, dataSource, urls, count, features} */ + const _loaded = new Map(); + /** productId → in-flight version */ + const _loading = new Map(); + /** productId → error text */ + const _failed = new Map(); + + const notifyRow = () => _rowListener?.(); + const render = () => _viewer?.scene?.requestRender?.(); + // Pins skip the depth test, and with Google 3D tiles the globe is hidden, + // so nothing else stops far-side pins drawing through the planet. + const _horizon = createHorizonCull({ + getViewer: () => _viewer, + items: function* () { + for (const item of _loaded.values()) yield* item.pins; + }, + onChange: render, + }); + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function wanted(product) { + return _selection ? _selection.has(product.id) : product.defaultOn; + } + + function selectedIds() { + return (_catalog?.products || []).filter(wanted).map(({ id }) => id); + } + + function unload(id) { + const item = _loaded.get(id); + if (!item) return; + _loaded.delete(id); + _viewer?.dataSources?.remove(item.dataSource, true); + item.urls.forEach((url) => URL.revokeObjectURL(url)); + if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + } + + /** Build one product's data source off-scene, then swap it in. */ + async function load(product, signal) { + const bytes = await source.getFile(product.id, product.version, { signal }); + const entries = await readZipEntries(bytes); + const main = kmzMainDocument(entries); + if (!main) throw new Error(msg('That KMZ holds no KML document.')); + const urls = []; + const resolvePacked = kmzImageResolver(entries, main, (url) => + urls.push(url), + ); + // Icons only: ArcGIS exports also pack multi-megabyte photo attachments, + // which fall back to the product marker instead of being decoded. + const resolveImage = (href) => { + const path = resolveKmzPath(href, main, entries); + return path && entries.get(path).length <= MAX_ICON_BYTES + ? resolvePacked(href) + : null; + }; + let parsed; + try { + parsed = readGhostMapsKml( + parseXml(decodeXml(entries.get(main)), 'KML'), + resolveImage, + ); + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees(...geometry.position); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), + ), + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { + dataSource.entities.add({ + id, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); + }); + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } + + /** Bring the globe in line with the catalog and the chip selection. */ + function sync() { + const run = _queue.then(async () => { + if (!_enabled || !_viewer || !_catalog) return; + const signal = _lifetime.signal; + const products = new Map(_catalog.products.map((p) => [p.id, p])); + for (const id of [..._loaded.keys()]) { + const product = products.get(id); + if (!product || !wanted(product)) unload(id); + } + for (const product of _catalog.products) { + if (!wanted(product) || signal.aborted) continue; + if (_loaded.get(product.id)?.version === product.version) continue; + _loading.set(product.id, product.version); + _failed.delete(product.id); + notifyRow(); + try { + const next = await load(product, signal); + if (signal.aborted || !_enabled) { + next.urls.forEach((url) => URL.revokeObjectURL(url)); + return; + } + // Cull before the first frame so far-side pins never flash. + _horizon.update(next.pins); + await _viewer.dataSources.add(next.dataSource); + // The new edition is on the globe before the old one leaves. + unload(product.id); + _loaded.set(product.id, next); + } catch (error) { + if (signal.aborted) return; + console.warn(`[Data:GhostMaps] ${product.id}:`, error); + _failed.set( + product.id, + error?.template || + error?.message || + msg('GhostMaps file could not be read'), + ); + } finally { + _loading.delete(product.id); + notifyRow(); + render(); + } + } + }); + _queue = run.catch(() => {}); + return run; + } + + function applyCatalog(catalog) { + _catalog = catalog; + _lastError = catalog.error || null; + notifyRow(); + return sync(); + } + + function toggleProduct(id) { + // The first change pins the whole current selection, defaults included. + if (!_selection) _selection = new Set(selectedIds()); + if (_selection.has(id)) _selection.delete(id); + else _selection.add(id); + writeStoredSelection(storage, _selection); + notifyRow(); + void sync(); + } + + // ---- selection card ---------------------------------------------------- + function featureFor(key) { + const [productId, index] = key.split(':'); + return { + product: _catalog?.products.find((p) => p.id === productId) || null, + feature: _loaded.get(productId)?.features[Number(index)] || null, + }; + } + + function publishCard() { + if (!canSelect()) return; + const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {}; + if (!feature) { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const { fields, links } = parseDescriptionFields(feature.description); + _selectedLink = links[0] || null; + const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')]; + if (feature.folder) heading.push(fieldLabel(feature.folder)); + if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs)); + const details = [heading.filter(Boolean).join(' · ')]; + for (const { label, value } of fields) { + if (details.length >= MAX_CARD_LINES) break; + // ArcGIS repeats the feature type as the first row; the title has it. + if (value === feature.name) continue; + const line = label ? `${fieldLabel(label)}: ${value}` : value; + details.push( + ...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)), + ); + } + if (_selectedLink && openExternal) + details.push( + tn( + links.length, + '{count} source link ↗ · click card to open', + '{count} source links ↗ · click card to open the first', + { count: formatNumber(links.length) }, + ), + ); + const title = t('S2 · {name}', { + name: fieldLabel(feature.name || feature.folder) || t('Report'), + }); + const card = { + id: `ghostmaps-card:${_selectedKey}`, + selected: true, + interactive: Boolean(_selectedLink && openExternal), + title, + details, + accent: PRODUCT_COLORS[product?.id] || ACCENT, + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + // A click card comes and goes with the click, as CCTV cards do, rather + // than holding the label arbiter's 2.5 s minimum lifetime; and it hides + // when its marker turns behind the planet. + stateless: true, + horizonCull: true, + position: Cesium.Cartesian3.fromDegrees(...feature.anchor), + }; + if (card.interactive) { + const link = _selectedLink; + card.accessibilityLabel = t('Open the source for {title}', { title }); + card.activate = () => { + openExternal(link); + return true; + }; + } + _selectedCardId = card.id; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true); + } + + function clearSelection() { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + // An empty publish, as the perimeter cards deselect: it releases the + // selected card's paint lane, which clearing the source alone left drawn. + overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS); + } + + function pickedKey(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':'); + return _loaded.get(productId)?.features[Number(index)] + ? `${productId}:${index}` + : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + picking.registerPickOwner?.( + GHOSTMAPS_LAYER_ID, + (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), + ); + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) { + if (_selectedLink && openExternal) openExternal(_selectedLink); + return; + } + const picked = _viewer.scene.pick(click.position); + const key = picked ? pickedKey(picked) : null; + if (key) { + _selectedKey = key; + publishCard(); + render(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId)) + return; + } + if (_selectedKey) { + clearSelection(); + render(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + _clickHandler?.destroy(); + _clickHandler = null; + picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); + } + + function featureCount() { + let count = 0; + for (const item of _loaded.values()) count += item.features.length; + return count; + } + + const layer = { + id: GHOSTMAPS_LAYER_ID, + name: 'S2 GhostMaps', + icon: '👻', + source: 'S2 Underground', + // Pushes arrive within seconds over the event stream; this interval is + // the backstop if the stream is down. + updateInterval: 300000, + + init(viewer) { + if (_viewer) throw new Error('GhostMaps layer is already initialized'); + _viewer = viewer; + }, + + enable() { + _enabled = true; + _lifetime = new AbortController(); + for (const item of _loaded.values()) item.dataSource.show = true; + installClickHandler(); + if (!_unsubscribe && typeof source.subscribe === 'function') + _unsubscribe = source.subscribe( + (catalog) => { + if (_enabled) void applyCatalog(catalog); + }, + (state) => { + if (_stream === state) return; + _stream = state; + notifyRow(); + }, + ); + }, + + disable() { + _enabled = false; + _lifetime.abort(); + _unsubscribe?.(); + _unsubscribe = null; + _stream = 'closed'; + removeClickHandler(); + clearSelection(); + _horizon.stop(); + for (const id of [..._loaded.keys()]) unload(id); + render(); + }, + + async update(_viewerArg, { signal } = {}) { + if (!_enabled || signal?.aborted) return false; + try { + const catalog = await source.getCatalog({ signal }); + if (!_enabled) return false; + await applyCatalog(catalog); + return true; + } catch (error) { + if (signal?.aborted || !_enabled) return false; + console.warn('[Data:GhostMaps] catalog error:', error); + _lastError = error?.message || msg('GhostMaps catalog unavailable'); + notifyRow(); + // Keep the layer on; the event stream or the next interval recovers. + return true; + } + }, + + destroy() { + layer.disable(); + _viewer = null; + _catalog = null; + _rowListener = null; + }, + + setRowControlsListener(listener) { + _rowListener = listener; + }, + + getRowControls() { + const products = _catalog?.products || []; + const commit = _catalog?.commit; + const info = commit + ? _stream === 'open' + ? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + seconds: formatNumber( + Math.round((_catalog.pollMs || 15000) / 1000), + ), + }) + : t('GitHub {commit} · {message} · reconnecting for live updates', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + }) + : t('Connecting to the S2 GhostMaps repository…'); + return { + chips: products.map((product) => { + const loaded = _loaded.get(product.id); + const failed = _failed.get(product.id); + return { + id: product.id, + label: t(PRODUCT_LABELS[product.id] || product.title), + active: wanted(product), + busy: _loading.has(product.id), + state: failed ? 'error' : undefined, + color: PRODUCT_COLORS[product.id], + title: [ + product.fileTitle, + loaded + ? tn( + loaded.features.length, + '{count} placemark', + '{count} placemarks', + { + count: formatNumber(loaded.features.length), + }, + ) + : null, + failed ? t(failed) : null, + ] + .filter(Boolean) + .join(' · '), + onClick: () => toggleProduct(product.id), + }; + }), + legend: [], + info, + infoTitle: t( + 'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.', + ), + }; + }, + + getStats() { + return { + count: featureCount(), + products: [..._loaded.keys()], + commit: _catalog?.commit || null, + lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null, + checkedAt: _catalog?.checkedAt || null, + stream: _stream, + loading: _loading.size > 0, + error: _lastError || [..._failed.values()][0] || null, + fallback: false, + }; + }, + + /** Loaded placemarks as plain records, for tools and QA. */ + getAnalystRecords(maxCount = Infinity) { + if (!_enabled) return []; + const out = []; + for (const [productId, item] of _loaded) { + for (const feature of item.features) { + if (out.length >= maxCount) return out; + out.push({ + product: productId, + name: feature.name, + folder: feature.folder, + lon: feature.anchor[0], + lat: feature.anchor[1], + }); + } + } + return out; + }, + }; + return layer; +} diff --git a/src/layers/ghostMaps/kml.js b/src/layers/ghostMaps/kml.js new file mode 100644 index 000000000..92e0c532f --- /dev/null +++ b/src/layers/ghostMaps/kml.js @@ -0,0 +1,198 @@ +// Read the drawable content of a parsed GhostMaps KML document: placemarks +// with their folder, geometry, resolved style and raw description. Network +// links, overlays, models and remote icons are never followed; an icon is +// either an image packed in the same KMZ or the layer's own marker. +import { kmlColor } from './records.js'; + +export const MAX_GHOSTMAPS_FEATURES = 20_000; +export const MAX_GHOSTMAPS_POSITIONS = 250_000; + +const childrenNamed = (el, name) => + [...(el?.children || [])].filter((child) => child.localName === name); +const firstChild = (el, name) => + [...(el?.children || [])].find((child) => child.localName === name) || null; +const descendants = (el, name) => + [...(el?.getElementsByTagName?.('*') || [])].filter( + (child) => child.localName === name, + ); +const text = (el) => (el?.textContent ?? '').trim(); + +function number(el, fallback) { + const value = Number(text(el)); + return el && Number.isFinite(value) ? value : fallback; +} + +/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */ +export function parseKmlCoordinates(value) { + const out = []; + for (const tuple of String(value ?? '') + .trim() + .split(/\s+/)) { + const [lon, lat] = tuple.split(',').map(Number); + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 + ) + out.push([lon, lat]); + } + return out; +} + +function readStyle(styleEl, resolveImage) { + const style = {}; + const icon = firstChild(styleEl, 'IconStyle'); + if (icon) { + const href = text(firstChild(firstChild(icon, 'Icon'), 'href')); + style.icon = href ? resolveImage(href) : null; + style.iconScale = number(firstChild(icon, 'scale'), 1); + style.iconColor = kmlColor(text(firstChild(icon, 'color'))); + } + const line = firstChild(styleEl, 'LineStyle'); + if (line) { + style.lineColor = kmlColor(text(firstChild(line, 'color'))); + style.lineWidth = number(firstChild(line, 'width'), 1); + } + const poly = firstChild(styleEl, 'PolyStyle'); + if (poly) { + style.polyColor = kmlColor(text(firstChild(poly, 'color'))); + style.fill = text(firstChild(poly, 'fill')) !== '0'; + style.outline = text(firstChild(poly, 'outline')) !== '0'; + } + return style; +} + +/** Shared styles by id; a StyleMap resolves to its `normal` pair. */ +function collectStyles(doc, resolveImage) { + const styles = new Map(); + for (const el of descendants(doc, 'Style')) { + const id = el.getAttribute('id'); + if (id) styles.set(id, readStyle(el, resolveImage)); + } + const maps = new Map(); + for (const el of descendants(doc, 'StyleMap')) { + const id = el.getAttribute('id'); + if (!id) continue; + const normal = childrenNamed(el, 'Pair').find( + (pair) => text(firstChild(pair, 'key')) === 'normal', + ); + if (!normal) continue; + const inline = firstChild(normal, 'Style'); + maps.set( + id, + inline + ? readStyle(inline, resolveImage) + : text(firstChild(normal, 'styleUrl')), + ); + } + const resolve = (url, depth = 0) => { + const id = String(url || '').replace(/^#/, ''); + if (!id || url.includes('/') || depth > 2) return {}; + if (styles.has(id)) return styles.get(id); + const mapped = maps.get(id); + if (typeof mapped === 'string') return resolve(mapped, depth + 1); + return mapped || {}; + }; + return resolve; +} + +function readGeometry(el, out, budget) { + switch (el.localName) { + case 'Point': { + const [position] = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (position) { + budget.positions += 1; + out.push({ type: 'point', position }); + } + break; + } + case 'LineString': + case 'LinearRing': { + const positions = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (positions.length >= 2) { + budget.positions += positions.length; + out.push({ type: 'line', positions }); + } + break; + } + case 'Polygon': { + const ring = (boundary) => + parseKmlCoordinates( + text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')), + ); + const outer = ring(firstChild(el, 'outerBoundaryIs')); + if (outer.length >= 3) { + const holes = childrenNamed(el, 'innerBoundaryIs') + .map(ring) + .filter((hole) => hole.length >= 3); + budget.positions += + outer.length + holes.reduce((n, hole) => n + hole.length, 0); + out.push({ type: 'polygon', outer, holes }); + } + break; + } + case 'MultiGeometry': + for (const child of el.children) readGeometry(child, out, budget); + break; + default: + // Models, tracks and anything newer are not drawn. + break; + } +} + +/** Name of the nearest enclosing Folder (or Document), for grouping. */ +function folderName(el) { + for (let node = el.parentElement; node; node = node.parentElement) { + if (node.localName === 'Folder' || node.localName === 'Document') { + const name = text(firstChild(node, 'name')); + if (name) return name; + } + } + return ''; +} + +/** + * @param {Document} doc Parsed KML. + * @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image. + * @returns {{features: Array<{name: string, folder: string, + * description: string, style: object, geometries: Array}>, + * positions: number, over: boolean}} + */ +export function readGhostMapsKml(doc, resolveImage = () => null) { + const styleFor = collectStyles(doc, resolveImage); + const features = []; + const budget = { positions: 0 }; + let over = false; + for (const placemark of descendants(doc, 'Placemark')) { + // A hidden placemark (visibility 0) is drawn hidden by Google Earth too. + if (text(firstChild(placemark, 'visibility')) === '0') continue; + const geometries = []; + for (const child of placemark.children) + readGeometry(child, geometries, budget); + if (!geometries.length) continue; + if ( + features.length >= MAX_GHOSTMAPS_FEATURES || + budget.positions > MAX_GHOSTMAPS_POSITIONS + ) { + over = true; + break; + } + const inline = firstChild(placemark, 'Style'); + features.push({ + name: text(firstChild(placemark, 'name')).slice(0, 200), + folder: folderName(placemark).slice(0, 120), + description: text(firstChild(placemark, 'description')), + style: { + ...styleFor(text(firstChild(placemark, 'styleUrl'))), + ...(inline ? readStyle(inline, resolveImage) : {}), + }, + geometries, + }); + } + return { features, positions: budget.positions, over }; +} diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js new file mode 100644 index 000000000..7c4eb58bf --- /dev/null +++ b/src/layers/ghostMaps/records.js @@ -0,0 +1,165 @@ +// Pure helpers for GhostMaps placemarks: description tables to plain fields, +// KML colors, and card text wrapping. Strings in, data out — description +// markup is only ever read as text, never inserted into the page. + +const ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}); + +/** Decode the HTML character references ArcGIS and Google Earth emit. */ +export function decodeHtmlEntities(text) { + return String(text ?? '').replace( + /&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi, + (whole, ref) => { + if (ref[0] === '#') { + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && + code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff) + ? String.fromCodePoint(code) + : ''; + } + return ENTITIES[ref.toLowerCase()] ?? whole; + }, + ); +} + +/** Markup to one line of plain text. */ +export function htmlToText(html) { + return decodeHtmlEntities( + String(html ?? '') + .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, ' '), + ) + .replace(/\s+/g, ' ') + .trim(); +} + +const EMPTY_VALUES = new Set(['', '', 'null', 'n/a', '-', '']); +// ArcGIS writes geometry type and internal ids into every popup table. +const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']); +const MAX_FIELDS = 24; +const MAX_VALUE = 600; +const MAX_LINKS = 6; + +/** + * Fields and links from a placemark description. ArcGIS "popup" exports are a + * two-column table (label, value); anything else becomes one unlabelled field. + * @param {string} html Description as written in the KML (CDATA text). + * @returns {{fields: Array<{label: string, value: string}>, links: string[]}} + */ +export function parseDescriptionFields(html) { + const source = String(html ?? '').replace( + /<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, + ' ', + ); + const fields = []; + const links = []; + const addLinks = (markup) => { + for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) { + const url = decodeHtmlEntities(match[1]).trim(); + if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url)) + links.push(url); + } + }; + // Innermost rows only: ArcGIS nests the field table inside an outer row, + // and only leaf rows with exactly two cells are label/value pairs. + for (const row of source.matchAll( + /]*>((?:(?!/gi, + )) { + const cells = [ + ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), + ].map((cell) => cell[1]); + if (cells.length !== 2) continue; + const label = htmlToText(cells[0]); + const value = htmlToText(cells[1]); + if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue; + if (EMPTY_VALUES.has(value.toLowerCase())) continue; + addLinks(cells[1]); + // A bare research link is carried by `links`; the card does not need + // the URL twice. + if (/^https?:\/\/\S+$/i.test(value)) continue; + if (fields.length < MAX_FIELDS) + fields.push({ label, value: value.slice(0, MAX_VALUE) }); + } + if (!fields.length && !links.length) { + const text = htmlToText(source); + if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) }); + addLinks(source); + } + return { fields, links: links.slice(0, MAX_LINKS) }; +} + +/** + * KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null. + * @param {string} text + * @returns {?number[]} + */ +export function kmlColor(text) { + const hex = String(text ?? '') + .trim() + .replace(/^#/, ''); + if (!/^[0-9a-f]{8}$/i.test(hex)) return null; + const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255; + return [byte(6), byte(4), byte(2), byte(0)]; +} + +/** + * Greedy word wrap for canvas card lines; overlong words are cut. + * @param {string} text + * @param {number} width Characters per line. + * @param {number} maxLines Lines kept; the last ends with an ellipsis when cut. + * @returns {string[]} + */ +export function wrapCardText(text, width = 56, maxLines = 4) { + const lines = []; + let line = ''; + let cut = false; + for (let word of String(text ?? '') + .split(/\s+/) + .filter(Boolean)) { + while (word.length > width) { + if (line) { + lines.push(line); + line = ''; + } + lines.push(word.slice(0, width)); + word = word.slice(width); + } + if (!line) line = word; + else if (line.length + 1 + word.length <= width) line += ` ${word}`; + else { + lines.push(line); + line = word; + } + if (lines.length >= maxLines) { + cut = true; + break; + } + } + if (line && lines.length < maxLines) lines.push(line); + else if (line) cut = true; + const kept = lines.slice(0, maxLines); + if (cut && kept.length) { + const last = kept[kept.length - 1]; + kept[kept.length - 1] = `${last.slice(0, width - 1)}…`; + } + return kept; +} + +/** Field labels ArcGIS writes CamelCase; show them as words. */ +export function fieldLabel(label) { + return String(label ?? '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .replace(/_/g, ' ') + .trim(); +} diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs new file mode 100644 index 000000000..86fecb52b --- /dev/null +++ b/src/layers/ghostMaps/records.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodeHtmlEntities, + fieldLabel, + htmlToText, + kmlColor, + parseDescriptionFields, + wrapCardText, +} from './records.js'; + +// The shape ArcGIS Pro writes into every S2 placemark description. +const ARCGIS = ` + + + +
HistoricProtestSite
+ + + + + + + + +
SHAPEPoint
InstallationTypeHistoricProtestSite
OperationalStatusACTIVE
Affiliation<Null>
DescriptionLocals gather here & protest — weekly.
Research1https://x.com/a/status/1
Research2bad
Research3<Null>
`; + +test('ArcGIS popup tables become plain label/value fields and http links', () => { + const { fields, links } = parseDescriptionFields(ARCGIS); + assert.deepEqual(fields, [ + { label: 'InstallationType', value: 'HistoricProtestSite' }, + { label: 'OperationalStatus', value: 'ACTIVE' }, + { label: 'Description', value: 'Locals gather here & protest — weekly.' }, + { label: 'Research2', value: 'bad' }, + ]); + // javascript: and other schemes never become links. + assert.deepEqual(links, ['https://x.com/a/status/1']); +}); + +test('free-form descriptions become one unlabelled field; scripts are dropped', () => { + assert.deepEqual( + parseDescriptionFields( + '

Front line
as of 10 Oct

more', + ), + { + fields: [{ label: '', value: 'Front line as of 10 Oct more' }], + links: ['https://s2.example/x'], + }, + ); + assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] }); + assert.deepEqual(parseDescriptionFields(undefined), { + fields: [], + links: [], + }); +}); + +test('field values and counts are bounded', () => { + const rows = Array.from( + { length: 40 }, + (_, i) => `K${i}${'x'.repeat(900)}`, + ).join(''); + const { fields } = parseDescriptionFields(`${rows}
`); + assert.equal(fields.length, 24); + assert.equal(fields[0].value.length, 600); +}); + +test('entities and tags decode to text', () => { + assert.equal( + decodeHtmlEntities('<b> AB  &bogus;'), + ' AB &bogus;', + ); + assert.equal(decodeHtmlEntities('��'), ''); + assert.equal(htmlToText('One\n two'), 'One two'); +}); + +test('KML aabbggrr colors convert to rgba', () => { + assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]); + assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]); + assert.equal(kmlColor('red'), null); + assert.equal(kmlColor(''), null); +}); + +test('card text wraps on words, cuts long words and marks truncation', () => { + assert.deepEqual(wrapCardText('one two three four', 9, 4), [ + 'one two', + 'three', + 'four', + ]); + assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [ + 'abcde', + 'fghij', + 'kl', + ]); + assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']); + assert.deepEqual(wrapCardText('', 5, 2), []); + for (const line of wrapCardText('word '.repeat(200), 20, 3)) + assert.ok(line.length <= 20); +}); + +test('CamelCase field labels read as words', () => { + assert.equal(fieldLabel('OperationalStatus'), 'Operational Status'); + assert.equal(fieldLabel('Research1'), 'Research 1'); + assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity'); +}); diff --git a/src/layers/ghostMaps/source.js b/src/layers/ghostMaps/source.js new file mode 100644 index 000000000..c52ebda64 --- /dev/null +++ b/src/layers/ghostMaps/source.js @@ -0,0 +1,81 @@ +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../../sources/httpBody.js'; +import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + +/** + * Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a + * push subscription that fires whenever the watcher sees a new commit. + */ +export function createGhostMapsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + EventSourceImpl = globalThis.EventSource, +} = {}) { + return { + async getCatalog({ signal } = {}) { + const response = await fetchImpl('/api/ghostmaps/catalog', { signal }); + if (!response.ok) + throw new Error( + `GhostMaps catalog unavailable (HTTP ${response.status})`, + ); + const catalog = await readResponseJsonCapped( + response, + 1024 * 1024, + signal, + ); + if (!Array.isArray(catalog?.products)) + throw new Error(msg('Malformed GhostMaps catalog')); + return catalog; + }, + + async getFile(id, version, { signal } = {}) { + const response = await fetchImpl( + `/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + { signal }, + ); + if (!response.ok) + throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`); + return readResponseBytesCapped( + response, + GHOSTMAPS_MAX_FILE_BYTES, + signal, + ); + }, + + /** + * Call `onCatalog(catalog)` for every catalog the server pushes, and + * `onState('open' | 'connecting' | 'closed')` as the stream changes. The + * browser's EventSource reconnects by itself. Returns an unsubscribe. + */ + subscribe(onCatalog, onState = () => {}) { + if (typeof EventSourceImpl !== 'function') { + onState('closed'); + return () => {}; + } + const events = new EventSourceImpl('/api/ghostmaps/events'); + const states = ['connecting', 'open', 'closed']; + const report = () => onState(states[events.readyState] ?? 'connecting'); + events.onopen = report; + events.onerror = report; + const listener = (event) => { + try { + const catalog = JSON.parse(event.data); + if (Array.isArray(catalog?.products)) onCatalog(catalog); + } catch { + /* ignore a malformed frame; the next one replaces it */ + } + }; + events.addEventListener('catalog', listener); + report(); + return () => { + events.removeEventListener('catalog', listener); + events.close(); + }; + }, + }; +} diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 04c24b92d..a049b40e5 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only' const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); + assert.ok(plugins.some(({ name }) => name === 'ghostmaps')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/gnss-integrity?lat=invalid', 400], + ['/api/ghostmaps/unknown', 404], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index d9674efea..2c918ad95 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), + 's2-ghostmaps': msg('S2 GhostMaps'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 109d96c60..18b71dabf 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -73,6 +73,7 @@ const PANEL_GROUPS = [ 'local-firms', 'fire-perimeters', 'gnss-integrity', + 's2-ghostmaps', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..b295e6f1b 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // S2 GhostMaps extends the manifest. + '64a7ae9719cbf53f2c5b57739c1e84e8b60dc4ede040decd48a9c06d1ccbcbca', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index eb0f09909..14b515fa6 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -57,6 +57,20 @@ const layers = [ context: false, noQuery: 'navigation-integrity cells; no countable analyst records', }, + { + id: 's2-ghostmaps', + aliases: [ + 's2 ghostmaps', + 'ghost maps', + 'ghostmaps', + 's2 underground', + 's2 intel', + 'intel picture', + 'common intelligence picture', + ], + context: false, + noQuery: 'published third-party intel placemarks; not analyst records', + }, { id: 'flights', track: true, From fb88dd6e1b1889fea7be58ee6d7ebe883f9c9fb1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:51:44 -0700 Subject: [PATCH 027/128] fix(tools): say when get_wind answers from a stale forecast run When the model upstream fails, the wind proxy keeps serving its last run, however old, with manifest.stale set. The wind layer shows that, but get_wind ignored it and gave the old run's wind as current. get_wind now returns stale and adds "(forecast may be stale)" to its summary, the way get_weather and the space tools flag stale data. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- src/tools/queries/atmosphere.js | 7 ++++++- src/tools/queries/atmosphere.test.mjs | 28 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..4c99e2cbc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5827,7 +5827,7 @@ closed. Performance and live agency reliability require recorded validation. ## Wind forecast models -Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary. ### Local Weather review candidate diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index d20a58027..9cbd8fec0 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -204,10 +204,14 @@ export const getWind = defineTool({ const speed = windSpeed(vector.u, vector.v); const from = windFrom(vector.u, vector.v); const kph = speed * 3.6; + // When the model upstream fails, the proxy keeps serving the last run it + // has, however old, marked stale. + const stale = snapshot.stale === true; return { summary: `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + - (from === 'Calm' ? ', calm.' : ` from the ${from}.`), + (from === 'Calm' ? ', calm' : ` from the ${from}`) + + (stale ? ' (forecast may be stale).' : '.'), data: { view: suggestView(services, { point, @@ -224,6 +228,7 @@ export const getWind = defineTool({ level: snapshot.level ?? '10 m above ground', model, cycle: snapshot.cycle ?? null, + stale, }, }; }, diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs index cf19d83f3..57b41b81a 100644 --- a/src/tools/queries/atmosphere.test.mjs +++ b/src/tools/queries/atmosphere.test.mjs @@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => { const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } }); assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.'); assert.equal(calm.data.calm, true); + assert.equal(calm.data.stale, false); + // The proxy's last run, served while the model upstream fails. + const cached = composeCatalog({ + tools: coreTools, + services: { + wind: { + getSnapshot: async (options) => ({ + ...(await wind.getSnapshot(options)), + stale: true, + reason: 'Wind upstream unavailable', + }), + }, + }, + }); + const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } }); + assert.equal( + stale.summary, + 'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).', + ); + assert.equal(stale.data.stale, true); + u.fill(10); + const staleWest = await cached.call('get_wind', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + staleWest.summary, + 'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).', + ); const down = composeCatalog({ tools: coreTools, services: { wind: { getSnapshot: async () => ({ unavailable: true }) } }, From 661a6cca02a63e2fa6409c4d28ee3e40d7b55f00 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:25:12 -0400 Subject: [PATCH 028/128] docs(changelog): name S2 GhostMaps fragment after #1297 Co-Authored-By: Claude Opus 5.5 --- .../{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} (100%) diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md similarity index 100% rename from changelog.d/0000-s2-ghostmaps-layer.md rename to changelog.d/1297-s2-ghostmaps-layer.md From 6d3e7c6c67c88fc59f4b69dfed7029f59569e991 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:09:38 -0700 Subject: [PATCH 029/128] docs(changelog): fragment for #1303 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1303-wind-stale.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1303-wind-stale.md diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md new file mode 100644 index 000000000..1a5fcdf32 --- /dev/null +++ b/changelog.d/1303-wind-stale.md @@ -0,0 +1,4 @@ +- `get_wind` says when it answers from a stale forecast run. When the model + upstream fails, the wind proxy keeps serving its last run; the tool now + returns `stale` and adds "(forecast may be stale)" instead of giving old + wind as current (quanticsoul4772, #1303). From 80292fab72c7d93d4a33ecbe279f03d26435afe5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:20:56 -0700 Subject: [PATCH 030/128] docs(changelog): reword fragment for #1303 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1303-wind-stale.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md index 1a5fcdf32..cb54224a5 100644 --- a/changelog.d/1303-wind-stale.md +++ b/changelog.d/1303-wind-stale.md @@ -1,4 +1,2 @@ -- `get_wind` says when it answers from a stale forecast run. When the model - upstream fails, the wind proxy keeps serving its last run; the tool now - returns `stale` and adds "(forecast may be stale)" instead of giving old - wind as current (quanticsoul4772, #1303). +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). From cb11e7dac855d3cde9a40f26fa031989222e7131 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 22 Oct 2026 02:42:26 +0200 Subject: [PATCH 031/128] fix: clear GhostMaps outage state on unchanged-head recovery --- changelog.d/1297-s2-ghostmaps-layer.md | 2 + docs/CURRENT-STATE.md | 1 + server/providers/ghostMaps.js | 8 +++- server/providers/ghostMaps.test.mjs | 52 ++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index b78d6a042..e79cc6eff 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -5,3 +5,5 @@ cards on click. `/api/ghostmaps` watches the repository's branch head every 15 s and pushes each new commit to open pages, which reload only the products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index db3ee9750..52fdb1e78 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5128,6 +5128,7 @@ are omitted rather than framing the wrong part of the globe. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent. - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js index dd30fd986..853fd35e3 100644 --- a/server/providers/ghostMaps.js +++ b/server/providers/ghostMaps.js @@ -172,7 +172,13 @@ export function createGhostMapsWatcher({ await loadDisk(); const head = await readHead(); state.checkedAt = now(); - if (head === state.commit) return false; + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } const next = await readCommit(head); const previous = new Map(state.products.map((p) => [p.id, p.version])); const changed = next.products diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 6251bf4de..5ddef6aa5 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -178,6 +178,58 @@ test('the catalog survives a restart from disk, and upstream failures are report assert.match(catalog.error, /HTTP 503/); })); +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + test('poll interval is configurable with a 5 s floor', () => { assert.equal(ghostMapsPollMs(undefined), 15000); assert.equal(ghostMapsPollMs('30'), 30000); From 626852401a98929db4f0362420a3407253ad3aa1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:29 +0700 Subject: [PATCH 032/128] test(terrain): pin which point the cache evicts after a refetch A stale point that is refetched becomes the newest entry, and the cache file's order is not its age order. The ceiling must still evict the oldest fetch. Passes on the current sort-based eviction. --- src/terrainHeightsBounds.test.mjs | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index 3746f4fe4..00811246d 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -165,6 +165,44 @@ test('the point cache stops growing, and an evicted point is simply refetched', assert.equal(asked[4], point(1)); }); +test('a refetched point counts as new when the cache evicts', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'terrain-evict-order-')); + t.after(() => rm(root, { recursive: true, force: true })); + const cachePath = path.join(root, 'cache.json'); + const day = 24 * 3600_000; + const now = Date.now(); + const point = (i) => `-97.7${i}000,30.26000`; + const entry = (at) => ({ at, result: { ellipsoid: 150 } }); + // Written newest first: the file's order is not the age order. Point 1 is + // past the 30-day TTL, so the next request for it refetches. + await writeFile( + cachePath, + JSON.stringify({ + version: 2, + points: { + [point(3)]: entry(now - 3600_000), + [point(2)]: entry(now - day), + [point(1)]: entry(now - 31 * day), + }, + }), + ); + const asked = stubTerrainUpstream(t); + const route = install( + terrainHeightsProxy({ cacheMaxEntries: 3, cachePath }), + ).get('/api/terrain/heights'); + + await request(route, { url: `/?points=${point(1)}` }); + assert.deepEqual(asked, [point(1)], 'the stale point is refetched'); + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 2); + + // Point 2 is now the oldest fetch; point 1 was just refreshed. + await request(route, { url: `/?points=${point(1)};${point(3)}` }); + assert.equal(asked.length, 2, 'the refreshed and the recent point stay'); + await request(route, { url: `/?points=${point(2)}` }); + assert.deepEqual(asked.slice(2), [point(2)], 'the oldest fetch was evicted'); +}); + test('an over-large cache file is not read back into memory', async (t) => { const asked = stubTerrainUpstream(t); const point = '-97.74000,30.26000'; From e60cbb42e3a310f8b8b5c71b19c0d29b92d98b7e Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:37 +0700 Subject: [PATCH 033/128] perf(terrain): evict from the front of the point cache instead of sorting it Once the cache was full, every write sorted all 50,000 entries to find the oldest. A Map keeps insertion order, so keep the map in fetch-time order and delete from the front: the resolver re-inserts each point it refetches (an overwrite would leave it in its old place), and the disk load sorts once, since a file written before this change, or a migrated raw-batch cache, is not in age order. Suggested by asturksever on #975. --- server/providers/terrain.js | 21 +++++++++++++++------ src/data/terrainHeightsProxy.js | 3 +++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/providers/terrain.js b/server/providers/terrain.js index c678ab7c0..f4817f602 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -90,15 +90,23 @@ export function terrainHeightsProxy(options = {}) { /** * Drop the oldest entries until the map is back inside its ceiling. - * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. - * Terrain does not move, so evicting an old point costs one refetch, never - * a wrong answer. + * The map is kept in fetch-time order (sorted once on load, and the resolver + * re-inserts every point it refetches), so the oldest are at the front and + * no write sorts the map. Terrain does not move, so evicting an old point + * costs one refetch, never a wrong answer. */ function evictOverflow() { - if (mem.size <= CACHE_MAX_ENTRIES) return; + for (const key of mem.keys()) { + if (mem.size <= CACHE_MAX_ENTRIES) return; + mem.delete(key); + } + } + + /** Put the map in fetch-time order; a file is not guaranteed to be. */ + function orderByAge() { const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); - for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) - mem.delete(byAge[i][0]); + mem.clear(); + for (const [key, entry] of byAge) mem.set(key, entry); } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); @@ -165,6 +173,7 @@ export function terrainHeightsProxy(options = {}) { } diskDirty = mem.size > 0; } + orderByAge(); evictOverflow(); } catch { /* no disk cache yet */ diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index 9134f9ec6..c83b30a87 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({ for (let i = 0; i < missing.length; i += 1) { const result = fetched[i]; if (validTerrainResult(result)) { + // Re-insert rather than overwrite: a Map keeps an existing key in + // its old position, and the proxy evicts from the front. + cache.delete(missing[i].key); cache.set(missing[i].key, { at: fetchedAt, result }); cacheChanged = true; } else if (absentTerrainDatum(result)) { From 07d6417446f4e1f724e392147bacce73713f8093 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:22:42 +0100 Subject: [PATCH 034/128] Realign periodic polling to the deadline after a visibility catch-up An overdue refresh on return to a visible tab advanced the due time but kept the old setInterval phase, so the next phase tick was skipped and the gap could approach two intervals (also after a failed resume refresh). The catch-up now re-arms the interval at the new deadline. Interval ticks no longer compare against the due time, since the phase and the deadline share one origin. Overlap prevention and teardown are unchanged, and a stale listener cannot re-arm a stopped loop. Tests cover the next poll one interval after a between-ticks resume, with a successful and a failed catch-up refresh. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1282-polling-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/data/lifecycle.js | 48 +++++++++++++++------- src/data/manager.test.mjs | 58 +++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 9931385bf..98885fc20 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,4 +1,4 @@ -- Pause periodic network updates in hidden tabs (asturksever, #1282). +- Pause periodic network updates in hidden tabs; the overdue update on return restarts the polling schedule, so the next poll follows one interval later even when that update fails (asturksever, #1282). - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8b3827634..3cb2493e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. +Manager-owned periodic network updates pause while the document is hidden. Returning to a visible tab runs one overdue update per enabled layer without overlapping an active refresh and restarts that layer's polling schedule from the return, so the next poll follows one interval later whether or not the catch-up update succeeds; explicit refresh requests remain available. Transit also defers camera-driven feed admission while hidden and rechecks coverage on return. Visibility listeners are released with their polling intervals. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 01aaf6124..c8946e7bb 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -553,24 +553,44 @@ export class LayerLifecycle { : 0; if (refreshInterval > 0) { const visibilityDocument = this._visibilityTarget; - let dueAt = Date.now() + refreshInterval; - const tick = () => { - if ( - visibilityDocument?.hidden || - visibilityDocument?.visibilityState === 'hidden' || - !entry.enabled || - entry.destroying || - entry.refreshing || - Date.now() < dueAt - ) - return; + let intervalId = null; + let dueAt = 0; + const canPoll = () => + !visibilityDocument?.hidden && + visibilityDocument?.visibilityState !== 'hidden' && + entry.enabled && + !entry.destroying && + !entry.refreshing && + entry.intervalId === intervalId; + // dueAt and the interval phase share one origin, so every interval tick + // is a due poll. A visibility catch-up re-arms the interval at the new + // deadline; otherwise the old phase would skip its next tick. + const schedule = () => { + clearInterval(intervalId); + dueAt = Date.now() + refreshInterval; + intervalId = setInterval(onInterval, refreshInterval); + entry.intervalId = intervalId; + }; + const onInterval = () => { + if (!canPoll()) return; dueAt = Date.now() + refreshInterval; void this._runPeriodicUpdate(layerId, entry); }; - entry.intervalId = setInterval(tick, refreshInterval); - visibilityDocument?.addEventListener?.('visibilitychange', tick); + const onVisibilityChange = () => { + if (!canPoll() || Date.now() < dueAt) return; + schedule(); + void this._runPeriodicUpdate(layerId, entry); + }; + schedule(); + visibilityDocument?.addEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); entry.removeVisibilityListener = () => - visibilityDocument?.removeEventListener?.('visibilitychange', tick); + visibilityDocument?.removeEventListener?.( + 'visibilitychange', + onVisibilityChange, + ); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 68a0644f1..b1384a2de 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -3523,3 +3523,61 @@ test('periodic polls pause hidden, catch up once when due, and dispose visibilit t.mock.timers.tick(10000); assert.equal(calls.update, 3); }); + +async function resumeBetweenTicks(t, { failResume = false } = {}) { + const listeners = new Set(); + const doc = { + hidden: false, + addEventListener: (_name, fn) => listeners.add(fn), + removeEventListener: (_name, fn) => listeners.delete(fn), + }; + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const { LayerLifecycle } = await import('./lifecycle.js'); + const manager = new LayerLifecycle({}, { visibilityTarget: doc }); + const { module, calls } = makeSlowLayer('resume-poll'); + const updates = []; + module.update = async () => { + calls.update++; + updates.push(Date.now()); + await Promise.resolve(); + if (failResume && updates.length === 2) throw new Error('resume failed'); + }; + manager.register(module); + await manager.setEnabled('resume-poll', true); + assert.deepEqual(updates, [1000]); + doc.hidden = true; + t.mock.timers.tick(1000); // 2000: due tick skipped while hidden + t.mock.timers.tick(500); // 2500: return between ticks + doc.hidden = false; + for (const fn of listeners) fn(); + assert.deepEqual(updates, [1000, 2500], 'overdue poll runs once on return'); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const managerRefreshError = manager.layers.get('resume-poll').managerRefreshError; + if (failResume) assert.equal(managerRefreshError, 'resume failed'); + else assert.equal(managerRefreshError, null); + t.mock.timers.tick(999); // 3499: the old 3000 phase must not fire + assert.deepEqual(updates, [1000, 2500]); + t.mock.timers.tick(1); // 3500: one interval after the resume poll + assert.deepEqual(updates, [1000, 2500, 3500]); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(1000); + assert.deepEqual(updates, [1000, 2500, 3500, 4500], 'new phase keeps one interval'); + await Promise.resolve(); + await Promise.resolve(); + await manager.destroyAll(); + assert.equal(listeners.size, 0); + t.mock.timers.tick(5000); + assert.equal(updates.length, 4); +} + +test('periodic polls realign to the deadline after a between-ticks resume', async (t) => { + await resumeBetweenTicks(t); +}); + +test('periodic polls realign to the deadline after a failed resume refresh', async (t) => { + await resumeBetweenTicks(t, { failResume: true }); +}); From e60caab1802c313683c826340beb783f51313ec5 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:23:07 +0100 Subject: [PATCH 035/128] Run a trailing hover check for the last suppressed pointer position A move inside the 120 ms hover throttle window was dropped, so a pointer that stopped right after a pick kept a stale cursor (pointer over empty space, or default over a mission) until the next move. Keep a copy of the last suppressed position (Cesium reuses the movement object) and run one trailing check when the window ends. The check rechecks hover eligibility and is cancelled by reset, disable, camera motion, and destroy. Tests cover a stopped pointer in both directions without another event, the copied position, one timer at a time, cancellation, and the eligibility recheck. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1279-launch-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 2 +- src/layers/launches/hover.test.mjs | 141 +++++++++++++++++++++++++++ src/layers/launches/lifecycle.js | 75 ++++++++++---- 4 files changed, 201 insertions(+), 19 deletions(-) diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md index 7005c53cd..ec451e7ae 100644 --- a/changelog.d/1279-launch-issue-fix.md +++ b/changelog.d/1279-launch-issue-fix.md @@ -1 +1 @@ -- Throttle hover picks and clear stale cursors (asturksever, #1279). +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1cd57febe..8405d4d50 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,7 +9,7 @@ broader suffix variants. Follow-ups retain boundary provenance, and cancelled lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. -Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. +Mission hover picking is limited to one drill-pick per 120 ms and suppressed during camera movement, hidden tabs, disabled layers, and pointer-owned tools. A move inside the 120 ms window keeps a copy of its position and runs one trailing check when the window ends, so a pointer that stops is still resolved; the trailing check rechecks eligibility and is cancelled by reset, disable, camera motion, and destroy. Motion, hiding, disable, and destroy clear the cursor. Click selection keeps its full overlap-aware drill-pick behavior. Terrain height requests accept at most 64 points, matching the browser service's existing batch size and one bounded upstream chunk. Larger requests return HTTP diff --git a/src/layers/launches/hover.test.mjs b/src/layers/launches/hover.test.mjs index 83a370f26..d09e5168b 100644 --- a/src/layers/launches/hover.test.mjs +++ b/src/layers/launches/hover.test.mjs @@ -3,6 +3,146 @@ import assert from 'node:assert/strict'; import { Event } from 'cesium'; import { createLaunchHover } from './lifecycle.js'; +function fakeTimers() { + const timers = new Map(); + let nextId = 1; + const api = { + setTimer(fn, ms) { + const id = nextId++; + timers.set(id, { fn, ms }); + return id; + }, + clearTimer(id) { + timers.delete(id); + }, + pending: () => [...timers.values()], + flush() { + const due = [...timers.entries()]; + timers.clear(); + for (const [, timer] of due) timer.fn(); + }, + }; + return api; +} + +function hoverRig({ overMarker }) { + let at = 0, + picks = 0, + enabled = true, + free = true; + const timers = fakeTimers(); + const viewer = { + camera: { moveStart: new Event(), moveEnd: new Event() }, + scene: { + canvas: { style: { cursor: '' } }, + drillPick(position) { + picks++; + return overMarker(position) ? [{ id: 'launch' }] : []; + }, + }, + }; + const hover = createLaunchHover({ + viewer, + enabled: () => enabled, + entityLaunchId: (id) => id === 'launch', + now: () => at, + documentRef: { hidden: false }, + pointerFree: () => free, + setTimer: timers.setTimer, + clearTimer: timers.clearTimer, + }); + return { + hover, + timers, + viewer, + cursor: () => viewer.scene.canvas.style.cursor, + picks: () => picks, + setTime: (value) => (at = value), + setEnabled: (value) => (enabled = value), + setFree: (value) => (free = value), + }; +} + +const onMarker = (position) => position?.x === 1; +const marker = () => ({ endPosition: { x: 1, y: 1 } }); +const empty = () => ({ endPosition: { x: 9, y: 9 } }); + +test('mission hover runs one trailing check for a pointer that stops on empty space', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + assert.equal(rig.cursor(), 'pointer'); + rig.setTime(50); + const movement = empty(); + rig.hover.move(movement); + // Cesium reuses its movement object; the trailing check must use a copy. + movement.endPosition.x = 1; + assert.equal(rig.picks(), 1); + assert.deepEqual( + rig.timers.pending().map((timer) => timer.ms), + [70], + ); + rig.setTime(80); + rig.hover.move(empty()); + assert.equal(rig.timers.pending().length, 1, 'one trailing check at a time'); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), ''); + assert.equal(rig.timers.pending().length, 0); + rig.hover.destroy(); +}); + +test('mission hover trailing check sets the cursor for a pointer that stops on a mission', () => { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + assert.equal(rig.cursor(), ''); + rig.setTime(50); + rig.hover.move(marker()); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 2); + assert.equal(rig.cursor(), 'pointer'); + rig.hover.destroy(); + assert.equal(rig.cursor(), ''); +}); + +test('mission hover trailing check is cancelled by reset, destroy, and camera motion', () => { + for (const cancel of [ + (rig) => rig.hover.reset(), + (rig) => rig.hover.destroy(), + (rig) => rig.viewer.camera.moveStart.raiseEvent(), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(empty()); + rig.setTime(50); + rig.hover.move(marker()); + assert.equal(rig.timers.pending().length, 1); + cancel(rig); + assert.equal(rig.timers.pending().length, 0); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + +test('mission hover trailing check rechecks eligibility before picking', () => { + for (const block of [ + (rig) => rig.setEnabled(false), + (rig) => rig.setFree(false), + ]) { + const rig = hoverRig({ overMarker: onMarker }); + rig.hover.move(marker()); + rig.setTime(50); + rig.hover.move(marker()); + block(rig); + rig.setTime(120); + rig.timers.flush(); + assert.equal(rig.picks(), 1); + assert.equal(rig.cursor(), ''); + rig.hover.destroy(); + } +}); + test('mission hover throttles, clears on motion/hidden, and releases listeners', () => { let at = 0, picks = 0, @@ -30,6 +170,7 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners', now: () => at, documentRef: doc, pointerFree: () => true, + ...fakeTimers(), }); for (at = 0; at < 120; at++) hover.move({ endPosition: {} }); assert.equal(picks, 1); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 4d4962242..6ac777da1 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,7 +1,13 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Bound hover picking independently of precise click selection. */ +const HOVER_PICK_INTERVAL_MS = 120; + +/** + * Bound hover picking independently of precise click selection. A move inside + * the throttle window is not dropped: its position is kept and one trailing + * check runs when the window ends, so a pointer that stops is still resolved. + */ export function createLaunchHover({ viewer, enabled, @@ -9,16 +15,26 @@ export function createLaunchHover({ now = () => performance.now(), documentRef = globalThis.document, pointerFree = isPointerFree, + setTimer = globalThis.setTimeout, + clearTimer = globalThis.clearTimeout, }) { let moving = false; let lastPick = -Infinity; let ownsCursor = false; + let pendingPosition = null; + let trailingTimer = null; const clearCursor = () => { if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer') viewer.scene.canvas.style.cursor = ''; ownsCursor = false; }; + const cancelTrailing = () => { + if (trailingTimer !== null) clearTimer(trailingTimer); + trailingTimer = null; + pendingPosition = null; + }; const reset = () => { + cancelTrailing(); clearCursor(); lastPick = -Infinity; }; @@ -34,31 +50,56 @@ export function createLaunchHover({ if (documentRef?.hidden || documentRef?.visibilityState === 'hidden') reset(); }; + const eligible = () => + enabled() && + !moving && + !documentRef?.hidden && + documentRef?.visibilityState !== 'hidden' && + pointerFree(); + const pick = (position, at) => { + lastPick = at; + const found = viewer.scene + .drillPick(position, 12) + .some((picked) => entityLaunchId(picked?.id)); + if (found) { + viewer.scene.canvas.style.cursor = 'pointer'; + ownsCursor = true; + } else clearCursor(); + }; + const trailing = () => { + trailingTimer = null; + const position = pendingPosition; + pendingPosition = null; + if (!position) return; + // Eligibility may have changed while the check waited. + if (!eligible()) { + reset(); + return; + } + pick(position, now()); + }; const removeStart = viewer.camera?.moveStart?.addEventListener(start); const removeEnd = viewer.camera?.moveEnd?.addEventListener(end); documentRef?.addEventListener?.('visibilitychange', visibility); return { move(movement) { - if ( - !enabled() || - moving || - documentRef?.hidden || - documentRef?.visibilityState === 'hidden' || - !pointerFree() - ) { + if (!eligible()) { reset(); return; } const at = now(); - if (at - lastPick < 120) return; - lastPick = at; - const found = viewer.scene - .drillPick(movement.endPosition, 12) - .some((picked) => entityLaunchId(picked?.id)); - if (found) { - viewer.scene.canvas.style.cursor = 'pointer'; - ownsCursor = true; - } else clearCursor(); + const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick); + if (wait > 0) { + // Cesium reuses the movement object, so keep a copy of the position. + pendingPosition = Cesium.Cartesian2.clone( + movement.endPosition, + pendingPosition ?? undefined, + ); + if (trailingTimer === null) trailingTimer = setTimer(trailing, wait); + return; + } + cancelTrailing(); + pick(movement.endPosition, at); }, reset, destroy() { From 0c20ed9975c60b217ff2fbedb9324e4644da6d41 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:33:05 +0100 Subject: [PATCH 036/128] fix(overpass): retry a joined caller's admission on its own quota A caller that joins a cold reservation no longer inherits another client's RATE_LIMITED result. Once the rejected reservation is released it retries: it either owns the next reservation and is admitted on its own quota, or joins an admitted fetch. Adds a two-client regression (exhausted owner, available joiner, exhausted joiner coalescing onto the admitted fetch). Also drops the duplicate memory publication for a disk hit in the handler; preflight already sets it, the handler only trims. Co-Authored-By: Claude Opus 5.5 --- server/providers/overpass.js | 6 ++-- server/providers/overpass/cache.js | 45 +++++++++++++++++------------- src/data/overpassProxy.test.mjs | 31 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/server/providers/overpass.js b/server/providers/overpass.js index a9726f22b..bf6004ac6 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -204,10 +204,8 @@ function overpassProxy({ routing = {} } = {}) { return; } } - if (preflight.source === 'DISK') { - _overpassCache.set(cacheKey, preflight.payload); - trimOverpassCache(); - } + // Preflight already published the disk hit to memory; only trim. + if (preflight.source === 'DISK') trimOverpassCache(); sendOverpassResponse( res, preflight.payload, diff --git a/server/providers/overpass/cache.js b/server/providers/overpass/cache.js index 5e88c3f19..b1a09908a 100644 --- a/server/providers/overpass/cache.js +++ b/server/providers/overpass/cache.js @@ -79,7 +79,9 @@ function writeOverpassDisk(cacheKey, payload) { /** * Resolve every cache/coalescing layer before admitting a request to the local * upstream rate limiter. The injected limiter callback is invoked exactly once - * for a complete cache miss and never for memory, in-flight, or disk hits. + * for a complete cache miss and never for memory, in-flight, or disk hits. A + * caller that joined a reservation rejected by another client's limiter + * retries with its own limiter instead of inheriting that 429. * Exported so the admission ordering can be tested without a Vite server. * * @param {object} options @@ -103,25 +105,30 @@ async function resolveOverpassPreflight({ now = Date.now(), cacheMs = OVERPASS_CACHE_MS, }) { - const cached = memoryCache.get(cacheKey); - if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) - return { source: 'HIT', payload: cached }; + for (;;) { + const cached = memoryCache.get(cacheKey); + if (overpassPayloadIsData(cached) && now - cached.cachedAt <= cacheMs) + return { source: 'HIT', payload: cached }; - // Reserve synchronously before disk I/O. The owner retains the reservation - // through upstream admission, fetch and cache publication. - const request = coalesceProxyRequest(inFlight, cacheKey, async () => { - const disk = await readDisk(); - if (overpassPayloadIsData(disk)) { - memoryCache.set(cacheKey, disk); - return { source: 'DISK', payload: disk }; - } - if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; - return { source: 'UPSTREAM', payload: await loadUpstream() }; - }); - const result = await request.promise; - return request.shared && result.source !== 'RATE_LIMITED' - ? { ...result, source: 'INFLIGHT' } - : result; + // Reserve synchronously before disk I/O. The owner retains the reservation + // through its own upstream admission, fetch and cache publication. + const request = coalesceProxyRequest(inFlight, cacheKey, async () => { + const disk = await readDisk(); + if (overpassPayloadIsData(disk)) { + memoryCache.set(cacheKey, disk); + return { source: 'DISK', payload: disk }; + } + if (!allowUpstream()) return { source: 'RATE_LIMITED', payload: null }; + return { source: 'UPSTREAM', payload: await loadUpstream() }; + }); + const result = await request.promise; + if (!request.shared) return result; + // The owner's quota rejection belongs to the owner's client. The rejected + // reservation is released before joiners resume, so retry and let this + // caller own the next reservation (or join an admitted one) on its quota. + if (result.source === 'RATE_LIMITED') continue; + return { ...result, source: 'INFLIGHT' }; + } } /** Return only last-good Overpass data, regardless of its age. */ diff --git a/src/data/overpassProxy.test.mjs b/src/data/overpassProxy.test.mjs index 7f9b117ae..4a3b5780f 100644 --- a/src/data/overpassProxy.test.mjs +++ b/src/data/overpassProxy.test.mjs @@ -249,3 +249,34 @@ test('cold disk lookup is single-flight through upstream publication and quota a await resolveOverpassPreflight({ ...options, readDisk: async () => null }); assert.equal(upstreams, 2, 'a rejected reservation does not poison the next retry'); }); + +test('a joined client retries admission with its own quota after another client is rate limited', async () => { + const inFlight = new Map(); + let release; + const disk = new Promise((resolve) => { release = resolve; }); + let reads = 0, upstreams = 0; + const admissions = { a: 0, b: 0, c: 0 }; + const quota = { a: false, b: true, c: false }; + const payload = { status: 200, body: '{"elements":[]}' }; + const client = (name) => resolveOverpassPreflight({ + cacheKey: 'quota', memoryCache: new Map(), inFlight, + readDisk: () => { reads++; return reads === 1 ? disk : null; }, + allowUpstream: () => { admissions[name]++; return quota[name]; }, + loadUpstream: async () => { upstreams++; return payload; }, + }); + // A (quota exhausted) owns the cold reservation; B (quota available) and + // C (quota exhausted) join it while the disk lookup is pending. + const pending = [client('a'), client('b'), client('c')]; + await Promise.resolve(); + assert.equal(inFlight.size, 1); + release(null); + const [a, b, c] = await Promise.all(pending); + assert.equal(a.source, 'RATE_LIMITED', "A's own quota still rejects A"); + assert.equal(b.source, 'UPSTREAM', 'B is admitted on its own quota'); + assert.equal(b.payload, payload); + assert.equal(c.source, 'INFLIGHT', "C coalesces onto B's admitted fetch"); + assert.equal(c.payload, payload); + assert.deepEqual(admissions, { a: 1, b: 1, c: 0 }); + assert.equal(upstreams, 1); + assert.equal(inFlight.size, 0); +}); From b2fb4a261e38557715c956e72ad2a3f9203b5a7a Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Sat, 24 Oct 2026 03:21:42 +0530 Subject: [PATCH 037/128] feat(camera): add globe rotation (#1225) Add continuous globe rotation with direction and speed controls, viewport fit checks, camera ownership handoff, and voice refusal guards. --- README.md | 4 + changelog.d/1225-globe-rotation.md | 2 + docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 4 +- scripts/voice-bench/phrases.mjs | 3 + server/providers/openai/instructions.js | 1 + server/providers/openai/toolDescriptions.js | 6 +- src/cameraGlobeRotation.test.mjs | 1322 +++++++++++++++++++ src/cameraHandoff.test.mjs | 2 +- src/cameraVerbs.js | 312 ++++- src/firstRunExperience.test.mjs | 18 +- src/radioMarkup.test.mjs | 29 + src/tooling/voiceBench.test.mjs | 13 + src/ui/applicationShell.js | 2 + src/ui/composition.js | 6 +- src/ui/locationNavigation.js | 1 + src/ui/navigationController.js | 3 + src/voice/actionSchemas.js | 2 +- src/voice/actionSchemas.test.mjs | 47 +- src/voice/geminiSession.js | 50 +- src/voice/geminiSession.test.mjs | 212 +++ src/voice/gevActions.js | 6 +- src/voice/globeResetConfirmation.test.mjs | 1131 ++++++++++++++++ src/voice/realtimeProtocol.js | 26 + src/voice/realtimeTurns.js | 239 +++- 25 files changed, 3407 insertions(+), 36 deletions(-) create mode 100644 changelog.d/1225-globe-rotation.md create mode 100644 src/cameraGlobeRotation.test.mjs create mode 100644 src/voice/globeResetConfirmation.test.mjs diff --git a/README.md b/README.md index 1b1fc46a0..80f052571 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,10 @@ Thirty tools, four jobs — the commands below come straight from the product's **🎥 Direct it** — drone-operator camera verbs: +“Rotate the globe” spins Earth to the right continuously; ask for left, a speed, +or stop. If it refuses, follow its reset-and-rotate or further-zoom guidance; +camera input stops rotation. + > 🗣️ _"Take me to Tokyo."_ · _"Orbit around this area slowly."_ · _"Draw the walking route from the Capitol to Zilker Park."_ → _"Fly the route we just drew."_ · _"Zoom out to a globe view."_ **🖊️ Annotate it** — a whiteboard over the real world: diff --git a/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md new file mode 100644 index 000000000..844966030 --- /dev/null +++ b/changelog.d/1225-globe-rotation.md @@ -0,0 +1,2 @@ +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b10f43f3b..994f9a623 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4982,7 +4982,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e313ade42..7b7171c68 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1086,5 +1086,7 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/dockerTrustedPeers.test.mjs" + "src/dockerTrustedPeers.test.mjs", + "src/cameraGlobeRotation.test.mjs", + "src/voice/globeResetConfirmation.test.mjs" ] diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 2f3f73a4c..9d1bcc8ba 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -116,6 +116,9 @@ export const CORE_PHRASES = Object.freeze([ // — camera verbs (tools #23/#24) — { phrase: 'Orbit around this area slowly', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'orbit' && a.speed === 'slow')] }, { phrase: 'Pan left a bit', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'pan' && a.direction === 'left')] }, + { phrase: 'Rotate the globe to the right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && (a.direction === undefined || a.direction === 'right'))] }, + { phrase: 'Keep spinning the Earth slowly to the left', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate_globe' && a.direction === 'left' && a.speed === 'slow' && (a.mode === undefined || a.mode === 'continuous'))] }, + { phrase: 'Turn the camera to look right', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'rotate' && a.direction === 'right')] }, { phrase: 'Stop moving the camera', expect: 'move_camera', typedArgs: [where('move_camera', (a) => a.motion === 'stop')] }, { phrase: 'Fly the route we just drew', expect: 'fly_route' }, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 7d0e35636..9735f300c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -16,6 +16,7 @@ function realtimeInstructions( 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', 'While a camera motion or route flight is active, a bare "stop" means move_camera{motion:stop} — NOT control_scene and NOT stop_tracking (those need explicit words like "stop the scene" / "stop tracking"). If move_camera stop returns stopped:false and an entity is being tracked, call stop_tracking next — the user means "stop whatever is moving". Flying somewhere while tracking stops the tracking.', 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', + 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 18de877db..b8121a201 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -511,18 +511,18 @@ export const ACTION_DESCRIPTIONS = { }, move_camera: { description: - 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").', + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', $position: 1, parameters: { properties: { direction: { description: - 'Required except for orbit (defaults right/clockwise) and stop.', + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', $position: 2, }, mode: { description: - 'once = bounded eased nudge (default); continuous = until stop/manual input.', + 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, }, diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs new file mode 100644 index 000000000..db9cbd852 --- /dev/null +++ b/src/cameraGlobeRotation.test.mjs @@ -0,0 +1,1322 @@ +import { GLOBE_VIEW } from './locations.js'; +import { projectEarthDiscToViewport } from './celestialRing.js'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + activeCameraMotionId, + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + interruptGlobeRotation, + interruptCameraMotionIfActive, + moveCamera, +} from './cameraVerbs.js'; +import { getRenderGovernorDiagnostics } from './renderGovernor.js'; + +const radians = Cesium.Math.toRadians; +const clone = (value) => Cesium.Cartesian3.clone(value); +const longitude = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; +const wrappedAngle = Cesium.Math.negativePiToPi; +const hasRenderHold = () => + getRenderGovernorDiagnostics().holds.includes('camera-verb'); + +function near(actual, expected, tolerance = 1e-10, message = '') { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function pose(camera) { + return { + position: clone(camera.positionWC), + direction: clone(camera.directionWC), + up: clone(camera.upWC), + }; +} + +function samePose(camera, expected) { + const actual = pose(camera); + for (const key of ['position', 'direction', 'up']) { + near( + Cesium.Cartesian3.distance(actual[key], expected[key]), + 0, + key === 'position' ? 1e-6 : 1e-10, + key, + ); + } +} + +function orthonormalBasis(camera) { + const { directionWC: direction, upWC: up, rightWC: right } = camera; + for (const vector of [direction, up, right]) { + near(Cesium.Cartesian3.magnitude(vector), 1, 1e-12, 'unit camera basis'); + } + near(Cesium.Cartesian3.dot(direction, up), 0, 1e-12); + near(Cesium.Cartesian3.dot(direction, right), 0, 1e-12); + near(Cesium.Cartesian3.dot(up, right), 0, 1e-12); + near( + Cesium.Cartesian3.distance( + Cesium.Cartesian3.cross(direction, up, new Cesium.Cartesian3()), + right, + ), + 0, + 1e-12, + 'right-handed camera basis', + ); +} + +/** Use Cesium itself as an independent oracle for the swept camera path. */ +function arcHeights(camera, axis, angle, ellipsoid = Cesium.Ellipsoid.WGS84) { + const heights = []; + for (let i = 0; i <= 64; i += 1) { + const probe = Cesium.Camera.clone(camera); + probe.rotate(axis, (angle * i) / 64); + heights.push(ellipsoid.cartesianToCartographic(probe.positionWC).height); + } + return heights; +} + +/** Camera-space screen coordinates, before FOV/aspect scaling. */ +function projection(camera, target = Cesium.Cartesian3.ZERO) { + const offset = Cesium.Cartesian3.subtract( + target, + camera.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(offset, camera.directionWC); + return { + x: Cesium.Cartesian3.dot(offset, camera.rightWC) / depth, + y: Cesium.Cartesian3.dot(offset, camera.upWC) / depth, + depth, + }; +} + +/** Real Cesium geometry with no renderer, GPU, provider or wall-clock waits. */ +function harness( + t, + { + latitude = 15, + longitudeDeg = 0, + height = GLOBE_VIEW.heightM, + heading = 0, + pitch = -90, + roll = 0, + ellipsoid = Cesium.Ellipsoid.WGS84, + viewportWidth = 1280, + viewportHeight = 800, + } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: viewportWidth, + clientHeight: viewportHeight, + width: viewportWidth, + height: viewportHeight, + }); + const scene = { + canvas, + drawingBufferWidth: viewportWidth, + drawingBufferHeight: viewportHeight, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + height, + ellipsoid, + ), + orientation: { + heading: radians(heading), + pitch: radians(pitch), + roll: radians(roll), + }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + const target = Cesium.Cartesian3.fromDegrees( + longitudeDeg, + latitude, + 0, + ellipsoid, + ); + initCameraVerbs(viewer, () => target); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 0.1) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finishOnce = () => { + for (let i = 0; i < 20 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'once motion must finish'); + }; + return { viewer, camera, canvas, target, tick, finishOnce }; +} + +for (const [name, sceneMode] of [ + ['2D', Cesium.SceneMode.SCENE2D], + ['Columbus', Cesium.SceneMode.COLUMBUS_VIEW], + ['morphing', Cesium.SceneMode.MORPHING], +]) { + test(`globe rotation refuses ${name} before changing the prior owner`, (t) => { + const { viewer, camera } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }) + .ok, + true, + ); + const priorId = activeCameraMotionId(); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + viewer.scene.mode = sceneMode; + let handoffs = 0; + let cancellations = 0; + camera.cancelFlight = () => { + cancellations += 1; + }; + for (const mode of ['once', 'continuous']) { + const result = moveCamera( + { motion: 'rotate_globe', direction: 'left', mode }, + () => { + handoffs += 1; + return { ok: true }; + }, + ); + assert.equal(result.ok, false); + assert.match(result.error, /3D/); + } + assert.equal(handoffs, 0); + assert.equal(cancellations, 0); + assert.equal(activeCameraMotionId(), priorId); + assert.equal(hasRenderHold(), true); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); + }); + + test(`active globe rotation yields to ${name} without changing the camera`, (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + viewer.scene.mode = sceneMode; + const before = pose(camera); + let rotations = 0; + camera.rotate = () => { + rotations += 1; + }; + tick(); + assert.equal(rotations, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +test('globe rotation yields to tracking acquired and cleared before the next tick', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const before = pose(camera); + const entity = { id: 'cockpit-aircraft', position: {} }; + viewer.trackedEntity = entity; + viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal( + getActiveCameraMotion(), + null, + 'tracking acquisition must synchronously yield', + ); + viewer.trackedEntity = undefined; + viewer.trackedEntityChanged.raiseEvent(undefined); + tick(); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('tracking event keeps legacy yaw and an untracked globe rotation active', (t) => { + const { viewer } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'right', mode: 'continuous' }).ok, + true, + ); + const yawId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent({ id: 'aircraft' }); + assert.equal(activeCameraMotionId(), yawId); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const globeId = activeCameraMotionId(); + viewer.trackedEntityChanged.raiseEvent(undefined); + assert.equal(activeCameraMotionId(), globeId); +}); + +test('replaced viewer releases the old tracking listener without duplicate subscriptions', (t) => { + const first = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + initCameraVerbs(first.viewer); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 1); + const next = harness(t); + assert.equal(first.viewer.trackedEntityChanged.numberOfListeners, 0); + assert.equal(next.viewer.trackedEntityChanged.numberOfListeners, 1); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + const id = activeCameraMotionId(); + first.viewer.trackedEntityChanged.raiseEvent({ id: 'stale-aircraft' }); + assert.equal(activeCameraMotionId(), id); + next.viewer.trackedEntityChanged.raiseEvent({ id: 'new-aircraft' }); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +for (const latitude of [0, 30, -60, 89.5]) { + test(`globe rotation preserves Earth framing and radius from latitude ${latitude} degrees`, (t) => { + const { camera, tick, finishOnce } = harness(t, { latitude }); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const direction = clone(camera.directionWC); + const started = moveCamera({ motion: 'rotate_globe', mode: 'once' }); + assert.equal(started.ok, true); + assert.equal(started.direction, 'right'); + assert.equal(started.mode, 'once'); + for (let i = 0; i < 5; i += 1) { + tick(); + const current = projection(camera); + near(current.x, before.x); + near(current.y, before.y); + near(current.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + } + finishOnce(); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, direction), + radians(15), + ); + assert.equal(hasRenderHold(), false); + }); +} + +for (const [direction, sign] of [ + ['left', -1], + ['right', 1], +]) { + test(`globe ${direction} means visible landmark motion ${direction}`, (t) => { + const { camera, target, finishOnce } = harness(t, { latitude: 0 }); + const originalLongitude = longitude(camera); + near(projection(camera, target).x, 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(sign * projection(camera, target).x > 0.05); + near( + wrappedAngle(longitude(camera) - originalLongitude), + -sign * radians(15), + ); + }); +} + +for (const view of [ + { label: 'north-up', heading: 0 }, + { label: 'east-up', heading: 90 }, + { label: 'south-up', heading: 180 }, + { label: 'tilted east-up', heading: 90, pitch: -86 }, + { label: 'tilted and rolled', heading: 180, pitch: -86, roll: 35 }, + { label: 'near the pole', latitude: 89.5, heading: 180, roll: -25 }, +]) { + for (const [direction, sign] of [ + ['left', -1], + ['right', 1], + ]) { + test(`globe ${direction} stays screen-horizontal in a ${view.label} view`, (t) => { + const { camera, tick, finishOnce } = harness(t, view); + const ray = new Cesium.Ray( + clone(camera.positionWC), + clone(camera.directionWC), + ); + const intersection = Cesium.IntersectionTests.rayEllipsoid( + ray, + Cesium.Ellipsoid.WGS84, + ); + assert.ok(intersection, 'the original screen center must see the globe'); + const landmark = Cesium.Ray.getPoint( + ray, + intersection.start, + new Cesium.Cartesian3(), + ); + const before = projection(camera); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + const originalLandmark = projection(camera, landmark); + near(originalLandmark.x, 0); + near(originalLandmark.y, 0); + // Exercise both bounded and continuous application of the same direction. + const mode = direction === 'left' ? 'once' : 'continuous'; + assert.equal( + moveCamera({ motion: 'rotate_globe', direction, mode }).ok, + true, + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(0.25); + } + const after = projection(camera); + const movedLandmark = projection(camera, landmark); + assert.ok( + sign * movedLandmark.x > 0.001, + 'landmark must move in the requested screen direction', + ); + near(movedLandmark.y, originalLandmark.y); + near(after.x, before.x); + near(after.y, before.y); + near(after.depth, before.depth, 1e-6); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + orthonormalBasis(camera); + }); + } +} + +test('equal left and right globe rotations restore the complete camera pose', (t) => { + const { camera, finishOnce } = harness(t, { + latitude: 42, + longitudeDeg: 170, + height: GLOBE_VIEW.heightM, + }); + const before = pose(camera); + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }); + finishOnce(); + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }); + finishOnce(); + samePose(camera, before); +}); + +test('globe rotation converts an inherited target frame without zooming or recentering', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-35), 50_000_000), + ); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + false, + ); + const before = pose(camera); + const screen = projection(camera); + const range = Cesium.Cartesian3.magnitude(camera.positionWC); + moveCamera({ + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', + }); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); + samePose(camera, before); + tick(); + tick(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), range, 1e-6); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); +}); + +for (const [speed, degreesPerSecond] of [ + ['slow', 4], + ['normal', 10], + ['fast', 20], +]) { + test(`continuous globe rotation honors ${speed} speed and explicit stop`, (t) => { + const { camera, tick } = harness(t); + const result = moveCamera({ + motion: 'rotate_globe', + mode: 'continuous', + speed, + }); + assert.equal(result.ok, true); + assert.equal(hasRenderHold(), true); + tick(); // First tick initializes the motion's wall-clock anchor. + const before = clone(camera.directionWC); + tick(0.1); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before), + radians(degreesPerSecond * 0.1), + ); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + const stopped = moveCamera({ motion: 'stop' }); + assert.equal(stopped.stopped, true); + assert.equal(hasRenderHold(), false); + const after = pose(camera); + tick(); + samePose(camera, after); + assert.equal(moveCamera({ motion: 'stop' }).stopped, false); + }); +} + +for (const event of ['pointerdown', 'touchstart', 'wheel']) { + test(`${event} immediately cancels globe rotation and releases the render hold`, (t) => { + const { camera, canvas, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + tick(); + const before = pose(camera); + canvas.dispatchEvent(new Event(event)); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + samePose(camera, before); + }); +} + +test('new navigation can cancel globe rotation before setting a new destination', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + tick(); + assert.equal(interruptCameraMotion('explicit-navigation').wasActive, true); + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(110, -25, 1_000_000), + }); + const destination = pose(camera); + tick(); + samePose(camera, destination); + assert.equal(hasRenderHold(), false); +}); + +test('replacement motion rejects a stale globe owner cancellation', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + const globeId = activeCameraMotionId(); + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }); + const yawId = activeCameraMotionId(); + assert.notEqual(yawId, globeId); + assert.equal(interruptCameraMotionIfActive(globeId).wasActive, false); + assert.equal(getActiveCameraMotion()?.kind, 'rotate'); + const position = clone(camera.positionWC); + tick(); + tick(); + near(Cesium.Cartesian3.distance(camera.positionWC, position), 0, 1e-6); + assert.equal(interruptCameraMotionIfActive(yawId).wasActive, true); + assert.equal(hasRenderHold(), false); +}); + +test('replacing the viewer retires globe motion and its old clock listener', (t) => { + const { viewer, camera, tick } = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + tick(); + const before = pose(camera); + const nextViewer = { + ...viewer, + clock: { onTick: new Cesium.Event() }, + }; + initCameraVerbs(nextViewer); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + tick(); + nextViewer.clock.onTick.raiseEvent(nextViewer.clock); + samePose(camera, before); +}); + +for (const direction of ['up', 'down', 'diagonal']) { + test(`invalid globe direction ${direction} is refused before camera authority handoff`, (t) => { + const { camera } = harness(t); + const before = pose(camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe', direction }, () => { + handoffs += 1; + }); + assert.equal(result.ok, false); + assert.match(result.error, /left or right/); + assert.equal(handoffs, 0); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); +} + +for (const mode of ['once', 'continuous']) { + for (const acquisition of ['tracking', 'reference frame']) { + test(`globe ${mode} yields to new ${acquisition} without moving the new owner`, (t) => { + const { viewer, camera, target, tick } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).ok, true); + tick(); + tick(); + assert.equal(hasRenderHold(), true); + const tracked = { id: 'new-tracked-aircraft' }; + if (acquisition === 'tracking') viewer.trackedEntity = tracked; + else + camera.lookAt( + target, + new Cesium.HeadingPitchRange(radians(35), radians(-40), 1_000), + ); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + assert.equal( + viewer.trackedEntity, + acquisition === 'tracking' ? tracked : undefined, + ); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + tick(); + samePose(camera, before); + }); + } +} + +test('direct globe rotation refuses a tracked camera without disturbing its reference frame', (t) => { + const { viewer, camera, target } = harness(t); + viewer.trackedEntity = { id: 'tracked-aircraft' }; + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-40), 1_000)); + const before = pose(camera); + const transform = Cesium.Matrix4.clone(camera.transform); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.match(result.error, /following tracked-aircraft/); + assert.equal(Cesium.Matrix4.equals(camera.transform, transform), true); + samePose(camera, before); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); +}); + +test('the existing Globe View preset remains unchanged', () => { + assert.deepEqual(GLOBE_VIEW, { + heightM: 18_000_000, + pitchDeg: -90, + durationS: 2.8, + }); +}); +for (const latitude of [0, 90, -90]) + for (const height of [12_500_000, 18_000_000, 30_000_000]) + test(`whole-globe admission depends on framing at latitude ${latitude}, height ${height}m`, (t) => { + const { camera, finishOnce } = harness(t, { latitude, height }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'left', mode: 'once' }) + .ok, + true, + ); + finishOnce(); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + }); +test('polar preset admission keeps centre-distance although geodetic altitude varies', (t) => { + const { camera, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.ok(Math.min(...arcHeights(camera, camera.upWC, Math.PI * 2)) > 0); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous', speed: 'fast' }) + .ok, + true, + ); + for (let i = 0; i < 45; i++) tick(0.1); + assert.ok( + Cesium.Ellipsoid.WGS84.cartesianToCartographic(camera.positionWC).height < + GLOBE_VIEW.heightM - 1000, + ); + assert.equal(getActiveCameraMotion().kind, 'rotate_globe'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + moveCamera({ motion: 'stop' }); + assert.equal(hasRenderHold(), false); +}); +test('close-up refusal preserves an inherited target frame', (t) => { + const { camera, target } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, -0.5, 5000)); + const before = pose(camera), + transform = Cesium.Matrix4.clone(camera.transform); + let calls = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(calls, 0); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform)); + samePose(camera, before); +}); +test('navigation callback never releases tracking for globe rotation', (t) => { + const { viewer, camera } = harness(t); + const tracked = (viewer.trackedEntity = { id: 'tracked-aircraft' }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + viewer.trackedEntity = undefined; + }).ok, + false, + ); + assert.equal(calls, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); +}); +test('direct globe request refuses Cockpit before navigation mutation', (t) => { + const { viewer, camera } = harness(t); + initCameraVerbs(viewer, null, { isCockpitActive: () => true }); + const before = pose(camera); + let calls = 0; + assert.equal( + moveCamera({ motion: 'rotate_globe' }, () => { + calls++; + }).ok, + false, + ); + assert.equal(calls, 0); + samePose(camera, before); + assert.equal(hasRenderHold(), false); +}); + +test('navigation handoff preserves the original world pose even when it levels a rolled camera', (t) => { + const { camera, tick } = harness(t, { + latitude: 50, + heading: 75, + pitch: -86, + roll: 35, + }); + const before = pose(camera); + const result = moveCamera( + { motion: 'rotate_globe', mode: 'continuous' }, + (start) => { + camera.setView({ + orientation: { heading: camera.heading, pitch: camera.pitch, roll: 0 }, + }); + return start(); + }, + ); + assert.equal(result.ok, true); + samePose(camera, before); + const screen = projection(camera); + tick(); + tick(); + near(projection(camera).x, screen.x); + near(projection(camera).y, screen.y); + orthonormalBasis(camera); +}); + +test('direct globe rotation cancels an existing camera flight before acquiring motion', (t) => { + const { camera } = harness(t); + let cancellations = 0; + const original = camera.cancelFlight; + camera.cancelFlight = function () { + cancellations += 1; + return original.call(this); + }; + const before = pose(camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + assert.equal(cancellations, 1); + samePose(camera, before); +}); + +test('legacy rotate remains local yaw at an unchanged camera position', (t) => { + const { camera, finishOnce } = harness(t, { latitude: 0 }); + const before = pose(camera); + moveCamera({ motion: 'rotate', direction: 'right' }); + finishOnce(); + near(Cesium.Cartesian3.distance(camera.positionWC, before.position), 0, 1e-6); + near( + Cesium.Cartesian3.angleBetween(camera.directionWC, before.direction), + radians(15), + ); + assert.ok( + Math.abs(projection(camera).x) > 0.2, + 'local yaw intentionally moves Earth out of the center', + ); +}); + +test('legacy orbit still revolves around the selected ground target', (t) => { + const { camera, target, tick } = harness(t); + camera.lookAt(target, new Cesium.HeadingPitchRange(0, radians(-45), 4_000)); + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + const range = Cesium.Cartesian3.distance(camera.positionWC, target); + moveCamera({ motion: 'orbit', direction: 'right', mode: 'continuous' }); + tick(); + tick(); + assert.equal(getActiveCameraMotion()?.kind, 'orbit'); + near(Cesium.Cartesian3.distance(camera.positionWC, target), range, 1e-6); + near(projection(camera, target).x, 0); + near(projection(camera, target).y, 0); + interruptCameraMotion('test-stop'); + assert.equal( + Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY), + true, + ); +}); + +// Actual UI/tool authority routes, not a direct call to the interrupt helper. +test('new UI navigation intent stops globe motion before its destination and stale ticks', async (t) => { + const { NavigationController } = await import('./ui/navigationController.js'); + const { viewer, camera, tick } = harness(t); + const navigation = new NavigationController({ + viewer, + tracking: { + flightsLayer: {}, + militaryFlightsLayer: {}, + satellitesLayer: {}, + }, + interruptCameraMotion(reason) { + assert.equal( + getActiveCameraMotion(), + null, + 'the intent stamp must retire rotation before release', + ); + return interruptCameraMotion(reason); + }, + interruptGlobeRotation, + isCockpitActive: () => false, + cancelOrientation() {}, + clearLocation() {}, + cancelShareSelection: () => false, + getDataManager: () => null, + stopOrbit() {}, + showToast() {}, + }); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + navigation._runExplicitNavigation('camera', () => { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + }); + const after = pose(camera); + tick(); + samePose(camera, after); +}); +test('orbit-toggle controller stops globe motion before toggling', async (t) => { + const { LocationNavigation } = await import('./ui/locationNavigation.js'); + const { target } = harness(t); + let toggled = false; + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + LocationNavigation.prototype._toggleOrbit.call({ + services: { interruptGlobeRotation }, + _currentTarget: target, + _currentPoi: {}, + orbitController: { + toggle() { + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + toggled = true; + return true; + }, + }, + _orbitIndicator: { classList: { toggle() {} } }, + }); + assert.equal(toggled, true); +}); +for (const owner of ['tracking', 'cockpit', 'close-up']) + test(`actual voice runner refuses ${owner} without releasing or repositioning`, async (t) => { + const { createGevActionRunner } = await import('./voice/gevActions.js'); + const { runExplicitNavigation } = await import('./navigationPolicy.js'); + const { viewer, camera } = harness(t, { + height: owner === 'close-up' ? 5000 : GLOBE_VIEW.heightM, + }); + const before = pose(camera); + if (owner === 'tracking') viewer.trackedEntity = { id: 'tracked-aircraft' }; + const tracked = viewer.trackedEntity; + let releases = 0, + stamps = 0; + const styleManager = { + getCockpitState: () => ({ active: owner === 'cockpit' }), + runImmediateNavigation(noun, navigate) { + return runExplicitNavigation({ + cockpitActive: owner === 'cockpit', + noun, + stamp() { + stamps++; + }, + release() { + releases++; + viewer.trackedEntity = undefined; + }, + navigate, + }); + }, + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { + layers: new Map(), + isEnabled: () => false, + getAll: () => [], + }, + }); + const result = await runner('move_camera', { + motion: 'rotate_globe', + direction: 'left', + }); + assert.equal(result.ok, false); + assert.equal(releases, 0); + assert.equal(stamps, 0); + assert.equal(viewer.trackedEntity, tracked); + samePose(camera, before); + if (owner === 'close-up') { + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + } + }); + +for (const [label, ellipsoid] of [ + ['nonfinite radii', { minimumRadius: 1, maximumRadius: Infinity }], + ['nonpositive radii', { minimumRadius: 0, maximumRadius: 1 }], + ['non-WGS84 radii', new Cesium.Ellipsoid(30_000_000, 30_000_000, 1_000_000)], +]) + test(`globe rotation refuses ${label} before handoff`, (t) => { + const { viewer, camera } = harness(t, { height: 100_000_000 }); + const before = pose(camera); + viewer.scene.globe.ellipsoid = ellipsoid; + let handedOff = false; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handedOff = true; + }); + assert.equal(result.ok, false); + assert.equal( + result.reason, + label === 'non-WGS84 radii' ? 'unsupported-view' : 'unsafe-ellipsoid', + ); + assert.equal(handedOff, false); + assert.equal(hasRenderHold(), false); + samePose(camera, before); + }); + +test('an external radius change retires globe rotation without another write', (t) => { + const { camera, tick } = harness(t); + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }); + camera.moveForward(1000); + const changed = pose(camera); + tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(camera, changed); +}); + +test('a non-WGS84 ellipsoid fails closed rather than borrowing the WGS84 silhouette', (t) => { + const ellipsoid = new Cesium.Ellipsoid(10_000_000, 10_000_000, 8_000_000); + const { camera } = harness(t, { ellipsoid, latitude: 60 }); + const before = pose(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(camera, before); +}); + +test('untouched polar framing permits repeated direction and speed changes below preset altitude', (t) => { + const { camera, finishOnce, tick } = harness(t, { latitude: 90 }); + const radius = Cesium.Cartesian3.magnitude(camera.positionWC); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok( + Cesium.Cartographic.fromCartesian(camera.positionWC).height < + GLOBE_VIEW.heightM - 1, + ); + assert.equal( + moveCamera({ motion: 'rotate_globe', direction: 'right', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.equal( + moveCamera({ + motion: 'rotate_globe', + direction: 'left', + speed: 'fast', + mode: 'continuous', + }).ok, + true, + ); + tick(); + tick(); + assert.equal(getActiveCameraMotion().speed, 'fast'); + near(Cesium.Cartesian3.magnitude(camera.positionWC), radius, 1e-6); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); +}); + +function projected(h) { + return projectEarthDiscToViewport( + h.viewer, + h.canvas.clientWidth, + h.canvas.clientHeight, + ); +} + +test('a smaller globe wholly inside the keyhole needs no overlay entry margin or reset', (t) => { + const h = harness(t, { latitude: 0, height: 12_500_000 }); + const disc = projected(h); + const clearance = + disc.keyholeRadius - + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) - + disc.earthRadius; + assert.ok( + clearance > 0 && clearance < 24, + `near-edge inside fit: ${clearance}`, + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +test('keyhole overlap refuses without a camera handoff or pose change', (t) => { + const h = harness(t, { latitude: 0, height: 11_000_000 }); + const before = pose(h.camera); + const disc = projected(h); + assert.ok(disc.earthRadius > disc.keyholeRadius); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + assert.equal(handoffs, 0); + samePose(h.camera, before); +}); + +for (const [angle, fits] of [ + [5, true], + [25, false], +]) + test(`actual off-centre globe at ${angle} degrees has fit ${fits}`, (t) => { + const h = harness(t, { height: 30_000_000 }); + h.camera.lookRight(radians(angle)); + const before = pose(h.camera); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, fits); + samePose(h.camera, before); + }); + +test('wide viewport uses vertical fovy rather than horizontal fov', (t) => { + const h = harness(t, { + viewportWidth: 2000, + viewportHeight: 500, + latitude: 0, + }); + assert.ok(h.camera.frustum.fovy < h.camera.frustum.fov); + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-reset-unavailable', + ); +}); + +for (const invalid of [ + 'orthographic', + 'behind', + 'zero-css-width', + 'offset-frustum', +]) + test(`unsupported ${invalid} fails without a reset offer`, (t) => { + const h = harness(t); + if (invalid === 'orthographic') + h.camera.frustum = new Cesium.OrthographicFrustum({ + width: 50_000_000, + aspectRatio: 1.6, + }); + if (invalid === 'behind') h.camera.lookRight(Math.PI); + if (invalid === 'zero-css-width') h.canvas.clientWidth = 0; + if (invalid === 'offset-frustum') h.camera.frustum.xOffset = 0.1; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('a new request rechecks close-up framing rather than using previous eligibility', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.dispatchEvent(new Event('wheel')); + h.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000) }); + const before = pose(h.camera); + assert.equal( + moveCamera({ motion: 'rotate_globe' }).reason, + 'globe-view-required', + ); + samePose(h.camera, before); + assert.equal(getActiveCameraMotion(), null); +}); + +// Exact WGS84 equatorial tangent points expose the shared disc approximation. +// Keep the concrete counterexample public; an overlay approximation is not admission proof. +test('off-axis ellipsoid limb overlap must not be admitted as a fully framed globe', (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000 }); + h.camera.lookRight(radians(6)); + const disc = projected(h); + assert.ok( + disc.earthRadius + + Math.hypot( + disc.earthCenterX - disc.keyholeCenterX, + disc.earthCenterY - disc.keyholeCenterY, + ) < + disc.keyholeRadius, + 'the shared approximation reports a fit', + ); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const x = (radius * radius) / h.camera.positionWC.x; + const y = Math.sqrt(radius * radius - x * x); + const limbRadii = [-y, y].map((value) => { + const pixel = Cesium.SceneTransforms.worldToWindowCoordinates( + h.viewer.scene, + new Cesium.Cartesian3(x, value, 0), + ); + return Math.hypot( + pixel.x - disc.keyholeCenterX, + pixel.y - disc.keyholeCenterY, + ); + }); + assert.ok( + Math.max(...limbRadii) > disc.keyholeRadius, + 'an actual ellipsoid tangent point overlaps the keyhole', + ); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, false); +}); + +for (const roll of [0, 35]) + test(`off-axis boundary overlap is refused with roll ${roll}`, (t) => { + const h = harness(t, { latitude: 0, height: 18_000_000, roll }); + h.camera.lookRight(radians(6)); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + }); + +for (const latitude of [0, 90]) + for (const pitch of [0, 15]) + test(`close-up horizon/upward view offers reset at latitude ${latitude}, pitch ${pitch}`, (t) => { + const h = harness(t, { latitude, pitch, height: 5000 }); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.reason, 'globe-view-required'); + assert.equal( + result.error, + 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’', + ); + samePose(h.camera, before); + }); + +for (const [viewportWidth, viewportHeight, fits] of [ + [390, 844, true], + [844, 390, true], + [800, 1200, true], + [1280, 800, true], + [3440, 1440, false], +]) + test(`unchanged reset preset is rechecked at ${viewportWidth}x${viewportHeight}`, (t) => { + const h = harness(t, { + viewportWidth, + viewportHeight, + height: GLOBE_VIEW.heightM, + latitude: 0, + }); + const disc = projected(h); + assert.equal( + disc.earthRadius < disc.keyholeRadius, + fits, + 'reset preset keyhole fit', + ); + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, fits); + if (!fits) assert.equal(result.reason, 'globe-reset-unavailable'); + samePose(h.camera, before); + }); + +test('keyhole overlap after resize stops rotation before another pose write', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'rotate_globe', mode: 'continuous' }).ok, + true, + ); + h.canvas.clientWidth = 2000; + h.canvas.clientHeight = 500; + h.camera.frustum.aspectRatio = 4; + assert.ok(projected(h).earthRadius > projected(h).keyholeRadius); + const before = pose(h.camera); + h.tick(); + assert.equal(getActiveCameraMotion(), null); + assert.equal(hasRenderHold(), false); + samePose(h.camera, before); +}); + +for (const clipped of ['near', 'far', 'entirely-beyond-far']) + test(`depth-${clipped} globe is unsupported without a reset offer`, (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + if (clipped === 'near') h.camera.frustum.near = distance - radius + 100; + if (clipped === 'far') h.camera.frustum.far = distance + radius - 100; + if (clipped === 'entirely-beyond-far') + h.camera.frustum.far = distance - 2 * radius; + const before = pose(h.camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); + }); + +test('fully depth-contained globe remains eligible at actual near/far limits', (t) => { + const h = harness(t, { latitude: 0 }); + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const distance = Cesium.Cartesian3.magnitude(h.camera.positionWC); + h.camera.frustum.near = distance - radius - 1; + h.camera.frustum.far = distance + radius + 1; + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); +}); + +for (const [near, far] of [ + [0, 1e9], + [-1, 1e9], + [NaN, 1e9], + [1, Infinity], + [10, 10], + [10, 9], +]) + test(`invalid depth limits ${near}/${far} are unsupported`, (t) => { + const h = harness(t); + h.camera.frustum.near = near; + h.camera.frustum.far = far; + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'unsupported-view'); + }); + +test('bare globe rotation defaults right and continues beyond a nudge', (t) => { + const { camera, tick } = harness(t, { latitude: 0 }); + const before = projection(camera); + const result = moveCamera({ motion: 'rotate_globe' }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'right'); + assert.equal(result.mode, 'continuous'); + assert.equal(result.speed, 'normal'); + for (let i = 0; i < 30; i++) tick(0.1); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + near(projection(camera).x, before.x); + near(projection(camera).y, before.y); + assert.equal(moveCamera({ motion: 'stop' }).stopped, true); + assert.equal(hasRenderHold(), false); +}); +for (const [args, direction, mode, speed] of [ + [{ mode: 'once' }, 'right', 'once', 'normal'], + [{ direction: 'left', speed: 'slow' }, 'left', 'continuous', 'slow'], + [{ direction: 'left', mode: 'once', speed: 'fast' }, 'left', 'once', 'fast'], + [ + { direction: 'right', mode: 'continuous', speed: 'fast' }, + 'right', + 'continuous', + 'fast', + ], +]) + test(`explicit globe controls prevail: ${JSON.stringify(args)}`, (t) => { + const { finishOnce, tick } = harness(t); + const result = moveCamera({ motion: 'rotate_globe', ...args }); + assert.deepEqual( + [result.ok, result.direction, result.mode, result.speed], + [true, direction, mode, speed], + ); + if (mode === 'once') finishOnce(); + else { + tick(); + tick(); + assert.equal(getActiveCameraMotion().mode, 'continuous'); + } + }); +for (const motion of ['rotate', 'pan', 'tilt', 'orbit']) + test(`legacy ${motion} retains its once default`, (t) => { + harness(t); + const result = moveCamera({ + motion, + direction: motion === 'tilt' ? 'up' : 'left', + }); + assert.equal(result.ok, true); + assert.equal(result.mode, 'once'); + }); +for (const mode of [null, '', 'invalid', 'CONTINUOUS']) + test(`invalid globe mode ${JSON.stringify(mode)} retains legacy once fallback`, (t) => { + const { finishOnce } = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe', mode }).mode, 'once'); + finishOnce(); + }); + +test('canvas-only clipping after resize preserves a keyhole-fitting rotation', (t) => { + const h = harness(t, { latitude: 0 }); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + h.canvas.clientWidth = 390; + h.canvas.clientHeight = 844; + h.camera.frustum.aspectRatio = 390 / 844; + const disc = projected(h); + assert.ok(disc.earthRadius < disc.keyholeRadius); + assert.ok(disc.earthRadius > h.canvas.clientWidth / 2); + const before = pose(h.camera); + h.tick(); + h.tick(); + assert.notDeepEqual(pose(h.camera).position, before.position); + assert.equal(getActiveCameraMotion()?.kind, 'rotate_globe'); + assert.equal(hasRenderHold(), true); +}); + +test('an invalid camera request leaves the active globe rotation untouched', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'rotate_globe' }).ok, true); + const before = pose(h.camera); + const active = getActiveCameraMotion(); + assert.equal(moveCamera({ motion: 'pan' }).ok, false); + assert.deepEqual(getActiveCameraMotion(), active); + samePose(h.camera, before); + assert.equal(hasRenderHold(), true); +}); + +test('close-up ultrawide refusal does not offer an unfit reset or move the camera', (t) => { + const h = harness(t, { + viewportWidth: 3440, + viewportHeight: 1440, + height: 5000, + }); + const before = pose(h.camera); + let handoffs = 0; + const result = moveCamera({ motion: 'rotate_globe' }, () => { + handoffs++; + }); + assert.equal(result.reason, 'globe-reset-unavailable'); + assert.equal(handoffs, 0); + samePose(h.camera, before); + assert.equal(hasRenderHold(), false); +}); diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index d91b9d8a4..1fe30ff06 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -223,7 +223,7 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion))", + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 278a86206..231d9357d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -4,7 +4,7 @@ * * One motion at a time, driven per clock tick. `once` = bounded eased nudge; * `continuous` runs until move_camera{stop}, ANY manual camera input on the - * canvas (pointerdown/wheel — the cancelFlight reflex), or a navigation tool + * canvas (pointerdown/wheel/touchstart — the cancelFlight reflex), or a navigation tool * starts (the shared UI navigation policy releases this owner before the new * destination mutates the camera). fly_route is a * continuous motion in the same slot: a cinematic dolly along an existing @@ -13,14 +13,24 @@ * Orbit circles the current screen-center ground target holding range and * pitch while advancing heading (per-frame camera.lookAt; the lookAt * transform is cleared on every stop so manual control returns cleanly). + * Globe rotation turns the world-frame camera about its screen-up axis through + * Earth's center, preserving range and framing. Left/right name the visible + * globe motion, opposite to the camera's travel around Earth. */ import * as Cesium from 'cesium'; +import { GLOBE_VIEW } from './locations.js'; +import { + projectEarthDiscToViewport, + earthDiscScreenRadius, + getKeyholeGeometry, +} from './celestialRing.js'; import { holdContinuousRender, releaseContinuousRender, } from './renderGovernor.js'; +let _isCockpitActive = null; /** °/s by speed word — orbit; pan uses fractions of view height/s. */ const ORBIT_DEG_S = { slow: 2, normal: 6, fast: 15 }; const TILT_DEG_S = { slow: 4, normal: 10, fast: 20 }; @@ -952,6 +962,7 @@ let _viewer = null; let _getTarget = null; let _active = null; // { kind, motion, direction, speed, mode, motionId, ...state } let _tickRemover = null; +let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; @@ -1162,7 +1173,11 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if (m.kind === 'tilt' || m.kind === 'rotate') { + } else if ( + m.kind === 'tilt' || + m.kind === 'rotate' || + m.kind === 'rotate_globe' + ) { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' @@ -1172,7 +1187,38 @@ function onTick() { (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; - if (m.kind === 'tilt') { + if (m.kind === 'rotate_globe') { + // Tracking can acquire the camera without going through navigation. + // Yield without resetting its pose/frame: the world-space arc proof + // only describes Cesium's rotation while the frame is identity. + if ( + _viewer.scene.mode !== Cesium.SceneMode.SCENE3D || + _viewer.trackedEntity || + _isCockpitActive?.() || + !Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ) { + interruptCameraMotion('globe-camera-owner-changed'); + return; + } + // Cesium rotates both position and basis about the reference-frame + // origin. The captured screen-up axis keeps left/right horizontal even + // in a tilted or rolled view; a positive angle moves the globe right. + const angle = m.direction === 'left' ? -stepRad : stepRad; + if (globeRotationFitsKeyhole() !== true) { + interruptCameraMotion('globe-framing-changed'); + return; + } + if ( + !globeRotationHasClearance(cam.positionWC) || + Math.abs( + Cesium.Cartesian3.magnitude(cam.positionWC) - m.rotationRadius, + ) > 0.01 + ) { + interruptCameraMotion('globe-surface-limit'); + return; + } + cam.rotate(m.rotationAxis, angle); + } else if (m.kind === 'tilt') { const up = m.direction === 'up'; // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. const next = cam.pitch + (up ? stepRad : -stepRad); @@ -1206,6 +1252,133 @@ function onTick() { } } +/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ +function globeRotationHasClearance(position) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const { minimumRadius, maximumRadius } = ellipsoid; + const radius = Cesium.Cartesian3.magnitude(position); + return ( + Number.isFinite(minimumRadius) && + minimumRadius > 0 && + Number.isFinite(maximumRadius) && + maximumRadius >= minimumRadius && + Number.isFinite(radius) && + radius > maximumRadius * (1 + 1e-12) + ); +} + +/** Use the existing WGS84/CSS-pixel projection; null means unsupported, not close-up. */ +function globeRotationFitsKeyhole(camera = _viewer.camera) { + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!Cesium.Cartesian3.equals(ellipsoid.radii, Cesium.Ellipsoid.WGS84.radii)) + return null; + const canvas = _viewer.scene.canvas; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const frustum = camera.frustum; + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 || + !(frustum instanceof Cesium.PerspectiveFrustum) || + !Number.isFinite(frustum.near) || + frustum.near <= 0 || + !Number.isFinite(frustum.far) || + frustum.far <= frustum.near || + frustum.xOffset !== 0 || + frustum.yOffset !== 0 + ) + return null; + try { + const cam = camera; + const distance = Cesium.Cartesian3.magnitude(cam.positionWC); + const sphereRadius = ellipsoid.maximumRadius; + if (!Number.isFinite(distance) || distance <= 0) return null; + if (distance <= sphereRadius) return false; // Near-surface polar views are close-up too. + const radiusPx = earthDiscScreenRadius(distance, height, frustum.fovy); + if (!radiusPx) return null; + const keyhole = getKeyholeGeometry(width, height); + // The known nadir reset probe uses the same exact cone below; projecting + // through SceneTransforms would read the live scene.camera instead. + if ( + camera === _viewer.camera && + !projectEarthDiscToViewport(_viewer, width, height) + ) { + // A near-surface horizon view cannot fit even when pointed at Earth; + // an orbital camera looking away is unsupported, not a close-up offer. + return radiusPx > Math.min(keyhole.radius, width / 2, height / 2) + ? false + : null; + } + // The shared disc is an overlay approximation off-axis. Bound the sphere + // against the existing keyhole cone. Canvas-edge clipping does not change + // framing during rigid globe rotation and is not a clearance condition, + // without changing the UI projection or defining another keyhole/mask. + const toCenter = Cesium.Cartesian3.negate( + cam.positionWC, + new Cesium.Cartesian3(), + ); + const depth = Cesium.Cartesian3.dot(toCenter, cam.directionWC); + const right = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.rightWC)); + const up = Math.abs(Cesium.Cartesian3.dot(toCenter, cam.upWC)); + const focalY = height / (2 * Math.tan(frustum.fovy / 2)); + const focalX = + width / (2 * Math.tan(frustum.fovy / 2) * frustum.aspectRatio); + if ( + ![depth, right, up, focalX, focalY].every(Number.isFinite) || + focalX <= 0 || + focalY <= 0 + ) + return null; + const halfAngle = Math.asin(sphereRadius / distance); + const centerAngle = Math.atan2(Math.hypot(right, up), depth); + const keyholeAngle = Math.atan(keyhole.radius / Math.max(focalX, focalY)); + // No overlay visibility hysteresis: a fully contained smaller globe needs no reset. + const framed = centerAngle + halfAngle <= keyholeAngle; + if (!framed) return false; + // Projection alone does not prove that a tiny globe is within depth planes. + if ( + depth - sphereRadius < frustum.near || + depth + sphereRadius > frustum.far + ) + return null; + return true; + } catch { + return null; + } +} + +/** Check the unchanged reset destination without moving the live camera. */ +function globeResetFitsKeyhole() { + try { + const camera = Cesium.Camera.clone(_viewer.camera); + const { longitude, latitude } = _viewer.camera.positionCartographic; + camera.setView({ + destination: Cesium.Cartesian3.fromRadians( + longitude, + latitude, + GLOBE_VIEW.heightM, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(GLOBE_VIEW.pitchDeg), + roll: 0, + }, + endTransform: Cesium.Matrix4.IDENTITY, + }); + return globeRotationFitsKeyhole(camera) === true; + } catch { + return false; + } +} + +/** Release only globe rotation when a new camera intent takes ownership. */ +export function interruptGlobeRotation(reason = 'camera-intent') { + if (_active?.kind === 'rotate_globe') return interruptCameraMotion(reason); + return { wasActive: false, reason, leveled: false }; +} + /** * Wire the module to the viewer once (idempotent). The view-target getter is * optional so a non-voice caller (the Directions FLY chip) can arm route @@ -1218,24 +1391,42 @@ function onTick() { * call brings none, so a callback from the old viewer can never answer for the * new one. */ -export function initCameraVerbs(viewer, getViewTargetCartesian = null) { +export function initCameraVerbs( + viewer, + getViewTargetCartesian = null, + { isCockpitActive } = {}, +) { const nextTarget = typeof getViewTargetCartesian === 'function' ? getViewTargetCartesian : null; if (_viewer === viewer) { if (nextTarget) _getTarget = nextTarget; + if (typeof isCockpitActive === 'function') + _isCockpitActive = isCockpitActive; return; } + // A globe motion belongs to the viewer whose world pose it captured. + if (_active?.kind === 'rotate_globe') + interruptCameraMotion('viewer-replaced'); _getTarget = nextTarget; + _isCockpitActive = + typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); + if (_trackingRemover) _trackingRemover(); + // Cockpit can acquire then clear tracking before the next clock tick. + // Yield on acquisition itself without changing legacy camera motions. + _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { + if (entity && _active?.kind === 'rotate_globe') + interruptCameraMotion('globe-camera-owner-changed'); + }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. - for (const evt of ['pointerdown', 'wheel']) { + for (const evt of ['pointerdown', 'touchstart', 'wheel']) { const h = () => interruptCameraMotion('manual-input'); canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); @@ -1256,7 +1447,11 @@ export function moveCamera(args = {}, runNavigation = null) { const speed = ORBIT_DEG_S[String(args.speed || 'normal').toLowerCase()] ? String(args.speed || 'normal').toLowerCase() : 'normal'; - const mode = args.mode === 'continuous' ? 'continuous' : 'once'; + const mode = + args.mode === 'continuous' || + (motion === 'rotate_globe' && args.mode === undefined) + ? 'continuous' + : 'once'; if (motion === 'stop') { const wasActiveBeforeHandoff = Boolean(_active); const stop = () => { @@ -1270,15 +1465,16 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate'].includes(motion)) { + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { return { ok: false, action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, or stop.`, + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } if ( motion !== 'orbit' && + motion !== 'rotate_globe' && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1294,6 +1490,17 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate needs left or right.', }; } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1318,9 +1525,9 @@ export function moveCamera(args = {}, runNavigation = null) { } } if ( - motion === 'orbit' && - _viewer?.trackedEntity && - typeof runNavigation !== 'function' + (motion === 'rotate_globe' || + (motion === 'orbit' && typeof runNavigation !== 'function')) && + _viewer?.trackedEntity ) { // The follow camera owns a tracked view; a lookAt orbit fights it frame // by frame and stop rips the camera out (field finding). Honest refusal @@ -1332,9 +1539,74 @@ export function moveCamera(args = {}, runNavigation = null) { return { ok: false, action: 'move_camera', - error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want a free orbit.`, + error: `Already following ${label} — the follow camera owns the view while tracking. Say "stop tracking" first if you want ${motion === 'rotate_globe' ? 'to rotate the globe' : 'a free orbit'}.`, }; } + let globePose; + if (motion === 'rotate_globe') { + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) { + return { + ok: false, + action: 'move_camera', + error: 'Globe rotation requires a 3D view.', + }; + } + const cam = _viewer.camera; + globePose = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + up: Cesium.Cartesian3.normalize(cam.upWC, new Cesium.Cartesian3()), + right: Cesium.Cartesian3.clone(cam.rightWC), + }; + if (_isCockpitActive?.()) + return { + ok: false, + action: 'move_camera', + reason: 'camera-owned', + error: 'Exit Cockpit before rotating the globe.', + }; + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + if (!( + Number.isFinite(ellipsoid.minimumRadius) && + ellipsoid.minimumRadius > 0 && + Number.isFinite(ellipsoid.maximumRadius) && + ellipsoid.maximumRadius >= ellipsoid.minimumRadius && + Number.isFinite(Cesium.Cartesian3.magnitude(globePose.position)) + )) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: + 'Globe rotation requires a finite supported ellipsoid and a clear globe view.', + }; + const fits = globeRotationFitsKeyhole(); + if (fits === null) + return { + ok: false, + action: 'move_camera', + reason: 'unsupported-view', + error: 'Globe rotation requires a supported perspective view.', + }; + if (!fits) { + const canReset = globeResetFitsKeyhole(); + return { + ok: false, + action: 'move_camera', + reason: canReset ? 'globe-view-required' : 'globe-reset-unavailable', + error: canReset + ? 'Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’' + : 'Zoom out further to fit the globe inside the keyhole; the Globe View preset does not fit this viewport.', + }; + } + if (!globeRotationHasClearance(globePose.position)) + return { + ok: false, + action: 'move_camera', + reason: 'unsafe-ellipsoid', + error: 'Globe rotation requires a clear ellipsoid bounding sphere.', + }; + } const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1343,6 +1615,22 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'rotate_globe') { + // Convert an inherited ground-target frame without changing the world + // pose. Rotation must pivot about Earth, never the previous local target. + const cam = _viewer.camera; + cam.cancelFlight(); + cam.lookAtTransform(Cesium.Matrix4.IDENTITY); + // Handoff may level a route's bank. Keep the caller's exact world pose. + // Copy the basis directly: setView's heading/pitch conversion is singular + // at nadir and can change the captured orientation near the poles. + Cesium.Cartesian3.clone(globePose.position, cam.position); + Cesium.Cartesian3.clone(globePose.direction, cam.direction); + Cesium.Cartesian3.clone(globePose.up, cam.up); + Cesium.Cartesian3.clone(globePose.right, cam.right); + state.rotationAxis = globePose.up; + state.rotationRadius = Cesium.Cartesian3.magnitude(globePose.position); + } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 40d42befc..a02c4391f 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -988,6 +988,16 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping ]); // Cyber deliberately adds one layout; first-run missions still change no tools. hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + const cameraMotion = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties.motion; + assert.deepEqual(cameraMotion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt @@ -997,10 +1007,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // Globe rotation adds one camera motion and its descriptions; Globe View + // framing/default guidance updates only move_camera and mode descriptions. + // Radio markup and the complete production-request comparison pin locality. + // Restore the base camera/Globe View wording with the globe-rotation append. + assert.equal(block.length, 30878, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 23ad778d5..14ea0f3e0 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -209,6 +209,35 @@ test('no unchanged Realtime tool definition drifts silently', () => { const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout; assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']); hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber'); + // Globe rotation adds one motion and clarifies the two affected descriptions. + // Check those edits explicitly, then keep the legacy pin for every other field. + const camera = unchanged.find((tool) => tool.name === 'move_camera'); + assert.deepEqual(camera.parameters.properties.motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + assert.equal( + camera.description, + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', + ); + assert.equal( + camera.parameters.properties.direction.description, + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + ); + assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); + camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; + camera.parameters.properties.motion.enum = + camera.parameters.properties.motion.enum.filter( + (motion) => motion !== 'rotate_globe', + ); + camera.description = + 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").'; + camera.parameters.properties.direction.description = + 'Required except for orbit (defaults right/clockwise) and stop.'; assert.equal(unchanged.length, 14); const digest = createHash('sha256') .update(JSON.stringify(unchanged)) diff --git a/src/tooling/voiceBench.test.mjs b/src/tooling/voiceBench.test.mjs index 84c31f3dc..1cc69c8da 100644 --- a/src/tooling/voiceBench.test.mjs +++ b/src/tooling/voiceBench.test.mjs @@ -234,6 +234,19 @@ test('typed argument checks feed full-call accuracy without changing the legacy const inMeters = gradeCore(altitude, [{ name: 'analyst_query', args: { layers: ['flights'], filters: [{ field: 'altitudeM', op: 'gt', value: 12192 }] } }]); assert.equal(inMeters.fullCallOk, true); assert.equal(where('set_hud', (a) => a.visible === 'off')({ name: 'set_hud', args: { visible: 'off' } }), true); + const globe = byPhrase('Keep spinning the Earth slowly to the left'); + const globeArgs = { motion: 'rotate_globe', direction: 'left', speed: 'slow' }; + for (const mode of [undefined, 'continuous', 'once', null]) { + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, mode } }]).fullCallOk, mode === undefined || mode === 'continuous'); + } + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, direction: 'right' } }]).fullCallOk, false); + assert.equal(gradeCore(globe, [{ name: 'move_camera', args: { ...globeArgs, speed: 'normal' } }]).fullCallOk, false); + const right = byPhrase('Rotate the globe to the right'); + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe' } }]).fullCallOk, true); + for (const direction of ['right', 'left', null]) { + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate_globe', direction } }]).fullCallOk, direction === 'right'); + } + assert.equal(gradeCore(right, [{ name: 'move_camera', args: { motion: 'rotate', direction: 'right' } }]).fullCallOk, false); }); test('gradeDialogue scores turn 2 in the context of turn 1', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index de37ab036..7c866bc36 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -147,6 +147,7 @@ export class StyleManager extends ShellFacade { }, searchInput: this._locationSearch, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, isCockpitActive: () => !!this.cockpitView?.active, clearLocation: () => this.clearSearchedLocation(), cancelShareSelection: () => this._shareRestoration.cancelSelection(), @@ -322,6 +323,7 @@ export class StyleManager extends ShellFacade { GLOBE_VIEW: services.GLOBE_VIEW, flyToGlobeView: services.flyToGlobeView, interruptCameraMotion: services.interruptCameraMotion, + interruptGlobeRotation: services.interruptGlobeRotation, flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, satellitesLayer: services.satellitesLayer, diff --git a/src/ui/composition.js b/src/ui/composition.js index 797157567..c56c6f37c 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -9,7 +9,10 @@ import { flyToPOI, searchAndFlyTo, } from '../locations.js'; -import { interruptCameraMotion } from '../cameraVerbs.js'; +import { + interruptCameraMotion, + interruptGlobeRotation, +} from '../cameraVerbs.js'; import { IntelHUD } from '../hud.js'; import { ShareLinkManager } from '../sharelink.js'; import { OrbitController } from '../orbit.js'; @@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell { flyToPOI, searchAndFlyTo, interruptCameraMotion, + interruptGlobeRotation, IntelHUD, ShareLinkManager, OrbitController, diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index 07ce43d8d..af8fb95b8 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -283,6 +283,7 @@ export class LocationNavigation { return; } + this.services.interruptGlobeRotation?.('orbit-intent'); const isActive = this.orbitController.toggle(this._currentTarget, { radius: this._currentPoi?.alt || 500, pitch: this._currentPoi?.pitch || -30, diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index f94eb46fe..437b643f3 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -14,6 +14,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation = () => {}, isCockpitActive, clearLocation, cancelShareSelection, @@ -27,6 +28,7 @@ export class NavigationController { tracking, searchInput, interruptCameraMotion, + interruptGlobeRotation, isCockpitActive, clearLocation, cancelShareSelection, @@ -46,6 +48,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { + this.interruptGlobeRotation('navigation-intent'); cancelCameraArrival(this.viewer); this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index d0810bf71..3f464cad1 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -729,7 +729,7 @@ const schemas = [ properties: { motion: { type: 'string', - enum: ['orbit', 'pan', 'tilt', 'rotate', 'stop'], + enum: ['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe', 'stop'], }, direction: { type: 'string', diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..d65d7d8db 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -1,3 +1,4 @@ +import { realtimeInstructions } from '../../server/providers/openai/instructions.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; @@ -38,7 +39,10 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // Globe rotation adds one motion; visual keyhole fit and explicit + // reset guidance/defaults update only move_camera and mode descriptions. + // Restore base descriptions; append only globe-rotation defaults and fit guidance. + '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', ); }); @@ -106,6 +110,17 @@ test('all legacy action arguments are byte-identical after removing the delibera delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; delete property('analyst_query').layers.items.enum; + // Earth-centered rotation is additive; local camera yaw keeps its contract. + const motion = property('move_camera').motion; + assert.deepEqual(motion.enum, [ + 'orbit', + 'pan', + 'tilt', + 'rotate', + 'rotate_globe', + 'stop', + ]); + motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), @@ -145,6 +160,24 @@ test('all legacy action arguments are byte-identical after removing the delibera ); }); +test('globe rotation is an additive camera motion with the existing control arguments', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + for (const motion of ['rotate_globe', 'rotate', 'orbit', 'pan', 'tilt']) { + assert.deepEqual( + validateArgs(schema, { + motion, + direction: 'right', + speed: 'slow', + mode: 'continuous', + }), + [], + ); + } + assert.ok(validateArgs(schema, { motion: 'spin_camera' }).length > 0); +}); + test('track_entity accepts a referent alone but rejects an empty target', () => { const schema = GEV_ACTION_SCHEMAS.find( (tool) => tool.name === 'track_entity', @@ -183,3 +216,15 @@ test('the benchmark still rejects an empty track_entity although the model-facin [], ); }); + +test('globe reset guidance requires a separate explicit command after refusal', () => { + const paragraph = realtimeInstructions() + .split('\n') + .find((line) => line.includes('globe-view-required')); + assert.ok(paragraph); + assert.match(paragraph, /calls only move_camera motion rotate_globe/); + assert.match(paragraph, /explicit user Reset\/Show Globe View/); + assert.match(paragraph, /never from your own plan/); + assert.match(paragraph, /bare yes\/ok/); + assert.match(paragraph, /Do not ask a question/); +}); diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 04a573f76..041f1f643 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -21,6 +21,7 @@ import { shouldSendViewportImage, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, } from './realtimeProtocol.js'; const SOCKET_PATH = @@ -182,6 +183,8 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.inputGeneration = 0; this.turnAbort = new AbortController(); this.disposed = false; @@ -659,6 +662,8 @@ export class GeminiSessionController { return; if (!pushToTalk || activityCaptureId === metadata.captureId) return; this.captureOwner.admitted = true; + this.globeToolsBlocked = false; + this.globeResetFailed = false; if ( !this.interrupt('push-to-talk', { keepPlayback: !this.pttPlaybackSuppressed, @@ -1294,6 +1299,8 @@ export class GeminiSessionController { this.replyPending = false; if (!this.interrupt()) return; } else if (!this.retiredReplyPending && !this.interrupt()) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; // Local activity ownership is only for expiry liveness, not tools. // Assign after retiring observed output so its boundary cannot clear // a newer detected activity that has not received a reply yet. @@ -1573,6 +1580,7 @@ export class GeminiSessionController { .then(async () => { if (!this.owns(epoch) || turn !== this.turn) return; if (this.cancelledCalls.has(call.id)) { + if (call.name === 'zoom_to_globe') this.globeResetFailed = true; if (this.tools.get(call.id) === action) this.tools.delete(call.id); this.pendingTools--; if (!this.pendingTools) { @@ -1618,8 +1626,28 @@ export class GeminiSessionController { (authority !== 'playback' || handoffEpoch === this.radio.radioHandoffEpoch); let result; + let skippedGlobeRotation = false; + let blockedGlobeTool = false; try { if (!actionCurrent()) throw cancelled(); + // Live has no response-scoped tool_choice. Recheck here so queued siblings + // cannot reset after a rotation refusal. + if (this.globeToolsBlocked) { + blockedGlobeTool = true; + throw new Error( + 'Wait for fresh user input before calling another tool.', + ); + } + if ( + this.globeResetFailed && + call.name === 'move_camera' && + String(args.motion || '').toLowerCase() === 'rotate_globe' + ) { + skippedGlobeRotation = true; + throw new Error( + 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + ); + } if (stronger) reservation = this.radio.reserveRadioToolHandoff({ abortScope }); handoffEpoch = this.radio.radioHandoffEpoch; @@ -1689,7 +1717,12 @@ export class GeminiSessionController { } catch (error) { result = { ok: false, - tool: call.name, + ...(skippedGlobeRotation + ? { action: call.name, reason: 'globe-reset-failed', skipped: true } + : { + tool: call.name, + ...(blockedGlobeTool ? { skipped: true } : {}), + }), ...(radioVisibility ? { layerId: 'radio' } : {}), error: error?.name === 'AbortError' @@ -1716,6 +1749,11 @@ export class GeminiSessionController { this.noteRadioStrongerCommit(action, abortScope, turn); } if (!this.owns(epoch) || turn !== this.turn) return; + if ( + call.name === 'zoom_to_globe' && + (result?.ok !== true || result.cancelled) + ) + this.globeResetFailed = true; const playbackObsolete = authority === 'playback' && handoffEpoch !== this.radio.radioHandoffEpoch; if (radioControl || radioVisibility) { @@ -1744,6 +1782,8 @@ export class GeminiSessionController { this.stopAfterReply = true; // Native call cancellation suppresses its output, without canceling siblings. if (!this.cancelledCalls.has(call.id)) { + if (isGlobeViewRefusal(result) && !skippedGlobeRotation) + this.globeToolsBlocked = true; this.postToolOutput = false; this.modelComplete = false; this.replyPending = true; @@ -1751,7 +1791,9 @@ export class GeminiSessionController { const mapEvents = mapSnapshot.map((text) => JSON.parse(text)); const response = { ...(result ?? { ok: false, error: 'Command returned no result' }), - responseGuidance: responseInstructionForToolResult(result), + responseGuidance: blockedGlobeTool + ? 'That action did not run. Wait for fresh user input; do not claim any change or call another tool.' + : responseInstructionForToolResult(result), }; this.send( { @@ -1902,6 +1944,8 @@ export class GeminiSessionController { { onSent: () => { if (!owned() || this.typedIntent !== typedIntent) return; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = true; this.bindTextIntent(typedIntent); if (!owned() || this.typedIntent !== typedIntent) return; @@ -2455,6 +2499,8 @@ export class GeminiSessionController { this.cancelledCalls.clear(); this.pendingMapEvents = []; this.userTurnPending = false; + this.globeToolsBlocked = false; + this.globeResetFailed = false; this.replyPending = false; this.unrequestedReply = false; this.automaticTurnOpen = false; diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index b2f380b37..9ce8d351a 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -3398,3 +3398,215 @@ test('a narration callback that submits a successor keeps the old packet tools f assert.deepEqual(h.calls, []); h.c.stop(); }); + +for (const reply of ['typed', 'push-to-talk', 'open-mic']) { + test(`globe refusal blocks queued and later tools until fresh ${reply} input`, async () => { + const names = []; + const h = harness({ + runAction: async (name) => { + names.push(name); + return name === 'move_camera' + ? { ok: false, action: 'move_camera', reason: 'globe-view-required' } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start({ pushToTalk: reply === 'push-to-talk' }); + establishToolInput(h, 'rotate the globe'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'rotate', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'queued-reset', name: 'zoom_to_globe', args: {} }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const blocked = ws.sent + .flatMap((entry) => entry.toolResponse?.functionResponses || []) + .find((call) => call.id === 'queued-reset'); + assert.equal(blocked.response.ok, false); + assert.match(blocked.response.error, /fresh user input/i); + assert.equal(blocked.response.skipped, true); + assert.match(blocked.response.responseGuidance, /did not run/i); + assert.doesNotMatch( + blocked.response.responseGuidance, + /confirm.*completed/i, + ); + requestTool(ws, 'repeated-rotation', 'move_camera', { + motion: 'rotate_globe', + }); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + const repeated = toolResponses(ws).find( + (call) => call.id === 'repeated-rotation', + ).response; + assert.equal(repeated.ok, false); + assert.equal(repeated.skipped, true); + assert.match(repeated.responseGuidance, /did not run/i); + assert.doesNotMatch( + repeated.responseGuidance, + /confirm.*completed/i, + ); + + if (reply === 'typed') { + const stimuli = [ + () => {}, + () => ws.message({ serverContent: { interrupted: true } }), + () => + ws.message({ + serverContent: { + inputTranscription: { text: 'reset to globe view' }, + }, + }), + () => { + h.c.input.setMicrophoneEnabled(false); + h.c.input.setMicrophoneEnabled(true); + }, + ]; + for (const [i, stimulus] of stimuli.entries()) { + stimulus(); + requestTool(ws, `unrequested-reset-${i}`, 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera']); + } + } + // Finish the refusal reply before admitting the actual next user input. + ws.message({ serverContent: { turnComplete: true } }); + if (reply === 'typed') + assert.equal(h.c.sendTextCommand('reset to globe view'), true); + else if (reply === 'push-to-talk') { + h.c.input.pushToTalkKeyHeld = true; + h.c.input.spaceKeyHeld = true; + h.c.input.setMicrophoneEnabled(true); + h.c.input.setMicrophoneEnabled(false); + } else ws.message({ voiceActivity: { type: 'ACTIVITY_START' } }); + requestTool(ws, 'accepted-reset', 'zoom_to_globe', {}); + await h.c.toolQueue; + assert.deepEqual(names, ['move_camera', 'zoom_to_globe']); + } finally { + h.c.stop(); + } + }); +} + +test('a cancelled globe reset skips queued rotation, preserves Stop and recovers on fresh input', async () => { + const calls = []; + let resets = 0; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return name === 'zoom_to_globe' && resets++ === 0 + ? { ok: false, action: name, cancelled: true } + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-failed', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-skipped', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + { id: 'stop-allowed', name: 'move_camera', args: { motion: 'stop' } }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, ['zoom_to_globe', 'stop']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-skipped', + ).response; + assert.equal(skipped.ok, false); + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + assert.equal( + toolResponses(ws).find((call) => call.id === 'stop-allowed').response.ok, + true, + ); + + ws.message({ serverContent: { turnComplete: true } }); + establishToolInput(h, 'reset to globe view and rotate'); + ws.message({ + toolCall: { + functionCalls: [ + { id: 'reset-success', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-success', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await h.c.toolQueue; + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'stop', + 'zoom_to_globe', + 'rotate_globe', + ]); + } finally { + h.c.stop(); + } +}); + +test('cancelling a queued globe reset also skips its dependent rotation', async () => { + const blocker = deferred(); + const calls = []; + const h = harness({ + runAction: async (name, args) => { + calls.push(name === 'move_camera' ? args.motion : name); + return args.motion === 'pan' + ? blocker.promise + : { ok: true, action: name }; + }, + }); + try { + await h.c.start(); + establishToolInput(h, 'pan, reset to globe view and rotate'); + const ws = h.sockets[0]; + ws.message({ + toolCall: { + functionCalls: [ + { + id: 'blocker', + name: 'move_camera', + args: { motion: 'pan', direction: 'left' }, + }, + { id: 'reset-cancelled', name: 'zoom_to_globe', args: {} }, + { + id: 'rotate-after-cancel', + name: 'move_camera', + args: { motion: 'rotate_globe' }, + }, + ], + }, + }); + await tick(); + ws.message({ toolCallCancellation: { ids: ['reset-cancelled'] } }); + blocker.resolve({ ok: true, action: 'move_camera' }); + await h.c.toolQueue; + assert.deepEqual(calls, ['pan']); + const skipped = toolResponses(ws).find( + (call) => call.id === 'rotate-after-cancel', + ).response; + assert.equal(skipped.reason, 'globe-reset-failed'); + assert.equal(skipped.skipped, true); + } finally { + blocker.resolve({ ok: true }); + h.c.stop(); + } +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 49e2ba20a..f7613aed1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,3 +1,4 @@ +import { interruptGlobeRotation } from '../cameraVerbs.js'; import { layerSnapshot, feedProvenanceEnvelope, @@ -262,7 +263,9 @@ export function createGevActionRunner({ }), )); installViewTargetPrewarm(viewer); - initCameraVerbs(viewer, getViewTargetCartesian); + initCameraVerbs(viewer, getViewTargetCartesian, { + isCockpitActive: () => Boolean(styleManager?.getCockpitState?.()?.active), + }); // Nested calls (select_nearest_aircraft composing layer + flight) use the // raw runner; only the outer call gets a voice envelope. const runGevAction = async function runGevAction( @@ -298,6 +301,7 @@ export function createGevActionRunner({ // Zoom during an active orbit adjusts the orbit RADIUS (spiral in/out) — // a straight camera move would be snapped back by the per-frame lookAt. if (name === 'adjust_camera_zoom') { + interruptGlobeRotation('voice-zoom-intent'); const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; diff --git a/src/voice/globeResetConfirmation.test.mjs b/src/voice/globeResetConfirmation.test.mjs new file mode 100644 index 000000000..be2778e6d --- /dev/null +++ b/src/voice/globeResetConfirmation.test.mjs @@ -0,0 +1,1131 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { GevRealtimeController } from './realtimeController.js'; +import { + CALL_DEDUPE_MS, + responseInstructionForToolResult, +} from './realtimeProtocol.js'; + +const refusal = { + ok: false, + action: 'move_camera', + reason: 'globe-view-required', +}; + +function harness(result) { + const sent = []; + const calls = []; + const controller = new GevRealtimeController({ + ui: {}, + runner: async (name, args, context) => { + calls.push(name); + return typeof result === 'function' + ? result(name, args, context) + : result; + }, + }); + controller.dc = { readyState: 'open' }; + controller.sendRealtimeEvent = (event) => { + sent.push(event); + return true; + }; + controller.debugLog = () => {}; + controller.setStatus = () => {}; + controller.setVoiceSpeaker = () => {}; + controller.recordUsage = () => {}; + controller.captureViewportScreenshot = async () => null; + controller.cancelRadioHandoff = () => {}; + return { controller, sent, calls }; +} + +async function invoke( + controller, + { + responseId = 'rotation-response', + callId = 'rotation-call', + name = 'move_camera', + args = { motion: 'rotate_globe' }, + } = {}, +) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: `${callId}-item`, + call_id: callId, + name, + arguments: JSON.stringify(args), + }), + }); +} +/** A real user input boundary: the provider detected new speech. */ +async function speak(controller) { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ type: 'input_audio_buffer.speech_started' }), + }); +} +function begin(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.created', + response: { id }, + }); +} +function complete(controller, id = 'rotation-response') { + controller.updateResponseState({ + type: 'response.done', + response: { id, status: 'completed' }, + }); +} + +test('close-up rotation refusal instructs an explicit reset in a response with tools disabled', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(response?.tool_choice, 'none'); + assert.equal( + response.instructions, + responseInstructionForToolResult(refusal), + ); + assert.match(response.instructions, /do not ask a question/i); + assert.doesNotMatch(response.instructions, /confirm the completed/); +}); + +test('normal camera success retains the existing unrestricted followup', async () => { + const { controller, sent } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate', + }); + await invoke(controller); + complete(controller); + const response = sent.find( + (event) => event.type === 'response.create', + )?.response; + assert.equal(typeof response?.instructions, 'string'); + assert.equal(Object.hasOwn(response, 'tool_choice'), false); +}); + +test('a newer typed request drops the old refusal followup and its tool restriction', async () => { + const { controller, sent } = harness(refusal); + await invoke(controller); + controller.sendTextCommand('turn the camera right'); + complete(controller); + const responses = sent.filter((event) => event.type === 'response.create'); + assert.equal(responses.length, 1); + assert.equal(responses[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('refusal instructions do not interpolate untrusted failure text or alter other tool results', () => { + const instruction = responseInstructionForToolResult({ + ...refusal, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.doesNotMatch(instruction, /UNTRUSTED/); + const unavailable = responseInstructionForToolResult({ + ...refusal, + reason: 'globe-reset-unavailable', + }); + assert.doesNotMatch(unavailable, /Reset to Globe View and rotate the globe/); + assert.match(unavailable, /do not offer or perform a reset/i); + const completedReset = responseInstructionForToolResult({ + action: 'zoom_to_globe', + ok: true, + }); + assert.equal( + completedReset, + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); + for (const outcome of [ + { action: 'zoom_to_globe', ok: false }, + { action: 'zoom_to_globe', ok: true, cancelled: true }, + { tool: 'zoom_to_globe', ok: false }, + ]) { + const resetFailure = responseInstructionForToolResult({ + ...outcome, + error: 'UNTRUSTED_ERROR', + say: 'UNTRUSTED_SAY', + }); + assert.notEqual(resetFailure, completedReset); + assert.match(resetFailure, /did not finish/i); + assert.doesNotMatch(resetFailure, /UNTRUSTED/); + } + assert.equal( + responseInstructionForToolResult({ + ok: true, + action: 'get_current_view_state', + }), + responseInstructionForToolResult({ ok: true, action: 'move_camera' }), + ); +}); + +test('globe refusal blocks same-response tools and preserves the reset instruction, but not the next response', async () => { + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : { ok: true, action: name }, + ); + begin(controller); + await invoke(controller); + await invoke(controller, { + callId: 'reset-call', + name: 'zoom_to_globe', + args: {}, + }); + // The second delivery of the original call must not produce another output. + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'reset-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, false); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); + // A fresh explicit reset request is a separate user input. + await speak(controller); + begin(controller, 'fresh-response'); + await invoke(controller, { + responseId: 'fresh-response', + callId: 'fresh-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'fresh-response'); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('a reset arriving during pending globe admission cannot bypass the refusal', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller, { args: { motion: 'ROTATE_GLOBE' } }); + const reset = invoke(controller, { + callId: 'overlap-reset', + name: 'zoom_to_globe', + args: {}, + }); + finishRotation(refusal); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.equal(outputs.length, 2); + assert.equal( + JSON.parse( + outputs.find((event) => event.item.call_id === 'overlap-reset').item + .output, + ).ok, + false, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('successful pending globe admission releases a queued ordinary tool', async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + begin(controller); + const rotation = invoke(controller); + const reset = invoke(controller, { + callId: 'allowed-reset', + name: 'zoom_to_globe', + args: {}, + }); + assert.deepEqual(calls, ['move_camera']); + finishRotation({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([rotation, reset]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + .tool_choice, + undefined, + ); +}); + +test('rotation waits for an earlier reset before evaluating the globe', async () => { + let finishReset; + let atGlobe = false; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name === 'zoom_to_globe') { + await pending; + atGlobe = true; + return { ok: true, action: name }; + } + return atGlobe + ? { ok: true, action: name, motion: 'rotate_globe' } + : refusal; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['first-reset', 'rotation-call'], + ); + assert.equal(JSON.parse(outputs[1].item.output).ok, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, undefined); +}); + +for (const outcome of [ + 'cancelled', + 'thrown', + 'retry-refused', + 'settled-failure', +]) { + test(`a ${outcome} reset sequence cannot suggest another reset`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(async (name) => { + if (name !== 'zoom_to_globe') return refusal; + await pending; + if (outcome === 'thrown') throw new Error('reset failed'); + return { + ok: outcome === 'retry-refused', + action: name, + cancelled: outcome === 'cancelled', + }; + }); + begin(controller); + const reset = invoke(controller, { + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (outcome === 'settled-failure') { + finishReset(); + await reset; + } + const rotation = invoke(controller); + finishReset(); + await Promise.all([reset, rotation]); + complete(controller); + assert.deepEqual( + calls, + outcome === 'retry-refused' + ? ['zoom_to_globe', 'move_camera'] + : ['zoom_to_globe'], + ); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.ok, false); + assert.equal(result.reason, 'globe-reset-failed'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.doesNotMatch( + replies[0].response.instructions, + /Reset to Globe View and rotate the globe/, + ); + }); +} + +test( + 'Stop releases a rotation waiting for reset and isolates a reused response id', + { timeout: 1000 }, + async () => { + let finishOld, finishNew; + const oldReset = new Promise((resolve) => { + finishOld = resolve; + }); + const newReset = new Promise((resolve) => { + finishNew = resolve; + }); + let resets = 0; + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? ++resets === 1 + ? oldReset + : newReset + : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const old = invoke(controller, { + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + const waiter = invoke(controller); + controller.stop(); + await waiter; + assert.deepEqual(calls, ['zoom_to_globe']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); + const replacement = invoke(controller, { + callId: 'new-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller, { callId: 'new-rotation' }); + finishOld({ ok: false, action: 'zoom_to_globe' }); + await old; + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe']); + assert.equal(sent.length, 0); + finishNew({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([replacement, rotation]); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); + assert.deepEqual( + sent + .filter((event) => event.item?.type === 'function_call_output') + .map((event) => event.item.call_id), + ['new-reset', 'new-rotation'], + ); + }, +); + +test( + 'a typed turn releases rotation without waiting for its reset runner', + { timeout: 1000 }, + async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness(() => pending); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'stale-first-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + // The tool response can finish while its reset and rotation still wait. + complete(controller); + controller.sendTextCommand('pan left'); + await rotation; + assert.deepEqual(calls, ['zoom_to_globe']); + const output = sent.find( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(JSON.parse(output.item.output).superseded, true); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'typed-response'); + complete(controller, 'typed-response'); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + finishReset({ ok: false, action: 'zoom_to_globe' }); + await reset; + // This protects rotation's cancellation only. Ordinary reset-result + // follow-ups after supersession remain the inherited sibling-tool limit. + }, +); + +test('an aborted reset still blocks a late same-response rotation', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + const reset = invoke(controller, { + callId: 'aborted-reset', + name: 'zoom_to_globe', + args: {}, + }); + try { + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + assert.deepEqual(calls, ['zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.call_id === 'rotation-call', + ); + assert.equal(outputs.length, 1); + const result = JSON.parse(outputs[0].item.output); + assert.equal(result.skipped, true); + assert.equal(result.reason, 'globe-reset-failed'); + } finally { + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + } + complete(controller); + assert.equal( + sent.find((event) => event.type === 'response.create').response.tool_choice, + 'none', + ); +}); + +test('an earlier tool finishing late cannot replace the globe refusal followup', async () => { + let finishLookup; + const pending = new Promise((resolve) => { + finishLookup = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? refusal : pending, + ); + begin(controller); + const lookup = invoke(controller, { + callId: 'lookup-call', + name: 'get_current_view_state', + args: {}, + }); + await invoke(controller); + finishLookup({ ok: true, action: 'get_current_view_state' }); + await lookup; + complete(controller); + assert.deepEqual(calls, ['get_current_view_state', 'move_camera']); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); + assert.equal( + replies[0].response.instructions, + responseInstructionForToolResult(refusal), + ); +}); + +test('a duplicate queued behind successive admissions cannot outlive the dedupe window', async (t) => { + let clock = 0; + t.mock.method(performance, 'now', () => clock); + let finishFirst, finishSecond, secondStarted; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const second = new Promise((resolve) => { + finishSecond = resolve; + }); + const enteredSecond = new Promise((resolve) => { + secondStarted = resolve; + }); + let executions = 0; + const { controller, sent, calls } = harness(() => { + if (++executions === 1) return first; + secondStarted(); + return second; + }); + begin(controller); + const original = invoke(controller); + const queued = invoke(controller, { callId: 'queued-rotation' }); + const duplicate = controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'response.output_item.done', + response_id: 'rotation-response', + item: { + type: 'function_call', + id: 'queued-rotation-item', + call_id: 'queued-rotation', + name: 'move_camera', + arguments: JSON.stringify({ motion: 'rotate_globe' }), + }, + }), + }); + finishFirst({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await enteredSecond; + clock += CALL_DEDUPE_MS + 1; + finishSecond({ ok: true, action: 'move_camera', motion: 'rotate_globe' }); + await Promise.all([original, queued, duplicate]); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'queued-rotation'], + ); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); +}); + +test( + 'Stop releases an admission waiter and stale completion cannot affect its replacement', + { timeout: 1000 }, + async () => { + let finishRotation; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'move_camera' ? pending : { ok: true, action: name }, + ); + const oldChannel = controller.dc; + oldChannel.close = () => { + oldChannel.readyState = 'closed'; + }; + begin(controller); + const rotation = invoke(controller); + const queued = invoke(controller, { + callId: 'stopped-reset', + name: 'zoom_to_globe', + args: {}, + }); + controller.stop(); + await queued; // Must settle even though the original runner has not completed. + assert.equal(oldChannel.readyState, 'closed'); + assert.deepEqual(calls, ['move_camera']); + assert.equal(sent.length, 0); + controller.dc = { readyState: 'open' }; + begin(controller); // Reuse the id to expose any leaked old refusal state. + finishRotation(refusal); + await rotation; + assert.equal(sent.length, 0); + await invoke(controller, { + callId: 'replacement-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller); + assert.deepEqual(calls, ['move_camera', 'zoom_to_globe']); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['replacement-reset'], + ); + assert.equal(JSON.parse(outputs[0].item.output).ok, true); + assert.equal( + sent.find((event) => event.type === 'response.create').response + .tool_choice, + undefined, + ); + }, +); + +for (const completedFirst of [false, true]) { + test(`a newer typed turn retires its queued sibling after response completion: ${completedFirst}`, async () => { + let finishRotation; + let oldSignal; + let abortObserved = false; + const pending = new Promise((resolve) => { + finishRotation = resolve; + }); + const { controller, sent, calls } = harness((name, args, context) => { + if (args.motion === 'rotate_globe') { + oldSignal = context.signal; + oldSignal.addEventListener('abort', () => { + abortObserved = true; + }); + return pending; + } + return { ok: true, action: name }; + }); + // Use the actual cancellation owner rather than the lightweight harness stub. + delete controller.cancelRadioHandoff; + begin(controller); + const rotation = invoke(controller); + const sibling = invoke(controller, { + callId: 'stale-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (completedFirst) complete(controller); + controller.sendTextCommand('pan left'); + assert.equal(oldSignal.aborted, true); + assert.equal(abortObserved, true); + finishRotation(refusal); + await Promise.all([rotation, sibling]); + if (!completedFirst) complete(controller); + const outputs = sent.filter( + (event) => event.item?.type === 'function_call_output', + ); + assert.deepEqual( + outputs.map((event) => event.item.call_id), + ['rotation-call', 'stale-reset'], + ); + assert.equal(JSON.parse(outputs[1].item.output).superseded, true); + assert.deepEqual(calls, ['move_camera']); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); + begin(controller, 'new-turn-response'); + await invoke(controller, { + responseId: 'new-turn-response', + callId: 'new-pan', + args: { motion: 'pan', direction: 'left' }, + }); + complete(controller, 'new-turn-response'); + assert.deepEqual(calls, ['move_camera', 'move_camera']); + const finalOutput = sent + .filter((event) => event.item?.type === 'function_call_output') + .at(-1); + assert.equal(finalOutput.item.call_id, 'new-pan'); + assert.equal(JSON.parse(finalOutput.item.output).ok, true); + assert.equal( + sent.filter((event) => event.type === 'response.create').at(-1).response + ?.tool_choice, + undefined, + ); + }); +} + +test('a newer typed turn during visual context cannot revive the old refusal followup', async () => { + let finishContext; + let enterContext; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pending = new Promise((resolve) => { + finishContext = resolve; + }); + const { controller, sent } = harness(refusal); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async () => { + enterContext(); + await pending; + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + finishContext(); + await rotation; + complete(controller); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 1, + ); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response?.tool_choice, undefined); + assert.equal(controller.pendingResponseInstructions, null); +}); + +test('stale globe completion preserves a newer tool status until that tool finishes', async () => { + let finishContext; + let enterContext; + let finishPan; + const entered = new Promise((resolve) => { + enterContext = resolve; + }); + const pendingContext = new Promise((resolve) => { + finishContext = resolve; + }); + const pendingPan = new Promise((resolve) => { + finishPan = resolve; + }); + const { controller, sent } = harness((name, args) => + args.motion === 'rotate_globe' ? refusal : pendingPan, + ); + controller.setStatus = (status) => { + controller.status = status; + }; + delete controller.cancelRadioHandoff; + controller.sendVisualContextIfUseful = async (result) => { + if (result === refusal) { + enterContext(); + await pendingContext; + } + }; + begin(controller); + const rotation = invoke(controller); + await entered; + controller.sendTextCommand('pan left'); + complete(controller); + begin(controller, 'new-pan-response'); + const pan = invoke(controller, { + responseId: 'new-pan-response', + callId: 'new-pan-call', + args: { motion: 'pan', direction: 'left' }, + }); + assert.equal(controller.status, 'executing'); + finishContext(); + await rotation; + assert.equal(controller.status, 'executing'); + assert.equal(controller.pendingResponseInstructions, null); + finishPan({ ok: true, action: 'move_camera', motion: 'pan' }); + await pan; + complete(controller, 'new-pan-response'); + assert.equal(controller.status, 'listening'); + assert.equal( + sent.filter((event) => event.item?.type === 'function_call_output').length, + 2, + ); + assert.equal( + sent + .filter((event) => event.type === 'response.create') + .every((event) => event.response?.tool_choice === undefined), + true, + ); +}); + +const rotated = { + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + direction: 'right', + mode: 'continuous', +}; +/** A close-up camera that fits only after an executed reset. */ +function closeUpCamera() { + const state = { atGlobeView: false, resets: 0 }; + const result = (name) => { + if (name === 'zoom_to_globe') { + state.resets += 1; + state.atGlobeView = true; + return { ok: true, action: 'zoom_to_globe', cancelled: false }; + } + return state.atGlobeView ? rotated : refusal; + }; + return { state, result }; +} +test('OpenAI: explicit reset and reset-then-rotate stay direct', async () => { + const camera = closeUpCamera(); + const { controller, calls } = harness(camera.result); + await speak(controller); + begin(controller, 'reset'); + await invoke(controller, { + responseId: 'reset', + callId: 'explicit-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset'); + assert.equal(camera.state.resets, 1); + // A fresh "Reset Globe View and rotate" resets, then rotates in order. + camera.state.atGlobeView = false; + await speak(controller); + begin(controller, 'both'); + await invoke(controller, { + responseId: 'both', + callId: 'reset-first', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { responseId: 'both', callId: 'then-rotate' }); + complete(controller, 'both'); + assert.equal(camera.state.resets, 2); + assert.deepEqual(calls, ['zoom_to_globe', 'zoom_to_globe', 'move_camera']); +}); + +test('a failed reset survives real tool followups and a rejected typed send', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? { ok: false, action: name } : rotated, + ); + begin(controller, 'reset-response'); + await invoke(controller, { + responseId: 'reset-response', + callId: 'failed-reset', + name: 'zoom_to_globe', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + for (const id of ['followup', 'after-rejected-text']) { + if (id === 'after-rejected-text') { + const send = controller.sendRealtimeEvent; + controller.sendRealtimeEvent = (event, source) => + source === 'client.user_text' ? false : send(event, source); + controller.sendTextCommand('rotate the globe'); + controller.sendRealtimeEvent = send; + } + begin(controller, id); + await invoke(controller, { responseId: id, callId: `${id}-rotation` }); + complete(controller, id); + const output = sent.find( + (event) => event.item?.call_id === `${id}-rotation`, + ); + assert.equal(JSON.parse(output.item.output).reason, 'globe-reset-failed'); + assert.equal(JSON.parse(output.item.output).skipped, true); + } + assert.deepEqual(calls, ['zoom_to_globe']); + const reply = sent.filter((event) => event.type === 'response.create').at(-1); + assert.equal(reply.response.tool_choice, 'none'); + assert.doesNotMatch( + reply.response.instructions, + /Reset to Globe View and rotate the globe/, + ); +}); + +test('failed reset skips waiting rotation without blocking queued Stop or lookup', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name, args) => + name === 'zoom_to_globe' + ? pending + : { ok: true, action: name, motion: args.motion }, + ); + begin(controller); + const reset = invoke(controller, { + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + const rotation = invoke(controller); + const stop = invoke(controller, { + callId: 'queued-stop', + args: { motion: 'stop' }, + }); + const lookup = invoke(controller, { + callId: 'queued-lookup', + name: 'get_current_view_state', + args: {}, + }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await Promise.all([reset, rotation, stop, lookup]); + complete(controller); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'move_camera', + 'get_current_view_state', + ]); + for (const id of ['queued-stop', 'queued-lookup']) { + const output = sent.find((event) => event.item?.call_id === id); + assert.equal(JSON.parse(output.item.output).ok, true); + } + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).skipped, true); + const replies = sent.filter((event) => event.type === 'response.create'); + assert.equal(replies.length, 1); + assert.equal(replies[0].response.tool_choice, 'none'); +}); + +for (const input of ['text', 'speech']) { + test(`aborted reset stays failed across followups; fresh ${input} isolates late settlement`, async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : rotated, + ); + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset'); + const reset = invoke(controller, { + responseId: 'old-reset', + callId: 'old-reset-call', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + complete(controller, 'old-reset'); + begin(controller, 'old-followup'); + await invoke(controller, { + responseId: 'old-followup', + callId: 'blocked-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe']); + const skipped = sent.find( + (event) => event.item?.call_id === 'blocked-rotation', + ); + assert.equal(JSON.parse(skipped.item.output).reason, 'globe-reset-failed'); + if (input === 'text') controller.sendTextCommand('rotate the globe'); + else await speak(controller); + begin(controller, 'fresh'); + await invoke(controller, { responseId: 'fresh', callId: 'fresh-rotation' }); + finishReset({ ok: false, action: 'zoom_to_globe', cancelled: true }); + await reset; + await invoke(controller, { + responseId: 'fresh', + callId: 'after-old-settlement', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera', 'move_camera']); + }); +} + +test('a real tool followup waits for a reset still pending in the previous response', async () => { + let finishReset; + const pending = new Promise((resolve) => { + finishReset = resolve; + }); + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' ? pending : { ok: true, action: name }, + ); + begin(controller, 'reset-response'); + const reset = invoke(controller, { + responseId: 'reset-response', + callId: 'pending-reset', + name: 'zoom_to_globe', + args: {}, + }); + await invoke(controller, { + responseId: 'reset-response', + callId: 'fast-lookup', + name: 'get_current_view_state', + args: {}, + }); + complete(controller, 'reset-response'); + assert.equal( + sent.filter((event) => event.type === 'response.create').length, + 1, + ); + assert.equal( + JSON.parse( + sent.find((event) => event.item?.call_id === 'fast-lookup').item.output, + ).ok, + true, + ); + begin(controller, 'followup'); + const rotation = invoke(controller, { + responseId: 'followup', + callId: 'waiting-rotation', + }); + assert.deepEqual(calls, ['zoom_to_globe', 'get_current_view_state']); + finishReset({ ok: true, action: 'zoom_to_globe' }); + await Promise.all([reset, rotation]); + assert.deepEqual(calls, [ + 'zoom_to_globe', + 'get_current_view_state', + 'move_camera', + ]); + assert.equal( + sent.filter((event) => event.item?.call_id === 'waiting-rotation').length, + 1, + ); +}); + +test('a recoverable collision keeps a completed reset successful for its rotation', async () => { + const { controller, sent, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: true, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + delete controller.cancelRadioHandoff; + begin(controller); + await invoke(controller, { + callId: 'done-reset', + name: 'zoom_to_globe', + args: {}, + }); + await controller.handleRealtimeEvent({ + data: JSON.stringify({ + type: 'error', + error: { code: 'conversation_already_has_active_response' }, + }), + }); + await invoke(controller); + complete(controller); + assert.deepEqual(calls, ['zoom_to_globe', 'move_camera']); + const output = sent.find((event) => event.item?.call_id === 'rotation-call'); + assert.equal(JSON.parse(output.item.output).ok, true); +}); + +test('an old response reset cannot poison a fresh spoken rotation request', async () => { + for (const lateReset of [false, true]) { + const { controller, calls } = harness((name) => + name === 'zoom_to_globe' + ? { ok: false, action: name } + : { ok: true, action: name, motion: 'rotate_globe' }, + ); + // Use the production speech-start cancellation wiring, not the harness stub. + delete controller.cancelRadioHandoff; + begin(controller, 'old-reset-response'); + if (lateReset) await speak(controller); + await invoke(controller, { + responseId: 'old-reset-response', + callId: 'old-reset', + name: 'zoom_to_globe', + args: {}, + }); + if (!lateReset) await speak(controller); + begin(controller, 'fresh-rotation-response'); + await invoke(controller, { + responseId: 'fresh-rotation-response', + callId: 'fresh-rotation', + }); + assert.deepEqual( + calls, + ['zoom_to_globe', 'move_camera'], + lateReset + ? 'late old reset must retain its original request' + : 'fresh speech clears prior reset failure', + ); + } +}); + +test('a late rotation without a reset retains existing arrival-time policy', async () => { + const { controller, calls } = harness({ + ok: true, + action: 'move_camera', + motion: 'rotate_globe', + }); + delete controller.cancelRadioHandoff; + begin(controller, 'old-rotation-response'); + await speak(controller); + await invoke(controller, { + responseId: 'old-rotation-response', + callId: 'late-rotation', + }); + assert.deepEqual(calls, ['move_camera']); +}); diff --git a/src/voice/realtimeProtocol.js b/src/voice/realtimeProtocol.js index 14105229c..a721bb7c9 100644 --- a/src/voice/realtimeProtocol.js +++ b/src/voice/realtimeProtocol.js @@ -99,6 +99,18 @@ export function hasStructuredViewIdentity(result) { ); } +export function isGlobeViewRefusal(result) { + return ( + result?.action === 'move_camera' && + result.ok === false && + [ + 'globe-view-required', + 'globe-reset-unavailable', + 'globe-reset-failed', + ].includes(result.reason) + ); +} + export function responseInstructionForToolResult(result) { if (result?.action === 'control_radio' && result.radioPlaybackSuppressed) { if (result.audioState === 'paused') { @@ -123,6 +135,20 @@ export function responseInstructionForToolResult(result) { : 'Be concise: useful names, place, layer and one or two notable properties.', ].join(' '); } + if (isGlobeViewRefusal(result)) { + // A refusal is not a completed action. Keep tool text out of instructions. + if (result.reason === 'globe-reset-failed') + return 'The globe did not rotate after the Globe View reset attempt. Say so briefly. Do not retry, offer or perform another reset for this request.'; + if (result.reason === 'globe-reset-unavailable') + return 'The globe did not rotate. Ask the user to zoom out further; the Globe View preset does not fit this viewport. Do not offer or perform a reset.'; + return 'The globe did not rotate. Say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" The suggested command is not a user request. Do not ask a question, perform a reset, or call tools in this reply.'; + } + if ( + (result?.action ?? result?.tool) === 'zoom_to_globe' && + (result.ok !== true || result.cancelled) + ) { + return 'The Globe View reset did not finish. Say so briefly; do not claim Globe View was reached. Do not retry the reset or rotate the globe for this request.'; + } if (result?.action === 'adjust_camera_zoom') { return result.ok ? `Confirm once that the camera zoomed ${result.direction}. Do not claim any other change.` diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index 2073be06d..29c4b3bc0 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -6,6 +6,7 @@ import { SUPERSEDED_RESPONSE_MEMORY, shouldStopVoiceAfterRadioTool, responseInstructionForToolResult, + isGlobeViewRefusal, extractFunctionCalls, callDedupeKeys, parseArguments, @@ -66,6 +67,11 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds = new Set(); + this.globeRefusals = new Map(); + this.globeAdmissionGates = new Map(); + this.globeResets = new Map(); + this.globeResponseRequests = new Map(); + this.globeRequest = { failed: false, pending: new Set() }; this.activeToolAbortControllers = new Set(); this.narration = null; this.narrationSequence = 0; @@ -138,6 +144,7 @@ export class RealtimeTurns { }, }; if (!this.sendRealtimeEvent(itemEvent, 'client.user_text')) return; + this.globeRequest = { failed: false, pending: new Set() }; this.requestUserTextResponse(); } @@ -330,6 +337,7 @@ export class RealtimeTurns { this.userTurnPending = true; this.pendingResponseInstructions = null; this.cancelRadioHandoff({ abortTools: true }); + this.globeRequest = { failed: false, pending: new Set() }; this.setVoiceSpeaker('user'); // Point-and-ask: read the pointer as speech starts (open mic), or reuse // the push-to-talk keydown snapshot, before the user audio commits. @@ -384,6 +392,27 @@ export class RealtimeTurns { } const toolResponseId = payload.response_id || payload.response?.id || null; + // Follow-up responses retain reset ownership until fresh admitted input. + const arrivalRequest = this.globeRequest; + const globeRequest = + this.globeResponseRequests.get(toolResponseId) ?? arrivalRequest; + // Later siblings must wait for globe admission before they can mutate state. + // Other tool responses keep their existing concurrency. + let heldForAdmission = false; + while (toolResponseId && this.globeAdmissionGates.has(toolResponseId)) { + // Drop claimed duplicates before every wait, before their keys can expire. + if ( + calls.every((call) => + callDedupeKeys(call).some((key) => this.processedCalls.has(key)), + ) + ) + return; + heldForAdmission = true; + await this.globeAdmissionGates.get(toolResponseId).promise; + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + } + // A newer typed command superseded the response these calls belong to. // They are stale intent — dispatching one would let the old turn mutate // the map after the operator asked for something else. @@ -394,7 +423,10 @@ export class RealtimeTurns { // hazard `callDedupeKeys` is written to avoid). So each refused call gets // a terminal output saying plainly that the turn moved on. No // `response.create` follows — the deferred typed turn is the single answer. - if (this.isSupersededResponse(toolResponseId)) { + if ( + this.isSupersededResponse(toolResponseId) || + (heldForAdmission && arrivalRequest !== this.globeRequest) + ) { this.pruneProcessedCalls(); for (const call of calls) { const keys = callDedupeKeys(call); @@ -429,7 +461,19 @@ export class RealtimeTurns { if (keys.some((key) => this.processedCalls.has(key))) continue; keys.forEach((key) => this.processedCalls.set(key, performance.now())); + if (this.globeRefusals.get(toolResponseId)?.blocking) { + this.sendToolOutput(call.call_id || call.id, { + ok: false, + action: call.name, + skipped: true, + error: 'Not run: this response must wait for the Globe View reply.', + }); + continue; + } + let result; + let globeAdmissionGate = null; + let globeResetGate = null; const resultChannel = this.dc; let radioHandoffEpochAtStart = this.radio.radioHandoffEpoch; let toolController = null; @@ -443,6 +487,80 @@ export class RealtimeTurns { this.narration?.preamble(); try { const parsedArguments = parseArguments(call.arguments); + if ( + toolResponseId && + call.name === 'move_camera' && + String(parsedArguments.motion || '').toLowerCase() === 'rotate_globe' + ) { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + globeAdmissionGate = { promise, resolve }; + this.globeAdmissionGates.set(toolResponseId, globeAdmissionGate); + // OpenAI delivers sibling calls concurrently. A reset dispatched + // first must finish before rotation evaluates the camera's fit. + // Hold later siblings at admission while this rotation waits. + const resets = this.globeResets.get(toolResponseId); + if ( + resets || + globeRequest.failed || + globeRequest.pending.size || + arrivalRequest !== this.globeRequest + ) { + while (resets?.pending.size || globeRequest.pending.size) + await Promise.all( + [ + ...new Set([ + ...(resets?.pending || []), + ...globeRequest.pending, + ]), + ].map((gate) => gate.promise), + ); + if (!this.ownsConversation(eventChannel) || this.isSessionEnding()) + return; + keys.forEach((key) => + this.processedCalls.set(key, performance.now()), + ); + const superseded = + this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest; + if (superseded || resets?.failed || globeRequest.failed) { + result = { + ok: false, + action: call.name, + reason: 'globe-reset-failed', + skipped: true, + ...(superseded ? { superseded: true } : {}), + error: superseded + ? 'Superseded by a newer command from the operator — this call was not run.' + : 'Not run: the Globe View reset/rotation sequence failed or was cancelled.', + }; + } + } + } + if (toolResponseId && call.name === 'zoom_to_globe') { + let resolve; + const promise = new Promise((settle) => { + resolve = settle; + }); + let resets = this.globeResets.get(toolResponseId); + if (!resets) { + resets = { pending: new Set(), failed: false }; + this.globeResets.set(toolResponseId, resets); + // Retain settled outcomes for later siblings, without evicting an + // active reset that another response still needs to await. + for (const [id, record] of this.globeResets) { + if (this.globeResets.size <= SUPERSEDED_RESPONSE_MEMORY) break; + if (!record.pending.size && id !== toolResponseId) + this.globeResets.delete(id); + } + } + globeResetGate = { promise, resolve, resets, request: globeRequest }; + resets.pending.add(globeResetGate); + globeRequest.pending.add(globeResetGate); + } + const isRadioControlCall = call.name === 'control_radio'; const isRadioVisibilityCall = call.name === 'set_layer_visibility' && @@ -499,7 +617,7 @@ export class RealtimeTurns { authorityDomain: radioAuthorityDomain, }); } - result = await (this.actionExecutor || this.runner)( + result ??= await (this.actionExecutor || this.runner)( call.name, parsedArguments, { @@ -515,7 +633,28 @@ export class RealtimeTurns { radioHandoffEpochAtStart === this.radio.radioHandoffEpoch), }, ); + if ( + globeAdmissionGate && + this.globeResets.has(toolResponseId) && + result?.reason === 'globe-view-required' + ) + result = { ...result, reason: 'globe-reset-failed' }; if (!this.ownsConversation(eventChannel)) return; + if ( + isGlobeViewRefusal(result) && + toolResponseId && + arrivalRequest === this.globeRequest && + !this.isSupersededResponse(toolResponseId) + ) { + this.globeRefusals.set(toolResponseId, { + result, + followupQueued: false, + blocking: !result.skipped, + }); + while (this.globeRefusals.size > SUPERSEDED_RESPONSE_MEMORY) { + this.globeRefusals.delete(this.globeRefusals.keys().next().value); + } + } if (result?.ok && result.radioPlaybackRequested) { const sessionIsCurrent = this.activeToolAbortControllers.has(toolController) && @@ -595,6 +734,23 @@ export class RealtimeTurns { : {}), }; } finally { + if (globeResetGate) { + const { resets, request } = globeResetGate; + if (result?.ok !== true || result.cancelled) { + resets.failed = true; + request.failed = true; + } + resets.pending.delete(globeResetGate); + request.pending.delete(globeResetGate); + globeResetGate.resolve(); + } + if (globeAdmissionGate) { + if ( + this.globeAdmissionGates.get(toolResponseId) === globeAdmissionGate + ) + this.globeAdmissionGates.delete(toolResponseId); + globeAdmissionGate.resolve(); + } if (toolController) { this.activeToolAbortControllers.delete(toolController); this.radio.releaseTool(toolController); @@ -671,13 +827,38 @@ export class RealtimeTurns { }); } if (!this.ownsConversation(eventChannel)) return; - // Keep the Radio handoff wording authoritative even when another tool - // result follows Radio in the same multi-intent response. - this.queueResponseCreate( - responseInstructionForToolResult( - this.radio.pendingRadioPlaybackResult || lastResult, - ), - ); + const refusal = this.globeRefusals.get(toolResponseId); + // A newer turn may arrive during admission or the viewport-image await. + // Preserve its reply; neither the refusal map nor lastResult may revive + // an old refusal follow-up after supersession. + if ( + (this.isSupersededResponse(toolResponseId) || + arrivalRequest !== this.globeRequest) && + (refusal || isGlobeViewRefusal(lastResult) || lastResult?.superseded) + ) { + // Complete an idle turn below, but leave a newer running tool's status + // authoritative while suppressing this obsolete refusal follow-up. + if (this.activeToolAbortControllers.size) return; + } else if (refusal) { + // Even an earlier sibling finishing late cannot replace the reset instruction. + if (!refusal.followupQueued) { + refusal.followupQueued = true; + this.queueResponseCreate({ + instructions: responseInstructionForToolResult(refusal.result), + tool_choice: 'none', + }); + } + } else { + // Keep Radio handoff wording authoritative for ordinary multi-tool replies. + const followupResult = + this.radio.pendingRadioPlaybackResult || lastResult; + const instructions = responseInstructionForToolResult(followupResult); + this.queueResponseCreate( + isGlobeViewRefusal(followupResult) + ? { instructions, tool_choice: 'none' } + : instructions, + ); + } } this.setStatus('listening', t('Ask or command')); } @@ -966,6 +1147,22 @@ export class RealtimeTurns { this.userTurnPending = false; this.activeResponseId = payload.response?.id || payload.response_id || null; + // Late calls retain only their globe reset/request ownership. Ordinary + // tool admission and concurrency continue to use their existing policy. + if ( + this.activeResponseId && + !this.globeResponseRequests.has(this.activeResponseId) + ) { + this.globeResponseRequests.set( + this.activeResponseId, + this.globeRequest, + ); + while (this.globeResponseRequests.size > SUPERSEDED_RESPONSE_MEMORY) + this.globeResponseRequests.delete( + this.globeResponseRequests.keys().next().value, + ); + } + this.setVoiceSpeaker('ai'); return; } @@ -1045,7 +1242,8 @@ export class RealtimeTurns { const sent = this.sendRealtimeEvent( { type: 'response.create', - response: { instructions }, + response: + typeof instructions === 'string' ? { instructions } : instructions, }, 'client.response_create.tool_followup', ); @@ -1057,6 +1255,20 @@ export class RealtimeTurns { controller.abort(); this.activeToolAbortControllers.clear(); this.radio.clearTools(); + this.releaseGlobeResets(); + } + + releaseGlobeResets() { + for (const resets of this.globeResets.values()) { + if (!resets.pending.size) continue; + resets.failed = true; + for (const gate of resets.pending) { + gate.request.failed = true; + gate.request.pending.delete(gate); + gate.resolve(); + } + resets.pending.clear(); + } } reset() { @@ -1068,6 +1280,13 @@ export class RealtimeTurns { this.pendingUserTextResponse = false; this.activeResponseId = null; this.supersededResponseIds.clear(); + this.globeRefusals.clear(); + for (const gate of this.globeAdmissionGates.values()) gate.resolve(); + this.globeAdmissionGates.clear(); + this.releaseGlobeResets(); + this.globeResets.clear(); + this.globeResponseRequests.clear(); + this.globeRequest = { failed: false, pending: new Set() }; this.narration?.cancel(); this.assistantAudioPlaying = false; this.speechResponseIds.clear(); From 8d454bf7f189bbd1264569a6b024f907a89c6017 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 15:05:23 -0700 Subject: [PATCH 038/128] fix: improve mobile contact selection and context drawers (#1338) Improve coarse-pointer contact selection, reveal tracked aircraft through Contacts, support downward drawer dismissal, and enlarge the mobile logo. --- changelog.d/1338-mobile-contact-context.md | 4 + docs/CURRENT-STATE.md | 17 +- scripts/package-boundaries.json | 1 + src/data/aisLiveVessels.test.mjs | 28 ++ src/data/inputOwnership.test.mjs | 5 +- src/data/pickRegistry.js | 35 ++ src/data/pickRegistry.test.mjs | 120 +++++++ src/layers/flights/tracking.js | 6 +- src/layers/military/tracking.js | 6 +- src/layers/vessels/selection.js | 6 +- src/ui/applicationShell.js | 29 ++ src/ui/mobileShell.js | 120 +++++++ src/ui/mobileShell.test.mjs | 388 ++++++++++++++++++++- src/ui/styles/mobile-shell.css | 6 +- 14 files changed, 759 insertions(+), 12 deletions(-) create mode 100644 changelog.d/1338-mobile-contact-context.md diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md new file mode 100644 index 000000000..e240dc82a --- /dev/null +++ b/changelog.d/1338-mobile-contact-context.md @@ -0,0 +1,4 @@ +- Improve touch selection of aircraft and vessels through the shared contact + picker, reveal tracked aircraft details when opening Context, support downward + swipe dismissal of phone drawers while preserving scrolling and controls, and + enlarge the phone logo (samehkhamis, #1338). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 994f9a623..b0b696b96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1399,11 +1399,26 @@ shows a bottom tab bar: Layers (Data Layers, with Scenes collapsed below), Cameras (CCTV and Street Level), Context (Global Context, plus Weather and Recent Imagery when shown), Search (Location) and More (Visual Presets and Map Source above Display). A tab opens one bottom sheet; the same tab, the backdrop -or Escape closes it, and focus moves into the sheet and back to its tab. Panels +or Escape closes it, and focus moves into the sheet and back to its tab. A +downward swipe of at least 64px on non-control sheet content also closes it, +when every scroll container under the finger starts at the top. Ordinary +vertical scrolling, horizontal gestures, controls and multitouch stay native; +a cancelled gesture or a sheet/viewport change cannot dismiss another sheet. Panels are not moved in the DOM: `styles/mobile-shell.css` presents the rails and dock as the sheet, and the dock's Location and Presets trays render inline. The voice control floats above the tab bar while no sheet is open. +The phone logo is 58px wide (about 39px high), aligned with the top controls. +The shared contact picker preserves exact picks. For a coarse primary pointer +that misses onto the map surface, it searches at most eight hits in a 14px square +and accepts the first registered layer hit, stopping at non-map objects. Civil +and military aircraft and vessels share this rule; fine pointers stay exact. + +Manually opening Context with a tracked civilian or military aircraft enters +the existing Contacts mode when Context is off. Contacts adopts that aircraft +without restarting its tracking; an active or entering Space Missions mode is +left alone. Programmatic sheet changes and closing the tab do not activate a mode. + Sheets borrow panels. Opening one expands its panels without persisting them or claiming a user choice, and share links keep reporting each borrowed panel's state from before the sheet; closing puts back what opening changed, while a diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..3970a1ee2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3672,6 +3672,7 @@ "src/data/labelArbiter.js", "src/data/meshFloorSampler.js", "src/data/pickRegistry.js", + "src/data/scenePick.js", "src/data/spriteOrder.js", "src/data/terrainHeights.js", "src/data/transit.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index b5a2c8de9..96a5cd105 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1011,6 +1011,34 @@ test('vessel interaction wire: flights-owned pick preserves vessel selection', ( } }); +test('vessel touch interaction yields to the first nearby flight behind map tiles', () => { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }); + registerPickOwner('flights', (pickedId) => pickedId === 'a1b2c3'); + const tile = { primitive: { isCesium3DTileset: true } }; + const harness = installWireHarness(tile); + let drills = 0; + harness.viewer.scene.drillPick = (_position, limit, width, height) => { + drills++; + assert.deepEqual([limit, width, height], [8, 14, 14]); + return [tile, { id: 'a1b2c3' }]; + }; + try { + harness.handler.click({ position: { x: 10, y: 20 } }); + assert.equal(drills, 1); + assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); + assert.equal(harness.trail.clearCalls, 0); + } finally { + harness.cleanup(); + unregisterPickOwner('flights'); + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + } +}); + test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () => { registerPickOwner('cctv', (pickedId) => pickedId === 'atx-cam-3'); const harness = installWireHarness({ id: 'atx-cam-3' }); diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 9b20f3ad4..718ef7068 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -134,7 +134,10 @@ test('every scene click handler consults ownership before it picks', () => { 'src/layers/submarineCables/interaction.js', 'viewer.scene.pick(click.position)', ], - ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'], + [ + 'src/layers/vessels/selection.js', + 'pickContact(viewer.scene, click.position)', + ], ]; for (const [file, firstPick] of guarded) { const source = read(file); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index e54ef6b29..ef3d8074e 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -11,6 +11,8 @@ * that belong to someone else and leave them alone. */ +import { isSurfacePick } from './scenePick.js'; + /** @type {Map boolean>} layerId -> ownership predicate */ const _owners = new Map(); @@ -100,3 +102,36 @@ export function isOwnedByOtherLayer(layerId, pickedId) { } return false; } + +/** + * Resolve a moving-contact click without letting map tiles hide a nearby touch + * target. Exact picks always win; only a coarse primary pointer may expand a + * surface miss. Every consumer uses the same ordered, bounded scene hits, so sibling layers + * do not each choose their own contact under one tap. + * Unowned non-surface objects remain barriers, not permission to pick through. + * + * @param {object} scene Cesium scene with pick and optional drillPick. + * @param {{x: number, y: number}} position Screen position in CSS pixels. + * @returns {object|null|undefined} Original pick or the first registered touch hit. + */ +export function pickContact(scene, position) { + const picked = scene.pick(position); + if ( + !globalThis.matchMedia?.('(pointer: coarse)').matches || + findPickOwner(resolvePickId(picked)) || + !isSurfacePick(picked) || + typeof scene.drillPick !== 'function' + ) + return picked; + let hits; + try { + hits = scene.drillPick(position, 8, 14, 14) || []; + } catch { + return picked; + } + for (const hit of hits.slice(0, 8)) { + if (findPickOwner(resolvePickId(hit))) return hit; + if (!isSurfacePick(hit)) break; + } + return picked; +} diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index f522ec072..43f11260f 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -16,6 +16,7 @@ import { unregisterPickOwner, isOwnedByOtherLayer, resolvePickId, + pickContact, } from './pickRegistry.js'; // --------------------------------------------------------------------------- @@ -97,3 +98,122 @@ test('ownership: unregister removes the predicate', () => { unregisterPickOwner('bikeshare'); assert.equal(isOwnedByOtherLayer('flights', 'station:1'), false); }); + +function primaryPointer(t, coarse) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'matchMedia'); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: (query) => ({ matches: query === '(pointer: coarse)' && coarse }), + }); + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'matchMedia', previous); + else delete globalThis.matchMedia; + }); +} + +function contactOwners(t) { + registerPickOwner('flights', (id) => id === 'civil'); + registerPickOwner('military', (id) => id === 'military'); + registerPickOwner('ais-live-vessels', (id) => id === '123456789'); + t.after(() => { + for (const id of ['flights', 'military', 'ais-live-vessels']) + unregisterPickOwner(id); + }); +} + +const mapTile = { primitive: { isCesium3DTileset: true } }; + +test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CSS pixels', (t) => { + primaryPointer(t, true); + contactOwners(t); + const position = { x: 30, y: 50 }; + const contact = { primitive: { id: 'civil' } }; + let drills = 0; + const scene = { + pick(point) { + assert.equal(point, position); + return mapTile; + }, + drillPick(...args) { + drills++; + assert.deepEqual(args, [position, 8, 14, 14]); + return [mapTile, contact]; + }, + }; + assert.equal(pickContact(scene, position), contact); + assert.equal(drills, 1); +}); + +test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + const scene = { + pick: () => mapTile, + drillPick: () => [mapTile, first, { id: 'civil' }], + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), first); + } +}); + +test('touch contact pick does not drill through exact registered or unregistered interactive picks', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const exact of [ + { id: 'military' }, + { id: 'custom-annotation' }, + { primitive: { id: 'custom-gizmo' } }, + { primitive: { id: 'civil', isCesium3DTileset: true } }, + ]) { + const scene = { + pick: () => exact, + drillPick: () => assert.fail('must keep exact interactive hit'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), exact); + } +}); + +test('touch expansion stops at a non-surface object and respects the drill bound', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const hits of [ + [mapTile, { id: 'custom-annotation' }, { id: 'civil' }], + [...Array(8).fill(mapTile), { id: 'civil' }], + [mapTile], + ]) { + assert.equal( + pickContact( + { pick: () => mapTile, drillPick: () => hits }, + { x: 1, y: 1 }, + ), + mapTile, + ); + } +}); + +test('fine primary pointers keep the existing exact mouse pick without a drill', (t) => { + primaryPointer(t, false); + contactOwners(t); + const scene = { + pick: () => mapTile, + drillPick: () => assert.fail('mouse must not expand'), + }; + assert.equal(pickContact(scene, { x: 1, y: 1 }), mapTile); +}); + +test('touch picking tolerates unavailable or failing drill support and preserves empty sky', (t) => { + primaryPointer(t, true); + contactOwners(t); + for (const drillPick of [ + undefined, + () => { + throw new Error('context lost'); + }, + () => [], + ]) { + assert.equal( + pickContact({ pick: () => undefined, drillPick }, { x: 1, y: 1 }), + undefined, + ); + } +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index cef9e1563..dde8f4f95 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { isMilitaryLayerActive, isMilitaryIcao } = services.militaryRegistry; const { trackedLabelModelFromText, refreshTrackedReadout } = services.readout; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const flightsLayer = layer; function _emitAwarenessEvent(type, detail) { @@ -1174,7 +1174,9 @@ export function createTracking({ // first-person reference. A globe click must not fall through to the // normal empty-space deselection path; cockpit has explicit exit controls. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself — ignore (don't deselect) diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 3530a4877..8c9723176 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -47,7 +47,7 @@ export function createTracking({ const { ensureGeoidReady, geoidHeight } = services.geoid; const { clearFocusTarget } = services.focus; const { applyTrackedCameraFrame } = services.camera; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; function _emitAwarenessEvent(type, detail) { if ( @@ -1079,7 +1079,9 @@ export function createTracking({ // first-person reference. Empty globe clicks are inert until the user // exits with C, Escape, or the dedicated button. if (document.body.classList.contains('cockpit-mode')) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); if (picked) { // Clicking the tracked entity itself -- ignore (don't deselect) diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 88e5aa2ec..96c5df6eb 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -10,7 +10,7 @@ export function createSelection({ options, }) { const { state } = vesselState; - const { resolvePickId, isOwnedByOtherLayer } = services.picking; + const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking; const { requestWorldFocus } = services.worldFocus; const { selectEntityContext, @@ -37,7 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; - const picked = viewer.scene.pick(click.position); + const picked = pickContact + ? pickContact(viewer.scene, click.position) + : viewer.scene.pick(click.position); const pickedId = resolvePickId(picked); let record = pickedId ? state.records.byMmsi.get(pickedId) : null; const rawId = picked?.id ?? picked?.primitive?.id; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 7c866bc36..4552da8d4 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -864,9 +864,38 @@ export class StyleManager extends ShellFacade { setPanelCollapsed: (panelId, collapsed, options) => this._panelChrome.setPanelCollapsed(panelId, collapsed, options), onSheetsClosed: () => this._syncShareState(), + onTabOpened: (sheet) => { + void this._onMobileTabOpened(sheet); + }, }); } + /** Reveal a tracked aircraft through Contacts when its phone tab is opened. */ + async _onMobileTabOpened(sheet) { + const context = this.getContextModeState(); + if ( + sheet !== 'context' || + this._disposed || + !context || + context.mode || + context.changing || + context.entering || + !this.getAircraftTrackingTarget() + ) + return false; + try { + const result = await this.setContextMode('contacts'); + if (result?.ok) return true; + if (result?.cancelled || this._disposed) return false; + } catch { + if (this._disposed) return false; + } + this._showToast( + t('Contacts could not complete the requested transition; try again'), + ); + return false; + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js index a2b8638b2..2363fd9ed 100644 --- a/src/ui/mobileShell.js +++ b/src/ui/mobileShell.js @@ -125,6 +125,8 @@ function buildChrome(documentRef) { * @param {(callback: () => void) => number} [input.requestFrame] * @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes * and its panels are put back (e.g. to refresh the share link). + * @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user + * opens a sheet from its tab, never on close or programmatic presentation. * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} */ export function initMobileShell({ @@ -135,6 +137,7 @@ export function initMobileShell({ ResizeObserverImpl = globalThis.ResizeObserver, requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), onSheetsClosed = () => {}, + onTabOpened = () => {}, } = {}) { const body = documentRef.body; const { backdrop, tabBar } = buildChrome(documentRef); @@ -159,6 +162,7 @@ export function initMobileShell({ */ let borrowed = null; let returnFocus = null; + let swipe = null; const written = new Map(); const writeVar = (name, value) => { @@ -259,6 +263,7 @@ export function initMobileShell({ }; const setSheet = (requested) => { + swipe = null; if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; const sheet = sheetBlocked(body) ? null : requested; const previous = currentSheetOf(body); @@ -348,8 +353,123 @@ export function initMobileShell({ // Closing from the tab bar returns focus to the tab that was pressed. if (!sheet) returnFocus = button; setSheet(sheet); + if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet); }); listen(backdrop, 'click', () => setSheet(null)); + // Touch events let native scrolling continue until a downward pull at the + // top is intentional. Pointer capture/touch-action:none would also seize + // ordinary vertical scrolling inside the borrowed panels. + const sheetRoot = (target, sheet) => { + const roots = + sheet === 'layers' + ? [element('left-panel-stack')] + : sheet === 'search' + ? [dock] + : sheet === 'more' + ? [element('right-context-rail'), dock] + : [element('right-context-rail')]; + return roots.find((root) => root?.contains(target)); + }; + const scrolled = (target, root) => { + for (let node = target; node; node = node.parentElement) { + if (node.scrollTop > 0) return true; + if (node === root) break; + } + return false; + }; + listen( + documentRef, + 'touchstart', + (event) => { + swipe = null; + const sheet = currentSheetOf(body); + const target = event.target; + if ( + !sheet || + !body.classList.contains(MOBILE_SHELL_CLASS) || + event.defaultPrevented || + event.touches.length !== 1 || + target?.closest?.( + 'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]', + ) + ) + return; + const root = sheetRoot(target, sheet); + if (!root || scrolled(target, root)) return; + const touch = event.touches[0]; + swipe = { + sheet, + target, + root, + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + claimed: false, + }; + }, + { passive: true }, + ); + listen( + documentRef, + 'touchmove', + (event) => { + if (!swipe) return; + const touch = event.touches[0]; + if ( + event.defaultPrevented || + event.touches.length !== 1 || + touch.identifier !== swipe.id || + currentSheetOf(body) !== swipe.sheet || + scrolled(swipe.target, swipe.root) + ) { + swipe = null; + return; + } + const dx = touch.clientX - swipe.x; + const dy = touch.clientY - swipe.y; + if (!swipe.claimed) { + // Claim direction before the browser crosses its native scroll slop. + // Android can make the next move non-cancelable after a ~9px first move. + if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return; + if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) { + swipe = null; + return; + } + swipe.claimed = true; + } + // Cancelling only the claimed pull leaves taps, sliders and normal scroll + // native, and suppresses the synthetic click at the end of a dismissal. + event.preventDefault(); + }, + { passive: false }, + ); + listen( + documentRef, + 'touchend', + (event) => { + const pull = swipe; + swipe = null; + if (!pull?.claimed || event.touches.length) return; + const touch = [...event.changedTouches].find( + (item) => item.identifier === pull.id, + ); + if (!touch || currentSheetOf(body) !== pull.sheet) return; + const dx = Math.abs(touch.clientX - pull.x); + const dy = touch.clientY - pull.y; + if (dy < 64 || dy <= dx * 1.25) return; + returnFocus = tabFor(pull.sheet) ?? returnFocus; + setSheet(null); + }, + { passive: true }, + ); + listen( + documentRef, + 'touchcancel', + () => { + swipe = null; + }, + { passive: true }, + ); // Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools // take Escape first; panels yield it to the sheet (PanelChrome), and an open // dialog keeps its own native close. diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs index 751333c9a..bb7599547 100644 --- a/src/ui/mobileShell.test.mjs +++ b/src/ui/mobileShell.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; +import { ContextControls } from './contextControls.js'; import { MOBILE_SHEET_PANELS, MOBILE_TABS, @@ -21,6 +23,8 @@ function element(tag, document) { textContent: '', className: '', parent: null, + scrollTop: 0, + parentElement: null, classList: { add: (...names) => names.forEach((name) => classes.add(name)), remove: (...names) => names.forEach((name) => classes.delete(name)), @@ -41,6 +45,7 @@ function element(tag, document) { append(...children) { for (const child of children) { child.parent = node; + child.parentElement = node; node.children.push(child); if (child.id) document.ids.set(child.id, child); } @@ -56,9 +61,20 @@ function element(tag, document) { return false; }, closest(selector) { - for (let current = node; current; current = current.parent) + for (let current = node; current; current = current.parent) { + if ( + selector.startsWith('button, a') && + (['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes( + current.tagName, + ) || + ['slider', 'button'].includes(current.getAttribute('role')) || + (current.getAttribute('contenteditable') !== null && + current.getAttribute('contenteditable') !== 'false')) + ) + return current; if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) return current; + } return null; }, querySelectorAll(selector) { @@ -94,7 +110,11 @@ function element(tag, document) { const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; -function fixture({ mobile = true, expanded = [] } = {}) { +function fixture({ + mobile = true, + expanded = [], + onTabOpened = () => {}, +} = {}) { const document = new EventTarget(); document.ids = new Map(); document.createElement = (tag) => element(tag, document); @@ -112,6 +132,12 @@ function fixture({ mobile = true, expanded = [] } = {}) { const search = element('input', document); search.id = 'location-search'; document.body.append(search); + const rails = {}; + for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) { + rails[id] = element('div', document); + rails[id].id = id; + document.body.append(rails[id]); + } for (const id of PANEL_IDS) { const panel = element('section', document); panel.id = id; @@ -123,7 +149,13 @@ function fixture({ mobile = true, expanded = [] } = {}) { const collapse = element('button', document); collapse.className = 'panel-collapse-btn'; panel.append(toggle, collapse, element('button', document)); - document.body.append(panel); + const root = + id === 'data-panel' + ? 'left-panel-stack' + : ['location-bar', 'control-panel'].includes(id) + ? 'command-dock' + : 'right-context-rail'; + rails[root].append(panel); } const media = new EventTarget(); @@ -143,6 +175,7 @@ function fixture({ mobile = true, expanded = [] } = {}) { } const shell = initMobileShell({ documentRef: document, + onTabOpened, matchMedia: () => media, MutationObserverImpl: FakeMutationObserver, ResizeObserverImpl: null, @@ -481,3 +514,352 @@ test('the phone shell keeps the required attribution visible', () => { assert.ok(z(sheetCredit) > z(openDock)); assert.ok(z(sheetCredit) > z(railSheet)); }); + +function touchEvent( + document, + type, + target, + { x = 10, y = 10, count = 1, id = 1, cancelable = true } = {}, +) { + const event = new Event(type, { cancelable }); + const touch = { identifier: id, clientX: x, clientY: y }; + Object.defineProperty(event, 'target', { value: target }); + event.touches = + type === 'touchend' || type === 'touchcancel' + ? [] + : Array.from({ length: count }, (_, i) => ({ + ...touch, + identifier: id + i, + })); + event.changedTouches = [touch]; + document.dispatchEvent(event); + return event; +} + +test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => { + for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) { + const phone = fixture(); + const { document, shell, press, tab } = phone; + press(sheet); + const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]); + touchEvent(document, 'touchstart', target); + const move = touchEvent(document, 'touchmove', target, { y: 90 }); + assert.equal(move.defaultPrevented, true); + assert.equal(shell.getSheet(), sheet, 'waits until finger lifts'); + touchEvent(document, 'touchend', target, { y: 90 }); + assert.equal(shell.getSheet(), null); + assert.equal(phone.closedCount(), 1); + assert.equal(document.activeElement, tab(sheet)); + for (const id of MOBILE_SHEET_PANELS[sheet]) + assert.equal(phone.collapsed(id), true); + assert.ok( + phone.calls.every( + (call) => call.persist === false && call.syncShare === false, + ), + ); + shell.destroy(); + } +}); + +test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => { + for (const nested of [false, true]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + const scroller = nested + ? panel + : document.getElementById('left-panel-stack'); + scroller.scrollTop = 40; + touchEvent(document, 'touchstart', panel); + scroller.scrollTop = 0; + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('taps, short pulls, upward and horizontal gestures never dismiss', () => { + for (const path of [ + [], + [{ x: 12, y: 14 }], + [{ y: 40 }], + [{ y: -30 }, { y: 120 }], + [{ x: 60, y: 12 }, { y: 120 }], + ]) { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + for (const point of path) touchEvent(document, 'touchmove', panel, point); + touchEvent(document, 'touchend', panel, path.at(-1) ?? {}); + assert.equal(shell.getSheet(), 'layers'); + shell.destroy(); + } +}); + +test('native controls and custom sliders keep touch gestures', () => { + for (const tag of [ + 'button', + 'a', + 'input', + 'select', + 'textarea', + 'label', + 'slider', + 'editable', + ]) { + const { document, shell, press } = fixture(); + press('more'); + const control = document.createElement( + ['slider', 'editable'].includes(tag) ? 'div' : tag, + ); + if (tag === 'slider') control.setAttribute('role', 'slider'); + if (tag === 'editable') control.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + control.append(child); + document.getElementById('control-panel').append(control); + touchEvent(document, 'touchstart', child); + assert.equal( + touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', child, { y: 120 }); + assert.equal(shell.getSheet(), 'more'); + shell.destroy(); + } +}); + +test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => { + for (const cancel of [ + 'multitouch', + 'cancel', + 'sheet', + 'desktop', + 'noncancelable', + ]) { + const { document, shell, press, resize } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + if (cancel === 'multitouch') + touchEvent(document, 'touchstart', document.body, { count: 2 }); + if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel); + if (cancel === 'sheet') press('context'); + if (cancel === 'desktop') { + resize(false); + resize(true); + press('layers'); + } + if (cancel === 'noncancelable') + touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers'); + shell.destroy(); + } +}); + +test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => { + const { document, shell, press } = fixture(); + press('layers'); + const hiddenPanel = document.getElementById('global-context-panel'); + touchEvent(document, 'touchstart', hiddenPanel); + assert.equal( + touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', hiddenPanel, { y: 120 }); + assert.equal(shell.getSheet(), 'layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel); + shell.destroy(); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented, + false, + ); + touchEvent(document, 'touchend', panel, { y: 120 }); + assert.equal(shell.getSheet(), null); +}); + +test('the tab-open callback reports only a successful manual opening', () => { + const opened = []; + const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) }); + phone.shell.setSheet('context'); + assert.deepEqual(opened, []); + phone.press('context'); + assert.deepEqual(opened, [], 'closing is not a new context request'); + phone.press('context'); + assert.deepEqual(opened, ['context']); + phone.setBodyClass('cockpit-mode'); + phone.press('context'); + assert.deepEqual( + opened, + ['context'], + 'blocked sheets cannot activate a mode', + ); + phone.shell.destroy(); +}); + +test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => { + const calls = []; + const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' }; + const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } }; + const entity = viewer.trackedEntity; + const enabled = new Set(['flights', 'weather']); + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => null, + claimVisualAuthority: () => calls.push('claim'), + syncDetection() {}, + scheduleLayout() {}, + showToast() {}, + }, + }); + controls.connect({ + layers: new Map( + ['flights', 'weather', 'military-awareness'].map((id) => [id, {}]), + ), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => ({}), + isEffectivelyEnabled: (id) => enabled.has(id), + isEnabled: (id) => enabled.has(id), + setLayerParams: (id, params) => calls.push({ id, params }), + setEnabled: async (id, value) => { + calls.push({ id, value }); + if (!value) enabled.delete(id); + if (id === 'flights' && !value) viewer.trackedEntity = null; + return true; + }, + _setEnabledWithIntent: (id, value) => { + calls.push({ id, value }); + enabled.add(id); + return { intentEpoch: 1, promise: Promise.resolve(true) }; + }, + }); + const shell = { + cockpitView: { + readAircraftInfo: () => (viewer.trackedEntity ? tracked : null), + }, + getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget, + getContextModeState: () => controls.getContextModeState(), + setContextMode: (mode) => controls.setContextMode(mode), + _showToast: () => assert.fail('successful entry must not report a failure'), + }; + try { + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + true, + ); + assert.equal(controls.getContextModeState().mode, 'flights'); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(shell.getAircraftTrackingTarget(), { + layerId: 'flights', + id: 'abc123', + }); + assert.ok(enabled.has('flights')); + assert.ok( + calls.some( + (call) => call.id === 'military-awareness' && call.value === true, + ), + ); + assert.ok(!calls.some((call) => call.id === 'flights')); + const count = calls.length; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + calls.length, + count, + 'an already active Contacts session is not restarted', + ); + } finally { + controls.stop(); + controls.disconnect(); + } +}); + +test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => { + for (const state of [ + { mode: 'space-missions' }, + { mode: 'flights' }, + { changing: true }, + { entering: 'space-missions' }, + { noTrack: true }, + { disposed: true }, + { sheet: 'layers' }, + ]) { + const shell = { + _disposed: state.disposed, + getContextModeState: () => state, + getAircraftTrackingTarget: () => + state.noTrack ? null : { layerId: 'flights', id: 'abc123' }, + setContextMode: () => + assert.fail('guarded tab must not mutate Context or tracking'), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call( + shell, + state.sheet || 'context', + ), + false, + ); + } +}); + +test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => { + for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) { + const notices = []; + const shell = { + getContextModeState: () => ({ mode: null }), + getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }), + setContextMode: async () => { + if (outcome === 'throw') throw new Error('source unavailable'); + if (outcome === 'disposed') shell._disposed = true; + return { ok: false, cancelled: outcome === 'cancelled' }; + }, + _showToast: (message) => notices.push(message), + }; + assert.equal( + await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'), + false, + ); + assert.equal( + notices.length, + ['failure', 'throw'].includes(outcome) ? 1 : 0, + ); + } +}); + +test('a slow downward pull is claimed before Android native scroll takes ownership', () => { + const { document, shell, press } = fixture(); + press('layers'); + const panel = document.getElementById('data-panel'); + touchEvent(document, 'touchstart', panel, { y: 100 }); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented, + false, + 'tap jitter is left native', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented, + true, + 'cancel the first directional move while Android still allows it', + ); + assert.equal( + touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented, + true, + ); + touchEvent(document, 'touchend', panel, { y: 175 }); + assert.equal(shell.getSheet(), null); + shell.destroy(); +}); diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css index 4cf32513a..aecfdf8d4 100644 --- a/src/ui/styles/mobile-shell.css +++ b/src/ui/styles/mobile-shell.css @@ -86,10 +86,14 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { /* ── Top bar: logo left, globe actions right ───────────────────────────── */ body.mobile-shell #title-bar { - top: calc(var(--mobile-top-inset) + 12px); + top: calc(var(--mobile-top-inset) + 10px); left: 14px; } +body.mobile-shell #title-bar .title-logo { + width: 58px; +} + body.mobile-shell #title-bar h1 > :not(.title-logo), body.mobile-shell #title-bar .subtitle { display: none; From 9e2879c0ba5021b1127586ad3b4ab1dde6d730cc Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:52 -0400 Subject: [PATCH 039/128] fix(kartaview): reject impossible photo timestamps Co-authored-by: Cursor --- .../streetLevel/providers/kartaview/policy.js | 29 +++++++++++++++++-- .../providers/kartaview/policy.test.mjs | 4 +++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index b4533c1b5..76a0ea208 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -222,9 +222,32 @@ export function kartaViewTimeMs(value) { String(value ?? ''), ); if (!match) return null; - const [, y, mo, d, h = '0', mi = '0', s = '0'] = match; - if (Number(y) < 1990) return null; - const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4] ?? 0); + const minutes = Number(match[5] ?? 0); + const seconds = Number(match[6] ?? 0); + if (year < 1990) return null; + if ( + month < 1 || + month > 12 || + day < 1 || + hours > 23 || + minutes > 59 || + seconds > 59 + ) + return null; + // Date.UTC rolls 31 February into March. A photo would then pass or fail + // the day filter on the wrong calendar day, so an impossible date is unknown. + const ms = Date.UTC(year, month - 1, day, hours, minutes, seconds); + const parsed = new Date(ms); + if ( + parsed.getUTCFullYear() !== year || + parsed.getUTCMonth() !== month - 1 || + parsed.getUTCDate() !== day + ) + return null; return Number.isFinite(ms) ? ms : null; } diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 82a68555e..540071f0b 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -158,6 +158,10 @@ test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => kartaViewTimeMs('2022-03-21T06:42:59'), Date.UTC(2022, 2, 21, 6, 42, 59), ); + assert.equal(kartaViewTimeMs('2022-02-31 12:00:00'), null); + assert.equal(kartaViewTimeMs('2022-13-01 00:00:00'), null); + assert.equal(kartaViewTimeMs('2022-03-21 25:00:00'), null); + assert.equal(kartaViewTimeMs('2022-04-31'), null); assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null); assert.equal(kartaViewTimeMs(''), null); assert.equal(kartaViewTimeMs(null), null); From 7d2294580b23c2d5a413bb76296d5a01a5dd8831 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:08:55 -0400 Subject: [PATCH 040/128] fix(firms): keep impossible acquisition dates off the fire clock Co-authored-by: Cursor --- src/data/firmsAdapt.js | 43 +++++++++++++++++++++++++++--------- src/data/firmsAdapt.test.mjs | 40 +++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/data/firmsAdapt.js b/src/data/firmsAdapt.js index 312838e80..18d9fc110 100644 --- a/src/data/firmsAdapt.js +++ b/src/data/firmsAdapt.js @@ -81,21 +81,44 @@ export function parseAcquisitionMs(date, time, cache = new Map()) { const cached = cache.get(key); if (cached !== undefined) return cached; + // Same calendar as the CSV gate. Date.UTC would otherwise roll 30 February + // into March, and the fire's age would describe a detection that never happened. + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + cache.set(key, 0); + return 0; + } + const timeText = String(time ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) { + cache.set(key, 0); + return 0; + } + const hhmm = timeText.padStart(4, '0'); const year = Number(date.slice(0, 4)); const month = Number(date.slice(5, 7)); const day = Number(date.slice(8, 10)); - const hhmm = String(time ?? '0000').padStart(4, '0'); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); - const valid = - Number.isFinite(year) && - Number.isFinite(month) && - Number.isFinite(day) && - Number.isFinite(hours) && - Number.isFinite(minutes); - const ms = valid ? Date.UTC(year, month - 1, day, hours, minutes) : 0; - cache.set(key, ms); - return ms; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hours > 23 || + minutes > 59 + ) { + cache.set(key, 0); + return 0; + } + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + const result = + parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : 0; + cache.set(key, result); + return result; } /** diff --git a/src/data/firmsAdapt.test.mjs b/src/data/firmsAdapt.test.mjs index 0e1e56dd6..fa5de9e69 100644 --- a/src/data/firmsAdapt.test.mjs +++ b/src/data/firmsAdapt.test.mjs @@ -63,12 +63,39 @@ test('unpadded acq_time parses in the adapter path ("45" = 00:45Z)', () => { test('parseAcquisitionMs: memo cache is honored, invalid input → 0', () => { const cache = new Map(); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(cache.size, 1); - assert.equal(parseAcquisitionMs('2026-07-16', '1006', cache), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + parseAcquisitionMs('2026-07-16', '1006', cache), + Date.UTC(2026, 6, 16, 10, 6), + ); assert.equal(parseAcquisitionMs(undefined, '1006', cache), 0); }); +test('impossible acquisition dates stay untimed instead of rolling forward', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ]) { + assert.equal(parseAcquisitionMs(date, time), 0, `${date}:${time}`); + const [fire] = adaptFirmsRecords([ + proxyRecord({ acqDate: date, acqTime: time }), + ]); + assert.equal(fire.acqMs, 0, `${date}:${time}`); + assert.equal(fire.lat, 38.99488); + } + const [leap] = adaptFirmsRecords([ + proxyRecord({ acqDate: '2024-02-29', acqTime: '45' }), + ]); + assert.equal(leap.acqMs, Date.UTC(2024, 1, 29, 0, 45)); +}); + test('daynight "D" → night false', () => { const [fire] = adaptFirmsRecords([proxyRecord({ daynight: 'D' })]); assert.equal(fire.night, false); @@ -89,11 +116,16 @@ test('records with non-finite lat/lon are skipped; index stays sequential', () = proxyRecord({ lat: 40.1 }), ]); assert.equal(fires.length, 2); - assert.deepEqual(fires.map((f) => f.index), [0, 1]); + assert.deepEqual( + fires.map((f) => f.index), + [0, 1], + ); }); test('non-finite frp/brightness → 0; empty input → []', () => { - const [fire] = adaptFirmsRecords([proxyRecord({ frp: 'n/a', brightness: undefined })]); + const [fire] = adaptFirmsRecords([ + proxyRecord({ frp: 'n/a', brightness: undefined }), + ]); assert.equal(fire.frp, 0); assert.equal(fire.brightness, 0); assert.deepEqual(adaptFirmsRecords([]), []); From dbf306379f4dce0c1639a6572c8f966ca53bf776 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:04 -0400 Subject: [PATCH 041/128] changelog: note 1341-kartaview-impossible-timestamps Co-authored-by: Cursor --- changelog.d/1341-kartaview-impossible-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-impossible-timestamps.md diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md new file mode 100644 index 000000000..302ff38f4 --- /dev/null +++ b/changelog.d/1341-kartaview-impossible-timestamps.md @@ -0,0 +1,3 @@ +- Reject impossible KartaView photo timestamps, so 31 February is unknown + instead of rolling forward into March and passing the imagery date filter + (Rudy Celekli, @rudycelekli, #1341). From bdb3851bb23e4b3aa76a2d9eba124eee38a39342 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:07 -0400 Subject: [PATCH 042/128] changelog: note 1342-firms-acquisition-calendar Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1342-firms-acquisition-calendar.md diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md new file mode 100644 index 000000000..c6b97d237 --- /dev/null +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -0,0 +1,3 @@ +- Keep an impossible wildfire acquisition date untimed, so 30 February is not + shown as a detection two days later + (Rudy Celekli, @rudycelekli, #1342). From cbefa2567a775effb275de55c14358580c95bb2f Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:32 -0400 Subject: [PATCH 043/128] Add the changelog note for impossible KartaView timestamps. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md new file mode 100644 index 000000000..5ccb0d473 --- /dev/null +++ b/changelog.d/1341-kartaview-timestamps.md @@ -0,0 +1,3 @@ +- Keep an impossible KartaView photo date unknown instead of rolling it onto + a later day, so the capture time and the imagery filter stay on the day + the photo was taken (Rudy Celekli, @rudycelekli, #1341). From e07b0e9256d1d4f215f3bf1e7f43228a5cf111a4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:12:34 -0400 Subject: [PATCH 044/128] Add the changelog note for impossible wildfire acquisition dates. Co-authored-by: Cursor --- changelog.d/1342-firms-acquisition-calendar.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md index c6b97d237..e0003636d 100644 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ b/changelog.d/1342-firms-acquisition-calendar.md @@ -1,3 +1,3 @@ -- Keep an impossible wildfire acquisition date untimed, so 30 February is not - shown as a detection two days later +- Keep an impossible wildfire acquisition date off the detection clock, so a + fire is not labeled with a later day it did not happen (Rudy Celekli, @rudycelekli, #1342). From d9ecd9869aa00ca98b3a7006590bf8a45f70bf97 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Fri, 23 Oct 2026 19:14:35 -0400 Subject: [PATCH 045/128] Drop the second changelog fragment for the KartaView fix. Co-authored-by: Cursor --- changelog.d/1341-kartaview-timestamps.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 changelog.d/1341-kartaview-timestamps.md diff --git a/changelog.d/1341-kartaview-timestamps.md b/changelog.d/1341-kartaview-timestamps.md deleted file mode 100644 index 5ccb0d473..000000000 --- a/changelog.d/1341-kartaview-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep an impossible KartaView photo date unknown instead of rolling it onto - a later day, so the capture time and the imagery filter stay on the day - the photo was taken (Rudy Celekli, @rudycelekli, #1341). From 0309a58643d52d90f1d251c8d50f14e0fcd90429 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:10:14 -0400 Subject: [PATCH 046/128] fix(transit): count a 90-second report as one minute The card kept seconds through 89s, then rounded 90s up to 2 minutes. --- src/layers/transit/policy.js | 4 +++- .../transit/selectionCopy.i18n.test.mjs | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 010a2efc2..5dc17daeb 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -442,10 +442,12 @@ export function buildTransitSelectionCopy( ? entry.sample.latestReportAgeMs : vehicleFixAgeMs(record, nowMs, fetchedAtMs)) / 1000, ); + // Seconds through 89s, then whole minutes. Rounding 90s up would skip + // "1 min" and call a minute-and-a-half-old report two minutes old. const reported = ageS < 90 ? t('Reported {seconds} s ago', { seconds: ageS }) - : t('Reported {minutes} min ago', { minutes: Math.round(ageS / 60) }); + : t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) }); // A time the feed did not give for THIS vehicle is labelled, so the card // never presents a borrowed or inferred time as the vehicle's own report. const ownTime = diff --git a/src/layers/transit/selectionCopy.i18n.test.mjs b/src/layers/transit/selectionCopy.i18n.test.mjs index 81fdfa0bb..26de5d28a 100644 --- a/src/layers/transit/selectionCopy.i18n.test.mjs +++ b/src/layers/transit/selectionCopy.i18n.test.mjs @@ -39,6 +39,26 @@ test('en: the copy is unchanged with and without the i18n t()', () => { ]); }); +test('a report 90 seconds old reads as 1 minute, not 2', () => { + setLocale('en'); + const line = (ageMs) => + buildTransitSelectionCopy( + feed, + { + id: 'age', + lat: 1, + lon: 1, + timestamp: Math.floor((now - ageMs) / 1000), + timestampSource: 'vehicle', + }, + 'bus', + now, + ).details[1]; + assert.equal(line(89_000), 'Reported 89 s ago'); + assert.equal(line(90_000), 'Reported 1 min ago'); + assert.equal(line(120_000), 'Reported 2 min ago'); +}); + for (const locale of ['es', 'ru']) { test(`${locale}: the Transit card is in the UI language`, () => { setLocale(locale); From 66ea77237f665332a9210633f54fd4d0547dd45e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:17:38 -0400 Subject: [PATCH 047/128] fix(cyclones): label NHC classification codes by their published meanings --- src/i18n/locales/es.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/layers/cyclones/index.js | 9 ++++++--- src/layers/cyclones/index.test.mjs | 7 ++++--- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fd55b2e25..4fe130f23 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1153,6 +1153,7 @@ "OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.", "Potential tropical cyclone": "Posible ciclón tropical", + "Post-tropical cyclone": "Ciclón postropical", "Hurricane": "Huracán", "Tropical storm": "Tormenta tropical", "Tropical depression": "Depresión tropical", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 442312de2..978640951 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1169,6 +1169,7 @@ "OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.", "Potential tropical cyclone": "Потенциальный тропический циклон", + "Post-tropical cyclone": "Посттропический циклон", "Hurricane": "Ураган", "Tropical storm": "Тропический шторм", "Tropical depression": "Тропическая депрессия", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fa08cca1f..01c495a27 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -530,6 +530,7 @@ "Cyclone advisories unavailable": "气旋公告不可用", "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", + "Post-tropical cyclone": "后热带气旋", "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index bcd66923a..2ef663b15 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -41,13 +41,16 @@ const ADVISORIES_UNAVAILABLE = msg('Cyclone advisories unavailable'); const SERVER_REASONS = new Set([msg('Cyclone data unavailable')]); const errorText = (error) => error && (SERVER_REASONS.has(error) ? t(error) : translateKnown(error)); +// NHC's Tropical Cyclone Status JSON reference: PTC is post-tropical, PC is a +// potential tropical cyclone, and subtropical systems are STS and STD. const CLASSIFICATION_NAMES = Object.freeze({ - PTC: msg('Potential tropical cyclone'), + PC: msg('Potential tropical cyclone'), + PTC: msg('Post-tropical cyclone'), HU: msg('Hurricane'), TS: msg('Tropical storm'), TD: msg('Tropical depression'), - SS: msg('Subtropical storm'), - SD: msg('Subtropical depression'), + STS: msg('Subtropical storm'), + STD: msg('Subtropical depression'), }); const classificationName = (code) => Object.hasOwn(CLASSIFICATION_NAMES, code) diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 3a7fbdfc7..aff654659 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -513,12 +513,13 @@ test('only the latest queued focus runs and an unchanged selection survives refr }); test('classification display expands known codes without altering the source or guessing unknown meanings', async () => { for (const [code, label] of [ - ['PTC', 'Potential tropical cyclone'], + ['PC', 'Potential tropical cyclone'], + ['PTC', 'Post-tropical cyclone'], ['HU', 'Hurricane'], ['TS', 'Tropical storm'], ['TD', 'Tropical depression'], - ['SS', 'Subtropical storm'], - ['SD', 'Subtropical depression'], + ['STS', 'Subtropical storm'], + ['STD', 'Subtropical depression'], ['EX', 'EX'], ['UNKNOWN', 'UNKNOWN'], ]) { From 7276e300ebace8e4626612c1deb679331307c4a1 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:18:37 -0400 Subject: [PATCH 048/128] docs(changelog): add cyclone classification fragment --- changelog.d/1349-cyclone-classification-codes.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1349-cyclone-classification-codes.md diff --git a/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md new file mode 100644 index 000000000..c22debfa1 --- /dev/null +++ b/changelog.d/1349-cyclone-classification-codes.md @@ -0,0 +1,3 @@ +- Label NHC cyclone classifications by their published meanings: `PTC` is a + post-tropical cyclone, `PC` a potential tropical cyclone, and subtropical + systems arrive as `STS` and `STD` (rudycelekli, #1349). From 0b19f11ad0d2fbdded50ece29f4b88fb19fc3658 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:21:05 -0400 Subject: [PATCH 049/128] fix(cyclones): convert NHC movement speed from mph to knots --- server/providers/cyclones.js | 7 ++++++- src/data/cyclonesProxy.test.mjs | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/providers/cyclones.js b/server/providers/cyclones.js index 3e5f2f6d1..5aab0f246 100644 --- a/server/providers/cyclones.js +++ b/server/providers/cyclones.js @@ -43,6 +43,11 @@ function number(value, min, max) { const n = Number(value); return Number.isFinite(n) && n >= min && n <= max ? n : null; } +// CurrentStorms.json reports movementSpeed in miles per hour (intensity is in +// knots); convert so `speedKt` means what it says. +function knotsFromMph(mph) { + return mph === null ? null : Math.round(mph * 0.868976); +} // NHC stamps an advisory with its nominal time (e.g. 03:00Z) and publishes it // up to about half an hour before; allow that, but not a clock far ahead. const EARLY_ADVISORY_MS = 90 * 60_000; @@ -120,7 +125,7 @@ export function parseCycloneStatus(payload, now = Date.now()) { pressureHpa: number(raw.pressure, 800, 1100), movement: { directionDegrees: number(raw.movementDir, 0, 360), - speedKt: number(raw.movementSpeed, 0, 200), + speedKt: knotsFromMph(number(raw.movementSpeed, 0, 200)), }, advisoryUrl: officialLink(forecast.url), outlookUrl: raw.id.startsWith('al') diff --git a/src/data/cyclonesProxy.test.mjs b/src/data/cyclonesProxy.test.mjs index 23e84826c..6757f6e5c 100644 --- a/src/data/cyclonesProxy.test.mjs +++ b/src/data/cyclonesProxy.test.mjs @@ -115,7 +115,8 @@ test('status retains official clocks, knots, source links and finite current pos assert.equal(storm.issuedAt, TIME); assert.equal(storm.positionAt, TIME); assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 }); - assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 }); + // NHC publishes movementSpeed in mph: 12 mph is 10 kt. + assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 10 }); assert.equal(storm.windKt, 25); assert.equal(storm.pressureHpa, 1006); assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//); From 1b52298c6c0949e8b15df71eaf4827569487a2ad Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:21:39 -0400 Subject: [PATCH 050/128] docs(changelog): add cyclone movement speed fragment --- changelog.d/1350-cyclone-movement-knots.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1350-cyclone-movement-knots.md diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md new file mode 100644 index 000000000..3fc8c8f9f --- /dev/null +++ b/changelog.d/1350-cyclone-movement-knots.md @@ -0,0 +1,3 @@ +- Convert NHC cyclone movement speed from miles per hour to knots, so + `movement.speedKt` in cyclone query results is no longer about 15% high + (rudycelekli, #1350). From 2beef9be393e18b3ebba9b62182119166cd1a369 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:23:11 -0400 Subject: [PATCH 051/128] fix(firms): drop the decimal when fire power rounds to 10 MW A 9.96 MW detection was labeled 10.0 MW because the one-decimal cutoff ran before rounding. --- src/data/firmsCards.test.mjs | 15 +++++++++++++++ src/data/firmsLabels.js | 11 +++++++++++ src/data/firmsLabels.test.mjs | 9 +++++++++ src/layers/firms/model.js | 8 +++++--- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index 4b423f687..a0ee83c01 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -53,6 +53,21 @@ test('buildFireCard: missing acquisition time omits the age segment', () => { assert.equal(card.details[0], 'high · N20'); }); +test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { + const crossed = buildFireCard( + { fire: fire({ frp: 9.96 }), position: {} }, + NOW, + ); + assert.equal(crossed.title, '▲ 10 MW'); + const below = buildFireCard( + { fire: fire({ frp: 9.94 }), position: {} }, + NOW, + ); + assert.equal(below.title, '▲ 9.9 MW'); + const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW); + assert.equal(selected.title, 'FIRE · 10 MW'); +}); + test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index 34b66b79a..b9e9d1765 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -39,6 +39,17 @@ export function accentForSeverity(stopName) { return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; } +/** + * Fire radiative power as shown on a card: "9.9" below 10 MW, "10" once + * one-decimal rounding reaches 10. "10.0" would sit beside a whole "10". + * @param {number} frp Megawatts. + * @returns {string} + */ +export function formatFrpMw(frp) { + const digits = Number(Number(frp).toFixed(1)) >= 10 ? 0 : 1; + return Number(frp).toFixed(digits); +} + /** Raw FIRMS satellite code → short display name (N = Suomi NPP). */ export function satelliteShortName(satellite) { const s = String(satellite || '') diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index e0ebb2056..8573d7330 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -6,6 +6,7 @@ import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID, satelliteShortName, + formatFrpMw, } from './firmsLabels.js'; test('FIRMS formatting helpers retain the shipped severity palette', () => { @@ -15,6 +16,14 @@ test('FIRMS formatting helpers retain the shipped severity palette', () => { assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow')); }); +test('FIRMS power that rounds to 10 MW drops the decimal', () => { + assert.equal(formatFrpMw(9.96), '10'); + assert.equal(formatFrpMw(9.94), '9.9'); + assert.equal(formatFrpMw(0.8), '0.8'); + assert.equal(formatFrpMw(1520.27), '1520'); + assert.equal(formatFrpMw(210.4), '210'); +}); + test('FIRMS satellite names retain the three VIIRS abbreviations', () => { assert.equal(satelliteShortName('N20'), 'N20'); assert.equal(satelliteShortName('N21'), 'N21'); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 226c32286..14b875bf8 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -4,6 +4,7 @@ import { satelliteShortName, fireDetectionKey, accentForSeverity, + formatFrpMw, } from '../../data/firmsLabels.js'; import { VIEW_PADDING, @@ -480,14 +481,15 @@ export function createModel({ } function formatFrp(frp) { - return frp >= 10 ? frp.toFixed(0) : frp.toFixed(1); + return formatFrpMw(frp); } /** formatFrp for a card, with the locale's decimal separator ("4,9"). */ function formatFrpLabel(frp) { - const digits = frp >= 10 ? 0 : 1; - return formatNumber(frp, { + const text = formatFrpMw(frp); + const digits = text.includes('.') ? 1 : 0; + return formatNumber(Number(text), { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping: false, From 2bcd27c1e0adcbed777e4ec0e748e8c9bebd9cd4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:23:44 -0400 Subject: [PATCH 052/128] fix(ais): keep a tracked vessel's motion when static data arrives --- server/providers/vessels/ais-store.js | 5 ++++ src/data/aisStreamSentinels.test.mjs | 36 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 297081b38..a456e19f4 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -51,6 +51,11 @@ export function ingestAisStreamEnvelope(envelope) { }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); + // AISStream repeats the last known position in a static message's + // MetaData, but the message has no speed, course or heading and is not a + // new fix. Rebuilding a tracked vessel from it would blank its motion and + // restamp its position time, so only merge the static fields. + if (_aisStreamVessels.has(mmsi)) return true; } const lat = numberValue( diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 6595b795e..49572c05d 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -63,3 +63,39 @@ test('the fastest encodable reading below the unavailable code is kept', () => { assert.equal(row.speed, 102.2); assert.equal(row.course, 359.9); }); + +test('a static data report keeps the motion and fix time of a tracked vessel', () => { + ingestAisStreamEnvelope( + positionReport('244690000', { Sog: 12.4, Cog: 86.7, TrueHeading: 87 }), + ); + // AISStream's MetaData carries the last known position on every message, + // static data included. + assert.equal( + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 244690000, + Name: 'EXAMPLE VESSEL', + Type: 70, + Destination: 'ROTTERDAM', + ImoNumber: 9876543, + }, + }, + MetaData: { + MMSI: '244690000', + latitude: 53.40879, + longitude: 6.19741, + time_utc: '2026-09-17 20:05:00.000000000 +0000 UTC', + }, + }), + true, + ); + const row = rowFor('244690000'); + assert.equal(row.speed, 12.4); + assert.equal(row.course, 86.7); + assert.equal(row.heading, 87); + assert.equal(row.last_position_UTC, '2026-09-17T20:00:00.000Z'); + assert.equal(row.destination, 'ROTTERDAM'); + assert.equal(row.imo, '9876543'); +}); From b8f8a8685dea49d6cb8eba1fcb2e09c940d644f0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:24:08 -0400 Subject: [PATCH 053/128] docs(changelog): add AIS static data fragment --- changelog.d/1351-ais-static-keeps-motion.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1351-ais-static-keeps-motion.md diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md new file mode 100644 index 000000000..78b0804f7 --- /dev/null +++ b/changelog.d/1351-ais-static-keeps-motion.md @@ -0,0 +1,3 @@ +- Keep a tracked vessel's speed, course, heading and fix time when AISStream + static data arrives; the static message only merges name, type, destination + and IMO (rudycelekli, #1351). From 05316bb34b327c6f3f63b4b60387faccf1ff888e Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:24:43 -0400 Subject: [PATCH 054/128] fix(cockpit): read a rounded-north destination heading as 000 359.6 degrees was shown as 360 degrees on the destination bearing while the compass tape uses 000. --- src/cockpitMath.js | 9 +++++++++ src/cockpitMath.test.mjs | 9 +++++++++ src/ui/cockpitInstruments.js | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 62015bb70..888c33ead 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -6,6 +6,15 @@ export function normalizeHeading(value) { return ((value % 360) + 360) % 360; } +/** + * Three-digit heading readout, 000–359. + * Rounding 359.5° and above lands on north (000), not 360. + */ +export function formatHeadingReadout(degrees) { + if (!Number.isFinite(degrees)) return '---'; + return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0'); +} + /** Advance a displayed heading along the shortest arc without exceeding a slew rate. */ export function slewHeading(current, target, maxStepDeg) { const from = normalizeHeading(current); diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 2189ca35e..e49b91a57 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -15,6 +15,7 @@ import { formatCockpitContextScope, formatCockpitEmergency, formatCompassDivision, + formatHeadingReadout, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -155,6 +156,14 @@ test('compass labels use cardinals where exact and degrees elsewhere', () => { assert.equal(formatCompassDivision(30), '030'); }); +test('a heading that rounds to north reads 000, not 360', () => { + assert.equal(formatHeadingReadout(359.6), '000'); + assert.equal(formatHeadingReadout(359.4), '359'); + assert.equal(formatHeadingReadout(0), '000'); + assert.equal(formatHeadingReadout(10.2), '010'); + assert.equal(formatHeadingReadout(Number.NaN), '---'); +}); + test('great-circle bearing resolves cardinal directions', () => { assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 1, 0) - 0) < 0.001); assert.ok(Math.abs(bearingBetweenCoordinates(0, 0, 0, 1) - 90) < 0.001); diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 1904b6cea..77a4bea7d 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -14,6 +14,7 @@ import { formatAltitudeRulerTick, formatCompassDivision, formatCockpitEmergency, + formatHeadingReadout, formatSpeedRulerTick, normalizeHeading, relativeBearing, @@ -238,7 +239,7 @@ export function updateRoute(info) { } if (this.routeDirectionLabel) { this.routeDirectionLabel.textContent = t('DEST {bearing}', { - bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`, + bearing: `${formatHeadingReadout(destinationBearing)}°`, }); } } From 1469cc2ade2668079bdb1e4b7cbfc2b1546d2309 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:35:53 -0400 Subject: [PATCH 055/128] changelog: note the 90-second transit report age --- changelog.d/1353-transit-ninety-second-age.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1353-transit-ninety-second-age.md diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md new file mode 100644 index 000000000..50611060d --- /dev/null +++ b/changelog.d/1353-transit-ninety-second-age.md @@ -0,0 +1,3 @@ +- Count a transit report that is 90 seconds old as one minute. The card kept + seconds through 89s, then rounded 90s up to "Reported 2 min ago" + (Rudy Celekli, @rudycelekli, #1353). From 9f8ed74e7bbf741ad3d3fce36398a8625d9a00b5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:35:59 -0400 Subject: [PATCH 056/128] changelog: note the 10 MW fire-power label --- changelog.d/1354-firms-frp-ten-megawatts.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1354-firms-frp-ten-megawatts.md diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md new file mode 100644 index 000000000..394eba1df --- /dev/null +++ b/changelog.d/1354-firms-frp-ten-megawatts.md @@ -0,0 +1,3 @@ +- Drop the decimal when fire radiative power rounds to 10 MW. A 9.96 MW + detection was labeled 10.0 MW because the one-decimal cutoff ran before + rounding (Rudy Celekli, @rudycelekli, #1354). From a9eda1d9e7a3101bdf48833e7956e29d333ff5b6 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:36:02 -0400 Subject: [PATCH 057/128] changelog: note the rounded-north destination heading --- changelog.d/1352-cockpit-destination-heading.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1352-cockpit-destination-heading.md diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md new file mode 100644 index 000000000..c7a5ea9b5 --- /dev/null +++ b/changelog.d/1352-cockpit-destination-heading.md @@ -0,0 +1,3 @@ +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). From 5c3b66c2636f43eb09b08d31998b4496037de721 Mon Sep 17 00:00:00 2001 From: manjunath Date: Sat, 24 Oct 2026 15:20:15 +0530 Subject: [PATCH 058/128] feat(voice): reconcile preview and settings with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add the shared Voice Preview and Settings panel with browser-local preview and inactivity preferences, keyboard controls and responsive positioning preserve main’s Rotation and Context behavior, clarify panel routing and refresh schema pins within the unchanged prompt limit --- changelog.d/1299-voice-panel-settings.md | 3 + docs/CURRENT-STATE.md | 19 + index.html | 2 +- scripts/package-boundaries.json | 12 + server/providers/openai/instructions.js | 5 +- server/providers/openai/toolDescriptions.js | 20 +- src/firstRunExperience.test.mjs | 7 +- src/i18n/locales/es.json | 16 +- src/i18n/locales/ru.json | 16 +- src/i18n/locales/zh-CN.json | 16 +- src/keySetup.js | 2 + src/radioMarkup.test.mjs | 35 +- src/standalone/tools.js | 1 + src/ui/styles/voice-card.css | 246 +++++++ src/ui/styles/voice-cost.css | 325 ++++++++- src/voice/actionSchemas.js | 13 + src/voice/actionSchemas.test.mjs | 35 +- src/voice/commands.js | 45 +- src/voice/control.js | 77 +- src/voice/gevActions.js | 188 +++++ src/voice/gevRealtime.js | 9 +- src/voice/panel/actionObserver.js | 178 +++++ src/voice/panel/actionObserver.test.mjs | 498 +++++++++++++ src/voice/panel/host.js | 304 ++++++++ src/voice/panel/host.test.mjs | 756 ++++++++++++++++++++ src/voice/panel/inactivity.js | 77 ++ src/voice/panel/inactivity.test.mjs | 108 +++ src/voice/panel/manualPanel.js | 182 +++++ src/voice/panel/manualPanel.test.mjs | 198 +++++ src/voice/panel/nativeActivity.js | 212 ++++++ src/voice/panel/nativeActivity.test.mjs | 473 ++++++++++++ src/voice/panel/preferences.js | 194 +++++ src/voice/panel/preferences.test.mjs | 272 +++++++ src/voice/panel/testDom.mjs | 204 ++++++ src/voice/sessionCommands.js | 54 +- src/voice/speech.js | 1 + src/voice/voiceCard.js | 59 +- src/voice/voiceCard.test.mjs | 510 +++++++++++-- src/voice/voiceCardPresentation.js | 13 +- 39 files changed, 5274 insertions(+), 111 deletions(-) create mode 100644 changelog.d/1299-voice-panel-settings.md create mode 100644 src/voice/panel/actionObserver.js create mode 100644 src/voice/panel/actionObserver.test.mjs create mode 100644 src/voice/panel/host.js create mode 100644 src/voice/panel/host.test.mjs create mode 100644 src/voice/panel/inactivity.js create mode 100644 src/voice/panel/inactivity.test.mjs create mode 100644 src/voice/panel/manualPanel.js create mode 100644 src/voice/panel/manualPanel.test.mjs create mode 100644 src/voice/panel/nativeActivity.js create mode 100644 src/voice/panel/nativeActivity.test.mjs create mode 100644 src/voice/panel/preferences.js create mode 100644 src/voice/panel/preferences.test.mjs create mode 100644 src/voice/panel/testDom.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md new file mode 100644 index 000000000..60a68e7d3 --- /dev/null +++ b/changelog.d/1299-voice-panel-settings.md @@ -0,0 +1,3 @@ +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts + (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..cb3f133e9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -517,6 +517,25 @@ replacement session; audio meters release failed initialization and reject revok frames. Delayed action results and post-capture continuations cannot resume a stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). +The opt-in voice panel shares Preview and Voice Settings, with browser-local +Preview and inactivity preferences. Voice-requested timeout changes apply directly +without a confirmation question; results distinguish a live change from a saved +browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60, +or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit +Cockpit before opening the requested voice UI. `control_cockpit` also accepts +`voicePanel: "preview" | "settings"` for an explicit exit-and-show request. +Newer manual choices, Stop and provider replacement prevent an older operation +from revealing its UI. Ambiguous requests to open settings ask whether the user +means Voice Settings or Provider Settings before opening either panel. Named +Voice Settings applies directly; Provider Settings opens the existing local +provider dialog when available. Visual Presets targets the visual controls tray, +while Scenes targets saved scenes. Selecting Preview does not enable a disabled +Preview preference. Unverified speech captions use the localized Recent +speech label; ordinary user captions retain YOU. Caption attribution does not +control tool dispatch. + +Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. + Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider instructions require an analyst query for requested lists and use the requested diff --git a/index.html b/index.html index a9874d550..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3970a1ee2..2f5737e45 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1652,6 +1652,12 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -2289,6 +2295,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/control.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 9735f300c..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,7 +10,7 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', @@ -18,13 +18,12 @@ function realtimeInstructions( 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index b8121a201..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index a02c4391f..b7fa42999 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1010,11 +1010,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Globe rotation adds one camera motion and its descriptions; Globe View // framing/default guidance updates only move_camera and mode descriptions. // Radio markup and the complete production-request comparison pin locality. - // Restore the base camera/Globe View wording with the globe-rotation append. - assert.equal(block.length, 30878, 'serialized tool schema length drifted'); + // Panel adds timeout and named voice/provider targets; consolidated guidance + // preserves the mission mapping and globe-rotation arguments. + assert.equal(block.length, 31196, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', + '56c91b309fb583c8737a9c3c1530553dcc8159ba2f3c5d4d2db99921f28d8f1e', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fd55b2e25..82551e604 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2947,5 +2947,19 @@ "Layers": "Capas", "Search": "Buscar", "More": "Más", - "Panels": "Paneles" + "Panels": "Paneles", + "Recent speech": "Habla reciente", + "Voice Settings": "Ajustes de voz", + "Preview": "Vista previa", + "Settings": "Ajustes", + "Close voice panel": "Cerrar panel de voz", + "Show Voice Preview": "Mostrar vista previa de voz", + "Voice inactivity timeout": "Tiempo de inactividad de voz", + "Custom": "Personalizado", + "Minutes": "Minutos", + "These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.", + "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", + "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", + "Preview is off.": "La vista previa está desactivada.", + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 442312de2..5358264cf 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3014,5 +3014,19 @@ "Layers": "Слои", "Search": "Поиск", "More": "Ещё", - "Panels": "Панели" + "Panels": "Панели", + "Recent speech": "Недавняя речь", + "Voice Settings": "Настройки голоса", + "Preview": "Предпросмотр", + "Settings": "Настройки", + "Close voice panel": "Закрыть голосовую панель", + "Show Voice Preview": "Показывать предпросмотр голоса", + "Voice inactivity timeout": "Тайм-аут бездействия голоса", + "Custom": "Другое", + "Minutes": "Минуты", + "These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.", + "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", + "Voice replies appear here.": "Здесь появляются голосовые ответы.", + "Preview is off.": "Предпросмотр отключён.", + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fa08cca1f..07e7fdd64 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2813,5 +2813,19 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "Recent speech": "最近语音", + "Voice Settings": "语音设置", + "Preview": "预览", + "Settings": "设置", + "Close voice panel": "关闭语音面板", + "Show Voice Preview": "显示语音预览", + "Voice inactivity timeout": "语音空闲超时", + "Custom": "自定义", + "Minutes": "分钟", + "These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。", + "Your choice applies now, but could not be saved.": "选择已生效,但无法保存。", + "Voice replies appear here.": "语音回复显示在此处。", + "Preview is off.": "预览已关闭。", + "Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。" } diff --git a/src/keySetup.js b/src/keySetup.js index b67c9df66..550ac6e16 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -395,6 +395,7 @@ export async function initKeySetup({ const destroy = () => { if (disposed) return; disposed = true; + delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); @@ -591,6 +592,7 @@ export async function initKeySetup({ chip.addEventListener('click', openDialog); closeButton?.addEventListener('click', close); applyButton?.addEventListener('click', onApply); + root.dataset.ready = 'true'; // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const authButton = event.target?.closest?.( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 14ea0f3e0..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index eec4d0796..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -19,6 +19,7 @@ export function createStandaloneTools(options) { ...options, voice: { supportedProviders: ['openai', 'gemini'], + panel: true, toolCatalog: loadToolCatalog, ...options?.voice, }, diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index ef3d77055..5afe57a26 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle { animation: none; } } + +/* Opt-in shared panel: header stays fixed while only the body scrolls. */ +#gev-voice-control .gev-voice-panel { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + z-index: 10; + display: flex; + flex-direction: column; + width: clamp(18rem, 26vw, 22rem); + max-width: calc(100vw - 1.5rem); + max-height: min( + 28rem, + max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px))) + ); + box-sizing: border-box; + padding: 0.6rem 0.7rem 0.65rem; + color: rgba(223, 245, 250, 0.92); + background: rgba(7, 22, 31, 0.96); + border: 1px solid rgba(54, 220, 255, 0.34); + border-radius: 0.6rem; + box-shadow: + 0 0.85rem 2rem rgba(0, 0, 0, 0.58), + 0 0 1.2rem rgba(0, 212, 255, 0.08); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + color-scheme: dark; + font: 0.75rem/1.45 var(--font-sans); + text-align: left; + transform: translateX(-50%); +} +:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel { + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + border-color: var(--glass-border); + box-shadow: var(--cyber-panel-shadow); +} +#gev-voice-control .gev-voice-panel[hidden], +#gev-voice-control .gev-voice-panel [hidden] { + display: none; +} +#gev-voice-control .gev-voice-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--glass-border); +} +.gev-voice-panel-title { + color: var(--voice-panel-accent, var(--accent)); + font: 600 0.65rem/1 var(--font-mono); + letter-spacing: 0.14em; +} +.gev-voice-panel-tabs { + margin-left: auto; +} +#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + padding: 0; +} +#gev-voice-control .gev-voice-panel-tab-icon { + font-size: 18px; +} +.gev-voice-panel-header [role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + min-width: 0; +} +#gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, select, input[type='number']) { + min-height: 2rem; + box-sizing: border-box; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font: inherit; +} +#gev-voice-control .gev-voice-panel .gev-voice-panel-header button { + padding: 0 0.6rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.04em; + line-height: 1; + cursor: pointer; +} +#gev-voice-control #gev-voice-settings-close { + width: 2rem; + padding: 0; + font-size: 1rem; +} +#gev-voice-control .gev-voice-panel-header button:hover { + color: var(--text-primary); + border-color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + box-shadow: 0 -2px 0 currentColor inset; +} +#gev-voice-control .gev-voice-panel [role='tab']:focus, +#gev-voice-control .gev-voice-panel :focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +.gev-voice-panel-body { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + padding: 0.55rem 0.75rem 3px 3px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.gev-voice-panel-body .gev-voice-card-who { + overflow-wrap: break-word; +} +.gev-voice-panel-body p { + margin: 0; +} +.gev-voice-preference { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} +.gev-voice-preference-toggle { + flex-direction: row-reverse; + justify-content: space-between; + min-height: 2.75rem; + font-weight: 600; + cursor: pointer; +} +.gev-voice-preference-toggle input { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--voice-panel-accent, var(--accent)); +} +.gev-voice-setting-title { + margin-bottom: 0.4rem; + font-weight: 600; +} +.gev-voice-timeout-group { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-timeout-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +#gev-voice-timeout-select { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.45rem; +} +#gev-voice-timeout-input { + flex: 0 0 4.5rem; + width: 4.5rem; + padding: 0 0.45rem; + font-variant-numeric: tabular-nums; +} +.gev-voice-timeout-unit { + order: 1; + color: var(--text-dim); + font-size: 0.63rem; +} +.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden]) + .gev-voice-timeout-unit { + display: none; +} +.gev-voice-settings-note { + color: rgba(190, 221, 230, 0.72); + font-size: 0.63rem; + line-height: 1.45; +} +.gev-voice-settings-note:not(:empty) { + margin-top: 0.25rem; +} +#gev-voice-control .gev-voice-panel .gev-voice-card { + position: static; + width: 100%; + max-height: none; + overflow: visible; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + animation: none; +} +#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) { + z-index: 101; +} +#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray { + display: none; +} +#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel { + bottom: min( + calc(100% + 4.8rem + var(--gev-provider-lift)), + calc(100dvh - 7.75rem) + ); +} + +#command-dock.dock-has-pinned-tray + #gev-voice-control:not([data-provider-choice='true']) + .gev-voice-panel { + bottom: min( + calc(100% + 1.7rem + var(--dock-pinned-stack-height)), + calc(100dvh - 18rem) + ); +} +@media (pointer: coarse) { + #gev-voice-control + .gev-voice-panel + :is( + .gev-voice-panel-header button, + select, + input[type='number'], + .gev-voice-card-dismiss + ) { + min-height: 44px; + } + #gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, .gev-voice-card-dismiss) { + min-width: 44px; + } +} + +/* A connection error remains reachable even while the manual panel is open. */ +#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray { + z-index: 12; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b43f40168..9d655e484 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -111,7 +111,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost { padding-left: 0.1rem; } -html:not([lang='en']) #command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } @@ -232,3 +232,326 @@ html:not([lang='en']) pointer-events: auto; } } + +/* The opt-in provider and gear share a discoverable row at the existing anchor. */ +#gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-settings-gap: 0.75rem; + --gev-voice-tray-clearance: 0px; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + ); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 46px 6px 6px; + border-color: rgba(123, 189, 211, 0.28); + background: rgba(9, 18, 27, 0.88); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46); + color-scheme: dark; + opacity: 1; + pointer-events: auto; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 34px; + font-size: 0.7rem; +} +#gev-voice-control .gev-voice-settings-button { + position: absolute; + top: 1rem; + right: 0.25rem; + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + color: rgba(190, 221, 230, 0.72); + background: rgba(9, 18, 27, 0.92); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font-size: 18px; + line-height: 1; + cursor: pointer; +} +#gev-voice-control[data-provider-choice='true']:has( + .gev-voice-provider-field:not([hidden]) + ) + .gev-voice-settings-button { + top: auto; + right: 7px; + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px + ); +} +#gev-voice-control + .gev-voice-settings-button:is(:hover, [aria-expanded='true']) { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + background: rgba(0, 212, 255, 0.12); +} +#gev-voice-control .gev-voice-settings-button:focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) { + --voice-panel-accent: var(--cyber-red-bright); +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field { + background: var(--cyber-panel-surface); + border-color: var(--glass-border); +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-settings-button { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + padding-right: 56px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 44px; + } +} + +/* Keep the row and popup on the same lift, with a small gap above pinned + trays. On very short screens the shared lift still yields to the viewport. */ +#command-dock.dock-has-pinned-tray + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-voice-settings-gap: 0.375rem; + --gev-provider-lift: min( + calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)), + max(0px, calc(100dvh - 20rem)) + ); +} + +/* Match the attribution-aware tray offsets without moving the trays. The + popup can scroll before the shared viewport budget caps its lift. */ +@media (max-width: 900px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(3rem - 1.2rem); + } +} +@media (max-width: 720px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem); + } +} + +/* Short touch landscapes have room beside the dock, not above its pinned + stack. Keep the same chooser and gear together in that empty lower gutter; + the open popup remains a bounded, scrollable overlay above the microphone. */ +@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) { + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ), + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-provider-lift: 0px; + --gev-landscape-row-width: clamp( + 108px, + calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem), + calc(100% - 2px) + ); + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-provider-field:not([hidden]) { + left: calc(50% - 50vw + 0.5rem); + right: auto; + bottom: 0.375rem; + width: var(--gev-landscape-row-width); + box-sizing: border-box; + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-settings-button { + left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px); + right: auto; + top: auto; + bottom: calc(0.375rem + 7px); + } +} + +/* The revealed row clears pinned wings without making the Voice column's + noninteractive gaps cover their controls. Keep hit surfaces stable so hover + promotion cannot oscillate when the pointer crosses root padding. */ +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + pointer-events: none; +} +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > :is( + #gev-voice-button, + .gev-voice-heading, + .gev-voice-readout, + .gev-voice-provider-field, + .gev-voice-settings-button, + .gev-voice-panel, + .gev-voice-error-tray + ) { + pointer-events: auto; +} +@media (hover: hover) and (pointer: fine) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):is( + :hover, + [data-row-grace], + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) { + z-index: 101; + } +} +@media (hover: none), (pointer: coarse) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + z-index: 101; + } +} + +/* Pointer grace crosses pinned trays without covering their controls. Keyboard + focus, the provider select and an open panel hold the shared row visible. */ +@media (hover: hover) and (pointer: fine) { + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: + opacity 160ms ease, + transform 180ms ease; + } + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):not(:hover):not([data-row-grace]):not( + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + } + #command-dock:not(.dock-has-pinned-tray) + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + .gev-voice-provider-field::after { + content: ''; + position: absolute; + top: 100%; + right: 0; + left: 0; + height: calc(var(--gev-voice-settings-gap) + 8px); + } +} + +/* Existing tier and estimate controls are readable Settings content in panel mode. */ +.gev-voice-cost-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.7rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has( + #gev-voice-cost-value[hidden] + ), +.gev-voice-cost-setting-row:has(> [hidden]) { + display: none; +} +.gev-voice-cost-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2rem; + color: var(--text-primary); + font-size: 0.68rem; +} +.gev-voice-cost-setting-row > span { + min-width: 0; +} +#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + flex: 0 0 auto; + min-height: 2rem; + min-width: 3.25rem; + padding: 0.3rem 0.55rem; + font-size: 0.67rem; +} +.gev-voice-cost-settings .gev-voice-cost-value { + flex: 0 0 auto; + font-size: 0.75rem; +} +.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] { + color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-settings-icon { + font-size: 18px; + line-height: 1; +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + min-height: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + #command-dock + #gev-voice-control:has(> .gev-voice-settings-button) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: none; + } +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 3f464cad1..bf7e2f66d 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -178,6 +178,9 @@ const schemas = [ 'global-context-panel', 'weather-panel', 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', ], }, open: { @@ -187,6 +190,15 @@ const schemas = [ required: ['panelId', 'open'], }, }, + { + name: 'set_voice_inactivity_timeout', + parameters: { + type: 'object', + additionalProperties: false, + properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } }, + required: ['minutes'], + }, + }, { name: 'set_context_mode', parameters: { @@ -223,6 +235,7 @@ const schemas = [ aircraftClass: { type: 'string', }, + voicePanel: { type: 'string', enum: ['preview', 'settings'] }, }, required: ['action'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d65d7d8db..5cb882527 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea .digest('hex'); assert.equal( digest, - // Re-derived for the voice layer manifest (generated layer enums and + // Re-derived for Provider Settings and explicit Visual Presets/Scenes routing, + // plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and // aliases), point-and-ask (pointer sentinels, referent args) and the // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves @@ -41,8 +42,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Air quality (ECCC AQHI) extends the manifest. // Globe rotation adds one motion; visual keyhole fit and explicit // reset guidance/defaults update only move_camera and mode descriptions. - // Restore base descriptions; append only globe-rotation defaults and fit guidance. - '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', + // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance + // is consolidated without changing either feature's arguments. + 'b2cd2decb77fb3f8f0ab65e7907c221580e21ee3e7e77581f1b52bf62126ae86', ); }); @@ -100,12 +102,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( - (tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name), + (tool) => + ![ + 'next_satellite_pass', + 'set_cyber_sonar', + 'set_voice_inactivity_timeout', + ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; @@ -123,7 +131,14 @@ test('all legacy action arguments are byte-identical after removing the delibera motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( - (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), + (id) => + ![ + 'weather-panel', + 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', + ].includes(id), ); for (const tool of legacy) { // Point-and-ask adds `referent` arguments and 'pointer' enum values. @@ -228,3 +243,13 @@ test('globe reset guidance requires a separate explicit command after refusal', assert.match(paragraph, /bare yes\/ok/); assert.match(paragraph, /Do not ask a question/); }); + +test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => { + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_voice_inactivity_timeout', + ); + for (const minutes of [0, 1, 5, 60]) + assert.equal(validateArgs(tool.parameters, { minutes }).length, 0); + for (const minutes of [-1, 61, 1.5, '5']) + assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); +}); diff --git a/src/voice/commands.js b/src/voice/commands.js index c5f92b209..1fed3bc12 100644 --- a/src/voice/commands.js +++ b/src/voice/commands.js @@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js'; import { createRealtimeSession } from './realtimeSession.js'; import { createGeminiSession } from './geminiSession.js'; import { createProviderCommands } from './providerCommands.js'; +import { createVoicePanelHost } from './panel/host.js'; /** Default composition; callers may supply another session adapter factory. */ export function createVoiceCommands(options = {}) { + const panelHost = + options.panel === true + ? createVoicePanelHost({ + storage: options.panelStorage, + clock: options.panelClock, + readCockpitState: options.styleManager?.getCockpitState + ? () => options.styleManager.getCockpitState() + : undefined, + }) + : null; + const bind = (settings) => + bindVoiceCommands({ + ...settings, + ...(panelHost + ? { + panelHost, + nativePanelProvider: settings.createController + ? null + : settings.createSession === createRealtimeSession + ? 'openai' + : settings.createSession === createGeminiSession + ? 'gemini' + : null, + } + : {}), + }); + let commands; // Generic hosts retain the existing default; standalone opts in explicitly. if (!options.createSession && options.supportedProviders !== undefined) - return createProviderCommands(options, { - bind: bindVoiceCommands, + commands = createProviderCommands(options, { + bind, factories: { openai: createRealtimeSession, gemini: createGeminiSession }, }); - return bindVoiceCommands({ - createSession: createRealtimeSession, - ...options, - }); + else commands = bind({ createSession: createRealtimeSession, ...options }); + if (panelHost) { + const lifetime = commands.signal ?? commands.session?.signal; + lifetime?.addEventListener('abort', () => panelHost.destroy(), { + once: true, + }); + if (lifetime?.aborted) panelHost.destroy(); + } + return commands; } diff --git a/src/voice/control.js b/src/voice/control.js index c77197412..af9d37cbc 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -27,7 +27,7 @@ export function voiceStatusLabel(status) { } /** Build the voice control independently of its connection backend. */ -export function createVoiceControl({ reset = false } = {}) { +export function createVoiceControl({ reset = false, panel = false } = {}) { let root = document.getElementById('gev-voice-control'); if (root && reset) { root.remove(); @@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) { ${t('Voice provider')} + ${panel ? `` : ''}
${t('AI AGENT')}
${voiceStatusLabel('idle')}
-
+ ${ + panel + ? '' + : `
~$0.00 -
+
` + }
+ + + + +
+
+

${t('Voice replies appear here.')}

` + : '' + }
+ ${ + panel + ? ` + + + ` + : '' + }
- + +
Feather - - - 11% + + 100%
diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html index b3c9785f1..ba786c30b 100644 --- a/src/ui/templates/scene-chrome.html +++ b/src/ui/templates/scene-chrome.html @@ -11,13 +11,13 @@

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

+

NO PLACE LEFT BEHIND

- ACTIVE STYLE - NORMAL + ACTIVE STYLE + NORMAL
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index c804ea342..1d5f16a28 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,3 +1,5 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { syncScopeControls } from './displayControls.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; @@ -136,11 +138,10 @@ export class VisualSettings { } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); + const pct = clampScopeFeatherPct(scopeFeatherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1302,11 +1303,14 @@ export class VisualSettings { const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); - this._scopeBtn?.classList.toggle('active', scopeState.enabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + scopeState.enabled, + ); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); + const pct = clampScopeFeatherPct(scopeState.featherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; From 4a18af54267a39484c9f01a5f352b81362a48f9b Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:24:34 +0100 Subject: [PATCH 119/128] fix: address GhostMaps resource and source ownership review findings --- changelog.d/1297-s2-ghostmaps-layer.md | 3 + docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 1 + server/providers/ghostMaps.test.mjs | 17 +- src/app/constructCatalog.js | 2 +- src/app/layers/ghostMaps.js | 6 +- src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 64 ++++++++ src/layers/ghostMaps/index.js | 201 ++++++++++++------------ src/layers/ghostMaps/lifecycle.test.mjs | 180 +++++++++++++++++++++ src/standalone/layerSources.js | 2 + 11 files changed, 372 insertions(+), 106 deletions(-) create mode 100644 src/layers/ghostMaps/lifecycle.test.mjs diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index e79cc6eff..938f5f89b 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -7,3 +7,6 @@ products that changed (Black Pine Defense). - Clear a temporary GhostMaps upstream error and notify open pages when the repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 52fdb1e78..72f6db4b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5133,6 +5133,7 @@ are omitted rather than framing the wrong part of the globe. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 71d51d253..879ef83a6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -884,6 +884,7 @@ "src/layers/ghostMaps/source.js", "src/layers/ghostMaps/catalog.test.mjs", "src/layers/ghostMaps/records.test.mjs", + "src/layers/ghostMaps/lifecycle.test.mjs", "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 5ddef6aa5..9723436d2 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -65,6 +65,17 @@ async function withCache(fn) { } } +// checkNow announces the catalog before background warm-up finishes. Join the +// same file reads before changing editions or removing the temporary cache. +async function warmDefaults(watcher) { + await Promise.all( + watcher + .catalog() + .products.filter((product) => product.defaultOn) + .map((product) => watcher.file(product.id)), + ); +} + test('a new commit is announced once, naming only the products that changed', () => withCache(async (cacheDir) => { const { repo, fetchImpl } = fakeGitHub(); @@ -78,6 +89,7 @@ test('a new commit is announced once, naming only the products that changed', () watcher.subscribe((catalog) => events.push(catalog)); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); const first = watcher.catalog(); assert.equal(first.commit, sha('1')); assert.equal(first.message, 'Oct 18 Update'); @@ -105,6 +117,7 @@ test('a new commit is announced once, naming only the products that changed', () }, ]); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); assert.equal(events.length, 2); const cip = events[1].products.find((p) => p.id === 'cip'); assert.equal(cip.version, sha('c')); @@ -165,7 +178,9 @@ test('the catalog survives a restart from disk, and upstream failures are report withCache(async (cacheDir) => { const { fetchImpl } = fakeGitHub(); const quiet = { log: () => {}, warn: () => {} }; - await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }); + await online.checkNow(); + await warmDefaults(online); const offline = createGhostMapsWatcher({ fetchImpl: async () => new Response('down', { status: 503 }), cacheDir, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7c149ab59..59edc02ad 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -127,7 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), - createApplicationGhostMaps(), + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index 5e373c3d8..ccca18850 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,8 +1,5 @@ import * as Cesium from 'cesium'; -import { - createGhostMapsLayer, - createGhostMapsSource, -} from '../../layers/ghostMaps/index.js'; +import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; @@ -10,7 +7,6 @@ import { isPointerFree } from '../../data/inputOwnership.js'; /** Wire the S2 Underground GhostMaps layer into the application catalog. */ export function createApplicationGhostMaps(options) { return createGhostMapsLayer({ - source: createGhostMapsSource(), overlayHost: { setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..442598658 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), 'air-quality': contract(['air-quality'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), + 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index bfb031026..5baf01dab 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej ); }); +test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected standalone acquisition'); + }); + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('s2-ghostmaps')); + assert.equal(await manager.setEnabled('s2-ghostmaps', true), false); + assert.equal(manager.layers.get('s2-ghostmaps').initialized, false); + assert.equal( + createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'], + null, + ); + + let reads = 0; + let subscriptions = 0; + let unsubscribed = 0; + const source = { + getCatalog: async () => { + reads++; + return { commit: 'a'.repeat(40), products: [] }; + }, + getFile: async () => { + throw new Error('Empty catalog must not read files'); + }, + subscribe: () => { + subscriptions++; + return () => { + unsubscribed++; + }; + }, + }; + const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source }); + assert.equal(supplied['s2-ghostmaps'], source); + const configured = catalog(supplied, t); + assert.equal( + configured.getSourceAvailability('s2-ghostmaps').available, + true, + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + }); + const layer = configured.get('s2-ghostmaps'); + t.after(() => layer.destroy()); + layer.init({ scene: { requestRender() {} } }); + layer.enable(); + await layer.update(); + layer.disable(); + assert.equal(reads, 1); + assert.equal(subscriptions, 1); + assert.equal(unsubscribed, 1); + assert.equal(layer.getStats().commit, 'a'.repeat(40)); + assert.equal(fetches, 0); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index b0aa7c88b..01d564d05 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -195,7 +195,9 @@ export function createGhostMapsLayer({ /** Build one product's data source off-scene, then swap it in. */ async function load(product, signal) { const bytes = await source.getFile(product.id, product.version, { signal }); + signal.throwIfAborted(); const entries = await readZipEntries(bytes); + signal.throwIfAborted(); const main = kmzMainDocument(entries); if (!main) throw new Error(msg('That KMZ holds no KML document.')); const urls = []; @@ -210,117 +212,118 @@ export function createGhostMapsLayer({ ? resolvePacked(href) : null; }; - let parsed; try { - parsed = readGhostMapsKml( + const parsed = readGhostMapsKml( parseXml(decodeXml(entries.get(main)), 'KML'), resolveImage, ); - } catch (error) { - urls.forEach((url) => URL.revokeObjectURL(url)); - throw error; - } - entries.clear(); - signal.throwIfAborted(); - const color = PRODUCT_COLORS[product.id] || ACCENT; - const dataSource = new Cesium.CustomDataSource( - `${GHOSTMAPS_LAYER_ID}:${product.id}`, - ); - const features = []; - const pins = []; - dataSource.entities.suspendEvents(); - parsed.features.forEach((feature, index) => { - const key = `${product.id}:${index}`; - const { style } = feature; - feature.geometries.forEach((geometry, part) => { - const id = `${PICK_PREFIX}${key}:${part}`; - if (geometry.type === 'point') { - const position = Cesium.Cartesian3.fromDegrees(...geometry.position); - const entity = dataSource.entities.add({ - id, - position, - billboard: { - image: style.icon || markerImage(color), - scale: Math.min( - 1.5, - Math.max(0.35, (style.iconScale ?? 1) * 0.7), - ), - color: - style.icon && style.iconColor - ? cesiumColor(style.iconColor) - : undefined, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Ground-clamped icons would sink into terrain and 3D tiles; the - // horizon cull hides the far side instead (see `_horizon`). - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - pins.push({ - position, - get show() { - return entity.show; - }, - set show(value) { - entity.show = value; - }, - }); - } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees( + ...geometry.position, + ); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { dataSource.entities.add({ - id: `${id}:outline`, + id, polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), material: cesiumColor(style.lineColor, color), clampToGround: true, }, }); - } + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); }); - features.push({ - name: feature.name, - folder: feature.folder, - description: feature.description, - anchor: anchorOf(feature.geometries[0]), - }); - }); - dataSource.entities.resumeEvents(); - return { version: product.version, dataSource, urls, features, pins }; + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } } /** Bring the globe in line with the catalog and the chip selection. */ diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs new file mode 100644 index 000000000..51d546017 --- /dev/null +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from './index.js'; + +function element(localName, children = [], value = '', attributes = {}) { + const node = { + localName, + children, + get textContent() { + return value + children.map((child) => child.textContent).join(''); + }, + getAttribute: (key) => attributes[key] ?? null, + getElementsByTagName(name) { + return children.flatMap((child) => [ + ...(name === '*' || name === child.localName ? [child] : []), + ...child.getElementsByTagName(name), + ]); + }, + }; + for (const child of children) child.parentElement = node; + return node; +} + +function documentFixture() { + return element('document', [ + element('kml', [ + element('Document', [ + element( + 'Style', + [ + element('IconStyle', [ + element('Icon', [element('href', [], 'icon.png')]), + ]), + ], + '', + { id: 'pin' }, + ), + element('Placemark', [ + element('name', [], 'Test report'), + element('styleUrl', [], '#pin'), + element('Point', [element('coordinates', [], '12,34')]), + ]), + ]), + ]), + ]); +} + +// A stored KMZ with a local icon; the XML DOM double supplies its one point. +function fixtureKmz() { + const files = [ + ['doc.kml', ''], + ['icon.png', 'packed icon'], + ]; + const local = []; + const central = []; + let offset = 0; + for (const [name, value] of files) { + const key = Buffer.from(name); + const bytes = Buffer.from(value); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt32LE(bytes.length, 18); + header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(key.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt32LE(bytes.length, 20); + directory.writeUInt32LE(bytes.length, 24); + directory.writeUInt16LE(key.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, key, bytes); + central.push(directory, key); + offset += header.length + key.length + bytes.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return new Uint8Array(Buffer.concat([...local, directory, end])); +} + +function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); + globalThis.DOMParser = class { + parseFromString() { + return documentFixture(); + } + }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'DOMParser', previous); + else delete globalThis.DOMParser; + }); + const created = []; + const revoked = []; + t.mock.method(URL, 'createObjectURL', () => { + const url = `blob:local-${created.length}`; + created.push(url); + onCreateUrl?.(layer); + return url; + }); + t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); + t.mock.method(console, 'warn', () => {}); + const mounted = new Set(); + const viewer = { + scene: { requestRender() {} }, + dataSources: { + async add(dataSource) { + mounted.add(dataSource); + return dataSource; + }, + remove(dataSource) { + return mounted.delete(dataSource); + }, + }, + }; + const layer = createGhostMapsLayer({ + storage: null, + source: { + getCatalog: async () => ({ + products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }], + }), + getFile: async () => { + beforeFileReturns?.(layer); + return fixtureKmz(); + }, + }, + }); + layer.init(viewer); + layer.enable(); + t.after(() => layer.destroy()); + return { layer, viewer, mounted, created, revoked }; +} + +test('cancelling a file read does not allocate packed-image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()), + }); + await layer.update(viewer); + assert.equal(created.length, 0); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 0); +}); + +test('cancellation during parsing releases every packed-image URL', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + onCreateUrl: (layer) => layer.disable(), + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); + +test('entity construction failure releases parsed image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { + throw new Error('fixture entity failure'); + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); + assert.equal(layer.getStats().error, 'fixture entity failure'); +}); + +test('successful load keeps icons until the product is disabled', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 1); + assert.equal(layer.getStats().count, 1); + layer.disable(); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..693ddd47f 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) { launches: createLaunchSource(), alpr: createAlprTileSource(), firms: createFirmsSource(), + 's2-ghostmaps': createGhostMapsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), From 9496ee4d64d771c64cfb555f13350a75c156ac93 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:29:48 +0100 Subject: [PATCH 120/128] fix: complete GhostMaps locale and voice integration with current main --- changelog.d/1297-s2-ghostmaps-layer.md | 2 ++ docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 1 + src/firstRunExperience.test.mjs | 5 +++-- src/i18n/locales/zh-CN.json | 26 +++++++++++++++++++++++++- src/i18n/same-as-english.json | 2 ++ src/voice/actionSchemas.test.mjs | 3 ++- src/voice/layerManifest.js | 4 +++- 8 files changed, 39 insertions(+), 5 deletions(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index 938f5f89b..7dd52862d 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -10,3 +10,5 @@ - Release packed-image resources when a load is cancelled or fails, respect injected or omitted GhostMaps sources, and wait for background cache warm-up before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5527e29b7..8c672d95c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5330,6 +5330,7 @@ request viewbox and continues searching without that geographic preference. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. - **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. +- **Voice and locales.** S2 can be toggled through its registered id and aliases. The model already sees the S2 GhostMaps brand in the id, and compact alias-hint separators keep the complete voice prompt within its existing 52,000-character limit; accepted aliases are unchanged. The row and reports are translated in Spanish, Russian and Simplified Chinese. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f99a0f43c..5fb99c576 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3880,6 +3880,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 2b18c6825..15598d338 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1054,10 +1054,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Radio markup and the complete production-request comparison pin locality. // Restore the base camera/Globe View wording with the globe-rotation append. // S2 GhostMaps adds its layer id to the generated layer enums. - assert.equal(block.length, 30946, 'serialized tool schema length drifted'); + // Alias-hint separators omit spaces to keep the existing prompt budget. + assert.equal(block.length, 30888, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'be53b22c55e620fb36c6ea0f276bfe5a59de47fa02ead99188ada40c0bf48cc0', + 'b809253254b5589f3c199c218b405d8f661f040962fdd841f1182e5009119420', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..ac15a9172 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2818,5 +2818,29 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "情报态势图", + "Kinetic 30d": "近 30 天军事行动", + "Border Crisis": "边境危机", + "Iran Picture": "伊朗态势图", + "Iran Kinetic": "伊朗军事行动", + "Yemen FLOT": "也门前线", + "Latest Drop": "最新发布", + "GhostMaps file could not be read": "无法读取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目录不可用", + "Malformed GhostMaps catalog": "GhostMaps 目录格式无效", + "{count} source links ↗ · click card to open the first": { + "other": "{count} 个来源链接 ↗ · 点击卡片打开第一个" + }, + "S2 · {name}": "S2 · {name}", + "Report": "报告", + "Open the source for {title}": "打开 {title} 的来源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "实时 · GitHub {commit} · {message} · 每 {seconds} 秒检查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新连接以获取实时更新", + "Connecting to the S2 GhostMaps repository…": "正在连接 S2 GhostMaps 仓库…", + "{count} placemarks": { + "other": "{count} 个地标" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 3a4a9e54a..05f5d3dee 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -169,6 +169,8 @@ "NVG", "REC", "SRC {value}", + "S2 GhostMaps", + "S2 · {name}", "Sentinel-2", "Techno", "Trance", diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 617d0077f..4b88fa12d 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -43,7 +43,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // reset guidance/defaults update only move_camera and mode descriptions. // Restore base descriptions; append only globe-rotation defaults and fit guidance. // S2 GhostMaps adds its layer id to the generated layer enums. - '1f95d78074559217872ccbdfa57d4363264ea88c1a22b259e6ebba3c8189b806', + // Compact alias separators preserve the existing prompt budget. + '0128b5e28a8baf0d6466bfdbc6e7a80fd4bb32d0d22ccadfade2af641714f12a', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 14b515fa6..267399e58 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,6 +525,8 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', + // The brand is already the layer id; all spoken aliases remain accepted. + 's2-ghostmaps', ]); /** @@ -539,7 +541,7 @@ export function voiceLayerAliasHint() { entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), ) .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) - .join('; '); + .join(';'); } /** Field names that already carry their unit (altitudeM, speedKts, …). */ From 074e2723564f8ad6a57f68bacb0166d41e5c240c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:42:41 -0700 Subject: [PATCH 121/128] fix(voice): recover idle timers and retire panel audio observers --- changelog.d/1299-voice-panel-settings.md | 4 +- docs/CURRENT-STATE.md | 13 +- scripts/format-scope.json | 10 +- src/ui/styles/voice-cost.css | 9 + src/voice/control.js | 3 +- src/voice/panel/nativeActivity.js | 94 +++++++++-- src/voice/panel/nativeActivity.test.mjs | 199 +++++++++++++++++++++++ src/voice/realtimeCost.js | 13 ++ src/voice/realtimeCostPanel.test.mjs | 100 ++++++++++++ 9 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 src/voice/realtimeCostPanel.test.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md index 60a68e7d3..1a95a6ed4 100644 --- a/changelog.d/1299-voice-panel-settings.md +++ b/changelog.d/1299-voice-panel-settings.md @@ -1,3 +1,5 @@ - Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7907a9640..4f58da8b9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -588,6 +588,17 @@ Preview preference. Unverified speech captions use the localized Recent speech label; ordinary user captions retain YOU. Caption attribution does not control tool dispatch. +Voice inactivity waits for local input, output and actions to become quiet. +A matching OpenAI response that explicitly completes without audio can restore +idle eligibility; completion of an audio response does not prove playback has +drained. Missing or ambiguous output evidence remains conservative. Gemini +retires its per-audio observer wrappers on close, replacement or session end, +so repeated Stop/Start does not retain old audio instances until the panel closes. +The panel keeps the model tier and regular cost estimate in Voice Settings; +warning, capped and unknown cost states also appear in the dock heading when +Settings is closed. The existing hard cost cap still stops the session and +announces the reason. + Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even @@ -5160,7 +5171,7 @@ and unreachable upstream (502/504) separately from road geometry. - **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded. - **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction. - **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration. -- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`). +- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): hosts without the voice panel keep the `STD`/`MINI` toggle and running session-cost readout (`~$0.42`) in the heading. Panel hosts place these controls in Voice Settings and mirror warning, capped or unknown cost states in the heading. - **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`. - **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker. - **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e60413624..53e581063 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1089,5 +1089,13 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", - "src/hudDms.test.mjs" + "src/hudDms.test.mjs", + "src/voice/panel/preferences.test.mjs", + "src/voice/panel/host.test.mjs", + "src/voice/panel/manualPanel.test.mjs", + "src/voice/panel/nativeActivity.test.mjs", + "src/voice/panel/inactivity.test.mjs", + "src/voice/panel/actionObserver.test.mjs", + "src/voice/panel/testDom.mjs", + "src/voice/realtimeCostPanel.test.mjs" ] diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index 9d655e484..4ca1d558d 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -62,6 +62,15 @@ color: #ffc46b; } +.gev-voice-cost-warning { + margin-left: auto; + flex: 0 0 auto; +} + +.gev-voice-cost-warning[data-level='unknown'] { + color: #ffc46b; +} + .gev-voice-cost-value[data-level='cap'] { color: #ff6b6b; font-weight: 600; diff --git a/src/voice/control.js b/src/voice/control.js index af9d37cbc..df40be104 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -49,7 +49,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
${voiceStatusLabel('idle')}
${ panel - ? '' + ? `` : `
~$0.00 @@ -193,6 +193,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) { errorDetail: root.querySelector('#gev-voice-error-detail'), tierButton: root.querySelector('#gev-voice-tier'), costValue: root.querySelector('#gev-voice-cost-value'), + costWarning: root.querySelector('#gev-voice-cost-warning'), card: root.querySelector('#gev-voice-card'), }; } diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index 322f14d75..c019f76c8 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -36,6 +36,33 @@ export function observeNativeActivity({ let audio = null, version = 0; const restores = []; + const audioRestores = []; + const pendingOutput = new Map(); + let untrackedOutput = !outputKnown; + function retireAudio() { + audio = null; + for (const restore of audioRestores.splice(0).reverse()) restore(); + } + function refreshOutputKnowledge() { + outputKnown = !untrackedOutput && pendingOutput.size === 0; + } + function hasNoAudioOutput(response) { + return ( + ['completed', 'cancelled', 'failed', 'incomplete'].includes( + response?.status, + ) && + Array.isArray(response.output) && + response.output.every( + (item) => + item?.type === 'function_call' || + (item?.type === 'message' && + Array.isArray(item.content) && + item.content.every((part) => + ['text', 'output_text', 'refusal'].includes(part?.type), + )), + ) + ); + } function snapshot() { const turns = nativeProvider === 'openai' ? controller?._turns : controller; return { @@ -72,7 +99,7 @@ export function observeNativeActivity({ } catch {} } } - function wrap(object, key, after) { + function wrap(object, key, after, ownedRestores = restores) { const original = object?.[key]; if (typeof original !== 'function') return; function wrapped(...args) { @@ -86,7 +113,7 @@ export function observeNativeActivity({ return result; } object[key] = wrapped; - restores.push(() => { + ownedRestores.push(() => { if (object[key] === wrapped) object[key] = original; }); } @@ -104,9 +131,11 @@ export function observeNativeActivity({ (state === 'connecting' && wasConnected) ) { version++; - audio = null; + retireAudio(); inputBusy = false; outputKnown = cleanNativeOutput(); + untrackedOutput = !outputKnown; + pendingOutput.clear(); } } notify(); @@ -127,13 +156,41 @@ export function observeNativeActivity({ inputBusy = true; if (event?.type === 'input_audio_buffer.speech_stopped') inputBusy = false; - // A previous turn's drain does not prove the next transport emits output events. - if (event?.type === 'response.created') outputKnown = false; + const type = event?.type; + const id = event?.response_id || event?.response?.id; + // Keep each unresolved response separate: a later silent reply cannot + // establish that an earlier audio reply has drained. + if (type === 'response.created') { + if (id && pendingOutput.size < 64) + pendingOutput.set(id, { audioSeen: false }); + else untrackedOutput = true; + } + const pending = pendingOutput.get(id); if ( - event?.type === 'output_audio_buffer.started' || - event?.type === 'output_audio_buffer.cleared' + pending && + (type?.startsWith('response.output_audio') || + type?.startsWith('response.audio') || + ['audio', 'output_audio'].includes(event?.part?.type)) ) - outputKnown = true; + pending.audioSeen = true; + if ( + type === 'response.done' && + pending && + !pending.audioSeen && + hasNoAudioOutput(event.response) + ) + pendingOutput.delete(id); + if ( + type === 'output_audio_buffer.started' || + type === 'output_audio_buffer.cleared' + ) { + // Native playback events establish the transport's output lifecycle. + // Generation completion alone never establishes audio drainage. + untrackedOutput = false; + if (id) pendingOutput.delete(id); + else pendingOutput.clear(); + } + refreshOutputKnowledge(); notify(); }); } else { @@ -183,12 +240,23 @@ export function observeNativeActivity({ forwarded[0] = decorated; current = Reflect.apply(original, this, forwarded); if (!dead && stamp === version) { + retireAudio(); audio = current; inputBusy = false; for (const key of ['play', 'clearPlayback', 'close']) - wrap(current, key, () => { - if (audio === current && controller.audio === current) notify(); - }); + wrap( + current, + key, + () => { + if (audio !== current) return; + if (key === 'close') { + retireAudio(); + inputBusy = false; + } + if (controller.audio === current) notify(); + }, + audioRestores, + ); } return current; } @@ -203,7 +271,9 @@ export function observeNativeActivity({ if (dead) return; dead = true; version++; - for (const restore of restores.reverse()) restore(); + retireAudio(); + pendingOutput.clear(); + for (const restore of restores.splice(0).reverse()) restore(); signal?.removeEventListener('abort', destroy); } signal?.addEventListener('abort', destroy, { once: true }); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index 40b298387..f1ac6cd5f 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -471,3 +471,202 @@ test('native typed input observation preserves receiver/result, refuses failed s old.call(controller, 'late'); assert.equal(inputs, 1); }); + +test('explicit silent OpenAI completion rearms expiry, including cancelled and failed responses', () => { + for (const status of ['completed', 'cancelled', 'failed', 'incomplete']) { + const turns = new RealtimeTurns({}); + const controller = { status: 'idle', dc: null, _turns: turns, _input: {} }; + let idle; + let timer; + let expired = 0; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire: () => expired++, + }); + controller.status = 'listening'; + controller.dc = {}; + observer.handle({ type: 'state', state: 'listening' }); + assert.equal(typeof timer, 'function'); + turns.responseActive = true; + turns.observeSpeech({ + type: 'response.created', + response: { id: 'silent' }, + }); + assert.equal(timer, null); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'silent', + status, + output: + status === 'completed' + ? [{ type: 'function_call', name: 'set_panel_open' }] + : [], + }, + }); + // The provider updates generation state after forwarding the raw event. + assert.equal(timer, null); + turns.responseActive = false; + observer.handle({ type: 'completion' }); + assert.equal(observer.snapshot().known, true); + assert.equal(typeof timer, 'function'); + timer(); + assert.equal(expired, 1); + idle.destroy(); + observer.destroy(); + } +}); + +test('silent OpenAI replies cannot clear unresolved older audio or incomplete output metadata', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + const created = (id) => + turns.observeSpeech({ type: 'response.created', response: { id } }); + const done = (id, output) => + turns.observeSpeech({ + type: 'response.done', + response: { id, status: 'completed', output }, + }); + created('audio'); + turns.observeSpeech({ + type: 'response.output_audio.delta', + response_id: 'audio', + }); + created('silent'); + done('silent', []); + assert.equal(observer.snapshot().known, false); + done('audio', []); // Even contradictory completion metadata cannot erase audio evidence. + assert.equal(observer.snapshot().known, false); + turns.observeSpeech({ + type: 'output_audio_buffer.started', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, true); + done('silent', []); + assert.equal(observer.snapshot().outputBusy, true); + turns.observeSpeech({ + type: 'output_audio_buffer.stopped', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, false); + created('unknown'); + done('unknown', undefined); + assert.equal(observer.snapshot().known, false); + done('unobserved', []); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('overlapping silent replies resolve independently and audio output items remain unknown until playback', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + for (const id of ['a', 'b']) + turns.observeSpeech({ type: 'response.created', response: { id } }); + for (const id of ['a', 'b']) { + turns.observeSpeech({ + type: 'response.done', + response: { + id, + status: 'completed', + output: [ + { type: 'message', content: [{ type: 'text', text: 'Done' }] }, + ], + }, + }); + assert.equal(observer.snapshot().known, id === 'b'); + } + turns.observeSpeech({ type: 'response.created', response: { id: 'audio' } }); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'audio', + status: 'completed', + output: [{ type: 'message', content: [{ type: 'audio' }] }], + }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('silent completion cannot establish output knowledge when observer attached mid-session', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'listening', dc: {}, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + turns.observeSpeech({ type: 'response.created', response: { id: 'new' } }); + turns.observeSpeech({ + type: 'response.done', + response: { id: 'new', status: 'completed', output: [] }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('Gemini restores each retired audio immediately across repeated same-mount restarts', () => { + const originals = new Map(); + const controller = { + input: {}, + createAudio() { + const audio = { pending: 0, play() {}, clearPlayback() {}, close() {} }; + originals.set(audio, { ...audio }); + return audio; + }, + handleMessage() {}, + }; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'gemini', + }); + const assertRestored = (audio) => { + for (const key of ['play', 'clearPlayback', 'close']) + assert.equal(audio[key], originals.get(audio)[key]); + }; + for (let i = 0; i < 5; i++) { + observer.handle({ type: 'state', state: 'connecting' }); + controller.audio = controller.createAudio({}); + const audio = controller.audio; + observer.handle({ type: 'state', state: 'listening' }); + assert.notEqual(audio.close, originals.get(audio).close); + audio.close(); + assertRestored(audio); + controller.audio = null; + observer.handle({ type: 'state', state: 'idle' }); + } + controller.audio = controller.createAudio({}); + const replaced = controller.audio; + controller.audio = controller.createAudio({}); + assertRestored(replaced); + const current = controller.audio; + const replacement = () => {}; + current.play = replacement; + observer.handle({ type: 'state', state: 'error' }); + assert.equal(current.play, replacement); + assert.equal(current.close, originals.get(current).close); + observer.destroy(); +}); diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 98b03781f..ce20e1fe2 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -46,6 +46,17 @@ export class RealtimeCost { */ syncCostUi() { const state = this.costTracker.state(); + const syncWarning = () => { + const warning = this.ui?.costWarning; + const value = this.ui?.costValue; + if (!warning || !value) return; + warning.hidden = !['warn', 'cap', 'unknown'].includes( + value.dataset.level, + ); + warning.textContent = warning.hidden ? '' : value.textContent; + warning.title = value.title; + warning.dataset.level = value.dataset.level; + }; const pendingTier = resolveVoiceModel(this.voiceTier).tier; const isMini = pendingTier === 'mini'; if (this.ui?.tierButton) { @@ -91,6 +102,7 @@ export class RealtimeCost { 'Usage is incomplete because a response was still in flight when the session ended.', ) : ''); + syncWarning(); return; } this.ui.costValue.hidden = false; @@ -114,6 +126,7 @@ export class RealtimeCost { 'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.', ) : ''); + syncWarning(); } } diff --git a/src/voice/realtimeCostPanel.test.mjs b/src/voice/realtimeCostPanel.test.mjs new file mode 100644 index 000000000..07622f84d --- /dev/null +++ b/src/voice/realtimeCostPanel.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVoiceControl } from './control.js'; +import { RealtimeCost } from './realtimeCost.js'; +import { createVoiceCostTracker, resolveVoiceModel } from './voiceCost.js'; +import { installPanelDom } from './panel/testDom.mjs'; + +function harness(t) { + installPanelDom(t); + const ui = createVoiceControl({ panel: true }); + const stops = []; + const statuses = []; + const cost = new RealtimeCost({ + readUi: () => ui, + readStatus: () => 'listening', + operations: { + isActive: () => true, + stop: (options) => stops.push(options), + setStatus: (...args) => statuses.push(args), + debugLog() {}, + }, + }); + cost.cloudVoiceAuth = 'api-key'; + cost.costTracker = createVoiceCostTracker({ + tier: 'standard', + limits: { warnUsd: 2, capUsd: 5 }, + }); + const recordThreeDollars = () => { + const tokens = Math.ceil( + (3 * 1_000_000) / resolveVoiceModel('standard').rates.textOutput, + ); + const state = cost.costTracker.record({ + output_tokens: tokens, + output_token_details: { text_tokens: tokens }, + }); + cost.applyCost(state); + }; + t.mock.method(console, 'warn', () => {}); + return { cost, ui, stops, statuses, recordThreeDollars }; +} + +test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', (t) => { + const { cost, ui, recordThreeDollars } = harness(t); + cost.syncCostUi(); + assert.equal(ui.voicePanel.hidden, true); + assert.equal(ui.costValue.checkVisibility(), false); + assert.equal(ui.costWarning.hidden, true); + assert.equal(ui.voicePanel.contains(ui.costWarning), false); + assert.equal(ui.root.querySelectorAll('#gev-voice-cost-value').length, 1); + + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'warn'); + assert.equal(ui.costWarning.textContent, ui.costValue.textContent); + assert.equal(ui.costWarning.title, ui.costValue.title); + assert.match(ui.costWarning.title, /Warns at/); + assert.equal( + ui.voicePanel.hidden, + true, + 'warning never forces Settings open', + ); + + cost.prepareSession(); + cost.syncCostUi(); + assert.equal( + ui.costWarning.hidden, + true, + 'new session clears the old warning', + ); + assert.equal(ui.costWarning.textContent, ''); +}); + +test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', (t) => { + const { ui, stops, statuses, recordThreeDollars } = harness(t); + recordThreeDollars(); + assert.equal(stops.length, 0); + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'cap'); + assert.deepEqual(stops, [{ preserveStatus: true }]); + assert.equal(statuses.at(-1)[0], 'idle'); + assert.match(statuses.at(-1)[1], /Session ended — cost cap/); +}); + +test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', (t) => { + const { cost, ui } = harness(t); + cost.sessionCloudVoiceAuth = 'oauth'; + cost.syncCostUi(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'unknown'); + assert.equal(ui.costWarning.textContent, 'COST UNKNOWN'); + assert.match( + ui.costWarning.title, + /USD cost and the API spend cap are unavailable/, + ); + assert.equal(ui.voicePanel.hidden, true); + cost.sessionCloudVoiceAuth = 'api-key'; + cost.syncCostUi(); + assert.equal(ui.costWarning.hidden, true); +}); From ddbcd2053b360a472e522b8c95304b05df165307 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:47:54 -0700 Subject: [PATCH 122/128] fix(voice): preserve idle evidence through recoverable response errors --- src/voice/panel/nativeActivity.js | 16 +++- src/voice/panel/nativeActivity.test.mjs | 98 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index c019f76c8..ce9426cdc 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -16,7 +16,8 @@ export function observeNativeActivity({ : null; let dead = false, connected = false, - inputBusy = false; + inputBusy = false, + recoverableError = false; function cleanNativeOutput() { if (nativeProvider === 'gemini') return true; if (nativeProvider !== 'openai') return false; @@ -122,13 +123,22 @@ export function observeNativeActivity({ if (event?.type === 'state') { const state = event.state; const wasConnected = connected; + const liveError = + state === 'error' && + nativeProvider === 'openai' && + controller.dc?.readyState === 'open'; + const restartingAfterError = state === 'connecting' && recoverableError; + recoverableError = liveError; connected = !['idle', 'connecting', 'error'].includes(state); // Connecting has multiple native phases around audio allocation. Retire // a previous connected generation once, not each phase of this start. + // A failed reply can briefly report error on a live OpenAI transport. + // Preserve its output evidence through recovery to listening; a later + // reconnect still retires the failed connection's generation. if ( state === 'idle' || - state === 'error' || - (state === 'connecting' && wasConnected) + (state === 'error' && !liveError) || + (state === 'connecting' && (wasConnected || restartingAfterError)) ) { version++; retireAudio(); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index f1ac6cd5f..81709ce53 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -670,3 +670,101 @@ test('Gemini restores each retired audio immediately across repeated same-mount assert.equal(current.close, originals.get(current).close); observer.destroy(); }); + +test('actual OpenAI failed-response error/listening recovery preserves only proven no-audio knowledge', async (t) => { + const { GevRealtimeController } = await import('../realtimeController.js'); + t.mock.method(console, 'error', () => {}); + for (const [output, known] of [ + [[], true], + [[{ type: 'message', content: [{ type: 'audio' }] }], false], + [undefined, false], + ]) { + let observer, idle, timer; + const states = []; + let expired = 0; + const controller = new GevRealtimeController({ + runner: async () => ({ ok: true }), + backend: {}, + debugSink: null, + ui: { + root: { + dataset: {}, + classList: { remove() {} }, + querySelectorAll: () => [], + }, + status: {}, + detail: {}, + }, + onSessionEvent(event) { + if (event.type === 'state') states.push(event.state); + observer?.handle(event); + }, + }); + observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire() { + expired++; + }, + }); + try { + controller.dc = { + readyState: 'open', + send() {}, + close() { + this.readyState = 'closed'; + }, + }; + controller.setStatus('listening', 'Ready'); + const event = (payload) => + controller.handleRealtimeEvent({ data: JSON.stringify(payload) }); + await event({ + type: 'response.created', + response: { id: 'failed-reply' }, + }); + await event({ + type: 'response.done', + response: { + id: 'failed-reply', + status: 'failed', + output, + status_details: { error: { message: 'Synthetic failed reply' } }, + }, + }); + assert.deepEqual(states.slice(-2), ['error', 'listening']); + assert.equal(controller.dc.readyState, 'open'); + assert.equal(observer.snapshot().known, known); + assert.equal(idle.snapshot().eligible, known); + if (known) { + timer(); + assert.equal(expired, 1); + } else assert.equal(timer, null); + // Fatal teardown can preserve the error status; the next connection must + // still retire the old generation rather than inherit unresolved output. + controller.setStatus('error', 'Transport failed'); + controller.stop({ preserveStatus: true }); + controller.setStatus('connecting', 'Reconnecting'); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + controller.stop(); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + } finally { + idle.destroy(); + observer.destroy(); + controller.stop(); + } + } +}); From b7ece1d4d13b551f635a9a5653380b4f40f26d1f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 13:56:19 -0700 Subject: [PATCH 123/128] fix(share): restore a share link opened in an already-open tab (#1416) * fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416 --- changelog.d/1416-share-hash-navigation.md | 4 + src/data/layerStateCoordinator.js | 48 ++- src/locationReadouts.test.mjs | 6 +- src/sharelink.js | 79 +++- src/sharelink.navigation.test.mjs | 425 ++++++++++++++++++++++ src/ui/navigationController.js | 4 +- src/ui/shareRestoration.js | 165 +++++++-- src/ui/visualSettings.js | 15 +- 8 files changed, 688 insertions(+), 58 deletions(-) create mode 100644 changelog.d/1416-share-hash-navigation.md create mode 100644 src/sharelink.navigation.test.mjs diff --git a/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md new file mode 100644 index 000000000..0effac1cb --- /dev/null +++ b/changelog.d/1416-share-hash-navigation.md @@ -0,0 +1,4 @@ +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 687b87dfb..ef3a4ea4a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -148,6 +148,36 @@ export class LayerStateCoordinator { return this.restorePromise; } + /** + * Replace the running layer state with a share opened later in this tab + * (hash navigation). Every in-flight restore and pending shared Follow is + * superseded; the new state restores through the same passive share origin + * as start-up, so recipient-local preferences are never written. A share + * without a layer payload restores the defaults, as a reload of it would. + */ + applyShareLayerState({ + shareLayerState = null, + shareCreatedAtMs = null, + } = {}) { + if (this._destroyed) return Promise.resolve([]); + for (const controller of this._restoreControllers.values()) + controller.abort('superseded-by-share-navigation'); + this._revokePendingTrackingWatch('superseded-by-share-navigation'); + const selected = shareLayerState + ? normalizeLayerState(shareLayerState) + : null; + this._source = selected ? 'share' : 'legacy-share'; + this._shareCreatedAtMs = + selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; + this._durableState = selected || createDefaultLayerState(); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + this.restorePromise = this._restoreSelectedState( + LAYER_RESTORE_ORIGINS.share, + ); + return this.restorePromise; + } + get source() { return this._source; } @@ -312,15 +342,19 @@ export class LayerStateCoordinator { } async _restoreSelectedState(origin) { + // One controller set per run: a superseded run must not clear the newer + // run's controllers when it settles. + const controllers = new Map(); for (const entry of LAYER_STATE_REGISTRY) { - this._restoreControllers.set(entry.id, new AbortController()); + controllers.set(entry.id, new AbortController()); } + this._restoreControllers = controllers; try { await this._waitForRestoreGate(); const enabled = new Set(this._durableState.enabledLayerIds); const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { - const controller = this._restoreControllers.get(entry.id); + const controller = controllers.get(entry.id); const targetEnabled = enabled.has(entry.id); const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { @@ -370,7 +404,7 @@ export class LayerStateCoordinator { })); }), ); - this.lastRestoreResults = settled.map((result, index) => { + const results = settled.map((result, index) => { if (result.status === 'fulfilled') return result.value; const entry = LAYER_STATE_REGISTRY[index]; return { @@ -387,9 +421,13 @@ export class LayerStateCoordinator { succeeded: false, }; }); - return this.lastRestoreResults.map((result) => ({ ...result })); + // A run superseded by a newer share keeps the newer run's results. + if (this._restoreControllers === controllers) + this.lastRestoreResults = results; + return results.map((result) => ({ ...result })); } finally { - this._restoreControllers.clear(); + if (this._restoreControllers === controllers) + this._restoreControllers.clear(); this._notifyDurableState(); } } diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 9d38592e3..6c63406ee 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => { /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); - const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); + const reassert = ui.indexOf( + " _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {", + ); assert.ok(reassert > 0, '_reassertNavigationHandoff is missing'); assert.match( ui.slice(reassert, reassert + 900), - /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/, + /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/, ); // …and the policy only reaches `release` after its authority checks pass. diff --git a/src/sharelink.js b/src/sharelink.js index c184a59f5..9668fa979 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -122,6 +122,13 @@ export class ShareLinkManager { this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; + // Each incoming share (start-up or a later hash navigation) gets a token; + // only the newest one may release URL-write suppression. + this._restoreToken = 0; + // The hash this page last read or wrote. A location change back to it is + // the app's own state, never an incoming share. + this._knownHash = null; + this._unwatchNavigation = null; this._restoreAuthority = { visual: 0, map: 0, @@ -152,6 +159,15 @@ export class ShareLinkManager { */ parseInitialHash() { const hash = window.location.hash.slice(1); + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return null; + this._beginIncomingRestore(state); + return state; + } + + /** Parse one hash without side effects. Returns null unless it is a share. */ + _parseShareHash(hash) { if (!hash) return null; const params = new URLSearchParams(hash); @@ -242,15 +258,55 @@ export class ShareLinkManager { panelState: decodePanelStateParams(params), sharedAtMs: decodeShareCreatedAtMs(params), }; + return state; + } + + /** Reserve restore lanes and hold URL writes for one incoming share. */ + _beginIncomingRestore(state) { state.restoreAuthority = { visual: this._restoreAuthority.visual, map: this._restoreAuthority.map, panels: new Map(this._restoreAuthority.panels), }; - - // Hold URL writes until the complete incoming state has been restored. + state.restoreToken = ++this._restoreToken; + // Hold URL writes until the complete incoming state has been restored. A + // debounced write of the previous view must not land over the new link. this._initialRestorePending = true; - return state; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Restore a share link opened in this tab after start-up: a pasted address, + * a followed link, or Back/Forward. The app's own replaceState writes never + * fire these events, and a hash equal to the last one read or written is + * ignored anyway, so only a genuinely different incoming share restores. + */ + watchNavigation(onNavigate) { + this._unwatchNavigation?.(); + if (this._destroyed || typeof onNavigate !== 'function') return; + const target = globalThis.window; + if (typeof target?.addEventListener !== 'function') return; + const handler = () => this._handleLocationChange(onNavigate); + target.addEventListener('hashchange', handler); + target.addEventListener('popstate', handler); + this._unwatchNavigation = () => { + target.removeEventListener('hashchange', handler); + target.removeEventListener('popstate', handler); + this._unwatchNavigation = null; + }; + } + + _handleLocationChange(onNavigate) { + if (this._destroyed) return; + const hash = window.location.hash.slice(1); + // Back/Forward fires both popstate and hashchange for one entry. + if (hash === this._knownHash) return; + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return; + this._beginIncomingRestore(state); + onNavigate(state); } /** @@ -374,9 +430,13 @@ export class ShareLinkManager { }; } - /** Release initial hash suppression only after every restore owner settles. */ - completeInitialRestore() { + /** + * Release hash suppression only after every restore owner settles. A token + * from a superseded share restore leaves the newer one holding the URL. + */ + completeInitialRestore(restoreToken = this._restoreToken) { if (!this._initialRestorePending) return; + if (restoreToken !== this._restoreToken) return; this._initialRestorePending = false; this._scheduleUpdate(); } @@ -528,11 +588,9 @@ export class ShareLinkManager { if (!params) return; // The hash belongs to this page's address, not the document base the // app may load under. - history.replaceState( - null, - '', - new URL(`#${params.toString()}`, window.location.href).href, - ); + const url = new URL(`#${params.toString()}`, window.location.href); + this._knownHash = url.hash.slice(1); + history.replaceState(null, '', url.href); } /** Build a deterministic snapshot without mutating history. */ @@ -615,6 +673,7 @@ export class ShareLinkManager { this._debounceTimer = null; this._removeCameraChanged?.(); this._removeCameraChanged = null; + this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs new file mode 100644 index 000000000..e4e70b629 --- /dev/null +++ b/src/sharelink.navigation.test.mjs @@ -0,0 +1,425 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { DataLayerManager } from './data/manager.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, +} from './data/layerState.js'; +import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; + +const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; +const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; + +/** + * A window whose address behaves like a tab's: `navigate` is a hash-only + * navigation (new history entry plus hashchange), `back`/`forward` traverse + * entries (popstate plus hashchange), and `history.replaceState` rewrites the + * current entry without firing anything. + */ +function installTab(t, initialHash = '') { + const prior = { + window: globalThis.window, + history: globalThis.history, + }; + const target = new EventTarget(); + const location = new URL( + `http://localhost/${initialHash ? `#${initialHash}` : ''}`, + ); + const entries = [location.hash]; + let index = 0; + const listeners = { hashchange: 0, popstate: 0 }; + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] += 1; + add(type, handler, options); + }; + target.removeEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] -= 1; + remove(type, handler, options); + }; + target.location = location; + globalThis.window = target; + globalThis.history = { + replaceState(_state, _title, next) { + location.hash = new URL(next, location.href).hash; + entries[index] = location.hash; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + const go = (nextIndex) => { + index = nextIndex; + location.hash = entries[index]; + target.dispatchEvent(new Event('popstate')); + target.dispatchEvent(new Event('hashchange')); + }; + return { + listeners, + navigate(hash) { + entries.splice(index + 1); + entries.push(`#${hash}`); + index = entries.length - 1; + location.hash = hash; + target.dispatchEvent(new Event('hashchange')); + }, + back: () => go(index - 1), + forward: () => go(index + 1), + }; +} + +/** Queue timers instead of running them, so delayed work runs only on demand. */ +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return timers; +} + +function fakeViewer() { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; +} + +test('a hash navigation to a different share restores it and holds URL writes', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + const initial = manager.parseInitialHash(); + manager.completeInitialRestore(initial.restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + assert.equal(navigated[0].style, 'normal'); + assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']); + assert.equal(manager._initialRestorePending, true); + // Nothing from the previous view may overwrite the incoming link. + manager._updateHash(); + assert.equal(window.location.hash, `#${SHARE_B}`); + manager.completeInitialRestore(navigated[0].restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test("the app's own hash writes never trigger a restore", (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + manager._updateHash(); + assert.notEqual(window.location.hash, `#${SHARE_A}`); + // replaceState fires nothing, but a stray event for our own hash is ignored. + window.dispatchEvent(new Event('hashchange')); + window.dispatchEvent(new Event('popstate')); + assert.equal(restores, 0); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The app rewrites entry 0 with its own state; Back must return to that. + manager._updateHash(); + const ownHash = window.location.hash.slice(1); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate('not-a-share'); + assert.equal(navigated.length, 0); + assert.equal(manager._initialRestorePending, false); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + manager.completeInitialRestore(navigated[0].restoreToken); + + tab.back(); // not-a-share + tab.back(); // the app's own rewritten first entry + assert.equal(navigated.length, 2, 'popstate and hashchange restore once'); + assert.equal(window.location.hash.slice(1), ownHash); + assert.equal(navigated[1].lat, 0); + manager.completeInitialRestore(navigated[1].restoreToken); + + tab.forward(); + tab.forward(); + assert.equal(navigated.length, 3); + assert.equal(navigated[2].lat, 48.8584); + manager.destroy(); +}); + +test('a newer navigation supersedes an older restore and its pending write', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + assert.equal(timers.size, 1, 'releasing suppression schedules a write'); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal( + timers.size, + 0, + 'the pending write of the old view is cancelled', + ); + tab.navigate(SHARE_A); + const [older, newer] = navigated; + assert.ok(newer.restoreToken > older.restoreToken); + manager.completeInitialRestore(older.restoreToken); + assert.equal( + manager._initialRestorePending, + true, + 'a superseded restore cannot release writes', + ); + manager.completeInitialRestore(newer.restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('destroy removes the navigation listeners', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 }); + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual( + tab.listeners, + { hashchange: 1, popstate: 1 }, + 'rewatching replaces', + ); + manager.destroy(); + assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 }); + tab.navigate(SHARE_B); + assert.equal(restores, 0); +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */ +function restorationHarness(t, initialHash, navigationOverrides = {}) { + const tab = installTab(t, initialHash); + const timers = installManualTimers(t); + + const restores = []; + let navigationGeneration = 0; + const manager = new ShareLinkManager(fakeViewer(), { + onRestore: (state) => { + const gate = deferred(); + restores.push({ state, gate }); + return gate.promise; + }, + isNavigationCurrent: () => false, + }); + const layerCalls = []; + const handoffs = []; + const owner = new ShareRestoration({ + viewer: { canvas: new EventTarget() }, + navigation: { + _beginDeferredNavigation: () => ++navigationGeneration, + _reassertNavigationHandoff: (generation, options) => { + handoffs.push({ generation, options }); + return true; + }, + _stampNavigation() {}, + ...navigationOverrides, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + }); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState(options) { + layerCalls.push(options); + return Promise.resolve([]); + }, + getDurableState: () => null, + cancelPendingShareTracking: () => false, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { tab, timers, manager, owner, restores, layerCalls, handoffs }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => { + const { tab, timers, manager, owner, restores, layerCalls } = + restorationHarness(t, SHARE_A); + assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight'); + + tab.navigate(SHARE_B); + assert.equal(timers.size, 0, 'the delayed start-up restore never runs'); + assert.equal((await owner.initialRestorePromise).status, 'superseded'); + assert.equal(restores.length, 1); + assert.equal(restores[0].state.style, 'normal'); + assert.equal(restores[0].state.mapStack, 'photoreal'); + assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']); + + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); +}); + +test('a navigated share releases the old Follow passively, never as a saved preference', (t) => { + const { tab, handoffs } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + assert.equal(handoffs.length, 1); + assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' }); +}); + +test('a navigated share that fails to start never freezes the address bar', async (t) => { + const settled = []; + let calls = 0; + const { tab, manager } = restorationHarness(t, SHARE_A, { + // Start-up reserves the camera; the navigated share then fails to. + _beginDeferredNavigation: () => { + calls += 1; + if (calls > 1) throw new Error('navigation unavailable'); + return calls; + }, + }); + window.addEventListener('gev:share-navigation-restore-settled', (event) => + settled.push(event.detail), + ); + tab.navigate(SHARE_B); + assert.equal(manager._initialRestorePending, false); + assert.equal(settled[0]?.status, 'failed'); +}); + +test('a second navigation during a restore supersedes it end to end', async (t) => { + const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + tab.navigate(SHARE_A); + assert.equal(restores.length, 2); + assert.equal(layerCalls.length, 2); + assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [ + 'earthquakes', + ]); + + // The newer restore finishes first; the older one settling late must not + // release or reopen anything. + restores[1].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); + manager._initialRestorePending = true; // observe any late release + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, true); +}); + +test('the layer coordinator replaces in-flight share layers with the navigated share', async () => { + const enableGates = new Map(); + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) { + manager.register({ + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + const gate = enableGates.get(id); + if (gate) await gate.promise; + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + }); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const quakeGate = deferred(); + enableGates.set('earthquakes', quakeGate); + const writes = []; + const storage = { + getItem: () => null, + setItem: (...args) => writes.push(args), + }; + const sink = { + provider: null, + setLayerStateProvider(provider) { + this.provider = provider; + }, + onLayerStateChange() {}, + }; + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + const first = coordinator.start({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)), + allowLocalState: false, + }); + const second = coordinator.applyShareLayerState({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)), + }); + quakeGate.resolve(); + await Promise.all([first, second]); + const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true; + assert.equal(enabled('earthquakes'), false); + assert.equal(enabled('flights'), true); + assert.deepEqual(sink.provider().enabledLayerIds, ['flights']); + assert.equal(coordinator.source, 'share'); + assert.deepEqual( + writes, + [], + 'a passive share restore never writes local preferences', + ); + + // A share without a layer payload restores the defaults, as a reload does. + await coordinator.applyShareLayerState({ shareLayerState: null }); + assert.equal(coordinator.source, 'legacy-share'); + assert.deepEqual( + sink.provider().enabledLayerIds, + createDefaultLayerState().enabledLayerIds, + ); + assert.equal( + enabled('flights'), + createDefaultLayerState().enabledLayerIds.includes('flights'), + ); + coordinator.destroy(); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 437b643f3..e2a135142 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -241,7 +241,7 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation) { + _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -253,7 +253,7 @@ export class NavigationController { // was refused by the cockpit leaves the old readout standing. release: () => { this.clearLocation(); - return this._releaseFollowCamera(); + return this._releaseFollowCamera({ trackingOrigin }); }, }); } diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 52c48c95b..7fd52d917 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -80,6 +80,10 @@ export class ShareRestoration { this._initialShareRestoreTimeout = null; this._layerStateCoordinator = null; this._layerStateRestorePromise = null; + // Bumped by every share opened after start-up; an older restore that + // settles later must neither apply follow-up work nor release URL writes. + this._shareRestoreGeneration = 0; + this._navigatedShareRestoreActive = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -108,42 +112,12 @@ export class ShareRestoration { const applyCamera = Number.isInteger(generation) && this.navigation._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || - Promise.resolve([])); - const tracking = - share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: - this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'settled', - share, - layers, - tracking, - }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); + void this._runShareRestore(savedState, { + applyCamera, + navigationToken: generation, + layersPromise: this._layerStateRestorePromise, + settle: (result) => this._settleInitialShareRestore(result), + }); }, 1500); } else { this.syncShareState(); @@ -155,7 +129,8 @@ export class ShareRestoration { if ( this._disposed || !this._hasShareState || - !this._resolveInitialShareRestore + (!this._resolveInitialShareRestore && + !this._navigatedShareRestoreActive) ) return; stampInitialShareGesture((options) => @@ -176,6 +151,122 @@ export class ShareRestoration { passive: true, }, ); + // A different share opened in this tab later (pasted address, link, + // Back/Forward) runs the same restore as start-up. + this.shareLinkManager?.watchNavigation?.((state) => + this._restoreNavigatedShare(state), + ); + } + + /** + * Apply one incoming share: camera, visual, map and panels, then layers and + * any shared Follow. Only the newest share may finish: a superseded run + * stops before follow-up work and leaves URL writes suppressed for the + * newer one. + */ + async _runShareRestore( + state, + { applyCamera, navigationToken, layersPromise, settle }, + ) { + const generation = this._shareRestoreGeneration; + const current = () => + !this._disposed && generation === this._shareRestoreGeneration; + try { + const share = await this.shareLinkManager.applyState(state, { + applyCamera, + navigationToken, + }); + const layers = await (layersPromise || Promise.resolve([])); + if (!current()) return; + const tracking = + share.camera === 'applied' + ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() + : { + status: 'superseded', + cleared: + this._layerStateCoordinator?.cancelPendingShareTracking?.( + 'shared-camera-superseded', + { clearSelection: true }, + ) === true, + }; + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ status: 'settled', share, layers, tracking }); + } catch (error) { + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + /** Restore a share opened in this tab after start-up, superseding any other. */ + _restoreNavigatedShare(state) { + if (this._disposed || !state) return; + this._shareRestoreGeneration += 1; + const settle = (result) => { + this._navigatedShareRestoreActive = false; + window.dispatchEvent( + new CustomEvent('gev:share-navigation-restore-settled', { + detail: result, + }), + ); + }; + try { + return this._beginNavigatedShareRestore(state, settle); + } catch (error) { + // Never leave the address bar frozen on a restore that did not start. + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + _beginNavigatedShareRestore(state, settle) { + // A start-up restore still waiting for its delayed flight never runs. + clearTimeout(this._initialShareRestoreTimeout); + this._initialShareRestoreTimeout = null; + this._settleInitialShareRestore({ + status: 'superseded', + share: null, + layers: [], + }); + this._initialShareState = state; + this._initialShareSelectionSuperseded = false; + this._hasShareState = true; + this._navigatedShareRestoreActive = true; + // A new deferred navigation generation retires any older share flight. + const navigationToken = this.navigation._beginDeferredNavigation( + 'shared view', + { cancelPendingSelection: false }, + ); + // Releasing the previous Follow is part of the passive share restore and + // must not be saved as the recipient's own layer preference. + const applyCamera = + Number.isInteger(navigationToken) && + this.navigation._reassertNavigationHandoff(navigationToken, { + trackingOrigin: 'programmatic', + }); + const layersPromise = this._layerStateCoordinator + ? this._layerStateCoordinator.applyShareLayerState({ + shareLayerState: state.layerState || null, + shareCreatedAtMs: state.sharedAtMs ?? null, + }) + : null; + return this._runShareRestore(state, { + applyCamera, + navigationToken, + layersPromise, + settle, + }); } connect(dataManager) { this._dataManager = dataManager; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 1d5f16a28..a6f7b5674 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -75,13 +75,24 @@ export class VisualSettings { panelState, styleParams, } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { + // Ignore the retired 'ai-edit' style from older share links. Normal is + // the start-up style, but a share opened later in the tab must still leave + // a filtered style for it. + if ( + style && + style !== 'ai-edit' && + (style !== 'normal' || this.activeStyle !== 'normal') + ) { + const alreadyActive = style === this.activeStyle; this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true, }); + // setStyle leaves an already-active style untouched; the incoming share + // still starts from that preset before its own parameters apply. + if (alreadyActive && style !== 'normal') + this._applyStylePresetDefaults(style); } if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { for (const [uniformName, uniformValue] of Object.entries(styleParams)) { From fefd5cdcf14b80cfb8ac3c7c60f9a489aa2b3c8e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 14:07:58 -0700 Subject: [PATCH 124/128] fix(layers): make Clear All clear the saved and shared layer selection (#1417) * fix(layers): make Clear All clear the saved and shared layer selection After Clear All every layer control was off, but the share URL could keep listing layers (for example l=e.f) and a reload switched them back on. The URL and local preferences are written from the committed layer state, which only explicit visibility changes update. Clear All issued an explicit OFF only for layers it found on at the start of the batch and whose turn was not superseded. A layer already off for a passive reason (isolated by Contacts or another context mode, or released by Contacts' own teardown while the batch ran) never received one, so it stayed in the committed state. Clear All also discards the context snapshot that would have restored those layers, leaving the screen and the URL out of step. Clear All now publishes the set of layers still on once its batch settles, and the layer-state coordinator commits that selection: every saved layer not in it is dropped and its passive restore is cancelled, while a layer a newer explicit request turned back on is kept. A context restore interrupted by Clear All no longer brings its snapshot back after Contacts settles. * docs: changelog for #1417 --- changelog.d/1417-clear-all-saved-layers.md | 3 + src/contextSessionOrdering.test.mjs | 9 ++ src/data/layerState.test.mjs | 132 +++++++++++++++++++++ src/data/layerStateCoordinator.js | 24 ++++ src/data/lifecycle.js | 9 ++ src/ui/contextSession.js | 3 + 6 files changed, 180 insertions(+) create mode 100644 changelog.d/1417-clear-all-saved-layers.md diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md new file mode 100644 index 000000000..8cffbd366 --- /dev/null +++ b/changelog.d/1417-clear-all-saved-layers.md @@ -0,0 +1,3 @@ +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 2f9bfeae0..d7127e8d5 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -94,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout' assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/); }); +test('Clear All during the Contacts settle stops the snapshot coming back', () => { + const restore = _restoreContextSession.toString(); + const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled('); + const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff); + const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff); + assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles'); + assert.ok(fanout > cancelled, 'and before the snapshot fans out'); +}); + test('context restore preserves its exact pending target after a failed transition', () => { const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index ba5496227..d05fb9a95 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated coordinator.destroy(); } }); + +function enabledLayerParam(sink) { + return new URLSearchParams(encode(sink.provider())).get('l'); +} + +test('Clear All drops a saved layer a context mode had already switched off', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + // Context isolation is a temporary, passive takeover: the saved choice stays. + await manager.setEnabled('earthquakes', false, { origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + + const updatesBefore = sink.updates; + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(result.targetIds, ['flights']); + assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh'); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers'); + assert.deepEqual( + parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)) + .enabledLayerIds, + [], + 'a reload restores nothing', + ); + coordinator.destroy(); +}); + +test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => { + let manager; + manager = productionManager({ + // Contacts-style owner: its own teardown releases a borrowed dependency + // without awaiting it, which supersedes Clear All's turn for that layer. + 'military-awareness': { + disable() { + void manager.setEnabled('flights', false, { origin: 'programmatic' }); + return true; + }, + }, + }); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('military-awareness', true, { origin: 'user' }); + + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.equal( + result.items.find((item) => item.id === 'flights')?.superseded, + true, + 'the fixture reproduces the superseded flights turn', + ); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); + +test('Clear All keeps a layer a newer explicit request turned back on', async () => { + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + const clearing = manager.clearSelectedLayers({ origin: 'user' }); + // A direct request issued during the batch owns its layer. + const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' }); + await Promise.all([clearing, reenable]); + assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + ]); + assert.equal(enabledLayerParam(sink), 'e'); + coordinator.destroy(); +}); + +test('a passive clear never rewrites saved layers', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + await coordinator.start(); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('flights', false, { origin: 'programmatic' }); + const writes = storage.writes.length; + await manager.clearSelectedLayers({ origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']); + assert.equal(storage.writes.length, writes); + coordinator.destroy(); +}); + +test('Clear All cancels a saved restore still waiting to start', async () => { + const gate = deferred(); + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + restoreGate: gate.promise, + }); + const params = new URLSearchParams('v=2&l=e.f'); + const restore = coordinator.start({ + shareLayerState: decodeLayerStateParams(params), + }); + await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + gate.resolve(); + const results = await restore; + for (const id of ['earthquakes', 'flights']) { + assert.equal(results.find((result) => result.layerId === id).succeeded, false); + } + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index ef3a4ea4a..40208831a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -250,6 +250,30 @@ export class LayerStateCoordinator { this._revokePendingTrackingWatch('owner-layer-disabled'); } if (!isExplicitLayerStateOrigin(change.origin)) return; + if (change.type === 'selection-cleared') { + // Only layers a newer request turned back on survive Clear All. Any + // other saved layer would reappear from the share link or a reload. + const stillEnabled = new Set(change.enabledLayerIds || []); + const dropped = this._durableState.enabledLayerIds.filter( + (id) => !stillEnabled.has(id), + ); + if (dropped.length === 0) return; + for (const layerId of dropped) { + this._restoreControllers + .get(layerId) + ?.abort('superseded-by-explicit-visibility'); + } + if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + this._commitExplicit({ + ...this._durableState, + enabledLayerIds: this._durableState.enabledLayerIds.filter((id) => + stillEnabled.has(id), + ), + }); + return; + } if (change.type === 'visibility') { this._restoreControllers .get(change.layerId) diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c8946e7bb..6c2290c72 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1650,6 +1650,15 @@ export class LayerLifecycle { } } } + // Clear All owns the whole selection, not only the layers it switched off + // itself. A layer already off for a passive reason (isolated by a context + // mode, released by a dependency owner, or superseded mid-batch) gets no + // OFF of its own here, so publish the set that is still on afterwards. + this._notifyListeners({ + type: 'selection-cleared', + origin, + enabledLayerIds: [...this.getEnabledLayerIds()], + }); const items = targetIds.map((id) => { const state = this.getLayerLifecycleState(id) || { enabled: false, diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 241b5f168..936fc2cb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -86,6 +86,9 @@ export async function _restoreContextSession({ throw error; } } + // Clear All may have run while Contacts settled. It owns the selection + // now, so the snapshot must not come back behind it. + if (restoreState.cancelled) return; await this._dataManager.restoreEnabledLayerIds( restoreState.enabledLayerIds, { From da02fd8645dbc2604e49bde8b4b174fcc3bcba35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 15:31:51 -0700 Subject: [PATCH 125/128] feat(share): short /share/ links with a compact versioned view format (#1418) * feat(share): add compact versioned share-view codec Add a standalone codec for `#s=` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/ links The share button now makes `/share/` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418 --- changelog.d/1418-short-share-links.md | 5 + docs/SHARE-LINK-FORMAT.md | 364 +++++++ scripts/format-scope.json | 10 +- scripts/package-boundaries.json | 10 + src/app/controls.js | 9 +- src/data/layerState.js | 2 +- src/i18n/locales/es.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/loadingFeedback.test.mjs | 25 +- src/panelStackLayout.test.mjs | 5 +- src/share/follow.js | 155 +++ src/share/legacyShareState.js | 119 +++ src/share/legacyShareState.test.mjs | 308 ++++++ src/share/shareLinkPath.js | 56 + src/share/shareLinkPath.test.mjs | 45 + src/share/viewCodec.js | 1399 +++++++++++++++++++++++++ src/share/viewCodec.test.mjs | 1307 +++++++++++++++++++++++ src/share/wire.js | 259 +++++ src/share/wire.test.mjs | 188 ++++ src/sharelink.celestial.test.mjs | 13 +- src/sharelink.js | 179 ++-- src/testSupport/shareViewFixtures.mjs | 103 ++ src/ui/applicationShell.js | 48 +- src/ui/shareButton.test.mjs | 160 +++ src/ui/shareFollow.js | 190 ++++ src/ui/shareLinks.test.mjs | 554 ++++++++++ src/ui/shareRestoration.js | 174 ++- 28 files changed, 5605 insertions(+), 97 deletions(-) create mode 100644 changelog.d/1418-short-share-links.md create mode 100644 docs/SHARE-LINK-FORMAT.md create mode 100644 src/share/follow.js create mode 100644 src/share/legacyShareState.js create mode 100644 src/share/legacyShareState.test.mjs create mode 100644 src/share/shareLinkPath.js create mode 100644 src/share/shareLinkPath.test.mjs create mode 100644 src/share/viewCodec.js create mode 100644 src/share/viewCodec.test.mjs create mode 100644 src/share/wire.js create mode 100644 src/share/wire.test.mjs create mode 100644 src/testSupport/shareViewFixtures.mjs create mode 100644 src/ui/shareButton.test.mjs create mode 100644 src/ui/shareFollow.js create mode 100644 src/ui/shareLinks.test.mjs diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md new file mode 100644 index 000000000..8dce5761f --- /dev/null +++ b/changelog.d/1418-short-share-links.md @@ -0,0 +1,5 @@ +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md new file mode 100644 index 000000000..f590e02bc --- /dev/null +++ b/docs/SHARE-LINK-FORMAT.md @@ -0,0 +1,364 @@ +# Share-link format + +A share link restores a view: where the camera is, what it follows, the visual +style, data layers and panel layout. There are two forms. + +- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by + `src/sharelink.js`. The address bar follows the live view in this form, and + links already in the wild keep working; the long form is never removed. +- **Short form** — `/share/`, where `` is wherever the app is + served (an origin's root, or a base path) and `` is encoded and + decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and + needs no server. The share button makes this form. The same code opens on + any copy of the app. `src/share/shareLinkPath.js` reads and builds the + address, and `src/share/legacyShareState.js` converts between the long + form's parsed state and the code's state. + +The same view in both forms (a real session's link): + +| Form | Characters | +| ----- | ----------------- | +| Long | 302 (the hash) | +| Short | 39 (the ``) | + +For example, a camera-only place view is a 24-character code (31 with its +created-at time). +Following an aircraft is 42–50: 42 when the link is made from the app's own +follow camera with the fix taken as the link is made, 44 with a fix four +seconds older, and 50 after orbiting to another heading and pitch. A view in +any other style that keeps the style's own look adds only the style (two +bytes): the 42-character followed aircraft in Noir is 44. + +## Opening a short link + +At start-up the app reads `` from an address whose path ends in +`share/` (a host can pass the code instead, as `initialShareCode` to +`createApplicationControls`). A code that decodes runs the same restore as a +long link; once the app next writes the address, `/share/` is replaced +by the app's own address and the long form, without a new history entry, so +a reload follows the live view. A code that is rejected restores nothing: the +app starts as usual and says the link can't be opened. + +A thing view turns on its entity's layer and follows the entity by id, using +the same restore as a long link's followed flight or satellite (and its +expiry windows); a vessel is selected once the vessel feed has it. Opening is +best effort: until the entity is found, and if it is not found in time, the +camera is where the link was made, looking at the entity's last-known fix, +and the app says that the entity isn't reporting and when the fix was taken. +The share button makes a thing view while the camera follows an aircraft, +military aircraft or satellite. A selected vessel is not followed (the camera +stays where the user puts it), so a link made then is a place view; vessel +thing views open, but the app does not make them yet. + +## Payload + +The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: + +1. one **version byte**, currently `1`; +2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type` + followed by its value; +3. one **check byte**: the CRC-8 of everything before it. + +| Wire type | Value | +| --------- | ----------------------------------------------------------- | +| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 | +| 1 fixed64 | 8 bytes (not used by v1; skipped) | +| 2 LEN | varint length, then that many bytes (text or a sub-message) | +| 5 fixed32 | 4 bytes (not used by v1; skipped) | + +Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable +ASCII. Fields are written in ascending order; the decoder accepts any order. + +A field whose value equals its default is **omitted**. The decoder fills every +omitted field from the same defaults table the encoder reads (for a +style-dependent field, the decoded style's defaults), so an empty +payload (code `AQc`: the version byte and its check byte) is the all-default +view. + +### Check byte + +The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial +value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the +ASCII digits `123456789` is `0xF4`). A decoder that computes a different value +rejects the code (`checksum`) before reading any field, so a mistyped code +never opens a different view. A degree-8 CRC catches every error burst of up +to 8 bits; one base64url character is 6 bits, so **every single-character +typo is rejected** (a test tries every substitution at every position). Two +swapped neighbours, a dropped or an extra character are caught with a +probability of about 255 in 256 (many of them already fail as base64url). + +## Field registry (top level) + +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | + +Field 1 absent means `thing` when field 13 is present and `place` otherwise, +so v1 never writes it; a later view kind writes its code. + +Enum codes are the list position (0, 1, 2…). A name not in the list is written +as LEN text in the same field, so the app can add a style or map before this +table learns its code. A new style's settings are encoded against its own +defaults, while a decoder that does not know the style uses Normal's; adding a +style also extends the pinned style-defaults table. + +Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 +`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). + +### Style defaults + +A default marked **style** is what the view's style applies when it is +selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's +first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`), +with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's +shader parameters at the preset's values, else the shader's own. The encoder +compares against the defaults of the view's style, and the decoder fills +omitted settings from the defaults of the style it decoded (an unknown style +uses Normal's). So a view that keeps its style's look costs its camera and +style alone. (A recipient-side preference the app remembers, such as the +detection allocation, is not a style default: a sender who changed it pays +for it.) In v1: + +| Setting | Every style | +| ------------------------- | ------------- | +| bloom, intensity | off, 0 | +| sharpen, intensity | on, 49 | +| HUD visible, variant | on, tactical | +| detection mode, density | DENSE, 75 | +| allocation, fade, outside | ELASTIC, 7, 1 | +| celestial ring | off | + +| Style | Shader parameters (field 27 defaults) | +| ------------ | --------------------------------------------------------------- | +| normal | none | +| retro | pixelation 1, distortion 0, instability 0.42 | +| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 | +| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | +| anime | saturation 1, edgeThick 0.5 | +| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | +| snow | density 0.6, wind 0.5 | + +These are frozen like every other default: the codec reads them from the +app's tables today, and a test pins them, so if a table moves the old values +are frozen in the codec rather than changing what shared links mean. + +### Precision + +| Value | Stored as | Matches the long form | +| --------------------------- | ------------------------- | --------------------- | +| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` | +| Altitude, range | whole metres | `Math.round` | +| Heading, pitch, roll | whole degrees | `Math.round` | +| Intensities and percentages | integers | `Math.round` | +| Style parameters | hundredths after clamping | `sp` (× 100) | +| Created-at and observed-at | whole seconds | `at` | + +Angles: heading and roll are normalized into [0, 360) — the long form can +write `roll=360` for a camera at 359.6°, which the compact form stores as 0 — +and written as the nearer signed turn, so 350° costs one byte (−10). +When a state is encoded, out-of-range input is clamped (latitude, pitch) or +wrapped (longitude). A payload that stores a latitude or longitude beyond +±90/±180 cannot come from the encoder and is rejected. + +### Layers (fields 10 and 11) + +A layer's number is derived from its permanent layer-state token +(`src/data/layerStateTokenReservations.json`): a one-character token is its +index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The +token ledger never reassigns a token, so layer numbers never change and new +layers need no change here. + +Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set +when layer _n_ is on. Present-but-empty means "every layer off"; an absent +field leaves the recipient's layers alone (the long form's `l=` versus no `l`). +A thing view's entity layer is always on, so its bit is not written: in a +thing view, present-but-empty means "only the entity's layer", and an absent +field opens with only the entity's layer on (the app writes the field +whenever it has a layer set). + +Field 11 repeats `(layer number, option key, value)` for each option that +differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The +option key is `option token index × 2 + kind`: kind 0 means the value is a +zigzag varint (an option whose long-form spelling is a canonical integer), +kind 1 means a varint length and ASCII text (the long-form spelling). Values +are decoded by the layer-state codec's own option decoders. + +A followed aircraft, military aircraft or satellite is a thing view in the +compact form, not a tracking option: the encoder moves it out of the layer +options, and the converter moves it back for the long form. + +### Panels (field 12) + +Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where +collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that +differ from the frozen default layout are written, plus a collapsed-0 entry +for each panel the state has no opinion about (an older long-form link that +predates the panel); a panel with no entry takes its default. Values of 3 are +reserved: a decoder ignores that entry, as for collapsed 0. + +| # | Panel | Long-form token | Default | +| --- | ---------------------- | --------------- | ------------------- | +| 0 | `control-panel` | `c` | collapsed, unpinned | +| 1 | `location-bar` | `l` | collapsed, unpinned | +| 2 | `data-panel` | `d` | collapsed | +| 3 | `cctv-panel` | `v` | collapsed | +| 4 | `recent-imagery-panel` | `i` | expanded | +| 5 | `street-level-panel` | `t` | collapsed | +| 6 | `radio-panel` | `r` | collapsed | +| 7 | `scene-panel` | `s` | collapsed | +| 8 | `global-context-panel` | `g` | collapsed | +| 9 | `pp-toggles` | `p` | collapsed | +| 10 | `param-slider-panel` | `m` | expanded | + +### Style parameters (field 27) + +Pairs of varints `(token index, value × 100)` for the parameters of the +view's style that differ from its defaults, using the long form's `sp` tokens +(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter +of the style: the stored ones over the style's defaults. A long link without +`sp` converts to the style's defaults. + +## View kinds + +A **place** view (field 13 absent) opens at its camera, fields 2–7. + +A **thing** view (field 13 present) follows an entity. Field 13 is a +sub-message: + +| # | Field | Encoding | Default | +| --- | ----------- | -------------------------------------------------------- | ------- | +| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | | +| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | | +| 3 | vessel | entity id: varint (MMSI) or LEN text | | +| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | | +| 5 | range | uint metres from the entity to the camera | 2000 | +| 6 | heading | angle, the camera's heading while following | 0 | +| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 | +| 8 | fix lat | coord, the entity's last-known latitude | none | +| 9 | fix lon | coord, its last-known longitude | none | +| 10 | fix alt | sint metres | 0 | +| 11 | fix age | sint seconds the fix predates the created-at time | 0 | +| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none | + +The entity kind is the field that carries the id (its code in +`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is +present (two is malformed). Entity ids are the ones the layers look up: an +aircraft's ICAO 24-bit address (six hex digits, stored as a number; other +forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's +NORAD catalog number (decimal numbers; an MMSI with a leading zero stays +text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`. + +Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at +the entity from `range` metres away, heading clockwise from north, in the +entity's local east-north-up frame. The defaults match the app's own follow +camera, which looks north at about −35°, so only its range is written. + +Fields 8–12 are the last-known fix, the entity's position when the link was +made. Fields 8 and 9 are both written whenever there is a fix; their presence +marks it. Its time is the created-at time (field 8 at the top level) minus +field 11, or field 12 when the view has no created-at time. + +**The camera of a thing view with a fix is derived, not stored**: fields 2–7 +are not written, and the camera is where following the fix from the offset +puts it (`followCameraFor` in `src/share/follow.js`): the camera sits +`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up +of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m, +and looks back at the fix (its heading and pitch are those of that direction +at the camera, roll 0; at follow distances they equal h and p). A +decoder ignores fields 2–7 when the fix is present. A thing view without a +fix (one converted from a long link, which records neither offset nor fix) +writes its camera in fields 2–7 like a place view. + +A decoder that meets a view kind or an entity kind it does not know (an id in +a thing field it skips) opens the view as a place at its camera — the derived +one when there is a fix — and reports `fallback`. + +## Decoding rules + +`decodeShareView(code)` takes the bare code and never throws. It returns +`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`. + +Rejected (the whole link): input over 2048 characters, bad base64url +(padding, foreign characters, impossible length, stray bits), fewer than two +bytes, a check byte that does not match (`checksum`), an unknown version byte, +a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an +overlong varint, a LEN longer than what remains (at any level, including +inside the thing sub-message), a reserved wire type, field number 0, a known +field with the wrong wire type or repeated, two entity ids in one thing, a +duplicate panel/option/style entry, non-ASCII text, and a latitude or +longitude (camera or fix) beyond ±90/±180. + +Tolerated and reported in `unknownFields`: unknown fields at any level +(skipped by wire type), unknown enum codes and names (the default is used), +unknown flag bits, layers, panel values and style parameters, and layer +options the layer-state codec rejects or that are filed under a layer other +than their owner. Detection mode and allocation names outside their lists +fall back to the default rather than reaching the long form's lenient +migration. The map stack and HUD variant are open: a name outside their lists +that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what +it means) and is not reported; only a name outside that grammar is. A +created-at time in the future is dropped, as the long form's `at` is. + +## Changing the format + +These rules keep every link that was ever shared working, in every version of +the app that can open it. + +1. **Field numbers are permanent.** Never renumber, never reuse. A field that + is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`. +2. **A field's type and wire type are permanent.** To change one, add a new + field. +3. **Defaults are frozen meanings.** A default is what an omitted field meant + when links were written. If the app's default moves, leave the table alone: + the encoder will start writing the new value explicitly. Style defaults + are read from the app's style tables; when those move, freeze the old + values in the codec (see Style defaults). The same applies + to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. +4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel + numbers and style parameter tokens only grow. +5. **Add, don't change.** A new setting takes the next free field number + (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + boolean takes the next flag bit instead, which costs nothing when it is at + its default. +6. **New view kinds and entity kinds** get the next code. A new entity kind's + id takes the next free thing field (13 onwards), so older apps skip it and + fall back to the camera. A thing that needs more than an id (for example a + CCTV camera, a radio station or a transit vehicle) adds further thing + fields the same way. +7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT` + (a test fails until it has one). +8. **Bump the version byte only** for a change older decoders must refuse. + Decoders reject any version they do not know. The trailing CRC-8 check + byte belongs to every version. +9. **Derivations are frozen too.** `followCameraFor` and the follow-offset + defaults are part of the format: changing them moves the camera of every + thing link already shared. + +Add a test for every change: a round trip, and a payload written with the new +field that the previous decoder still reads. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 84d2dc323..ee23b8b6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,5 +1109,13 @@ "src/voice/panel/inactivity.test.mjs", "src/voice/panel/actionObserver.test.mjs", "src/voice/panel/testDom.mjs", - "src/voice/realtimeCostPanel.test.mjs" + "src/voice/realtimeCostPanel.test.mjs", + "docs/SHARE-LINK-FORMAT.md", + "src/share/wire.test.mjs", + "src/share/viewCodec.test.mjs", + "src/share/legacyShareState.test.mjs", + "src/testSupport/shareViewFixtures.mjs", + "src/ui/shareLinks.test.mjs", + "src/ui/shareButton.test.mjs", + "src/share/shareLinkPath.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4b4f3f18f..dedf4c272 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -770,6 +770,9 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", + "src/share/follow.js", + "src/data/trackedCamera.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -2195,6 +2198,11 @@ "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", + "src/share/follow.js", + "src/share/legacyShareState.js", + "src/share/shareLinkPath.js", + "src/share/viewCodec.js", + "src/share/wire.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -2287,6 +2295,7 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -3283,6 +3292,7 @@ "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", + "src/share/follow.js", "src/ui/shareRestoration.js", "src/ui/uiLifetime.js", "src/i18n/index.js", diff --git a/src/app/controls.js b/src/app/controls.js index 40b354d06..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -13,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -27,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); diff --git a/src/data/layerState.js b/src/data/layerState.js index f43129896..089c8c08b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ -const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; +export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ff8e57a5..2cfa47a69 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -302,6 +302,11 @@ "CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "Link copied!": "¡Enlace copiado!", + "Link shared": "Enlace compartido", + "This share link can't be opened": "Este enlace compartido no se puede abrir", + "{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez", + "Shared vessel is unavailable": "El buque compartido no está disponible", "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3c7ff17b1..b3bdd165f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -294,6 +294,11 @@ "Scene run complete": "Воспроизведение сцены завершено", "CCTV layer unavailable": "Слой CCTV недоступен", "Link copied!": "Ссылка скопирована!", + "Link shared": "Ссылка отправлена", + "This share link can't be opened": "Не удаётся открыть эту ссылку", + "{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})", + "{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место", + "Shared vessel is unavailable": "Судно по ссылке недоступно", "Copy failed": "Не удалось скопировать", "VISUAL PRESETS": "ПРЕСЕТЫ", "LOCATION": "МЕСТО", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3eefe7c5..a44973167 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1477,6 +1477,11 @@ "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", + "Link shared": "链接已分享", + "This share link can't be opened": "无法打开此分享链接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置", + "Shared vessel is unavailable": "分享的船只不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index b9903f97e..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d test('share-follow failures use the universal top-center status instead of the bottom toast', () => { const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 746d5b3bc..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -8,6 +8,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); diff --git a/src/sharelink.js b/src/sharelink.js index 9668fa979..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -11,12 +11,30 @@ import { encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -155,17 +141,42 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); this._knownHash = hash; - const state = this._parseShareHash(hash); + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; this._beginIncomingRestore(state); return state; } + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + /** Parse one hash without side effects. Returns null unless it is a share. */ _parseShareHash(hash) { if (!hash) return null; @@ -459,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -559,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -587,8 +632,13 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - const url = new URL(`#${params.toString()}`, window.location.href); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); this._knownHash = url.hash.slice(1); history.replaceState(null, '', url.href); } @@ -649,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -677,6 +727,7 @@ export class ShareLinkManager { this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/testSupport/shareViewFixtures.mjs b/src/testSupport/shareViewFixtures.mjs new file mode 100644 index 000000000..629132459 --- /dev/null +++ b/src/testSupport/shareViewFixtures.mjs @@ -0,0 +1,103 @@ +/** + * Shared fixtures for the share-view codec tests: a seeded generator and + * real long-form links. + */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; + +/** A long-form link copied from a real session. */ +export const HAL_EXAMPLE_HASH = + 'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0'; + +/** Deterministic generator (mulberry32) for property-style tests. */ +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const OPTION_CANDIDATES = [ + true, + false, + 'light', + 'strong', + 'gfs', + 'ifs', + 'none', + 'speed', + 'temperature', + 'pressure', + 'km/h', + 'm/s', + 'mph', + 'proximity', + 'all', + 'core', + 'dense', + 'off', + 'on', + 'viewshed', + 'tomtom', + 'osm', + 'hybrid', + 'clouds', + 'clouds-regional', + 'pano', + 'flat', + 'filtered', + 'full', + 0, + 1, + 2, + 37, + 50, + 100, + 365, + -12345678, + 12345678, + 8505110, + 'S30:2026-09-18', + 'L30:2024-02-29', + 'VIIRS:2025-01-02', + 'genre:jazz & blues', + 'news', + 'public-safety', + 0.35, + 0.8, + 'abc-1*def-0', + 'osm-1', + 25544, + 'a1b2c3', + '~abc123', +]; + +/** A random layer state using only values each option accepts. */ +export function randomLayerState(random) { + const state = createDefaultLayerState(); + state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3); + for (const ownerId of Object.keys(state.options)) { + for (const spec of optionSpecs(ownerId)) { + if (random() < 0.5) continue; + const accepted = OPTION_CANDIDATES.filter( + (value) => spec.normalize(value) !== null, + ); + if (accepted.length) + state.options[ownerId][spec.key] = + accepted[Math.floor(random() * accepted.length)]; + } + } + return state; +} + +/** One element of `values`, chosen by `random`. */ +export function pick(random, values) { + return values[Math.floor(random() * values.length)]; +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4552da8d4..e3ed24867 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,6 +35,10 @@ import * as Cesium from 'cesium'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade { */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + mapStackController = null, + placeSearch, + services, + requestServices, + initialShareCode = null, + } = {}, ) { super(); const { @@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), }); this._visualSettings = new VisualSettings({ @@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade { isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + // A host may open a share code without a `/share/` address. + initialShareCode, }); + this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) => + readShareFollowSubject({ viewer: this.viewer, nowMs }), + ); this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState(), ); @@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade { // ── Share Button ───────────────────────────── /** - * Wires the share button click to copy the current share link to the clipboard. + * Wires the share button. By default it copies the current share link to the + * clipboard; a host may pass `services.shareLink(url)` to share it another way + * (a system share sheet, for example). It resolves to 'shared', 'copied', + * 'cancelled' (no toast) or false (failed). * @returns {void} */ _initShareButton() { this._lifetime.listen(this._shareBtn, 'click', async () => { - const success = await this.shareLinkManager.copyLink(); - if (!this._disposed) - this._showToast(success ? t('Link copied!') : t('Copy failed')); + const share = this.services.shareLink; + let result; + if (typeof share === 'function') { + // A host's hook may return a plain value or throw; both are answers. + result = await Promise.resolve() + .then(() => { + const href = this.shareLinkManager.buildLink(); + return href ? share(href) : false; + }) + .catch(() => false); + } else { + result = (await this.shareLinkManager.copyLink()) ? 'copied' : false; + } + if (this._disposed || result === 'cancelled') return; + this._showToast( + result === 'shared' + ? t('Link shared') + : result === 'copied' + ? t('Link copied!') + : t('Copy failed'), + ); }); } diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs new file mode 100644 index 000000000..e5e8422c0 --- /dev/null +++ b/src/ui/shareButton.test.mjs @@ -0,0 +1,160 @@ +// The share button copies by default; a host's `services.shareLink` shares the +// link its own way and reports how it went. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { parseSharePath } from '../share/shareLinkPath.js'; +import { decodeShareView } from '../share/viewCodec.js'; + +/** The decoded view of a `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; +const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/; + +function makeManager(href, { positioned = true } = {}) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: positioned + ? { latitude: 0, longitude: 0, height: 1000 } + : undefined, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +/** Wire the real share button handler onto a minimal shell; return a click. */ +function shell({ manager, services = {} }) { + const toasts = []; + let click; + const owner = { + services, + shareLinkManager: manager, + _shareBtn: {}, + _disposed: false, + _lifetime: { listen: (_el, _type, handler) => (click = handler) }, + _showToast: (message) => toasts.push(message), + }; + StyleManager.prototype._initShareButton.call(owner); + return { toasts, click: () => click() }; +} + +test('buildLink is a /share link stamped with its creation time, without ?lang', () => { + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + const href = manager.buildLink({ nowMs: 5_000_000 }); + assert.match(href, SHARE_LINK); + const link = new URL(href); + assert.equal(link.searchParams.get('lang'), null); + assert.equal(link.hash, ''); + assert.equal(sharedState(href).sharedAtMs, 5_000_000); + // The camera is the live one, not the address bar's. + assert.equal(sharedState(href).lat, 0); + assert.equal( + makeManager('https://gev.example/', { positioned: false }).buildLink(), + null, + ); +}); + +test('copyLink copies exactly what buildLink builds', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/#v=2'); + assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true); + assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 })); +}); + +test('without a host hook the button copies the link', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + }); + await click(); + assert.match(copied, SHARE_LINK); + assert.deepEqual(toasts, ['Link copied!']); + + installClipboard(async () => { + throw new Error('denied'); + }); + await click(); + assert.deepEqual(toasts, ['Link copied!', 'Copy failed']); +}); + +test('a host hook receives the link and its result picks the toast', async () => { + installClipboard(async () => assert.fail('the host shares instead')); + const shared = []; + const results = ['shared', 'copied', 'cancelled', false]; + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { + shareLink: async (url) => { + shared.push(url); + return results.shift(); + }, + }, + }); + for (let i = 0; i < 4; i++) await click(); + assert.equal(shared.length, 4); + assert.match(shared[0], SHARE_LINK); + // 'cancelled' (the user closed the sheet) shows nothing. + assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']); +}); + +test('a failing host hook, or no camera position, reports a failure', async () => { + const failing = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink: async () => Promise.reject(new Error('no sheet')) }, + }); + await failing.click(); + assert.deepEqual(failing.toasts, ['Copy failed']); + + let called = false; + const unpositioned = shell({ + manager: makeManager('https://gev.example/', { positioned: false }), + services: { + shareLink: async () => { + called = true; + return 'shared'; + }, + }, + }); + await unpositioned.click(); + assert.equal(called, false); + assert.deepEqual(unpositioned.toasts, ['Copy failed']); +}); + +test('a host hook that answers without a promise, or throws, still gets a toast', async () => { + for (const [shareLink, toast] of [ + [() => 'shared', 'Link shared'], + [ + () => { + throw new Error('no sheet'); + }, + 'Copy failed', + ], + ]) { + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink }, + }); + await click(); + assert.deepEqual(toasts, [toast]); + } +}); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js new file mode 100644 index 000000000..ba5851da6 --- /dev/null +++ b/src/ui/shareFollow.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; +import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + followOffsetFromEnu, + followOffsetToEnu, +} from '../share/follow.js'; + +/** + * The bridge between the camera following an entity and a share link's thing + * view: what the camera follows now, in the share format's terms, and the + * stored follow offset applied to an entity the camera has started to follow. + */ + +const KIND_BY_LAYER = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]), +); +const MAX_FRAMES = 120; + +/** `position`'s local east/north/up offset from `origin`, in metres. */ +function enuOffset(origin, position) { + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()), + position, + new Cesium.Cartesian3(), + ); + return { east: local.x, north: local.y, up: local.z }; +} + +/** + * The entity the camera follows as `{ kind, id, offset, lastKnown }`, with + * the camera's offset from it and its displayed position as of `nowMs`; null + * when the camera follows nothing a share link can name. A selected vessel is + * not followed (the camera stays where the user puts it), so a link made then + * keeps its own camera. + */ +export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { + const cameraPosition = viewer?.camera?.positionWC; + if (!cameraPosition) return null; + const tracked = viewer.trackedEntity; + const trackedId = String(tracked?.gevTrackedId || ''); + const separator = trackedId.indexOf(':'); + if (separator <= 0) return null; + const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); + const id = trackedId.slice(separator + 1); + const target = kind + ? trackedDisplayPositionForCamera( + tracked, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!kind || !id || !target) return null; + const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); + const fix = Cesium.Cartographic.fromCartesian(target); + if (!offset || !fix) return null; + return { + kind, + id, + offset, + lastKnown: { + lat: Cesium.Math.toDegrees(fix.latitude), + lon: Cesium.Math.toDegrees(fix.longitude), + alt: fix.height, + observedAtMs: nowMs, + }, + }; +} + +/** Largest turn, in radians, the camera makes around the entity per frame. */ +const MAX_TURN_PER_FRAME = Math.PI / 3; + +/** + * Put the camera at a shared follow offset from `entity` once following it + * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * the user orbits to. The camera swings round the entity at most 60° a frame: + * the follow's own guard treats a jump to the far side as the camera crossing + * through the entity and puts it back. Returns a disposer. + */ +export function applyShareFollowOffset(viewer, entity, offset) { + if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) + return () => {}; + const local = followOffsetToEnu(offset); + let frames = 0; + let remove = null; + const stop = () => { + remove?.(); + remove = null; + }; + remove = viewer.scene.preUpdate.addEventListener(() => { + if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + stop(); + return; + } + frames += 1; + const camera = viewer.camera; + // Let the follow frame the camera first (its first frame), then move it. + const framed = + frames > 1 && + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const target = framed + ? trackedDisplayPositionForCamera( + entity, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!target) { + if (frames >= MAX_FRAMES) stop(); + return; + } + // The offset is east/north/up at the entity; the follow's own frame may + // differ, so place the camera in world space and re-express it there. + const world = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const goal = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ), + world, + new Cesium.Cartesian3(), + ); + const from = Cesium.Cartesian3.clone( + camera.position, + new Cesium.Cartesian3(), + ); + const angle = + Cesium.Cartesian3.magnitude(from) > 0 + ? Cesium.Cartesian3.angleBetween(from, goal) + : 0; + const done = !(angle > MAX_TURN_PER_FRAME); + const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + if (done) stop(); + }); + return stop; +} + +/** + * `from` turned `step` radians toward `to`, with its length moved the same + * fraction of the way: one frame of the swing round the entity. + */ +function turnToward(from, to, step) { + const angle = Cesium.Cartesian3.angleBetween(from, to); + const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + // The direction perpendicular to `from` in the plane toward `to` (or any + // perpendicular when they are opposite). + let toward = Cesium.Cartesian3.subtract( + Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(angle), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitude(toward) < 1e-9) + toward = Cesium.Cartesian3.cross( + unitFrom, + Math.abs(unitFrom.z) < 0.9 + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.UNIT_X, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.normalize(toward, toward); + const length = + Cesium.Cartesian3.magnitude(from) + + (Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) * + (step / angle); + const direction = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(step), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + toward, + Math.sin(step), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Cesium.Cartesian3.multiplyByScalar(direction, length, direction); +} diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs new file mode 100644 index 000000000..d7b747d66 --- /dev/null +++ b/src/ui/shareLinks.test.mjs @@ -0,0 +1,554 @@ +// Short share links: `/share/` opens through the start-up restore +// pipeline, the address then follows the live view, and the share button +// makes `/share/` links (following an entity when the camera does). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { ShareLinkManager } from '../sharelink.js'; +import { ShareRestoration, sharedFixNotice } from './shareRestoration.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js'; +import { + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetToEnu, +} from '../share/viewCodec.js'; + +const NOW_MS = Date.now(); +const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; +const OFFSET = { range: 5800, heading: 0, pitch: -35 }; + +const PLACE_CODE = encodeShareView({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + style: 'noir', + panelState: { specs: [{ id: 'data-panel', collapsed: false }] }, + sharedAtMs: NOW_MS - 60_000, +}); + +function aircraftCode() { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + return encodeShareView({ + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 }, + }, + layerState, + sharedAtMs: NOW_MS - 120_000, + }); +} + +/** + * A page at `href`. `history.replaceState` rewrites the address in place and + * is counted; `pushState` (a new history entry) is counted too. + */ +function installPage(t, href) { + const prior = { window: globalThis.window, history: globalThis.history }; + const target = new EventTarget(); + const location = new URL(href); + target.location = location; + globalThis.window = target; + const writes = { replaced: 0, pushed: 0 }; + globalThis.history = { + replaceState(_state, _title, next) { + writes.replaced += 1; + location.href = new URL(next, location.href).href; + }, + pushState() { + writes.pushed += 1; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return { location, writes, target }; +} + +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return { + timers, + runAll() { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + }; +} + +/** A viewer whose camera flights land at once. */ +function fakeViewer() { + const flights = []; + return { + flights, + canvas: new EventTarget(), + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + flyTo(options) { + flights.push(options); + options.complete?.(); + }, + setView() {}, + }, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */ +function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { + const page = installPage(t, href); + const clock = installManualTimers(t); + const viewer = fakeViewer(); + const restores = []; + const manager = new ShareLinkManager(viewer, { + onRestore: async (state) => restores.push(state), + isNavigationCurrent: () => true, + ...managerOptions, + }); + const notices = []; + let generation = 0; + const owner = new ShareRestoration({ + viewer, + navigation: { + _beginDeferredNavigation: () => ++generation, + _reassertNavigationHandoff: () => true, + _stampNavigation() {}, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), + }); + // Notices are checked by message; their start-up-cover timing is not. + owner._presentAfterStartupCover = (message) => notices.push(message); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState: () => Promise.resolve([]), + getDurableState: () => null, + cancelPendingShareTracking: () => false, + restoreShareTrackingSelection: async () => tracking?.(owner) ?? null, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { ...page, ...clock, viewer, manager, owner, restores, notices }; +} + +/** Run the delayed start-up restore and let it settle. */ +async function restore(page) { + page.runAll(); + const result = await page.owner.initialRestorePromise; + await flush(); + return result; +} + +test('a /share/ address opens its place view through the start-up restore', async (t) => { + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight'); + assert.equal(page.manager._initialRestorePending, true); + const result = await restore(page); + assert.equal(result.status, 'settled'); + assert.equal(result.share.camera, 'applied'); + assert.equal(page.restores[0].style, 'noir'); + assert.deepEqual(page.restores[0].panelState.specs, [ + { id: 'data-panel', collapsed: false, pinned: null }, + ]); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6, + ); + assert.equal(page.manager._initialRestorePending, false); + assert.deepEqual(page.notices, []); +}); + +test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => { + for (const [href, root] of [ + [`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'], + [ + `http://localhost:4173/gev/share/${PLACE_CODE}`, + 'http://localhost:4173/gev/', + ], + ]) { + const page = openPage(t, href); + await restore(page); + page.manager._updateHash(); + assert.equal(`${page.location.origin}${page.location.pathname}`, root); + // The live view's long form, as written for any other page. + const params = new URLSearchParams(page.location.hash.slice(1)); + assert.equal(params.get('v'), '2'); + assert.ok(params.has('lat') && params.has('style')); + assert.equal(page.writes.pushed, 0); + assert.equal(page.writes.replaced, 1); + page.manager.destroy(); + } +}); + +test('a host can pass the share code instead of the address', async (t) => { + const page = openPage(t, 'http://localhost/', { + managerOptions: { initialShareCode: PLACE_CODE }, + }); + await restore(page); + assert.equal(page.restores[0].style, 'noir'); +}); + +test('a code that fails its check restores nothing and says so', async (t) => { + // One character changed: the check byte no longer matches. + const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A'; + const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`; + const page = openPage(t, `http://localhost/share/${typo}`); + assert.equal(page.manager.rejectedShareCode, 'checksum'); + assert.notEqual(page.owner._hasShareState, true); + assert.equal(page.timers.size, 0, 'nothing to restore'); + assert.equal( + (await page.owner.initialRestorePromise).status, + 'not-requested', + ); + assert.deepEqual(page.notices, ["This share link can't be opened"]); + // The default start's first address write leaves the bad link behind. + page.manager._updateHash(); + assert.equal(page.location.pathname, '/'); +}); + +test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => { + let entity = null; + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // What the coordinator does when the aircraft is in the feed. + owner.viewer.trackedEntity = entity; + owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + classification: 'followed', + }); + return { classification: 'followed' }; + }, + }); + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + Object.assign(page.viewer, { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + isDestroyed: () => false, + }); + Object.assign(page.viewer.camera, { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + // The app's own follow framing: south of the aircraft and above it. + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(offset); + this.position = Cesium.Cartesian3.clone(offset); + }, + }); + entity = { + gevTrackedId: 'flights:a1b2c3', + gevDisplayPosition: () => target, + }; + + const parsed = page.manager._parseShareCode(aircraftCode()); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); + assert.equal( + parsed.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + await restore(page); + // The opening flight goes where following the fix puts the camera. + const camera = followCameraFor(FIX, OFFSET); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3, + ); + // Once followed, the camera takes the shared offset after the follow's + // own first frame. + assert.equal(listeners.length, 1); + listeners[0](); + assert.equal(looks.length, 0); + listeners[0]?.(); + assert.equal(looks.length, 1); + const expected = followOffsetToEnu(OFFSET); + assert.ok(Math.abs(looks[0].x - expected.east) < 1); + assert.ok(Math.abs(looks[0].y - expected.north) < 1); + assert.ok(Math.abs(looks[0].z - expected.up) < 1); + assert.equal(listeners.length, 0, 'the offset is applied once'); + assert.deepEqual(page.notices, []); +}); + +test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => { + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // The coordinator's verdict once the follow window has passed. + const result = { + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }; + owner._handleShareTrackingRestoreStatus(result); + return result; + }, + }); + await restore(page); + await flush(); + assert.equal(page.notices.length, 1); + assert.match( + page.notices[0], + /^A1B2C3 isn't reporting now — showing where it was at .+/, + ); + // A long link names no fix and keeps the existing notice. + page.owner._initialShareState = { shareView: null }; + page.owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }); + assert.equal(page.notices[1], 'Shared flight follow expired'); +}); + +test('the not-reporting notice names the id and the time of the fix', () => { + const at = new Date(2026, 9, 26, 14, 5).getTime(); + assert.match( + sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000), + /^A1B2C3 isn't reporting now — showing where it was at .*05/, + ); + assert.equal( + sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), + "366999712 isn't reporting now — showing where it was last seen", + ); +}); + +test('a shared vessel is selected once the vessel feed has it', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: NOW_MS }); + const page = openPage(t, 'http://localhost/'); + const selected = []; + let present = false; + page.owner._dataManager = { + layers: new Map([ + [ + 'ais-live-vessels', + { + module: { + hasContact: (id) => present && id === '366999712', + selectById: (id) => selected.push(id) > 0, + }, + }, + ], + ]), + }; + const view = { + kind: 'thing', + entity: { kind: 'vessel', id: '366999712' }, + lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 }, + }; + const found = page.owner._restoreSharedVessel(view, () => true); + await flush(); + present = true; + page.runAll(); + assert.equal(await found, true); + assert.deepEqual(selected, ['366999712']); + + present = false; + const missing = page.owner._restoreSharedVessel(view, () => true); + for (let i = 0; i < 61; i += 1) { + await flush(); + t.mock.timers.tick(2_000); + page.runAll(); + } + assert.equal(await missing, false); + assert.match(page.notices.at(-1), /^366999712 isn't reporting now/); +}); + +test('the share button links to /share/, following the followed entity', (t) => { + installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); + const manager = new ShareLinkManager(fakeViewer()); + // A fresh session's look, as the app reports it at start-up. + manager.onToggleChange(false, true, { + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + }); + // No entity: a place view at the live camera. + const place = manager.buildLink({ nowMs: NOW_MS }); + assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/); + assert.equal( + decodeShareView(parseSharePath(new URL(place).pathname).code).state.view + .kind, + 'place', + ); + + manager.setFollowSubjectProvider(() => ({ + kind: 'aircraft', + id: 'a1b2c3', + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS }, + })); + const href = manager.buildLink({ nowMs: NOW_MS }); + const code = parseSharePath(new URL(href).pathname).code; + const { state } = decodeShareView(code, { nowMs: NOW_MS }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 }, + }); + // The followed aircraft's layer is implied; the camera is derived. + assert.ok(code.length <= 45, `${code.length} characters`); + assert.equal(href, shareLinkUrl(code, 'https://gev.example/')); + manager.destroy(); +}); + +test('the followed entity is read in the share format, and its offset round-trips', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const offset = { range: 2400, heading: 30, pitch: -20 }; + const local = followOffsetToEnu(offset); + const cameraPosition = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const viewer = { + camera: { positionWC: cameraPosition }, + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: { + gevTrackedId: 'military:ae1234', + gevDisplayPosition: () => target, + }, + }; + const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); + assert.equal(subject.kind, 'military_aircraft'); + assert.equal(subject.id, 'ae1234'); + assert.deepEqual(subject.offset, offset); + assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); + assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); + // A followed entity the share format has no kind for is not a thing view. + viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + assert.equal(readShareFollowSubject({ viewer }), null); + // Nor is a camera that follows nothing (a selected vessel is not followed). + viewer.trackedEntity = undefined; + assert.equal(readShareFollowSubject({ viewer }), null); +}); + +test('a shared offset on the far side swings round, never jumping through the entity', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + const entity = { gevDisplayPosition: () => target }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: entity, + camera: { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(Cesium.Cartesian3.clone(offset)); + this.position = Cesium.Cartesian3.clone(offset); + }, + }, + }; + // Looking south at the nose: the opposite side of the app's framing. + const offset = { range: 5800, heading: 180, pitch: -35 }; + applyShareFollowOffset(viewer, entity, offset); + let previous = viewer.camera.position; + for (let frame = 0; frame < 10 && listeners.length; frame += 1) { + listeners[0](); + const now = viewer.camera.position; + // The follow's guard treats a turn of 90° or more as a crossing. + assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2); + previous = now; + } + assert.equal(listeners.length, 0, 'it arrives'); + assert.ok(looks.length >= 2, `${looks.length} steps`); + const expected = followOffsetToEnu(offset); + const last = looks.at(-1); + assert.ok(Math.abs(last.x - expected.east) < 1); + assert.ok(Math.abs(last.y - expected.north) < 1); + assert.ok(Math.abs(last.z - expected.up) < 1); +}); + +test('applying a follow offset gives up when the follow ends', () => { + const listeners = []; + const viewer = { + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: null, + }; + applyShareFollowOffset(viewer, {}, OFFSET); + listeners[0](); + assert.equal(listeners.length, 0); +}); + +test('long links and hash navigation are unaffected by a share code start', async (t) => { + // A long link still opens from the hash. + const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg'); + await restore(long); + assert.equal(long.restores[0].style, 'surveillance'); + assert.equal(long.restores[0].shareView, undefined); + long.manager._updateHash(); + assert.equal(long.location.pathname, '/'); + + // After a /share start, navigating to a long link restores it (#1416). + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + await restore(page); + page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal'; + page.target.dispatchEvent(new Event('hashchange')); + await flush(); + assert.equal(page.restores.length, 2); + assert.equal(page.restores[1].style, 'normal'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 7fd52d917..12f09c18b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,18 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, formatDate } from '../i18n/index.js'; +import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +/** How long a shared vessel may take to appear in the vessel feed. */ +export const SHARED_VESSEL_WAIT_MS = 120_000; +const SHARED_VESSEL_POLL_MS = 2_000; +const DAY_MS = 86_400_000; +const ENTITY_BY_KIND = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]), +); + // One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES // label) and outcome: a label dropped into a template cannot take the case or // article the sentence needs in every language. The generic template stays @@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) { : t('Shared {subject} is unavailable', { subject }); } +/** + * The notice for a shared entity that is not reporting: the view shows where + * it was when the link was made. + * @param {{kind: string, id: string}} entity + * @param {number|null} observedAtMs + * @param {number} [nowMs] + * @returns {string} + */ +export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) { + // ICAO addresses read as they are written on the aircraft: upper case. + const id = + ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24' + ? String(entity.id).toUpperCase() + : String(entity.id); + if (!Number.isFinite(observedAtMs)) + return t("{id} isn't reporting now — showing where it was last seen", { + id, + }); + const time = formatDate( + observedAtMs, + nowMs - observedAtMs < DAY_MS + ? { hour: '2-digit', minute: '2-digit' } + : { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + ); + return t("{id} isn't reporting now — showing where it was at {time}", { + id, + time, + }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -61,6 +100,7 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + applyFollowOffset = null, }) { Object.assign(this, { viewer, @@ -70,6 +110,9 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + // (entity, offset) => disposer: frame a followed entity from a shared + // follow offset (shareFollow.js). + applyFollowOffset, }); this._lifetime = new UiLifetime(); this._disposed = false; @@ -84,6 +127,10 @@ export class ShareRestoration { // settles later must neither apply follow-up work nor release URL writes. this._shareRestoreGeneration = 0; this._navigatedShareRestoreActive = false; + this._stopFollowOffset = null; + // Bumped to abandon the wait for a shared vessel. + this._sharedVesselGeneration = 0; + this._sharedVesselPending = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -121,11 +168,21 @@ export class ShareRestoration { }, 1500); } else { this.syncShareState(); + if (this.shareLinkManager?.rejectedShareCode) + this._presentAfterStartupCover( + t("This share link can't be opened"), + ++this._shareTrackingNoticeGeneration, + ); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { + // The recipient taking the camera also ends the wait for a vessel. + if (this._sharedVesselPending) { + this._sharedVesselGeneration += 1; + this._sharedVesselPending = false; + } if ( this._disposed || !this._hasShareState || @@ -191,6 +248,10 @@ export class ShareRestoration { }; if (!current()) return; this.shareLinkManager.completeInitialRestore(state.restoreToken); + // Selecting a vessel leaves the camera alone, so it does not depend on + // the shared flight having landed. + const vessel = this._sharedVessel(state); + if (vessel) void this._restoreSharedVessel(vessel, current); settle({ status: 'settled', share, layers, tracking }); } catch (error) { if (!current()) return; @@ -301,7 +362,79 @@ export class ShareRestoration { }); } } + /** The shared vessel a share's thing view selects, if it names one. */ + _sharedVessel(state) { + const view = state?.shareView; + return view?.kind === 'thing' && view.entity.kind === 'vessel' + ? view + : null; + } + + /** + * Select a shared vessel once the vessel feed has it. Vessels are selected, + * not followed: the camera is already at the shared view. If the vessel does + * not appear in time, the view stays where it was when the link was made. + */ + async _restoreSharedVessel(view, current) { + const generation = ++this._sharedVesselGeneration; + const live = () => current() && generation === this._sharedVesselGeneration; + const deadline = Date.now() + SHARED_VESSEL_WAIT_MS; + const { id } = view.entity; + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + this._sharedVesselPending = true; + try { + while (live()) { + const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; + if (Date.now() >= deadline) break; + await new Promise((resolve) => + this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS), + ); + } + } finally { + if (generation === this._sharedVesselGeneration) + this._sharedVesselPending = false; + } + if (!live()) return false; + this._presentAfterStartupCover( + view.lastKnown + ? sharedFixNotice(view.entity, view.lastKnown.observedAtMs) + : t('Shared vessel is unavailable'), + ++this._shareTrackingNoticeGeneration, + ); + return false; + } + + /** The current share's thing view when `result` is about its entity. */ + _sharedThingFor(result) { + const view = this._initialShareState?.shareView; + if (view?.kind !== 'thing') return null; + const entry = ENTITY_BY_KIND.get(view.entity.kind); + return entry?.tracking === result?.layerId && + String(view.entity.id) === String(result?.targetId) + ? view + : null; + } + + /** Frame a shared entity the camera now follows from the shared offset. */ + _applySharedFollowOffset(result) { + const view = this._sharedThingFor(result); + const entity = this.viewer?.trackedEntity; + // Only a link made while following carries a real offset (with the fix); + // a long link's follow keeps the app's own framing. + if ( + !view?.lastKnown || + String(entity?.gevTrackedId || '') !== + `${result.layerId}:${result.targetId}` + ) + return; + this._stopFollowOffset?.(); + this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + } + cancelSelection() { + // An explicit navigation also ends the wait for a shared vessel. + this._sharedVesselGeneration += 1; if ( this._hasShareState && this._resolveInitialShareRestore && @@ -347,17 +480,39 @@ export class ShareRestoration { this.updateFeedback(); } } - if ( - result.classification === 'followed' || - result.classification === 'cancelled' - ) + if (result.classification === 'followed') { + this._applySharedFollowOffset(result); return; + } + if (result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const message = shareTrackingRestoreNotice(result); + // A share link that knows where the entity was says so; the view is + // already there. + const fix = this._sharedThingFor(result)?.lastKnown; + const message = fix + ? sharedFixNotice( + this._initialShareState.shareView.entity, + fix.observedAtMs, + ) + : shareTrackingRestoreNotice(result); + if (this._resolveInitialShareRestore) { + void this.initialRestorePromise.then(() => + this._presentAfterStartupCover(message, noticeGeneration), + ); + return; + } + this._presentAfterStartupCover(message, noticeGeneration); + } + + /** + * Show a status notice once the start-up cover is gone, unless a newer + * notice (generation) has taken its place. + */ + _presentAfterStartupCover(message, noticeGeneration) { const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( @@ -399,10 +554,6 @@ export class ShareRestoration { fallbackTimer = this._lifetime.timeout(showOnce, 1000); }); }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } showAfterStartupCover(); } _settleInitialShareRestore(result) { @@ -419,6 +570,9 @@ export class ShareRestoration { this._disposed = true; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; + this._sharedVesselGeneration += 1; + this._stopFollowOffset?.(); + this._stopFollowOffset = null; this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; From 3d9f153bc055acc58d18c6a4b329f044816409a7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:20:23 -0700 Subject: [PATCH 126/128] feat(share): export the share-view codec and link-path helpers (#1419) * feat(share): export the share-view codec for host servers Add `@gods-eye-view/core/share/view-codec` (src/share/viewCodec.js) as a package export with its own boundary group. The codec is pure (no DOM, no Cesium, no external dependencies), so a host's server can decode a /share/ link, for example to describe the view in link previews. * feat(share): export the share-link address helpers Add `@gods-eye-view/core/share/link-path` (src/share/shareLinkPath.js), so a host's server reads /share/ addresses the way the app does. --- changelog.d/1419-share-view-codec-export.md | 4 +++ docs/CODE-BOUNDARIES.md | 4 +++ docs/SHARE-LINK-FORMAT.md | 3 +- package.json | 2 ++ scripts/package-boundaries.json | 33 +++++++++++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1419-share-view-codec-export.md diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md new file mode 100644 index 000000000..e3ce1b105 --- /dev/null +++ b/changelog.d/1419-share-view-codec-export.md @@ -0,0 +1,4 @@ +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9f8e0f629..efc7b86bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -598,6 +598,10 @@ state subscriptions retain their existing event contract. tracking handoff. It reads Cockpit admission and uses supplied tracking operations. - `ui/share-restoration` owns the initial restore transaction, layer coordinator, status notices and gesture/timer cleanup. +- `share/view-codec` is the share-link codec (`decodeShareView`, + `encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can + read a `/share/` link, for example to describe it in link previews. + `share/link-path` reads and builds the `/share/` address itself. - `ui/visual-settings` owns style preferences, detection overrides, display inputs and IR cleanup. Its engine services and panel operations are explicit. - `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index f590e02bc..cfb6d50fb 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -9,7 +9,8 @@ style, data layers and panel layout. There are two forms. - **Short form** — `/share/`, where `` is wherever the app is served (an origin's root, or a base path) and `` is encoded and decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and - needs no server. The share button makes this form. The same code opens on + needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec` + (a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on any copy of the app. `src/share/shareLinkPath.js` reads and builds the address, and `src/share/legacyShareState.js` converts between the long form's parsed state and the code's state. diff --git a/package.json b/package.json index cb06324ee..75a1f994c 100644 --- a/package.json +++ b/package.json @@ -223,6 +223,8 @@ "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", + "./share/view-codec": "./src/share/viewCodec.js", + "./share/link-path": "./src/share/shareLinkPath.js", "./ui/visual-settings": "./src/ui/visualSettings.js", "./ui/panel-chrome": "./src/ui/panelChrome.js", "./layers/flights/records": "./src/layers/flights/records.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dedf4c272..d32997d5c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3887,6 +3887,39 @@ ], "external": [] }, + "share-view-codec": { + "exports": ["./share/view-codec"], + "modules": [ + "src/share/viewCodec.js", + "src/share/follow.js", + "src/share/wire.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", + "src/data/idSwitches.js", + "src/data/detectionPolicy.js", + "src/scopeMaskPolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" + ], + "external": [] + }, + "share-link-path": { + "exports": ["./share/link-path"], + "modules": ["src/share/shareLinkPath.js"], + "external": [] + }, "ghostmaps": { "exports": ["./layers/ghostmaps"], "modules": [ From 6152f9f3110a91d2198d2d3fd642b51855b91561 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:44:34 -0700 Subject: [PATCH 127/128] fix(search): keep a missed location query in the field so it can be corrected (#1420) A location search that found nothing or failed now keeps its query, the open field and focus (text selected), and shows a persistent role=alert message under the search row instead of a toast. Typing, a new search or closing the emptied field clears it. HAL-727. --- .../1420-keep-missed-location-query.md | 4 + docs/CURRENT-STATE.md | 8 ++ src/cameraHandoff.test.mjs | 2 +- src/i18n/locales/es.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/ui/applicationShell.js | 1 + src/ui/locationControls.js | 6 + src/ui/locationControls.test.mjs | 11 ++ src/ui/locationNavigation.js | 34 +++++- src/ui/locationSearchRecovery.test.mjs | 110 ++++++++++++++++++ src/ui/navigationController.js | 11 +- src/ui/shellElements.js | 1 + src/ui/shellFacade.js | 2 +- src/ui/styles/location.css | 17 +++ src/ui/styles/scenes.css | 3 +- src/ui/templates/command-dock.html | 1 + 17 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1420-keep-missed-location-query.md create mode 100644 src/ui/locationSearchRecovery.test.mjs diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md new file mode 100644 index 000000000..233abf495 --- /dev/null +++ b/changelog.d/1420-keep-missed-location-query.md @@ -0,0 +1,4 @@ +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be5972034..0cb46940c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5268,6 +5268,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. +A location search that finds nothing, or fails, leaves the field open with the +query still in it, focused and selected, so it can be corrected and resubmitted +without reopening search. The miss is shown under the field as a persistent +`role="alert"` message ("Location not found." or "Search failed." with what to +do next) referenced by the input's `aria-describedby`, with `aria-invalid` set; +it is not a toast. Typing, a new search, or closing the emptied field clears it. +A search that arrives still clears and closes the field. + For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds before computing their midpoint, then wraps that midpoint back into ±180 degrees. Malformed viewport bounds add no proximity bias. Nominatim omits a crossing diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 1fe30ff06..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => { assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cfa47a69..0285917b0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -310,6 +310,8 @@ "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", + "Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.", + "Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.", "Fly to a POI first": "Ve primero a un punto de interés", "Clearing selected data layers": "Limpiando las capas de datos seleccionadas", "Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b3bdd165f..b1c1be229 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -530,6 +530,8 @@ "Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса", "Location not found": "Место не найдено", "Search failed": "Не удалось выполнить поиск", + "Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.", + "Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.", "MAP": "КАРТА", "panel": "панель", "Expand {name}": "Развернуть {name}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a44973167..9bf76700c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1819,6 +1819,8 @@ "ORBIT": "环绕", "Location not found": "未找到位置", "Search failed": "搜索失败", + "Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重试。", "Detailed outline unavailable": "无法获取详细轮廓", "Fly to a POI first": "请先飞往一个 POI", "Reset to full globe view": "重置为完整地球视图", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e3ed24867..4b999a75f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -348,6 +348,7 @@ export class StyleManager extends ShellFacade { _poiRow: this._poiRow, _locationBarDivider: this._locationBarDivider, _locationSearch: this._locationSearch, + _locationSearchError: this._locationSearchError, _searchToggle: this._searchToggle, _resetGlobeBtn: this._resetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index f2b67c7db..27363c1ac 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -22,6 +22,7 @@ export class LocationControls { onCity, onPoi, onSearch, + onSearchEdit, onReset, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), @@ -74,6 +75,11 @@ export class LocationControls { this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); + // Typing corrects a failed query; closing the field (which empties it) drops its error. + this.bind(elements.search, 'input', () => onSearchEdit?.()); + this.bind(elements.search, 'blur', () => { + if (!elements.search.value) onSearchEdit?.(); + }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 928e48554..33f5eaa7f 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -86,6 +86,7 @@ function fixture() { onCity: (id) => calls.push(['city', id]), onPoi: (id, index) => calls.push(['poi', id, index]), onSearch: (query) => calls.push(['search', query]), + onSearchEdit: () => calls.push(['edit']), onReset: () => calls.push(['reset']), doc, requestFrame: (fn) => { @@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () => f.elements.resetButtons[1].fire('click'); assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); +test('typing clears a failed search error and closing the emptied field drops it too', () => { + const f = fixture(); + f.elements.search.value = 'Pariss'; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error'); + f.elements.search.fire('input'); + f.elements.search.value = ''; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [['edit'], ['edit']]); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index af8fb95b8..d0664509f 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -80,19 +80,27 @@ export class LocationNavigation { _handleLocationSearchState(state, change) { if (this._disposed || !change) return; - if (change.type === 'started') + if (change.type === 'started') { this._activeLocationSearchGeneration = change.generation; - else if (change.type === 'found') { + this._setLocationSearchError(null); + } else if (change.type === 'found') { this._searchedLocationLabel = state.destination.label || state.query; this._setActiveLocation(null); this._currentPoi = null; this._collapsePOIRow(); this._updateLocationMiniStatus(); } else if (change.type === 'missing') - this._showToast(t('Location not found')); - else if (change.type === 'failed') this._showToast(t('Search failed')); + this._setLocationSearchError( + `${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`, + ); + else if (change.type === 'failed') + this._setLocationSearchError( + `${t('Search failed')}. ${t('Press Enter to try again.')}`, + ); else if (change.type === 'settled') - this._settleLocationSearchUi(change.generation); + this._settleLocationSearchUi(change.generation, { + keepQuery: state.status === 'missing' || state.status === 'failed', + }); else if ( change.type === 'reset' && this._activeLocationSearchGeneration !== null @@ -101,6 +109,21 @@ export class LocationNavigation { } } + /** Show, or with null clear, the announced error under the search field. */ + _setLocationSearchError(message) { + const input = this._locationSearch; + const error = this._locationSearchError; + if (error) error.textContent = message || ''; + if (!input) return; + if (message) { + input.setAttribute('aria-invalid', 'true'); + if (error?.id) input.setAttribute('aria-describedby', error.id); + } else { + input.removeAttribute('aria-invalid'); + input.removeAttribute('aria-describedby'); + } + } + _initLocationBar() { const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services; this._locationControls?.destroy(); @@ -144,6 +167,7 @@ export class LocationNavigation { onCity: (id) => this._onCityPillClick(id), onPoi: (id, index) => this._onPoiClick(id, index), onSearch: (query) => this._locationLookup.run(query), + onSearchEdit: () => this._setLocationSearchError(null), onReset: () => this.resetToGlobeView(), }); } diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs new file mode 100644 index 000000000..19a310203 --- /dev/null +++ b/src/ui/locationSearchRecovery.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { NavigationController } from './navigationController.js'; +import { LocationNavigation } from './locationNavigation.js'; + +function input() { + const classes = new Set(['expanded', 'searching']); + const attributes = new Map(); + return { + id: 'location-search', + value: 'Pariss', + focused: false, + selected: false, + classList: { + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + focus() { + this.focused = true; + }, + blur() { + this.focused = false; + }, + select() { + this.selected = true; + }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: (name) => attributes.delete(name), + getAttribute: (name) => attributes.get(name) ?? null, + }; +} + +function settle(searchInput, options) { + const owner = { _activeLocationSearchGeneration: 4, searchInput }; + NavigationController.prototype._settleLocationSearchUi.call( + owner, + 4, + options, + ); + return owner; +} + +test('a search that found nothing keeps its query, the open field and focus', () => { + const field = input(); + const owner = settle(field, { keepQuery: true }); + assert.equal(owner._activeLocationSearchGeneration, null); + assert.equal(field.value, 'Pariss'); + assert.equal(field.classList.contains('expanded'), true); + assert.equal(field.classList.contains('searching'), false); + assert.equal(field.focused, true); + assert.equal(field.selected, true); +}); + +test('a search that arrived still clears and closes the field', () => { + const field = input(); + field.focused = true; + settle(field); + assert.equal(field.value, ''); + assert.equal(field.classList.contains('expanded'), false); + assert.equal(field.focused, false); +}); + +function navigation() { + const field = input(); + const error = { id: 'location-search-error', textContent: '' }; + const settled = []; + const toasts = []; + const owner = { + _disposed: false, + _locationSearch: field, + _locationSearchError: error, + _activeLocationSearchGeneration: null, + _settleLocationSearchUi: (generation, options) => + settled.push([generation, options]), + _showToast: (message) => toasts.push(message), + _setLocationSearchError: + LocationNavigation.prototype._setLocationSearchError, + }; + const handle = (status, type) => + LocationNavigation.prototype._handleLocationSearchState.call( + owner, + { status, query: field.value }, + { type, generation: 7 }, + ); + return { owner, field, error, settled, toasts, handle }; +} + +for (const [status, text] of [ + ['missing', 'Location not found'], + ['failed', 'Search failed'], +]) { + test(`a ${status} search announces a lasting error on the field instead of a toast`, () => { + const n = navigation(); + n.handle('searching', 'started'); + n.handle(status, status); + n.handle(status, 'settled'); + assert.match(n.error.textContent, new RegExp(`^${text}\\. `)); + assert.equal(n.field.getAttribute('aria-invalid'), 'true'); + assert.equal( + n.field.getAttribute('aria-describedby'), + 'location-search-error', + ); + assert.deepEqual(n.settled, [[7, { keepQuery: true }]]); + assert.deepEqual(n.toasts, []); + n.handle('searching', 'started'); + assert.equal(n.error.textContent, ''); + assert.equal(n.field.getAttribute('aria-invalid'), null); + assert.equal(n.field.getAttribute('aria-describedby'), null); + }); +} diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index e2a135142..3c0bc7684 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -119,10 +119,17 @@ export class NavigationController { return this._navigationGeneration; } - _settleLocationSearchUi(generation) { + /** A search that found nothing keeps its query and focus so it can be corrected. */ + _settleLocationSearchUi(generation, { keepQuery = false } = {}) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; - this.searchInput?.classList.remove('searching', 'expanded'); + this.searchInput?.classList.remove('searching'); + if (keepQuery) { + this.searchInput?.focus(); + this.searchInput?.select?.(); + return; + } + this.searchInput?.classList.remove('expanded'); if (this.searchInput) this.searchInput.value = ''; this.searchInput?.blur(); } diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9134b9045..df7fcbc16 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) { _cctvSyncProgress: document.getElementById('cctv-sync-progress'), _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), + _locationSearchError: document.getElementById('location-search-error'), _searchToggle: document.getElementById('search-toggle'), _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 2c1861b31..01962f8f9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -550,7 +550,7 @@ export class ShellFacade { } /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { + _settleLocationSearchUi(generation, options) { return this._navigation._settleLocationSearchUi(...arguments); } diff --git a/src/ui/styles/location.css b/src/ui/styles/location.css index 281e4b08f..5c10f20b5 100644 --- a/src/ui/styles/location.css +++ b/src/ui/styles/location.css @@ -298,3 +298,20 @@ #location-search.searching { opacity: 0.5; } + +.location-search-error { + /* Its own line under the search row, which clips overflow in the dock. */ + margin: 6px 0 0; + padding: 6px 10px; + border: 1px solid rgba(255, 99, 99, 0.45); + border-radius: 6px; + background: rgba(30, 8, 10, 0.92); + color: #ffb3b3; + font-family: var(--font-sans); + font-size: 12px; + line-height: 1.35; +} + +.location-search-error:empty { + display: none; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index b048c5bd2..29250a636 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #param-slider-panel.collapsed #param-sliders, #location-bar.collapsed .poi-row-container, #location-bar.collapsed .location-bar-divider, -#location-bar.collapsed .location-city-row { +#location-bar.collapsed .location-city-row, +#location-bar.collapsed .location-search-error { display: none !important; } diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index e6e2735ea..3dac977ae 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -91,6 +91,7 @@
+ From 15de9d738c4a85be1a96726c6114181caef87eeb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:54:01 -0700 Subject: [PATCH 128/128] fix(i18n): mark style labels without loading the catalogs (#1421) The style shaders and visualPresets imported msg() from src/i18n/index.js, which loads every locale catalog. The share-view codec reads the styles' defaults, so a server bundling the codec carried about 0.9 MB of translations it never uses. They now import the catalog-free marker, as other portable modules do; the codec's bundle drops from about 1 MB to 130 KB. --- changelog.d/1421-style-modules-skip-catalogs.md | 3 +++ scripts/package-boundaries.json | 13 +++---------- src/styles/anime.js | 2 +- src/styles/noir.js | 2 +- src/styles/retro.js | 2 +- src/styles/snow.js | 2 +- src/styles/surveillance.js | 2 +- src/styles/thermal.js | 2 +- src/ui/visualPresets.js | 2 +- 9 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 changelog.d/1421-style-modules-skip-catalogs.md diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md new file mode 100644 index 000000000..bc79c5cd6 --- /dev/null +++ b/changelog.d/1421-style-modules-skip-catalogs.md @@ -0,0 +1,3 @@ +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d32997d5c..b42d90fdc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -463,11 +463,7 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": ["cesium"] }, @@ -3330,6 +3326,7 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/ui/segmentedControl.js", + "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -3907,11 +3904,7 @@ "src/data/idSwitches.js", "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": [] }, diff --git a/src/styles/anime.js b/src/styles/anime.js index deb79c355..4097ecc48 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Anime / Studio Ghibli Style — Cel-shading + Saturation boost diff --git a/src/styles/noir.js b/src/styles/noir.js index 5ea458859..472dde990 100644 --- a/src/styles/noir.js +++ b/src/styles/noir.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Noir Style — Desaturation + High Contrast + Vignette diff --git a/src/styles/retro.js b/src/styles/retro.js index 396f95853..f4e2d8f62 100644 --- a/src/styles/retro.js +++ b/src/styles/retro.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * CRT Terminal — Dynamic CRT Monitor Aesthetic diff --git a/src/styles/snow.js b/src/styles/snow.js index 569f6cbbc..a4db0a029 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Snow Style — White overlay + Desaturation + Falling particles diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 810ac7e4f..5eae7fdd3 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Night Vision / NVG — PVS-14 Image Intensifier Emulation diff --git a/src/styles/thermal.js b/src/styles/thermal.js index 90aff5312..f62327507 100644 --- a/src/styles/thermal.js +++ b/src/styles/thermal.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * FLIR — Forward Looking Infrared diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js index 48a758a33..e3830601a 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js'; import { nightVisionShader } from '../styles/surveillance.js'; import { thermalShader } from '../styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** Duration (ms) for shader intensity crossfade between style presets. */ export const TRANSITION_DURATION_MS = 500;