From 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 001/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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/178] 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 37acb4842f65c6725146216367eb54b31cf2cadc Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:23:52 +0100 Subject: [PATCH 015/178] fix(docker): honor trusted-peer environment overrides --- README.md | 10 ++ SECURITY.md | 5 + compose.yaml | 5 + docs/CURRENT-STATE.md | 9 ++ scripts/docker-start.mjs | 12 ++- scripts/format-scope.json | 3 +- src/dockerLauncher.test.mjs | 3 +- src/dockerTrustedPeers.test.mjs | 179 ++++++++++++++++++++++++++++++++ 8 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/dockerTrustedPeers.test.mjs diff --git a/README.md b/README.md index 3b005c8dc..eb00b9635 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,16 @@ Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address. +```bash +GEV_KEY_SETUP_TRUSTED_PEERS=none docker compose up +``` + +Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from your +shell or the project's `.env`; an exported shell value takes precedence. You can +also put `GEV_KEY_SETUP_TRUSTED_PEERS=none` in `.env` for a persistent setting. +Unset or blank trusted peers retain automatic gateway discovery; `none` explicitly +disables it. The startup log confirms that no additional peers are trusted. +
Running on Linux, a NAS, or behind a reverse proxy diff --git a/SECURITY.md b/SECURITY.md index b694d8dcd..aa2f5a397 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -98,6 +98,11 @@ The dev server is a **key broker**: every server-side key above is spendable by `compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN client arrives from the same gateway address, which hands them Provider Settings too.** Treat publishing beyond loopback as the LAN opt-in above. + Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` from + the shell or project `.env` before the launcher runs. Set trusted peers to + `none` when widening the publish: it becomes an explicit empty list before + Vite loads configuration, so the gateway cannot regain Provider Settings + access. Unset or blank values preserve automatic gateway discovery. - **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider. - **Pinokio LAN and Cloudflare sharing are refused.** The current supported Pinokio release re-reads sharing state when an app registers its Open URL and diff --git a/compose.yaml b/compose.yaml index 7826bf376..c2d3bf3d4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,6 +5,11 @@ services: # lands in the bind-mounted repo, so run as you, not root. UID/GID are # unset on Docker Desktop, where the 1000 fallback is ignored anyway. user: "${UID:-1000}:${GID:-1000}" + # Forward explicit shell/.env policy before the launcher discovers a gateway. + # Unset values stay unset so the default loopback-only install still works. + environment: + - GEV_KEY_SETUP_TRUSTED_PEERS + - GEV_ALLOWED_HOSTS ports: # Publish to loopback only. Docker's default ("4173:4173") would bind # every host interface, which quietly turns the LAN warning in the README diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60937dd05..300ee9279 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Docker Compose forwards `GEV_KEY_SETUP_TRUSTED_PEERS` and `GEV_ALLOWED_HOSTS` +from the shell or project `.env` before `scripts/docker-start.mjs` runs; shell +values take precedence. The launcher's `none` policy resolves to an empty trusted +peer list and is assigned before importing Vite, preventing later dotenv loading +from replacing it. Unset or blank values retain automatic gateway discovery. +Startup logs distinguish an explicit `none` policy from a missing default route. +This changes neither loopback-only port publishing nor the Provider Settings +Host, Origin, proxy-header and sharing checks. + 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/scripts/docker-start.mjs b/scripts/docker-start.mjs index 3935f2b3b..c77d6335f 100644 --- a/scripts/docker-start.mjs +++ b/scripts/docker-start.mjs @@ -48,7 +48,8 @@ export function defaultGatewayFromRouteTable(text) { /** * Decide this launch's trusted-peer list. An operator value always wins, so a - * compose override stays authoritative; otherwise the gateway alone is + * compose override stays authoritative; `none` trusts no additional peers. + * An unset or blank value means the gateway alone is * trusted. No gateway trusts nothing and Provider Settings stays loopback-only, * exactly as under `npm run dev`. * @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input @@ -56,6 +57,7 @@ export function defaultGatewayFromRouteTable(text) { */ export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) { const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(); + if (explicit.toLowerCase() === 'none') return ''; if (explicit) return explicit; return defaultGatewayFromRouteTable(routeTable) ?? ''; } @@ -109,15 +111,17 @@ async function start() { String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(), ); const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() }); + // Set even an empty list before Vite loads .env: the resolved launcher policy + // must remain authoritative when there is no trusted gateway. + process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; if (trustedPeers) { - // Set before Vite is imported: the dev server snapshots process.env during - // configuration, and the admission gate reads it per request. - process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers; console.log( fromOperator ? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).` : `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`, ); + } else if (fromOperator) { + console.log('[Docker] Provider Settings trusts no additional peers (GEV_KEY_SETUP_TRUSTED_PEERS=none).'); } else { console.log('[Docker] No default gateway; Provider Settings stays loopback-only.'); } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 543b0f9a4..faac60584 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", + "src/dockerTrustedPeers.test.mjs" ] diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index ae9987b01..213fe59dc 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -72,6 +72,7 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8'); assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only'); - assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded'); + assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); + assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); }); diff --git a/src/dockerTrustedPeers.test.mjs b/src/dockerTrustedPeers.test.mjs new file mode 100644 index 000000000..74343bd04 --- /dev/null +++ b/src/dockerTrustedPeers.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { resolveTrustedPeers } from '../scripts/docker-start.mjs'; + +const ROUTES = 'Iface Destination Gateway Flags\neth0 00000000 0100A8C0 0003\n'; +const launcher = fileURLToPath( + new URL('../scripts/docker-start.mjs', import.meta.url), +); +const gateUrl = new URL('./keySetupCore.mjs', import.meta.url).href; + +test('none resolves to an empty trust list even with a valid container gateway', () => { + for (const value of ['none', ' NONE ', 'None']) { + assert.equal( + resolveTrustedPeers({ + env: { GEV_KEY_SETUP_TRUSTED_PEERS: value }, + routeTable: ROUTES, + }), + '', + ); + } +}); + +function launchWithPolicy(value, routeTable = ROUTES) { + // Run the real entry point and routing-table reader. Replace only the Linux + // route file and Vite's listening server so this works on macOS and CI too. + const fakeVite = ` + import { admitKeySetupRequest } from ${JSON.stringify(gateUrl)}; + const trustedAtImport = process.env.GEV_KEY_SETUP_TRUSTED_PEERS; + export async function createServer() { + const request = { + method: 'POST', hostHeader: 'localhost:4173', + origin: 'http://localhost:4173', contentType: 'application/json', + env: { GEV_KEY_SETUP_TRUSTED_PEERS: process.env.GEV_KEY_SETUP_TRUSTED_PEERS } + }; + console.log('POLICY ' + JSON.stringify({ + trustedAtImport, + allowedHosts: process.env.GEV_ALLOWED_HOSTS, + gateway: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1' }), + loopback: admitKeySetupRequest({ ...request, remoteAddress: '127.0.0.1' }), + foreignOrigin: admitKeySetupRequest({ ...request, remoteAddress: '192.168.0.1', origin: 'https://foreign.example' }) + })); + return { async listen() {}, printUrls() {}, async close() {} }; + } + `; + const preload = ` + import fs from 'node:fs'; + import { registerHooks, syncBuiltinESMExports } from 'node:module'; + const originalRead = fs.readFileSync; + fs.readFileSync = (file, ...args) => file === '/proc/net/route' + ? ${JSON.stringify(routeTable)} : originalRead(file, ...args); + syncBuiltinESMExports(); + registerHooks({ resolve(specifier, context, next) { + if (specifier === 'vite') return { + url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(fakeVite)}`)}, shortCircuit: true + }; + return next(specifier, context); + }}); + `; + const env = { ...process.env, GEV_ALLOWED_HOSTS: 'globe.example.lan' }; + delete env.GEV_KEY_SETUP_TRUSTED_PEERS; + if (value !== undefined) env.GEV_KEY_SETUP_TRUSTED_PEERS = value; + const child = spawnSync( + process.execPath, + [ + '--import', + `data:text/javascript,${encodeURIComponent(preload)}`, + launcher, + ], + { env, encoding: 'utf8', timeout: 10000 }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + const line = child.stdout + .split('\n') + .find((line) => line.startsWith('POLICY ')); + assert.ok(line, child.stdout); + return { ...JSON.parse(line.slice(7)), output: child.stdout }; +} + +test('the real Docker entry point seals none before importing Vite and the setup gate refuses the gateway', () => { + const result = launchWithPolicy('none'); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.equal(result.gateway.code, 'not-loopback'); + assert.equal(result.loopback.ok, true); + assert.equal(result.allowedHosts, 'globe.example.lan'); + assert.match(result.output, /trusts no additional peers/); + assert.doesNotMatch(result.output, /No default gateway/); +}); + +test('unset, blank and explicit peers retain gateway admission without weakening Origin checks', () => { + for (const value of [undefined, '', ' ', '192.168.0.1']) { + const result = launchWithPolicy(value); + assert.equal(result.trustedAtImport, '192.168.0.1'); + assert.equal(result.gateway.ok, true); + assert.equal(result.foreignOrigin.ok, false); + } + const result = launchWithPolicy(undefined, ''); + assert.equal(result.trustedAtImport, ''); + assert.equal(result.gateway.status, 403); + assert.match(result.output, /No default gateway/); +}); + +test('Compose resolves shell and project dotenv policy before the launcher starts', (t) => { + const version = spawnSync('docker', ['compose', 'version'], { + encoding: 'utf8', + timeout: 10000, + }); + if (version.error || version.status !== 0) + return t.skip('Docker Compose CLI is not installed'); + const root = mkdtempSync(path.join(tmpdir(), 'gev-compose-policy-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + writeFileSync( + path.join(root, 'compose.yaml'), + readFileSync(new URL('../compose.yaml', import.meta.url)), + ); + const baseEnv = { ...process.env }; + for (const key of Object.keys(baseEnv)) { + if ( + key.startsWith('COMPOSE_') || + ['GEV_KEY_SETUP_TRUSTED_PEERS', 'GEV_ALLOWED_HOSTS'].includes(key) + ) + delete baseEnv[key]; + } + function config(dotenv = '', overrides = {}) { + writeFileSync(path.join(root, '.env'), dotenv); + const child = spawnSync( + 'docker', + [ + 'compose', + '-f', + 'compose.yaml', + '--env-file', + '.env', + 'config', + '--format', + 'json', + ], + { + cwd: root, + env: { ...baseEnv, ...overrides }, + encoding: 'utf8', + timeout: 10000, + }, + ); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout).services['gods-eye-view'].environment || {}; + } + const unset = config(); + assert.ok(unset.GEV_KEY_SETUP_TRUSTED_PEERS == null); + assert.ok(unset.GEV_ALLOWED_HOSTS == null); + const dotenv = + 'GEV_KEY_SETUP_TRUSTED_PEERS=none\nGEV_ALLOWED_HOSTS=globe.example.lan\n'; + assert.deepEqual(config(dotenv), { + GEV_KEY_SETUP_TRUSTED_PEERS: 'none', + GEV_ALLOWED_HOSTS: 'globe.example.lan', + }); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '10.9.8.7' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '10.9.8.7', + ); + assert.equal( + config('', { GEV_KEY_SETUP_TRUSTED_PEERS: 'none' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + 'none', + ); + assert.equal( + config(dotenv, { GEV_KEY_SETUP_TRUSTED_PEERS: '' }) + .GEV_KEY_SETUP_TRUSTED_PEERS, + '', + ); +}); From f11fd3cb0525645431ede32980a65fbe55e4363a Mon Sep 17 00:00:00 2001 From: Said Date: Mon, 19 Oct 2026 23:49:59 +0100 Subject: [PATCH 016/178] docs: add changelog for Docker trusted-peer fix --- changelog.d/1265-docker-trusted-peers.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1265-docker-trusted-peers.md diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md new file mode 100644 index 000000000..f86fe3732 --- /dev/null +++ b/changelog.d/1265-docker-trusted-peers.md @@ -0,0 +1,3 @@ +- Honor Docker Compose trusted-peer and allowed-host settings from the shell + or project `.env`, and let `GEV_KEY_SETUP_TRUSTED_PEERS=none` disable gateway + access to Provider Settings before Vite starts (asturksever, #1265). From 32b0304c2ce1bdb71def174cf30ecd2501ec967a Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:23:10 +0100 Subject: [PATCH 017/178] fix(radio): normalize station tags and retain valid mixed rows --- docs/CURRENT-STATE.md | 7 +++++++ server/providers/radio/catalog.js | 17 ++++------------- server/providers/radio/stations.js | 1 + src/data/radio.test.mjs | 18 ++++++++++++++++++ src/data/radioProxy.test.mjs | 8 ++++++++ src/layers/radio/ingestion.js | 12 ++++++------ src/layers/radio/presentation.js | 1 + src/layers/radio/state.js | 1 + src/sources/radioBrowser.js | 18 +++++++++++------- 9 files changed, 57 insertions(+), 26 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..278c4e0bd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Radio tags are lowercased and whitespace-normalized before the final 80-character +cap, including requested-tag coverage matching. Malformed station rows are skipped +individually; valid rows retain their accepted catalog generation. The UI snapshot +reports a bounded `rejectedStationCount` and degraded coverage when rows were +skipped. Malformed envelope/freshness/generation metadata and all-invalid responses +still preserve the previous usable catalog. + 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/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..7eed9f01a 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -5,7 +5,7 @@ import { readResponseTextCapped } from '../common/http.js'; import { normalizeRadioBrowserStation, publicRadioStation, - cleanRadioText, + normalizeRadioTag, } from './stations.js'; import { radioMirrorOrigin, @@ -177,8 +177,8 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - if ( - !rows.every( + const stations = rows + .filter( (row) => row && typeof row === 'object' && @@ -188,18 +188,9 @@ export function createRadioProxyMiddleware({ (typeof row.url_resolved === 'string' || typeof row.url === 'string'), ) - ) - throw new Error( - 'Radio Browser catalog contained a malformed station row', - ); - const stations = rows .map(normalizeRadioBrowserStation) .filter(Boolean); - const requestedTag = cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + const requestedTag = normalizeRadioTag(tag); const requestedTagCovered = !requestedTag || stations.some((station) => diff --git a/server/providers/radio/stations.js b/server/providers/radio/stations.js index b97b2d3c6..49279e2d2 100644 --- a/server/providers/radio/stations.js +++ b/server/providers/radio/stations.js @@ -1,5 +1,6 @@ export { cleanRadioText, + normalizeRadioTag, isNonGlobalIpv4, publicRadioHttpsUrl, normalizeRadioBrowserStation, diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..6ab8561c6 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -712,6 +712,24 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, true); assert.equal(getRadioUIState().degraded, true); } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 2, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().stationCount, 1); + assert.equal(getRadioUIState().rejectedStationCount, 1); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioUIState().stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioUIState().stale, true); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index 60463205b..d1c4d2660 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -533,3 +533,11 @@ test('a mirror answering an invalid reason phrase rejects instead of crashing', const crashed = mockMirrorStatus(t, 200, 'OK\x7f'); await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError); }); + + +test('case expansion is capped after tag normalization in the portable and server station path', () => { + const row = normalizeRadioBrowserStation(station({tags: 'İ'.repeat(80) + ', NEWS__RADIO ,news-radio'})); + assert.equal(row.tags[0].length, 80); + assert.equal(row.tags[0], 'i\u0307'.repeat(40)); + assert.deepEqual(row.tags.slice(1), ['news radio']); +}); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 869b8d403..5a0ccfa1d 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -70,11 +70,6 @@ export function createIngestion({ ); if (!acceptedRows.length) throw new Error('Radio directory returned no usable stations'); - if (acceptedRows.length !== rows.length) { - throw new Error( - 'Radio directory response contained malformed stations', - ); - } const acceptedGeneration = body.acceptedGeneration; if ( acceptedGeneration !== null && @@ -147,7 +142,12 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } - layerState._degraded = body.degraded; + layerState._rejectedStationCount = Math.min( + 1000000, + rows.length - acceptedRows.length, + ); + layerState._degraded = + body.degraded || layerState._rejectedStationCount > 0; layerState._stale = body.stale; layerState._error = preservingWarmCatalog ? ERROR_REFRESH_DEGRADED diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js index 5d6763ca4..c30889018 100644 --- a/src/layers/radio/presentation.js +++ b/src/layers/radio/presentation.js @@ -20,6 +20,7 @@ export function createPresentation({ loading: layerState._loading, stale: layerState._stale, degraded: layerState._degraded, + rejectedStationCount: layerState._rejectedStationCount, error: layerState._error, updatedAt: layerState._updatedAt, filter: layerState._filter, diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index dca461e03..918ebb989 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -25,6 +25,7 @@ export function createState({ services }) { layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; diff --git a/src/sources/radioBrowser.js b/src/sources/radioBrowser.js index 8511b6beb..8329f6257 100644 --- a/src/sources/radioBrowser.js +++ b/src/sources/radioBrowser.js @@ -11,6 +11,16 @@ export function cleanRadioText(value, maxLength) { .trim(); } +/** Normalize a station/filter tag and cap its final, case-folded representation. */ +export function normalizeRadioTag(value) { + return cleanRadioText( + String(value ?? '') + .toLowerCase() + .replace(/[_-]+/g, ' '), + 80, + ); +} + export function isNonGlobalIpv4(hostname) { const pieces = hostname.split('.'); if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece))) @@ -99,13 +109,7 @@ export function normalizeRadioBrowserStation( if (!name) return null; const tags = String(raw?.tags ?? '') .split(',') - .map((tag) => - cleanRadioText(tag, 80) - .toLocaleLowerCase() - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(), - ) + .map(normalizeRadioTag) .filter(Boolean) .filter((tag, index, all) => all.indexOf(tag) === index) .slice(0, 24); From 34507f606ab99ca2dee5ead78a133045da4315b8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:23:34 +0100 Subject: [PATCH 018/178] docs: record radio fix --- changelog.d/1268-radio-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1268-radio-issue-fix.md diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md new file mode 100644 index 000000000..de6b0bcae --- /dev/null +++ b/changelog.d/1268-radio-issue-fix.md @@ -0,0 +1 @@ +- Normalize station tags and retain valid mixed rows (asturksever, #1268). From ed2f0685e0e011e355283cc64bdd1ecbc52114b5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:25:17 +0100 Subject: [PATCH 019/178] fix(aircraft): coalesce refreshes and cool down OpenSky failures --- docs/CURRENT-STATE.md | 7 + server/providers/aircraft/adsb-lol.js | 111 +++++++----- server/providers/aircraft/opensky.js | 252 ++++++++++++++------------ src/tooling/liveProviders.test.mjs | 121 +++++++++++++ 4 files changed, 333 insertions(+), 158 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..8ce0c5ff1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +Global OpenSky and adsb.lol military snapshot refreshes are single-flight through +body reading and cache publication. Each caller retains its own response and +viewport-specific regional fallback. OpenSky 5xx responses cancel their bodies, +start a 15-second cooldown and serve dated last-good data with the upstream status +in the stale reason. Cold failures use the existing regional fallback or a JSON +upstream error; no error HTML is relayed as JSON. + 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/aircraft/adsb-lol.js b/server/providers/aircraft/adsb-lol.js index 8fc40170d..b70da4f32 100644 --- a/server/providers/aircraft/adsb-lol.js +++ b/server/providers/aircraft/adsb-lol.js @@ -1,4 +1,7 @@ -import { readResponseTextCapped } from '../common/http.js'; +import { + coalesceProxyRequest, + readResponseTextCapped, +} from '../common/http.js'; /** * Vite plugin: adsb.lol military aircraft proxy with 12 s response cache. @@ -19,6 +22,7 @@ import { readResponseTextCapped } from '../common/http.js'; export function adsbLolProxy() { /** @type {string|null} Cached upstream JSON body. */ let _cache = null; + const inFlight = new Map(); /** @type {number} Epoch-ms when the cache was populated. */ let _cacheAt = 0; /** @type {number} Epoch-ms until which upstream is not contacted. */ @@ -104,60 +108,73 @@ export function adsbLolProxy() { ); return; } - const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); - const upstream = await fetch('https://api.adsb.lol/v2/mil', { - headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, - signal, - }); - if (upstream.ok) { + const refresh = coalesceProxyRequest(inFlight, 'global', async () => { + const result = (status, body, cacheStatus, extra = {}) => ({ + status, + body, + cacheStatus, + extra, + }); + const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const upstream = await fetch('https://api.adsb.lol/v2/mil', { + headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' }, + signal, + }); + if (upstream.ok) { + const body = await readResponseTextCapped( + upstream, + ADSBLOL_MIL_MAX_RESPONSE_BYTES, + signal, + ); + _cache = body; + _cacheAt = Date.now(); + _cooldownUntil = 0; + _cooldownStatus = 0; + return result(200, body, 'MISS'); + } + if (upstream.status === 429 || upstream.status >= 500) { + const failedAt = Date.now(); + _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); + _cooldownStatus = upstream.status; + console.warn( + `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + ); + if (_cache) { + // Do not wait for a failing response body before serving usable data. + upstream.body?.cancel().catch(() => {}); + return result(200, _cache, 'STALE', { + 'X-ADS-B-Upstream-Status': String(upstream.status), + 'X-ADS-B-Retry-After-Seconds': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + }); + } + } const body = await readResponseTextCapped( upstream, ADSBLOL_MIL_MAX_RESPONSE_BYTES, signal, ); - _cache = body; - _cacheAt = Date.now(); - _cooldownUntil = 0; - _cooldownStatus = 0; - serve(res, 200, body, 'MISS'); - return; - } - if (upstream.status === 429 || upstream.status >= 500) { - const failedAt = Date.now(); - _cooldownUntil = failedAt + cooldownFor(upstream, failedAt); - _cooldownStatus = upstream.status; - console.warn( - `[adsb.lol Proxy] upstream ${upstream.status}; cooling down ${Math.round((_cooldownUntil - Date.now()) / 1000)} s${_cache ? ', serving stale' : ''}`, + return result( + upstream.status, + body, + 'MISS', + _cooldownUntil > Date.now() + ? { + 'Retry-After': String( + Math.ceil((_cooldownUntil - Date.now()) / 1000), + ), + } + : {}, ); - if (_cache) { - // Do not wait for a failing response body before serving usable data. - upstream.body?.cancel().catch(() => {}); - serve(res, 200, _cache, 'STALE', { - 'X-ADS-B-Upstream-Status': String(upstream.status), - 'X-ADS-B-Retry-After-Seconds': String( - Math.ceil((_cooldownUntil - Date.now()) / 1000), - ), - }); - return; - } - } - const body = await readResponseTextCapped( - upstream, - ADSBLOL_MIL_MAX_RESPONSE_BYTES, - signal, - ); + }); + const response = await refresh.promise; serve( res, - upstream.status, - body, - 'MISS', - _cooldownUntil > Date.now() - ? { - 'Retry-After': String( - Math.ceil((_cooldownUntil - Date.now()) / 1000), - ), - } - : {}, + response.status, + response.body, + response.cacheStatus, + response.extra, ); } catch (e) { console.error('[adsb.lol Proxy]', e.message); diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index 752b3cf64..a69885e2f 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -67,15 +67,24 @@ async function fetchOpenSkyStates(headers) { try { const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS); const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal }); - if (upstream.status === 429) { + if (upstream.status === 429 || upstream.status >= 500) { // Start the cooldown from the headers and drop the rejected body // unread, so a stalled or oversized 429 body cannot skip it. _openskyCooldownUntil = - Date.now() + openSkyRateLimitCooldownMs(upstream); + Date.now() + + (upstream.status === 429 + ? openSkyRateLimitCooldownMs(upstream) + : 15000); + _openskyCooldownStatus = upstream.status; void upstream.body?.cancel().catch(() => {}); return { upstream, - body: JSON.stringify({ error: 'OpenSky rate limited.' }), + body: JSON.stringify({ + error: + upstream.status === 429 + ? 'OpenSky rate limited.' + : 'OpenSky upstream unavailable.', + }), }; } const body = await readResponseTextCapped( @@ -109,6 +118,8 @@ async function fetchOpenSkyStates(headers) { let _openskyTtlMs = OPENSKY_CACHE_MS; /** @type {number} Epoch-ms before which no upstream fetch is attempted. */ let _openskyCooldownUntil = 0; +let _openskyCooldownStatus = 429; +const _openskySnapshotInFlight = new Map(); /** * Picks the cache TTL from the remaining daily credit budget. * Client polls every 30 s, so tiers ≤30 s cost the same 480 credits/h; the @@ -460,7 +471,9 @@ export function openSkyProxy() { requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', reason: isStale - ? 'rate_limited_serving_stale' + ? _openskyCooldownStatus === 429 + ? 'rate_limited_serving_stale' + : `upstream_http_${_openskyCooldownStatus}_serving_stale` : cachedMeta.reason || 'cached', staleSeconds: isStale ? (now - _openskyCacheTime) / 1000 @@ -487,80 +500,140 @@ export function openSkyProxy() { ) return; res.writeHead( - 429, + _openskyCooldownStatus, buildOpenSkyHeaders({ cacheStatus: 'COOLDOWN', requestedMode, usedMode: 'none', - reason: 'rate_limited', + reason: + _openskyCooldownStatus === 429 + ? 'rate_limited' + : `upstream_http_${_openskyCooldownStatus}`, retryAfterSeconds: (_openskyCooldownUntil - now) / 1000, }), ); res.end( JSON.stringify({ - error: 'OpenSky rate limited; proxy cooling down.', + error: 'OpenSky upstream unavailable; proxy cooling down.', }), ); return; } - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); - const headers = { Accept: 'application/json' }; - let usedMode = 'anon'; - let reason = 'forced_anonymous'; + const refresh = coalesceProxyRequest( + _openskySnapshotInFlight, + 'global', + async () => { + const basicUser = process.env.OPENSKY_USERNAME || ''; + const basicPass = process.env.OPENSKY_PASSWORD || ''; + const hasBasicCreds = Boolean(basicUser && basicPass); + const headers = { Accept: 'application/json' }; + let usedMode = 'anon'; + let reason = 'forced_anonymous'; - if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; - } - } else if (requestedMode === 'oauth') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else { - reason = 'oauth_invalid_or_missing'; - } - } else if (requestedMode === 'auto') { - const token = await getOpenSkyToken(); - if (token) { - headers.Authorization = `Bearer ${token}`; - usedMode = 'oauth'; - reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; - } else { - reason = 'missing_oauth_and_basic_creds'; - } - } + if (requestedMode === 'basic') { + if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'basic_credentials'; + } else { + reason = 'missing_basic_creds'; + } + } else if (requestedMode === 'oauth') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else { + reason = 'oauth_invalid_or_missing'; + } + } else if (requestedMode === 'auto') { + const token = await getOpenSkyToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + usedMode = 'oauth'; + reason = 'oauth_token'; + } else if (hasBasicCreds) { + headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; + usedMode = 'basic'; + reason = 'oauth_unavailable_fallback_basic'; + } else { + reason = 'missing_oauth_and_basic_creds'; + } + } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + let { upstream, body } = await fetchOpenSkyStates(headers); + // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials + if ( + (upstream.status === 401 || upstream.status === 403) && + requestedMode === 'auto' && + usedMode === 'oauth' && + hasBasicCreds + ) { + const retryHeaders = { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, + }; + ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); + usedMode = 'basic'; + reason = 'oauth_rejected_fallback_basic'; + } - const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) : null; + const sourceEpochMs = upstream.ok + ? openSkySourceEpochMs(body) + : null; + // Refine the reason string to reflect the actual outcome + if (upstream.ok && reason === 'forced_anonymous') { + reason = 'anonymous_ok'; + } else if ( + upstream.ok && + usedMode === 'basic' && + reason === 'basic_credentials' + ) { + reason = 'basic_ok'; + } else if ( + upstream.ok && + usedMode === 'oauth' && + reason === 'oauth_token' + ) { + reason = 'oauth_ok'; + } + + // Only cache successful responses — error responses (401/403/429/5xx) + // should not be served from cache on subsequent requests + if (upstream.ok) { + _openskyCacheBody = body; + _openskyCacheStatus = upstream.status; + _openskyCacheTime = now; + _openskyCacheSourceEpochMs = sourceEpochMs; + _openskyCacheMeta = { + requestedMode, + usedMode, + reason, + }; + // Credit governor: adapt the cache TTL to the remaining daily + // budget so a continuously-open app stretches its polls instead of + // exhausting the quota mid-day. Success also clears any cooldown. + const remaining = Number( + upstream.headers.get('x-rate-limit-remaining'), + ); + _openskyTtlMs = openskyAdaptiveTtlMs(remaining); + _openskyCooldownUntil = 0; + } + + return { + upstream, + body, + usedMode, + reason, + hasBasicCreds, + sourceEpochMs, + }; + }, + ); + let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + await refresh.promise; if ( upstream.ok && openSkySourceIsStale(sourceEpochMs, now) && @@ -571,20 +644,16 @@ export function openSkyProxy() { 'opensky_snapshot_stale_regional_fallback', )) ) { - // Keep the last global snapshot available as a fail-soft cache, - // but do not label or render it as a fresh live result. - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { requestedMode, usedMode, reason }; return; } - if (upstream.status === 429) { - reason = 'rate_limited'; + if (upstream.status === 429 || upstream.status >= 500) { + reason = + upstream.status === 429 + ? 'rate_limited' + : `upstream_http_${upstream.status}`; // Credit governor: fetchOpenSkyStates already started the cooldown // from OpenSky's retry-after — no upstream attempts until then. - const cooldownMs = openSkyRateLimitCooldownMs(upstream); + const cooldownMs = Math.max(0, _openskyCooldownUntil - Date.now()); // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { @@ -594,7 +663,7 @@ export function openSkyProxy() { cacheStatus: 'STALE', requestedMode, usedMode, - reason: 'rate_limited_serving_stale', + reason: `${reason}_serving_stale`, staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), @@ -652,45 +721,6 @@ export function openSkyProxy() { } } - // Refine the reason string to reflect the actual outcome - if (upstream.ok && reason === 'forced_anonymous') { - reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; - } else if ( - upstream.ok && - usedMode === 'oauth' && - reason === 'oauth_token' - ) { - reason = 'oauth_ok'; - } - - // Only cache successful responses — error responses (401/403/429/5xx) - // should not be served from cache on subsequent requests - if (upstream.ok) { - _openskyCacheBody = body; - _openskyCacheStatus = upstream.status; - _openskyCacheTime = now; - _openskyCacheSourceEpochMs = sourceEpochMs; - _openskyCacheMeta = { - requestedMode, - usedMode, - reason, - }; - // Credit governor: adapt the cache TTL to the remaining daily - // budget so a continuously-open app stretches its polls instead of - // exhausting the quota mid-day. Success also clears any cooldown. - const remaining = Number( - upstream.headers.get('x-rate-limit-remaining'), - ); - _openskyTtlMs = openskyAdaptiveTtlMs(remaining); - _openskyCooldownUntil = 0; - } - res.writeHead( upstream.status, buildOpenSkyHeaders({ diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index a60a37cea..60b2c9f25 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -583,3 +583,124 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('global aircraft snapshots coalesce cold and expired callers and clear rejected flights', async (t) => { + environment(t, { OPENSKY_AUTH_MODE: 'anon' }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'error', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['opensky', 'military']) { + const module = + kind === 'opensky' + ? await import( + `../../server/providers/aircraft/opensky.js?coalesce=${Math.random()}` + ) + : await import('../../server/providers/aircraft/adsb-lol.js'); + const request = install( + kind === 'opensky' ? module.openSkyProxy() : module.adsbLolProxy(), + ); + const route = kind === 'opensky' ? '/api/flights' : '/api/military'; + let calls = 0; + let release; + let blocked; + let fail = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + await blocked; + if (fail) throw new Error('fixture connection failed'); + return Response.json( + { time: now / 1000, states: [], ac: [] }, + { headers: { 'x-rate-limit-remaining': '5000' } }, + ); + }); + for (const phase of ['cold', 'expired', 'failed', 'recovered']) { + now += 13000; + fail = phase === 'failed'; + blocked = new Promise((resolve) => { + release = resolve; + }); + const before = calls; + const pending = Array.from({ length: 5 }, () => request(route)); + await new Promise(setImmediate); + assert.equal(calls - before, 1, `${kind} ${phase}: one refresh starts`); + release(); + const responses = await Promise.all(pending); + assert.equal(calls - before, fail && kind === 'opensky' ? 2 : 1); + assert.ok(responses.every((res) => res.statusCode === 200)); + assert.ok(responses.every((res) => res.body === responses[0].body)); + } + } +}); + +test('OpenSky 5xx retains dated last-good data, cancels error bodies and cools down before recovery', async (t) => { + environment(t, { OPENSKY_AUTH_MODE: 'anon' }); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const status of [502, 503, 504]) { + const module = await import( + `../../server/providers/aircraft/opensky.js?server-error=${status}` + ); + const request = install(module.openSkyProxy()); + let fail = false; + let calls = 0; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status }, + ); + return Response.json( + { time: now / 1000, states: [] }, + { headers: { 'x-rate-limit-remaining': '5000' } }, + ); + }); + const good = await request('/api/flights'); + now += 10000; + fail = true; + const stale = await request('/api/flights'); + assert.equal(stale.statusCode, 200); + assert.equal(stale.body, good.body); + assert.equal(stale.headers['x-opensky-cache'], 'STALE'); + assert.equal( + stale.headers['x-opensky-auth-reason'], + `upstream_http_${status}_serving_stale`, + ); + assert.equal(stale.headers['x-opensky-stale-seconds'], '10'); + assert.equal(cancelled, true); + const cooling = await request('/api/flights'); + assert.equal(cooling.body, good.body); + assert.equal( + cooling.headers['x-opensky-auth-reason'], + `upstream_http_${status}_serving_stale`, + ); + assert.equal(calls, 2); + now += 16000; + fail = false; + assert.equal( + (await request('/api/flights')).headers['x-opensky-cache'], + 'MISS', + ); + assert.equal(calls, 3); + } + const coldModule = + await import('../../server/providers/aircraft/opensky.js?cold-server-error'); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('failure', { status: 503 }); + }); + const cold = install(coldModule.openSkyProxy()); + for (let i = 0; i < 2; i++) { + const response = await cold('/api/flights'); + assert.equal(response.statusCode, 503); + assert.match(JSON.parse(response.body).error, /OpenSky/); + } + assert.equal(calls, 1); +}); From e20a56bcb0f980a000470a995a35f4e5b60335d9 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:25:43 +0100 Subject: [PATCH 020/178] docs: record aircraft fix --- changelog.d/1269-aircraft-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1269-aircraft-issue-fix.md diff --git a/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md new file mode 100644 index 000000000..8984a9141 --- /dev/null +++ b/changelog.d/1269-aircraft-issue-fix.md @@ -0,0 +1 @@ +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). From 9d3d5164fe42ec3ee25afb34c6817b0cfe4bd0db Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:28:00 +0100 Subject: [PATCH 021/178] fix(space): respect provider cooldowns and bound TLE groups --- docs/CURRENT-STATE.md | 7 ++ scripts/package-boundaries.json | 3 + server/providers/space/celestrak.js | 56 ++++++++++++++-- server/providers/space/cooldown.js | 12 ++++ server/providers/space/launch-library.js | 36 ++++++++++- src/proxyErrorResponses.test.mjs | 16 +++-- src/tooling/spaceProviders.test.mjs | 82 +++++++++++++++++++++++- 7 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 server/providers/space/cooldown.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..106e66ad9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,12 @@ # God's Eye View Current State +CelesTrak accepts only the seven satellite groups used by the app and gates +requests before provider work. CelesTrak and Launch Library failures start a +provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds +through 30 minutes, with a 60-second fallback. During cooldown dated last-good +data is served with stale provenance, otherwise the proxy returns a bounded +error without another upstream attempt. Error bodies are cancelled before reading. + 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/scripts/package-boundaries.json b/scripts/package-boundaries.json index ca5dc0c7f..d7d9ba727 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -96,6 +96,9 @@ "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs", + "server/providers/space/cooldown.js", "server/providers/space/launch-library.js", "server/providers/common/http.js", "src/data/spaceProviderRequests.js", diff --git a/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 8baf59ba7..315e9b3f0 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -1,6 +1,8 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; +import { providerCooldownMs } from './cooldown.js'; +import { admitSameSite } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -24,6 +26,16 @@ import { readResponseTextCapped } from '../common/http.js'; export function celestrakProxy() { const TLE_TTL_MS = 6 * 3600_000; const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); + const groups = new Set([ + 'stations', + 'visual', + 'gps-ops', + 'glo-ops', + 'galileo', + 'geo', + 'starlink', + ]); + let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } const inflight = new Map(); // group -> Promise<{at, body}|null> @@ -64,7 +76,11 @@ export function celestrakProxy() { 'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', }, }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + cooldownUntil = Date.now() + providerCooldownMs(res); + void res.body?.cancel().catch(() => {}); + throw new Error(`HTTP ${res.status}`); + } const body = await readResponseTextCapped( res, CELESTRAK_MAX_RESPONSE_BYTES, @@ -80,12 +96,18 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (!/^[a-z0-9-]+$/i.test(group)) { + if (admitSameSite(req, res)) return; + if (req.method !== 'GET') { + res.writeHead(405); + res.end(); + return; + } + if (!groups.has(group)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('invalid group'); return; } - const send = (status, body, cacheStatus) => { + const send = (status, body, cacheStatus, entry) => { // Guard against a double-send (e.g. a throw AFTER a response already // went out routing into the catch's send): writeHead after headersSent // throws "Cannot set headers after they are sent". @@ -93,6 +115,17 @@ export function celestrakProxy() { res.writeHead(status, { 'Content-Type': 'text/plain', 'x-tle-cache': cacheStatus, + 'Cache-Control': 'no-store', + ...(entry + ? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() } + : {}), + ...(cooldownUntil > 0 && Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), }); res.end(body); }; @@ -104,7 +137,16 @@ export function celestrakProxy() { if (entry) mem.set(group, entry); } if (entry && now - entry.at < TLE_TTL_MS) { - send(200, entry.body, 'HIT'); + send(200, entry.body, 'HIT', entry); + return; + } + if (Date.now() < cooldownUntil) { + send( + entry ? 200 : 502, + entry?.body || 'celestrak upstream cooling down', + entry ? 'STALE-ERROR' : 'NONE', + entry, + ); return; } // Stale or missing → refresh, single-flight per group. @@ -118,6 +160,8 @@ export function celestrakProxy() { return fresh; }) .catch((err) => { + if (Date.now() >= cooldownUntil) + cooldownUntil = Date.now() + 60000; console.warn( '[celestrak-proxy] refresh failed — serving cache if any', ); @@ -128,9 +172,9 @@ export function celestrakProxy() { } const fresh = await inflight.get(group); if (fresh) { - send(200, fresh.body, 'MISS'); + send(200, fresh.body, 'MISS', fresh); } else if (entry) { - send(200, entry.body, 'STALE-ERROR'); // upstream down — stale beats empty + send(200, entry.body, 'STALE-ERROR', entry); // upstream down — stale beats empty } else { send(502, 'celestrak fetch failed and no cache available', 'NONE'); } diff --git a/server/providers/space/cooldown.js b/server/providers/space/cooldown.js new file mode 100644 index 000000000..acb850124 --- /dev/null +++ b/server/providers/space/cooldown.js @@ -0,0 +1,12 @@ +/** Bound an upstream Retry-After delay (seconds or HTTP date). */ +export function providerCooldownMs(response, now = Date.now()) { + const raw = response?.headers?.get('retry-after')?.trim(); + const seconds = raw && /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : NaN; + const deadline = raw && !Number.isFinite(seconds) ? Date.parse(raw) : NaN; + const delay = Number.isFinite(seconds) + ? seconds * 1000 + : Number.isFinite(deadline) + ? deadline - now + : 60000; + return Math.min(30 * 60000, Math.max(5000, delay)); +} diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 7b56b6220..4e8ac629d 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -1,4 +1,5 @@ import path from 'node:path'; +import { providerCooldownMs } from './cooldown.js'; import { promises as fsp } from 'node:fs'; import { readResponseTextCapped, @@ -30,6 +31,8 @@ export function rocketLaunchesProxy() { 'launch-library-2-v2.3.json', ); let cache = null; + let cooldownUntil = 0; + let cooldownStatus = 502; let diskLoaded = false; const inFlight = new Map(); @@ -62,7 +65,18 @@ export function rocketLaunchesProxy() { function send(res, status, body, cacheState) { res.writeHead(status, { 'Content-Type': 'application/json', - 'Cache-Control': status === 200 ? 'public, max-age=900' : 'no-store', + 'Cache-Control': + status === 200 && cacheState !== 'STALE-ERROR' + ? 'public, max-age=900' + : 'no-store', + ...(cache ? { 'X-GEV-Cached-At': new Date(cache.at).toISOString() } : {}), + ...(Date.now() < cooldownUntil + ? { + 'Retry-After': String( + Math.ceil((cooldownUntil - Date.now()) / 1000), + ), + } + : {}), 'X-GEV-Cache': cacheState, }); res.end(body); @@ -75,17 +89,21 @@ export function rocketLaunchesProxy() { signal: AbortSignal.timeout(20000), headers: launchLibraryRequestHeaders(), }); - const body = await readResponseTextCapped(upstream, maxResponseBytes); if (!upstream.ok) { + cooldownUntil = Date.now() + providerCooldownMs(upstream); + cooldownStatus = upstream.status; + void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; throw error; } + const body = await readResponseTextCapped(upstream, maxResponseBytes); const parsed = JSON.parse(body); if (!Array.isArray(parsed?.results)) throw new Error('malformed upstream response'); const fresh = { at: Date.now(), body }; cache = fresh; + cooldownUntil = 0; void saveDiskCache(fresh); return fresh; } @@ -103,6 +121,16 @@ export function rocketLaunchesProxy() { return; } const stale = cache; + if (now < cooldownUntil) { + send( + res, + stale ? 200 : cooldownStatus, + stale?.body || + JSON.stringify({ error: 'Launch Library 2 unavailable' }), + stale ? 'STALE-ERROR' : 'NONE', + ); + return; + } const request = coalesceProxyRequest( inFlight, 'recent-launches', @@ -112,6 +140,10 @@ export function rocketLaunchesProxy() { const fresh = await request.promise; send(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); } catch (error) { + if (Date.now() >= cooldownUntil) { + cooldownUntil = Date.now() + 60000; + cooldownStatus = 502; + } // Log only a bounded status, never upstream bodies, URLs, or credentials. const status = Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 78f3f59f0..d215e78a3 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { providerCooldownMs } from '../server/providers/space/cooldown.js'; import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; @@ -23,7 +24,8 @@ function extract(name) { function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { - readResponseTextCapped, coalesceProxyRequest, + readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, + admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, @@ -117,7 +119,7 @@ test('Launch Library retains single-flight, fresh cache, stale fallback, and met test('CelesTrak unexpected failures hide details', async () => { const app = fixture('celestrakProxy', { Date: { now() { throw new Error(detail); } } }); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 500); assert.equal(res.body, 'celestrak proxy error'); assert.equal(res.headers['x-tle-cache'], 'ERROR'); @@ -126,7 +128,7 @@ test('CelesTrak unexpected failures hide details', async () => { test('CelesTrak retains invalid-group and unavailable responses', async () => { const app = fixture('celestrakProxy'); assert.equal((await app.request('/../')).status, 400); - const res = await app.request('/active'); + const res = await app.request('/stations'); assert.equal(res.status, 502); assert.equal(res.headers['x-tle-cache'], 'NONE'); }); @@ -135,13 +137,13 @@ test('CelesTrak retains fresh and stale TLE caches', async () => { let now = Date.now(); let calls = 0; const app = fixture('celestrakProxy', { - Date: { now: () => now }, + Date: class extends Date { static now() { return now; } }, fetch: async () => { if (++calls > 1) throw new Error(detail); return new Response('1 valid-fixture-TLE'); }, }); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'MISS'); - assert.equal((await app.request('/active')).headers['x-tle-cache'], 'HIT'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'MISS'); + assert.equal((await app.request('/stations')).headers['x-tle-cache'], 'HIT'); now += 7 * 3600_000; - const stale = await app.request('/active'); + const stale = await app.request('/stations'); assert.equal(stale.status, 200); assert.equal(stale.body, '1 valid-fixture-TLE'); assert.equal(stale.headers['x-tle-cache'], 'STALE-ERROR'); diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index a5b2cecba..2abc9d5fc 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -161,7 +161,11 @@ test('CelesTrak answers 502 to an oversized TLE body without caching it', async assert.ok(bodies[0].pulled <= 17, 'reading stops at the 16 MiB cap'); assert.equal(fsp.writeFile.mock.callCount(), 0); assert.equal((await request('/api/celestrak', '/stations')).status, 502); - assert.equal(bodies.length, 2, 'nothing was cached in memory'); + assert.equal( + bodies.length, + 1, + 'failed bodies are not cached but a cooldown bounds retry work', + ); }); for (const preview of [false, true]) @@ -190,3 +194,79 @@ for (const preview of [false, true]) assert.equal(hit.body, first.body); assert.equal(calls, 1); }); + +test('space providers honor Retry-After, gate groups, preserve stale dates and recover', async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + for (const kind of ['tle', 'launches']) { + for (const retryAfter of ['90', new Date(now + 90000).toUTCString()]) { + let calls = 0; + let fail = false; + let cancelled = false; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + if (fail) + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + status: kind === 'tle' ? 403 : 429, + headers: { 'Retry-After': retryAfter }, + }, + ); + return new Response( + kind === 'tle' + ? 'ISS\n1 25544U fixture\n2 25544 fixture\n' + : '{"results":[]}', + ); + }); + const request = install( + kind === 'tle' ? celestrakProxy() : rocketLaunchesProxy(), + ); + const route = kind === 'tle' ? '/api/celestrak' : '/api/launches'; + const url = kind === 'tle' ? '/stations' : '/'; + if (kind === 'tle') { + assert.equal( + (await request(route, '/not-a-supported-group')).status, + 400, + ); + assert.equal(calls, 0); + } + const first = await request(route, url); + now += 7 * 3600000; + // Recompute the HTTP-date relative to the clock at the failed refresh. + const originalFetch = globalThis.fetch; + t.mock.method(globalThis, 'fetch', async (...args) => { + const response = await originalFetch(...args); + if (fail && !/^\d+$/.test(retryAfter)) + response.headers.set( + 'Retry-After', + new Date(now + 90000).toUTCString(), + ); + return response; + }); + fail = true; + const stale = await request(route, url); + assert.equal(stale.status, 200); + assert.equal(stale.body, first.body); + assert.equal(stale.headers['Retry-After'], '90'); + assert.ok( + stale.headers[kind === 'tle' ? 'X-TLE-Cached-At' : 'X-GEV-Cached-At'], + ); + assert.equal(cancelled, true); + await request(route, url); + if (kind === 'tle') + assert.equal((await request(route, '/visual')).status, 502); + assert.equal(calls, 2, 'cooldown applies across TLE groups'); + now += 91000; + fail = false; + assert.equal((await request(route, url)).status, 200); + assert.equal(calls, 3); + } + } +}); From 8602999056ca09fdd25e26f4ac8c6b24f592a496 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:29:24 +0100 Subject: [PATCH 022/178] docs: record space fix --- changelog.d/1270-space-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1270-space-issue-fix.md diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md new file mode 100644 index 000000000..b483c4c01 --- /dev/null +++ b/changelog.d/1270-space-issue-fix.md @@ -0,0 +1 @@ +- Respect provider cooldowns and bound tle groups (asturksever, #1270). From d49877ad2e44e37f97e4c300bb2088e0c9786352 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:38:09 +0100 Subject: [PATCH 023/178] fix(share): reject inherited style keys during restore --- docs/CURRENT-STATE.md | 5 +++++ src/sharelink.celestial.test.mjs | 17 +++++++++++++++++ src/sharelink.js | 4 +++- src/ui/visualSettings.js | 5 +++++ src/view/index.js | 4 +++- src/view/index.test.mjs | 17 +++++++++++++++++ 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..74994075a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +Share links and structured views accept only own keys in their style registries. +Inherited names such as `constructor` and `__proto__` fall back to the existing +normal/null defaults without interrupting other restored fields. Programmatic +visual-style calls ignore unknown or non-string values before mutating state. + 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/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index e862aba66..13dedd709 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -778,3 +778,20 @@ test('visual input listeners are revoked before asynchronous UI teardown', () => assert.match(VisualSettings.prototype.stop.toString(), /this\._styleParameters\?\.destroy\(\)/); assert.match(VisualSettings.prototype.stop.toString(), /this\._visualEffects\.stop\(\)/); }); + + +test('share restore rejects inherited styles while continuing later fields', () => { + for (const style of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown']) { + const restored = makeManager(`#lat=10&lon=20&style=${style}&cr=1`).parseInitialHash(); + assert.equal(restored.style, 'normal'); + assert.equal(restored.celestialRing, true); + } +}); + +test('programmatic invalid styles are ignored before changing visual state', () => { + const target = { activeStyle: 'normal' }; + for (const value of ['constructor', '__proto__', 'toString', 'hasOwnProperty', 'unknown', {}, null, () => {}]) { + assert.doesNotThrow(() => VisualSettings.prototype.setStyle.call(target, value)); + assert.equal(target.activeStyle, 'normal'); + } +}); diff --git a/src/sharelink.js b/src/sharelink.js index 6072fb1bd..7e0e78b35 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -175,7 +175,9 @@ export class ShareLinkManager { parseOr(params.get('dd'), 50), 50, ); - const style = URL_TO_STYLE[params.get('style')] || 'normal'; + const style = Object.hasOwn(URL_TO_STYLE, params.get('style')) + ? URL_TO_STYLE[params.get('style')] + : 'normal'; const decodedLayerState = decodeLayerStateParams(params); const state = { lat, diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 2aab58f10..b80e4452f 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1539,6 +1539,11 @@ export class VisualSettings { preserveCyberRestore = false, } = {}, ) { + if ( + typeof styleName !== 'string' || + (styleName !== 'normal' && !Object.hasOwn(STYLES, styleName)) + ) + return; const { setDetectionStyle } = this.services; if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual'); if (this.hud.getVariant() === 'cyber') { diff --git a/src/view/index.js b/src/view/index.js index 8ab56d5a4..fc5385aaa 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -370,7 +370,9 @@ export function viewFromParams(params) { pitch_deg: number('pitch'), }, layers: state?.enabledLayerIds ?? [], - style: STYLES_BY_URL_NAME[params.get('style')] ?? null, + style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) + ? STYLES_BY_URL_NAME[params.get('style')] + : null, map: VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 818feb1ba..34299c498 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -126,3 +126,20 @@ test('annotations travel in the link, keeping only what the app draws', async () // Too long for a link: the view keeps them, the link leaves them out. assert.equal(viewToParams(many).has('an'), false); }); + +test('inherited and unknown style keys do not corrupt a restored view', () => { + for (const style of [ + 'constructor', + '__proto__', + 'toString', + 'hasOwnProperty', + 'unknown', + ]) { + const view = viewFromParams( + new URLSearchParams({ lat: '10', lon: '20', style, map: 'osm' }), + ); + assert.equal(view.style, null); + assert.equal(view.map, 'osm'); + assert.equal(view.camera.lat, 10); + } +}); From 53b6eae1222cc6852d73e6be8596697b564b4819 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:38:18 +0100 Subject: [PATCH 024/178] docs: record share fix --- changelog.d/1271-share-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1271-share-issue-fix.md diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md new file mode 100644 index 000000000..7a01330ba --- /dev/null +++ b/changelog.d/1271-share-issue-fix.md @@ -0,0 +1 @@ +- Reject inherited style keys during restore (asturksever, #1271). From 5f3d1eaa2fcf5d05d11afc948a2b31b3737e1454 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:43:47 +0100 Subject: [PATCH 025/178] fix(military): retire unavailable history and keep local trails --- docs/CURRENT-STATE.md | 2 ++ server/providers/aircraft/tracks.js | 21 +++++++++++++-------- src/layers/military/tracking.js | 2 +- src/sources/live/contract.test.mjs | 9 ++++----- src/sources/live/standalone.js | 22 ++-------------------- src/tooling/liveProviders.test.mjs | 18 ++++++++++++++++++ 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..70c741f79 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. This does not restore historical coverage or download bulk daily archives. + 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/aircraft/tracks.js b/server/providers/aircraft/tracks.js index 5f800ac59..3b0acd8d2 100644 --- a/server/providers/aircraft/tracks.js +++ b/server/providers/aircraft/tracks.js @@ -7,10 +7,9 @@ import { readCappedResponseText } from '../common/http.js'; * own credit bucket, 4 credits per call on the free tier). OAuth via the * shared coalesced token. 60s per-icao cache; 404/429 forwarded so the * client can fall back to its accumulated trail silently. - * /api/military/track?hex= — adsb.lol tar1090 readsb trace - * (undocumented but live; no browser CORS, hence this proxy). Up to ~24h - * of real history per aircraft. Treat as best-effort; data is ODbL — - * credit "adsb.lol (ODbL)" in the UI. + * /api/military/track?hex= — compatibility refusal for the retired, + * undocumented ADSB.lol trace route. The standalone military source uses + * locally accumulated trails; provider-supplied history remains optional. */ export function trackBackfillProxies() { const TRACK_CACHE_MS = 60000; @@ -107,10 +106,16 @@ export function trackBackfillProxies() { ); return; } - await proxyJson( - res, - `lol:${hex}`, - `https://adsb.lol/data/traces/${hex.slice(-2)}/trace_full_${hex}.json`, + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Retry-After', '1800'); + res.end( + JSON.stringify({ + error: + 'Military history unavailable; locally accumulated trails remain available', + code: 'history_unavailable', + }), ); } catch (error) { res.statusCode = 502; diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8a440a54f..3530a4877 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -593,7 +593,7 @@ export function createTracking({ } /** - * Fire-and-forget adsb.lol readsb trace backfill (PRD F2). Trace points are + * Fire-and-forget optional source history backfill (PRD F2). Trace points are * stride-thinned to the remaining vertex budget, then spliced strictly older * than the oldest seeded fix AHEAD of the locally accumulated fine segment, * capped at TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/ diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 658db1596..b8b1787da 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -214,11 +214,10 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo assert.equal(mil.observedAtMs, now - 60000); assert.equal(mil.records[0].positionTimeMs, now - 75000); assert.equal(mil.stale, true); - const track = await military.getTrack('abc123'); - assert.equal(track.complete, false); - assert.equal(track.records[0].observedAtMs, now - 50000); - assert.equal(track.records[0].baroAltitudeM, 304.8); - assert.equal(track.records[0].ellipsoidAltitudeM, null); + assert.equal(military.getTrack, undefined); + assert.ok( + requests.every(({ url }) => !url.startsWith('/api/military/track')), + ); }); test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => { diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 55670c629..7311c492d 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -159,26 +159,8 @@ export function createMilitarySource({ status: response.status, }; }, - async getTrack(reference, { signal } = {}) { - const { response, payload } = await readResponse( - fetchImpl, - '/api/military/track?hex=' + encodeURIComponent(reference), - { signal }, - 'Military aircraft', - ); - if (!response.ok) throw httpError(response, 'Military aircraft'); - const baseTimeMs = epoch(payload?.timestamp, 1000); - return { - records: - baseTimeMs == null - ? [] - : normalizeAircraftTrack(payload?.trace, { - baseTimeMs, - readsb: true, - }), - complete: false, - }; - }, + // ADSB.lol does not expose a supported live-history endpoint. Omitting + // optional getTrack keeps the tracking layer on its accumulated trail. }; } diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index a60a37cea..488c2d40c 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -583,3 +583,21 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(res2.statusCode, 502); assert.equal(callCount, 1); }); + +test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { + assert.fail('retired history must not contact the upstream'); + }); + const route = install(providers.trackBackfillProxies()); + for (const hex of ['abc123', 'abc456']) { + const response = await route('/api/military/track', '?hex=' + hex); + assert.equal(response.statusCode, 503); + assert.equal(JSON.parse(response.body).code, 'history_unavailable'); + assert.equal(response.headers['retry-after'], '1800'); + assert.equal(response.headers['cache-control'], 'no-store'); + } + assert.equal( + (await route('/api/military/track', '?hex=invalid')).statusCode, + 400, + ); +}); From 5d9eabc9192e22d7e2620445e27a85df956bb895 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:44:15 +0100 Subject: [PATCH 026/178] docs: record history fix --- changelog.d/1273-history-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1273-history-issue-fix.md diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md new file mode 100644 index 000000000..f672a5c22 --- /dev/null +++ b/changelog.d/1273-history-issue-fix.md @@ -0,0 +1 @@ +- Retire unavailable history and keep local trails (asturksever, #1273). From 6552a89ddd944ec43e4e68fe936dd7744fa5722e Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:48:25 +0100 Subject: [PATCH 027/178] fix(import): scan the KML root delimiter in bounded time --- docs/CURRENT-STATE.md | 6 +++ src/data/geoFileImport.js | 96 ++++++++++++++++++++++++++------- src/data/geoFileImport.test.mjs | 53 ++++++++++++++++++ 3 files changed, 137 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..40bddc651 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +KML namespace repair locates the opening token once and scans its quoted tag +delimiter once. Malformed or unterminated tags fail before DOM parsing, so a file +of repeated incomplete `' && subsetDepth === 0) break; + } + if (end === text.length) return text; + start = end + 1; + continue; + } + const close = text.startsWith('' + : text.startsWith('' + : null; + if (!close) break; + const end = text.indexOf(close, start + 2); + if (end < 0) return text; // The XML parser reports an unterminated preamble. + start = end + close.length; + } + const match = /^') { + end = index + 1; + break; + } + } + if (end < 0) + throw new GeoImportError( + `That ${what} file is not valid XML.`, + msg('That {format} file is not valid XML.'), + { format: what }, + ); + const seen = new Set(); + let root = text + .slice(start, end) + .replace( + /\sxmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, + (declaration, prefix) => { + if (seen.has(prefix)) return ''; + seen.add(prefix); + return declaration; + }, + ); + if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) + root = root.replace( + /(\/?)>$/, + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"$1>', + ); + return text.slice(0, start) + root + text.slice(end); +} + function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. - if (what !== 'GPX') - text = String(text).replace(/]*>/i, (root) => { - const seen = new Set(); - root = root.replace( - /\s+xmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, - (declaration, prefix) => { - if (seen.has(prefix)) return ''; - seen.add(prefix); - return declaration; - }, - ); - if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) - root = root.replace( - />$/, - ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', - ); - return root; - }); + if (what !== 'GPX') text = repairKmlRootNamespaces(text, what); const doc = new DOMParser().parseFromString( String(text).replace(/^/, ''), 'application/xml', diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index af02ec222..29545b0b6 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { deflateRawSync } from 'node:zlib'; +import { spawnSync } from 'node:child_process'; import * as Cesium from 'cesium'; import { GeoImportError, + repairKmlRootNamespaces, loadGeoFile, MAX_IMPORT_POSITIONS, MAX_IMPORT_ENTITIES, @@ -1171,3 +1173,54 @@ test('a dated import exposes no clock and draws for all time', () => { assert.equal(recorded.getValue(Cesium.JulianDate.now()).length, 2); assert.ok(track.polyline.clampToGround.getValue()); }); + +test('KML root namespace repair respects quoted delimiters and preserves the document tail', () => { + const text = + ''; + const result = repairKmlRootNamespaces(text); + assert.match(result, /note="a > b"/); + assert.match(result, /xmlns:x="first"/); + assert.doesNotMatch(result, /second/); + assert.match( + result, + /xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance"/, + ); + assert.ok(result.endsWith('')); + assert.match( + repairKmlRootNamespaces(''), + /XMLSchema-instance"\/>$/, + ); + for (const malformed of ['\n'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); + +test('KML namespace repair follows a doctype with a quoted root-like entity', () => { + const input = + '">]>'; + assert.equal( + repairKmlRootNamespaces(input), + input.replace(' xmlns:gx="b"', ''), + ); +}); From 9541963aa6521d0190c412be162e2cc9498f12ae Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:48:45 +0100 Subject: [PATCH 028/178] docs: record import fix --- changelog.d/1274-import-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1274-import-issue-fix.md diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md new file mode 100644 index 000000000..99bbc1429 --- /dev/null +++ b/changelog.d/1274-import-issue-fix.md @@ -0,0 +1 @@ +- Scan the kml root delimiter in bounded time (asturksever, #1274). From 0cb6857229b497043177db267ff07d95a9fa7f99 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:55:40 +0100 Subject: [PATCH 029/178] fix(weather): preserve enable during first-load replacement --- docs/CURRENT-STATE.md | 2 ++ src/layers/weather/index.js | 15 +++++++++---- src/layers/weather/weather.test.mjs | 32 +++++++++++++++++++++++++++- src/layers/wind/index.js | 5 +++-- src/layers/wind/index.test.mjs | 33 +++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..2309269b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Wind and weather parameter changes may supersede a first-load request without rejecting the layer’s enable transaction. Obsolete responses cannot publish data; a replacement request remains visible through the loading state. Explicit caller cancellation, disable, and destroy still reject the obsolete update. Source failures continue to report their existing unavailable state. + 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/weather/index.js b/src/layers/weather/index.js index db62ea98a..188955090 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -357,7 +357,7 @@ export function createWeatherLayer({ signal: controller.signal, }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; if (snapshot.unavailable) { error = msg( 'Weather source unavailable; previous observation retained', @@ -376,7 +376,9 @@ export function createWeatherLayer({ ) await show(snapshot.latest, controller.signal); return ( - !controller.signal.aborted && request === controller && enabled + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) ); } const time = @@ -388,9 +390,14 @@ export function createWeatherLayer({ (satellite && rendering.getDiagnostics().infrared !== infrared) ) await show(time, controller.signal); - return !controller.signal.aborted && request === controller && enabled; + return ( + enabled && + !signal?.aborted && + (!controller.signal.aborted || request !== controller) + ); } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Weather unavailable'); stop(); return true; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 1a4ba5177..38668909a 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -694,7 +694,11 @@ test('superseding a pending stage preserves the newer manifest loading state', a await flush(); const newer = h.layer.update(); await flush(); - assert.equal(await old, false); + assert.equal( + await old, + true, + 'replacement is accepted by the enable lifecycle', + ); assert.equal( h.layer.getStats().loading, true, @@ -2311,3 +2315,29 @@ for (const product of ['radar', 'clouds-regional', 'lightning']) { assert.equal(h.viewer.camera.moveEnd.size, 0); }); } + +test('a product replacement accepts supersession but never paints the obsolete first response', async () => { + const pending = []; + const h = layerHarness({ + id: 'weather-satellite', + feed: { + getSnapshot: (args) => { + const task = deferred(); + pending.push({ ...args, ...task }); + return task.promise; + }, + }, + }); + const first = h.layer.update(); + h.layer.setParams({ product: 'clouds' }); + assert.equal(pending.length, 2); + pending[0].resolve({ ...snapshot, product: 'clouds-regional' }); + assert.equal(await first, true); + assert.equal(h.stages.length, 0); + pending[1].resolve({ ...snapshot, product: 'clouds' }); + await flush(); + h.stages.at(-1).finish(); + await flush(); + assert.equal(h.layer.getParams().product, 'clouds'); + h.layer.destroy(); +}); diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index c76e2501c..aa6161f06 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -180,7 +180,7 @@ export function createWindLayer({ overlay: requestedScalar(), }); if (!enabled || controller.signal.aborted || request !== controller) - return false; + return enabled && !signal?.aborted && request !== controller; manifest = snapshot; error = null; if (sampledPosition) sample(sampledPosition); @@ -194,7 +194,8 @@ export function createWindLayer({ } return true; } catch (cause) { - if (controller.signal.aborted || request !== controller) return false; + if (controller.signal.aborted || request !== controller) + return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Wind source unavailable'); return true; } finally { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index ac843f62a..3b5e09697 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -412,3 +412,36 @@ test('wind unit chips appear only alongside a speed legend, including canvas tra for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); } layer.destroy(); }); + +test('a parameter replacement during manager enable does not roll the layer back', async () => { + const { LayerLifecycle } = await import('../../data/lifecycle.js'); + const pending = []; + const rendering = Object.fromEntries( + ['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [ + name, + () => {}, + ]), + ); + const layer = createWindLayer({ + feed: { + getSnapshot: (args) => + new Promise((resolve) => pending.push({ ...args, resolve })), + }, + createRendering: () => rendering, + }); + const manager = new LayerLifecycle({ container: {} }); + manager.register(layer); + const enabled = manager.setEnabled('wind', true); + for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve(); + layer.setParams({ model: 'ifs' }); + await Promise.resolve(); + pending[0].resolve(snapshot('gfs')); + assert.equal(await enabled, true); + assert.equal(manager.isEnabled('wind'), true); + assert.equal(layer.getStats().loading, true); + pending[1].resolve(snapshot('ifs')); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal(layer.getStats().model, 'IFS'); + assert.equal(layer.getStats().count, 4); + await manager.destroyAll(); +}); From 2f23bbe87f3c183d311dd758c74c3b721a966650 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:56:00 +0100 Subject: [PATCH 030/178] docs: record forecast fix --- changelog.d/1275-forecast-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1275-forecast-issue-fix.md diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md new file mode 100644 index 000000000..9308249e2 --- /dev/null +++ b/changelog.d/1275-forecast-issue-fix.md @@ -0,0 +1 @@ +- Preserve enable during first-load replacement (asturksever, #1275). From 7860cbf59e05549604c08414a086dacf3f5adf2b Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:57:24 +0100 Subject: [PATCH 031/178] fix(terrain): bound each height request to one upstream chunk --- docs/CURRENT-STATE.md | 5 +++ server/providers/terrain.js | 10 +++--- src/terrainHeightsBounds.test.mjs | 21 ++++++++++++ src/tooling/environmentProviders.test.mjs | 41 ++++++++++++----------- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..e5c4bb9f9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,10 @@ # God's Eye View Current State +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 +413 before disk-cache loading or upstream work; malformed coordinates remain 400. +The browser continues to split larger workloads into sequential 64-point requests. + 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/terrain.js b/server/providers/terrain.js index 32083d23a..c678ab7c0 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -20,8 +20,8 @@ import { admitSameSiteKeyless } from './common/same-site.js'; * cache and serve-stale shape. Cache entries and stale fallback are keyed per * 5dp point, so reordered and partially overlapping batches reuse prior work. * Only missing/stale points go upstream; the response is rebuilt in exact - * request order. Larger requests are chunked sequentially, and one failing - * chunk does not discard the chunks that resolved. + * request order. Each request is bounded to one 64-point upstream chunk; + * larger client workloads are split by the browser terrain service. * * Bounds, because every distinct 5dp point is its own key: coordinates must be * in WGS-84 range (rejected at parse), the point cache has an entry ceiling @@ -55,7 +55,7 @@ export function terrainHeightsProxy(options = {}) { // 2026-08-21). 64 points costs 5.6-12 s, inside the timeout at both ends of // that range. const UPSTREAM_CHUNK = 64; - const MAX_POINTS = 2000; + const MAX_POINTS = UPSTREAM_CHUNK; /** * Entry ceiling for the point cache, and with it the file written from it. * @@ -255,7 +255,6 @@ export function terrainHeightsProxy(options = {}) { res.end(JSON.stringify({ error: 'Rate limit exceeded' })); return; } - await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); const points = parseTerrainPoints(rawPoints); @@ -267,12 +266,13 @@ export function terrainHeightsProxy(options = {}) { return; } if (points.length > MAX_POINTS) { - send(500, { + send(413, { error: `too many points (${points.length}); max ${MAX_POINTS} per request`, }); return; } + await loadDiskOnce(); const outcome = await resolveTerrainHeightRequest({ points, cache: mem, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs index cd1a9ad36..3746f4fe4 100644 --- a/src/terrainHeightsBounds.test.mjs +++ b/src/terrainHeightsBounds.test.mjs @@ -366,3 +366,24 @@ test('a second client keeps its own quota', async (t) => { }); assert.notEqual(neighbour.statusCode, 429); }); + +test('one 64-point chunk is accepted; larger batches fail before cache or upstream work', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'terrain-batch-limit-')); + t.after(() => rm(root, { recursive: true, force: true })); + const asked = stubTerrainUpstream(t); + const route = install( + terrainHeightsProxy({ cachePath: path.join(root, 'cache.json') }), + ).get('/api/terrain/heights'); + const points = (count) => + Array.from({ length: count }, (_, i) => `${i / 1000},30`).join(';'); + for (const count of [65, 2001]) { + const response = await request(route, { url: `/?points=${points(count)}` }); + assert.equal(response.statusCode, 413); + assert.match(response.body.error, /max 64/); + assert.equal(asked.length, 0); + } + const response = await request(route, { url: `/?points=${points(64)}` }); + assert.equal(response.statusCode, 200); + assert.equal(response.body.results.length, 64); + assert.equal(asked.length, 64); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index b6faeb34c..bbb0e9d6e 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -98,7 +98,7 @@ test('standalone composition mounts every extracted provider exactly once withou assert.equal(plugins.filter((p) => p.name === factory().name).length, 1); }); -test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => { +test('terrain middleware bounds missing points and reconstructs repeated/reordered requests from cache', async (t) => { isolate(t); let calls = 0; const sizes = []; @@ -116,25 +116,23 @@ test('terrain middleware chunks missing points and reconstructs repeated/reorder }); }); const request = install(terrainHeightsProxy()); - const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`); + const points = Array.from({ length: 64 }, (_, i) => `${i / 100},1`); const res = await request('/?points=' + points.join(';')); assert.equal(res.status, 200); - // Tracks UPSTREAM_CHUNK in server/providers/terrain.js (64): 257 points - // split into four full chunks and a remainder. - assert.deepEqual(sizes, [64, 64, 64, 64, 1]); - const reordered = await request('/?points=2.56,1;0,1;2.56,1'); + assert.deepEqual(sizes, [64]); + const reordered = await request('/?points=0.63,1;0,1;0.63,1'); assert.deepEqual(json(reordered), { - results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }], + results: [{ ellipsoid: 100.63 }, { ellipsoid: 100 }, { ellipsoid: 100.63 }], }); - // Five chunks for the first batch; the reordered request is fully cached. - assert.equal(calls, 5); + // One bounded batch; the reordered request is fully cached. + assert.equal(calls, 1); assert.equal((await request('/?points=invalid')).status, 400); assert.equal( (await request('/?points=' + Array(2001).fill('0,1').join(';'))).status, - 500, + 413, ); - // Rejected requests add no upstream calls: still the five from the first batch. - assert.equal(calls, 5); + // Rejected requests add no upstream calls. + assert.equal(calls, 1); }); test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => { @@ -388,21 +386,26 @@ test('terrain middleware retains successful chunks around a failure and retries }); const request = install(terrainHeightsProxy()); const points = Array.from({ length: 130 }, (_, i) => `${i / 100},1`); - const url = '/?points=' + points.join(';'); - assert.equal((await request(url)).status, 502); + const urls = [0, 64, 128].map( + (start) => '/?points=' + points.slice(start, start + 64).join(';'), + ); assert.deepEqual( - requests.map((p) => p.length), - [64, 64, 2], + await Promise.all(urls.map(async (url) => (await request(url)).status)), + [200, 502, 200], + ); + assert.deepEqual( + requests.map((p) => p.length).sort((a, b) => a - b), + [2, 64, 64], ); fail = false; - const recovered = await request(url); - assert.equal(recovered.status, 200); + const recovered = await Promise.all(urls.map((url) => request(url))); + assert.ok(recovered.every((response) => response.status === 200)); assert.deepEqual( requests[3], Array.from({ length: 64 }, (_, i) => [(i + 64) / 100, 1]), ); assert.deepEqual( - json(recovered).results, + recovered.flatMap((response) => json(response).results), Array.from({ length: 130 }, (_, i) => ({ ellipsoid: i / 100 + 100 })), ); assert.equal(requests.length, 4); From f436d72d1bccf20f95141fb470d497a35831b157 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 00:57:54 +0100 Subject: [PATCH 032/178] docs: record terrain fix --- changelog.d/1276-terrain-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1276-terrain-issue-fix.md diff --git a/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md new file mode 100644 index 000000000..17e8f4b50 --- /dev/null +++ b/changelog.d/1276-terrain-issue-fix.md @@ -0,0 +1 @@ +- Bound each height request to one upstream chunk (asturksever, #1276). From 5980a085337907e20444887226f649168749b3ee Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:12:36 +0100 Subject: [PATCH 033/178] 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 034/178] 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 035/178] 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 036/178] 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 122b511d1ee04b9e135d2fb729254689a5284b4d Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:21:53 +0100 Subject: [PATCH 037/178] fix(traffic): hold rendering only for animated content --- docs/CURRENT-STATE.md | 2 ++ src/layers/traffic/animation.js | 26 +++++++++++++- src/layers/traffic/lifecycle.js | 5 ++- src/layers/traffic/motion.test.mjs | 9 ++++- src/layers/traffic/renderDemand.test.mjs | 45 ++++++++++++++++++++++++ src/layers/traffic/rendering.js | 2 ++ src/layers/traffic/retention.js | 1 + src/layers/transit/lifecycle.test.mjs | 2 +- 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 src/layers/traffic/renderDemand.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 55b7a63ab..109015c4d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. + 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/traffic/animation.js b/src/layers/traffic/animation.js index 05afde1c1..4d9f6c4b3 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -20,6 +20,20 @@ export function createAnimation({ parts, source, }) { + function syncRenderDemand() { + const needed = Boolean( + layerState._enabled && + (layerState._dots.length || layerState._heatJamPrim), + ); + if (needed === Boolean(layerState._renderHeld)) return; + layerState._renderHeld = needed; + if (needed) { + layerState._lastAnimTime = 0; + services.render.holdContinuousRender('traffic'); + layerState._viewer?.scene?.requestRender?.(); + } else services.render.releaseContinuousRender('traffic'); + } + /** * Spawn animated dot primitives along a single road. * @@ -188,6 +202,7 @@ export function createAnimation({ : null, }); } + syncRenderDemand(); } /** @@ -206,6 +221,8 @@ export function createAnimation({ */ function animate() { + syncRenderDemand(); + if (!layerState._renderHeld) return; const now = Date.now(); // Delta time in seconds, capped to avoid jumps when returning from background tab const dt = layerState._lastAnimTime @@ -339,6 +356,13 @@ export function createAnimation({ layerState._count = 0; layerState._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 0 }; layerState._closedRoads = 0; + syncRenderDemand(); } - return { spawnDotsForRoad, animate, maybeStopLight, clearDots }; + return { + spawnDotsForRoad, + animate, + maybeStopLight, + clearDots, + syncRenderDemand, + }; } diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index 94c40d935..fc13e205d 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -12,7 +12,6 @@ export function createLifecycle({ parts, source, }) { - const { holdContinuousRender, releaseContinuousRender } = services.render; const { resetFlowTileCache } = source; const methods = { @@ -81,7 +80,7 @@ export function createLifecycle({ layerState._enabled = true; layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene); if (layerState._roadMode !== 'tomtom') source.prefetch?.(); - holdContinuousRender('traffic'); // per-frame animator (perf wave 2) + parts.animation.syncRenderDemand(); layerState._lastAnimTime = 0; layerState._pointCollection.show = true; @@ -142,7 +141,7 @@ export function createLifecycle({ disable(viewer) { layerState._enabled = false; services.credits?.hideOsmCredit?.(layerState._viewer, 'traffic'); - releaseContinuousRender('traffic'); + parts.animation.syncRenderDemand(); clearTimeout(layerState._fetchTimeout); clearInterval(layerState._enableKickTimer); layerState._enableKickTimer = null; diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 3c230d870..6338eb10f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -37,7 +37,14 @@ function setup(t) { visibleRoadsForAltitude: (roads) => roads, }, }; - const ctx = { state, services: {}, parts, source: {} }; + const ctx = { + state, + services: { + render: { holdContinuousRender() {}, releaseContinuousRender() {} }, + }, + parts, + source: {}, + }; parts.style = createStyle(ctx); parts.model = createModel(ctx); parts.animation = createAnimation(ctx); diff --git a/src/layers/traffic/renderDemand.test.mjs b/src/layers/traffic/renderDemand.test.mjs new file mode 100644 index 000000000..df5965145 --- /dev/null +++ b/src/layers/traffic/renderDemand.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAnimation } from './animation.js'; + +test('traffic render demand follows animated population and jam lines across empty, disable and re-enable', () => { + const calls = []; + const state = { _enabled: true, _dots: [], _motion: { rebuilds: 0 } }; + const animation = createAnimation({ + state, + services: { + render: { + holdContinuousRender: (id) => calls.push(['hold', id]), + releaseContinuousRender: (id) => calls.push(['release', id]), + }, + }, + parts: { + retention: { clear() {} }, + rendering: { + removeHeatLines() { + state._heatJamPrim = null; + }, + }, + }, + }); + animation.syncRenderDemand(); + assert.deepEqual(calls, []); + state._dots.push({}); + animation.syncRenderDemand(); + animation.syncRenderDemand(); + assert.deepEqual(calls, [['hold', 'traffic']]); + animation.clearDots(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._heatJamPrim = {}; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['hold', 'traffic']); + state._enabled = false; + animation.syncRenderDemand(); + assert.deepEqual(calls.at(-1), ['release', 'traffic']); + state._enabled = true; + animation.clearDots(); + assert.equal(state._renderHeld, false); + state._dots.push({}); + animation.syncRenderDemand(); + assert.equal(state._renderHeld, true); +}); diff --git a/src/layers/traffic/rendering.js b/src/layers/traffic/rendering.js index 7e148148d..bf0132b98 100644 --- a/src/layers/traffic/rendering.js +++ b/src/layers/traffic/rendering.js @@ -50,6 +50,7 @@ export function createRendering({ layerState._heatSlowPrim = null; } layerState._heatLineCount = 0; + parts.animation.syncRenderDemand?.(); } /** @@ -152,6 +153,7 @@ export function createRendering({ } layerState._heatLineCount = kept.length; + parts.animation.syncRenderDemand?.(); if (candidates.length > kept.length) { console.log( `[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`, diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index 4bfe7bfb6..ec0762b5c 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -125,6 +125,7 @@ export function createRetention({ state, parts }) { count++; } state._count = count; + parts.animation.syncRenderDemand?.(); } function reconcile(roads, height, replace) { altitude = height; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..9bc5cb5b1 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3576,7 +3576,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve source: {}, parts: { flow: { ensureFlowStatus: noop }, - animation: { animate: noop, clearDots: noop }, + animation: { animate: noop, clearDots: noop, syncRenderDemand: noop }, viewport: { onCameraChanged: noop }, ingestion: { cancelActiveFetch: noop }, }, From 34b064fc6229cd044a8d09e68733ac1c13129030 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:22:32 +0100 Subject: [PATCH 038/178] docs: record traffic fix --- changelog.d/1281-traffic-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1281-traffic-issue-fix.md diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md new file mode 100644 index 000000000..3c546d333 --- /dev/null +++ b/changelog.d/1281-traffic-issue-fix.md @@ -0,0 +1 @@ +- Hold rendering only for animated content (asturksever, #1281). From ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH 039/178] 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 040/178] 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 9fd434438b052f6cd5b4c09a30ffb85244a762d5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 13:50:42 +0100 Subject: [PATCH 041/178] test: target mounted voice input in radio takeover gate --- changelog.d/1268-radio-issue-fix.md | 1 + scripts/qa-radio.mjs | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md index de6b0bcae..7a5be28b7 100644 --- a/changelog.d/1268-radio-issue-fix.md +++ b/changelog.d/1268-radio-issue-fix.md @@ -1 +1,2 @@ - Normalize station tags and retain valid mixed rows (asturksever, #1268). +- Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 648075bd2..cfc601ca3 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -2761,13 +2761,16 @@ async function main() { const beforeSpace = radio.getUIState().audioState; // Simulate an already-started hold-Space session; click-started open mic // deliberately ignores Space takeover, and short taps never claim voice. - const priorPushToTalkMode = voice.pushToTalkMode; - voice.pushToTalkMode = true; + // The provider facade forwards reads, not writes. Set the mounted + // controller's input owner; assigning on the facade only shadows it. + const controller = voice.session.adapter.controller; + const priorPushToTalkMode = controller.pushToTalkMode; + controller.pushToTalkMode = true; document.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); await new Promise((resolve) => setTimeout(resolve, 700)); const afterSpace = radio.getUIState().audioState; document.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true, code: 'Space', key: ' ' })); - voice.pushToTalkMode = priorPushToTalkMode; + controller.pushToTalkMode = priorPushToTalkMode; await radio.play(); voice.setVoiceSpeaker('ai'); return { beforeSpace, afterSpace, afterAi: radio.getUIState().audioState }; From d6f89e3cfd9b27e21c2ee5ad0307107ed46c079f Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:01:22 +0100 Subject: [PATCH 042/178] fix: release weather camera listeners while disabled --- changelog.d/1275-forecast-issue-fix.md | 1 + docs/CURRENT-STATE.md | 1 + scripts/qa-weather-teardown.mjs | 28 ++++++++++++++++++++++---- src/layers/weather/index.js | 12 +++++++---- src/layers/weather/weather.test.mjs | 15 ++++++++++++++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md index 9308249e2..6168e7f1f 100644 --- a/changelog.d/1275-forecast-issue-fix.md +++ b/changelog.d/1275-forecast-issue-fix.md @@ -1 +1,2 @@ - Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2309269b8..00e533540 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -253,6 +253,7 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. +Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index b64d1d59c..c81a7bec1 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -141,7 +141,7 @@ const snapshot = (page) => ), }, weather, - windCells: wind.gpu?.pathCount ?? wind.paths ?? null, + windCells: wind.gpu?.pathCount ?? wind.paths ?? 0, enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id), }; }); @@ -220,7 +220,7 @@ try { const baseline = await snapshot(a.page); console.log('baseline', JSON.stringify(baseline)); for (const id of WEATHER) await setLayer(a.page, id, true); - await a.page + const weatherReady = await a.page .waitForFunction( () => ['weather-radar', 'weather-satellite', 'weather-lightning'].every( @@ -233,7 +233,13 @@ try { ), { timeout: 120_000 }, ) - .catch(() => console.log(' !! weather not ready')); + .then( + () => true, + () => false, + ); + results.weatherReady = weatherReady; + if (!weatherReady) + console.log(' !! weather not ready — feature coverage incomplete'); await sleep(2000); // exercise: history playback, wind reading, storm selection, product switch await a.page.evaluate(() => { @@ -331,11 +337,25 @@ try { 'errors (control run):', results.errorsB.length ? results.errorsB : 'none', ); + // Resource ownership is an acceptance check; provider availability remains + // separately reported diagnostic evidence in both runs. + if ( + !weatherReady || + Object.keys(leak).length || + [...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:')) + ) { + process.exitCode = 1; + } +} catch (error) { + results.failure = { name: error.name, message: error.message }; + throw error; +} finally { + // Preserve completed resource evidence even if a later provider or browser + // phase aborts. A partial run still exits nonzero. writeFileSync( path.join(outDir, 'results.json'), JSON.stringify(results, null, 2), ); console.log(`wrote ${path.join(outDir, 'results.json')}`); -} finally { await browser.close(); } diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 188955090..757764fe6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -296,10 +296,6 @@ export function createWeatherLayer({ motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); registerClock(); - removeCamera = viewer.camera.moveEnd?.addEventListener(() => { - checkHost(); - if (enabled) notify(); - }); }, attachShellServices(services) { imageryHost = @@ -315,10 +311,18 @@ export function createWeatherLayer({ }, enable() { enabled = true; + if (!removeCamera) { + removeCamera = viewer.camera.moveEnd?.addEventListener(() => { + checkHost(); + notify(); + }); + } registerClock(); }, disable() { enabled = false; + removeCamera?.(); + removeCamera = null; unregisterClock?.(); unregisterClock = null; ++generation; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 38668909a..486d42520 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -2341,3 +2341,18 @@ test('a product replacement accepts supersession but never paints the obsolete f assert.equal(h.layer.getParams().product, 'clouds'); h.layer.destroy(); }); + +test('disabled weather releases its camera listener and re-enabling binds only once', () => { + const h = layerHarness(); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work'); + h.layer.enable(); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.destroy(); + assert.equal(h.moveEnd.size, 0); +}); From e4955f351ff8c7068748b22e9673e7b61edd7d92 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:07:13 +0100 Subject: [PATCH 043/178] 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 f16fda23506d70329dc73bbc3ec925737ea5c498 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:18:23 +0100 Subject: [PATCH 044/178] test: exercise traffic with current vector and terrain fixtures --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 39 +++---- scripts/traffic-fixtures.mjs | 144 ++++++++++++++++++-------- src/tooling/trafficFixtures.test.mjs | 92 ++++++++++++++++ 4 files changed, 214 insertions(+), 62 deletions(-) create mode 100644 src/tooling/trafficFixtures.test.mjs diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 3c546d333..ade62efe4 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1 +1,2 @@ - Hold rendering only for animated content (asturksever, #1281). +- Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 424f3486e..5da4b7db7 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -25,8 +25,8 @@ * Visual proof saved to qa-shots/ (gitignored). * * Run: node scripts/qa-traffic.mjs --url http://localhost:4410 - * Add --fixtures for synthetic roads and the recorded flow tile without a key; - * that mode does not qualify live source access or quota accounting. + * Add --fixtures for synthetic road/flow tiles and a flat terrain surface without a key; + * that mode does not qualify geographic terrain, live source access or quota accounting. * Exits non-zero on any FAIL. Does not commit anything. */ @@ -44,7 +44,9 @@ 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:4410'); +const appUrl = new URL(getOpt('--url', 'http://localhost:4410')); +appUrl.searchParams.set('welcome', '0'); +const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; @@ -122,8 +124,8 @@ async function main() { return; } - const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null); - if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.'); + const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()).catch(() => null); + if (FIXTURES) console.log('Source mode: synthetic road/flow tiles and a flat terrain surface; live quota is not qualified.'); if (!statusBefore?.hasKey) { console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m'); process.exitCode = 2; @@ -135,7 +137,9 @@ async function main() { headless: HEADFUL ? false : 'new', ...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}), args: [ - '--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader', + '--no-sandbox', '--disable-setuid-sandbox', + ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), + '--disable-backgrounding-occluded-windows', '--disable-dev-shm-usage', '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', @@ -236,19 +240,16 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); trafficLogs.length = 0; - await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); + const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { - const fetchLine = trafficLogs.find((t) => t.includes('fetch') && t.includes('[')); - let ok = false; let detail = 'no fetch log captured'; - if (fetchLine) { - const m = fetchLine.match(/\[(-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\]/); - if (m) { - const [s, w, n, e] = m.slice(1).map(Number); - const cLat = (s + n) / 2; const cLon = (w + e) / 2; - const dKm = Math.sqrt(((cLat - 30.245) * 111) ** 2 + ((cLon - (-97.72)) * 111 * Math.cos(30.27 * Math.PI / 180)) ** 2); - ok = dKm > 1 && dKm <= 13; // look-at ~6.9 km ahead; clamp allows ≤12 (+margin) - detail = `box center (${cLat.toFixed(4)},${cLon.toFixed(4)}) is ${dKm.toFixed(1)} km from camera (want 1–13 km; pre-fix bug: 25+ km)`; - } + const bounds = oblique?.roadBounds; + let ok = false; let detail = 'no road bounds published'; + if (bounds) { + const cLat = (bounds.south + bounds.north) / 2; + const cLon = (bounds.west + bounds.east) / 2; + const dKm = Math.hypot((cLat - 30.245) * 111, (cLon + 97.72) * 111 * Math.cos(30.27 * Math.PI / 180)); + ok = dKm > 1 && dKm <= 13; + detail = `road box center is ${dKm.toFixed(1)} km from camera (want 1–13 km)`; } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; @@ -258,7 +259,7 @@ async function main() { // ── (iii) budget honesty ───────────────────────────────────────────────── console.log('\n(iii) Budget — dailyCount grew by ≤ requests this run...'); { - const statusAfter = FIXTURES ? statusBefore : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()); + const statusAfter = FIXTURES ? statusBefore : await fetch(`${appUrl.origin}/api/tomtom/status`).then((r) => r.json()); const grew = statusAfter.dailyCount - statusBefore.dailyCount; const ok = grew >= 0 && grew <= flowRequests.length; record('BUDGET: /api/tomtom/status growth ≤ page tile requests', FIXTURES ? null : ok, diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 54ad48358..af51487db 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,56 +1,114 @@ -import { readFileSync } from 'node:fs'; -import { decodeFlowTile } from '../src/layers/traffic/flowDecode.js'; -import { tilesForBounds } from '../src/data/tomtomTiles.js'; +import { PbfWriter } from 'pbf'; -const tile = readFileSync( - new URL( - '../src/data/fixtures/tomtom-flow-austin-12-935-1686.pbf', - import.meta.url, - ), -); const json = (body) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body), }); -/** Reuse the recorded flow tile for an explicitly synthetic browser exercise. */ +// Cesium heightmap-1.0: 65x65 little-endian uint16 heights, scale .2, +// offset -1000, followed by child availability and a uniform land mask. +const flatTerrain = Buffer.alloc(65 * 65 * 2 + 2); +for (let i = 0; i < 65 * 65; i++) flatTerrain.writeUInt16LE(5000, i * 2); + +/** Synthetic, geographically aligned motor roads at both source zooms. */ +export function trafficFixtureTile(zoom, flow = false) { + const writer = new PbfWriter(); + writer.writeMessage( + 3, + (_, layer) => { + layer.writeVarintField(15, 2); + layer.writeStringField(1, flow ? 'Traffic flow' : 'transportation'); + layer.writeVarintField(5, 4096); + layer.writeStringField(3, flow ? 'traffic_level' : 'class'); + layer.writeMessage( + 4, + (_, value) => { + if (flow) value.writeDoubleField(3, 0.6); + else value.writeStringField(1, 'primary'); + }, + null, + ); + if (flow) { + layer.writeStringField(3, 'road_type'); + layer.writeMessage( + 4, + (_, value) => value.writeStringField(1, 'Major road'), + null, + ); + } + // z12 has four times the span of z14. Keep the same world spacing, + // and cover only alternate roads with flow to exercise hide/sim controls. + const step = 256 * 2 ** (zoom - 14); + for (let y = step; y < 4096; y += step) { + if (flow && (y / step) % 2) continue; + layer.writeMessage( + 2, + (_, feature) => { + feature.writeVarintField(3, 2); + feature.writePackedVarint(2, flow ? [0, 0, 1, 1] : [0, 0]); + feature.writePackedVarint(4, [9, 0, y * 2, 10, 8192, 0]); + }, + null, + ); + } + }, + null, + ); + return Buffer.from(writer.finish()); +} + +/** Intercept the current vector sources; never fall through to live roads. */ export function trafficFixtureResponse(request) { const url = new URL(request.url()); + if (url.hostname === 'terrain.reearth.land') { + if (url.pathname.endsWith('/layer.json')) + return json({ + tilejson: '2.1.0', + format: 'heightmap-1.0', + version: 'qa-flat', + scheme: 'tms', + projection: 'EPSG:4326', + minzoom: 0, + maxzoom: 0, + tiles: ['{z}/{x}/{y}.terrain?v={version}'], + available: [[{ startX: 0, startY: 0, endX: 1, endY: 0 }]], + attribution: + 'Synthetic flat terrain for Traffic QA; not live elevation data', + }); + if (/\/0\/[01]\/0\.terrain$/.test(url.pathname)) + return { + status: 200, + contentType: 'application/octet-stream', + body: flatTerrain, + }; + throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); + } if (url.pathname === '/api/tomtom/status') return json({ hasKey: true, dailyCount: 0 }); - if (/^\/api\/tomtom\/flow\/\d+\/\d+\/\d+\.pbf$/.test(url.pathname)) - return { status: 200, contentType: 'application/x-protobuf', body: tile }; - if (url.pathname !== '/api/overpass') return null; - const query = new URLSearchParams(request.postData()).get('data') || ''; - if (!query.includes('highway')) return null; - const match = query.match(/\((-?[\d.]+),(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)\)/); - if (!match) throw new Error('Missing road fixture viewport'); - const [south, west, north, east] = match.slice(1).map(Number); - const inside = ([lon, lat]) => - lat >= south && lat <= north && lon >= west && lon <= east; - const segments = tilesForBounds({ south, west, north, east }, 12).flatMap( - ({ z, x, y }) => decodeFlowTile(tile, z, x, y), + const flow = url.pathname.match( + /^\/api\/tomtom\/flow\/(\d+)\/\d+\/\d+\.pbf$/, ); - const elements = segments - .filter((segment) => segment.coords.some(inside)) - .slice(0, 240) - .map((segment, index) => ({ - type: 'way', - id: index + 1, - tags: { highway: 'primary', oneway: 'yes' }, - geometry: segment.coords.map(([lon, lat]) => ({ lon, lat })), - })); - const latitude = (south + north) / 2, - longitude = (west + east) / 2; - elements.push({ - type: 'way', - id: 99999, - tags: { highway: 'residential' }, - geometry: [ - { lat: latitude, lon: longitude }, - { lat: latitude + 0.003, lon: longitude + 0.003 }, - ], - }); - return json({ elements }); + if (flow) + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(flow[1]), true), + }; + if (url.hostname !== 'tiles.openfreemap.org') return null; + if (url.pathname === '/planet') + return json({ + tilejson: '3.0.0', + minzoom: 0, + maxzoom: 14, + tiles: ['https://tiles.openfreemap.org/qa-traffic/{z}/{x}/{y}.pbf'], + }); + const road = url.pathname.match(/^\/qa-traffic\/(\d+)\/\d+\/\d+\.pbf$/); + if (!road) + throw new Error(`Unrecognized road fixture request: ${url.pathname}`); + return { + status: 200, + contentType: 'application/x-protobuf', + body: trafficFixtureTile(Number(road[1])), + }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs new file mode 100644 index 000000000..308c0aaa6 --- /dev/null +++ b/src/tooling/trafficFixtures.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + trafficFixtureResponse, + trafficFixtureTile, +} from '../../scripts/traffic-fixtures.mjs'; +import { decodeOpenFreeMapTile } from '../sources/openFreeMap.js'; +import { isDrivableFlowSegment } from '../layers/traffic/roadModes.js'; +import { decodeFlowTile } from '../layers/traffic/flowDecode.js'; + +test('traffic fixtures provide aligned current road tiles and partial flow coverage', () => { + for (const z of [12, 14]) { + const roads = decodeOpenFreeMapTile( + trafficFixtureTile(z), + z, + 935, + 1686, + ).roads; + const flow = decodeFlowTile(trafficFixtureTile(z, true), z, 935, 1686); + assert.ok(roads.length > flow.length && flow.length > 0); + for (const segment of flow) { + assert.equal(segment.trafficLevel, 0.6); + assert.equal(isDrivableFlowSegment(segment), true); + assert.ok( + roads.some( + (road) => + JSON.stringify(road.coordinates) === JSON.stringify(segment.coords), + ), + ); + } + } +}); + +test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { + const request = (url) => trafficFixtureResponse({ url: () => url }); + const metadata = JSON.parse( + request('https://tiles.openfreemap.org/planet').body, + ); + const tileUrl = metadata.tiles[0] + .replace('{z}', '14') + .replace('{x}', '935') + .replace('{y}', '1686'); + assert.ok( + decodeOpenFreeMapTile(request(tileUrl).body, 14, 935, 1686).roads.length, + ); + assert.ok( + decodeFlowTile( + request('http://localhost/api/tomtom/flow/12/935/1686.pbf').body, + 12, + 935, + 1686, + ).length, + ); + assert.throws( + () => request('https://tiles.openfreemap.org/unexpected'), + /Unrecognized/, + ); + assert.equal(request('http://localhost/api/unrelated'), null); +}); + +test('flat terrain fixture decodes through Cesium and advertises no network children', async () => { + const { createServer } = await import('node:http'); + const { CesiumTerrainProvider } = await import('cesium'); + const server = createServer((req, res) => { + const response = trafficFixtureResponse({ + url: () => `https://terrain.reearth.land${req.url}`, + }); + res.writeHead(response.status, { 'content-type': response.contentType }); + res.end(response.body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const provider = await CesiumTerrainProvider.fromUrl( + `http://127.0.0.1:${server.address().port}`, + ); + for (const x of [0, 1]) { + const data = await provider.requestTileGeometry(x, 0, 0); + const rect = provider.tilingScheme.tileXYToRectangle(x, 0, 0); + assert.equal( + data.interpolateHeight(rect, (rect.west + rect.east) / 2, 0), + 0, + ); + for (const dx of [0, 1]) + for (const dy of [0, 1]) { + assert.equal(data.isChildAvailable(x, 0, x * 2 + dx, dy), false); + } + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } +}); From 2c8b3593d1af5ae2291c178f9105da0a01060223 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:19:11 +0100 Subject: [PATCH 045/178] 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 eb2ac9197d9b0f76ae46d8fcf7fc849638df25e5 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:05 +0100 Subject: [PATCH 046/178] test: keep weather teardown browser actively rendering --- changelog.d/1275-forecast-issue-fix.md | 1 + scripts/qa-weather-teardown.mjs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md index 6168e7f1f..2013bc4f2 100644 --- a/changelog.d/1275-forecast-issue-fix.md +++ b/changelog.d/1275-forecast-issue-fix.md @@ -1,2 +1,3 @@ - Preserve enable during first-load replacement (asturksever, #1275). - Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). +- Keep visible-page weather teardown probes focused through the browser testing API (asturksever, #1275). diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index c81a7bec1..bf7b00269 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -61,6 +61,10 @@ const browser = await puppeteer.launch({ const boot = async () => { const page = await browser.newPage(); + // This probe measures active rendering; an OS-minimized window must not + // silently exercise the application's intentional hidden-page pause. + const focus = await page.createCDPSession(); + await focus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1400, height: 920, deviceScaleFactor: 1 }); const errors = []; page.on('pageerror', (e) => From 5b9c726f33e7eb6e9af400f981f42ae2ef243628 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:57 +0100 Subject: [PATCH 047/178] 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 048/178] 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 77ed0a7c31633d8c8b8bef8321f733e3c8f2d4a8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:53:08 +0100 Subject: [PATCH 049/178] test: run Traffic fixtures with normal browser security --- changelog.d/1281-traffic-issue-fix.md | 2 ++ scripts/qa-traffic.mjs | 15 ++++++++++++++- scripts/traffic-fixtures.mjs | 5 +++++ src/tooling/trafficFixtures.test.mjs | 6 +++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index ade62efe4..2c4d1900c 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -1,2 +1,4 @@ - Hold rendering only for animated content (asturksever, #1281). - Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). +- Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). +- Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index 5da4b7db7..bb00596ce 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -82,6 +82,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** Enable traffic + teleport, then poll the layer until settled. */ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { + // The app deliberately stops rendering hidden pages. Programmatic camera + // changes do not activate a headful window the way a real UI click does. + await page.bringToFront(); + await page.waitForFunction(() => document.visibilityState === 'visible'); return page.evaluate(async (v, minC, tS) => { const gev = window.__godsEyeView; const dm = gev.dataManager; @@ -99,6 +103,7 @@ async function settleTraffic(page, view, { minCount = 1, timeoutS = 30 } = {}) { }); let s = null; for (let i = 0; i < tS; i++) { + if (document.visibilityState !== 'visible') throw new Error('Traffic QA page became hidden; rendering is intentionally paused'); await new Promise((r) => setTimeout(r, 1000)); s = mod.getStats(); // Retained roads from the previous city can satisfy count/loading before @@ -140,7 +145,7 @@ async function main() { '--no-sandbox', '--disable-setuid-sandbox', ...(!HEADFUL ? ['--use-gl=angle', '--use-angle=swiftshader'] : []), '--disable-backgrounding-occluded-windows', - '--disable-dev-shm-usage', '--disable-web-security', + '--disable-dev-shm-usage', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1440,900', ], @@ -149,6 +154,10 @@ async function main() { let exitCode = 0; try { const page = await browser.newPage(); + // Keep this visible-page regression deterministic even if the OS starts + // Chrome minimized. Use browser focus emulation, not patched DOM getters. + const focusSession = await page.createCDPSession(); + await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); const onFixture = (request) => { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); @@ -162,6 +171,9 @@ async function main() { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); const trafficLogs = []; + const pageErrors = []; + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { const t = msg.text(); if (t.includes('[Data:Traffic]')) trafficLogs.push(t); @@ -319,6 +331,7 @@ async function main() { await sleep(1000); await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); } + record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { console.error('\x1b[31mHarness error:\x1b[0m', e); exitCode = 3; diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index af51487db..2f94993cc 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -1,7 +1,9 @@ import { PbfWriter } from 'pbf'; +const corsHeaders = { 'access-control-allow-origin': '*' }; const json = (body) => ({ status: 200, + headers: corsHeaders, contentType: 'application/json', body: JSON.stringify(body), }); @@ -80,6 +82,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/octet-stream', + headers: corsHeaders, body: flatTerrain, }; throw new Error(`Unexpected terrain fixture request: ${url.pathname}`); @@ -93,6 +96,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(flow[1]), true), }; if (url.hostname !== 'tiles.openfreemap.org') return null; @@ -109,6 +113,7 @@ export function trafficFixtureResponse(request) { return { status: 200, contentType: 'application/x-protobuf', + headers: corsHeaders, body: trafficFixtureTile(Number(road[1])), }; } diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index 308c0aaa6..e02b959e7 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -33,9 +33,9 @@ test('traffic fixtures provide aligned current road tiles and partial flow cover test('fixture TileJSON and every road/flow request stay on intercepted sources', () => { const request = (url) => trafficFixtureResponse({ url: () => url }); - const metadata = JSON.parse( - request('https://tiles.openfreemap.org/planet').body, - ); + const tileJson = request('https://tiles.openfreemap.org/planet'); + assert.equal(tileJson.headers['access-control-allow-origin'], '*'); + const metadata = JSON.parse(tileJson.body); const tileUrl = metadata.tiles[0] .replace('{z}', '14') .replace('{x}', '935') From 116f28a5256078e8e76281cbf1c96075b594d7f1 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:55:18 +0100 Subject: [PATCH 050/178] test: isolate keyed and keyless Traffic browser sessions --- changelog.d/1281-traffic-issue-fix.md | 1 + scripts/qa-traffic.mjs | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 2c4d1900c..10dc045d0 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -2,3 +2,4 @@ - Exercise current vector road sources and published camera bounds in Traffic fixture QA (asturksever, #1281). - Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). - Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). +- Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index bb00596ce..d07b7196f 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -153,7 +153,7 @@ async function main() { let exitCode = 0; try { - const page = await browser.newPage(); + let page = await browser.newPage(); // Keep this visible-page regression deterministic even if the OS starts // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); @@ -162,7 +162,7 @@ async function main() { const response = fixtureResponse(request); if (response) void request.respond(response); else void request.continue(); }; - if (FIXTURES) { await page.setRequestInterception(true); page.on('request', onFixture); } + if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -281,7 +281,16 @@ async function main() { // ── (v) KEYLESS fallback (intercepted — server key untouched) ──────────── console.log('\n(v) KEYLESS — intercepted status, expect pure simulation...'); - if (FIXTURES) page.off('request', onFixture); + // Keyless is an independent fresh-session contract. Close the keyed page + // so its workers, terrain work and intercepted cache cannot leak into it. + await page.close(); + page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900 }); + const keylessFocus = await page.createCDPSession(); + await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); + page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); + page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); + await page.setCacheEnabled(false); await page.setRequestInterception(true); const keylessFlowReqs = []; page.on('request', (req) => { @@ -299,7 +308,7 @@ async function main() { if (response) { void req.respond(response); return; } try { req.continue(); } catch { /* already handled */ } }); - await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, { timeout: 60000 }, From 168d491038134ce28d25324f5510daa3384b9fb8 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:32 +0100 Subject: [PATCH 051/178] test: keep Traffic interception off workers and restore screenshot windows --- changelog.d/1281-traffic-issue-fix.md | 2 + scripts/qa-chrome.mjs | 27 ++++++++++ scripts/qa-traffic.mjs | 37 ++++--------- scripts/traffic-fixtures.mjs | 56 +++++++++++++++++++ src/qaChrome.test.mjs | 57 +++++++++++++++++++- src/tooling/trafficFixtures.test.mjs | 78 +++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 27 deletions(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index 10dc045d0..f9477de48 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -3,3 +3,5 @@ - Establish browser-level foreground visibility for Traffic QA and reject hidden-page samples (asturksever, #1281). - Supply CORS headers on cross-origin traffic fixtures and report browser exceptions without disabling browser security (asturksever, #1281). - Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). +- Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). 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-traffic.mjs b/scripts/qa-traffic.mjs index d07b7196f..a6c0bdc53 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -31,6 +31,7 @@ */ import puppeteer from 'puppeteer'; +import { captureQaScreenshot } from './qa-chrome.mjs'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -49,7 +50,7 @@ appUrl.searchParams.set('welcome', '0'); const APP_URL = appUrl.href; const HEADFUL = argv.includes('--headful'); const FIXTURES = argv.includes('--fixtures'); -const fixtureResponse = FIXTURES ? (await import('./traffic-fixtures.mjs')).trafficFixtureResponse : () => null; +const { installTrafficRoutes } = await import('./traffic-fixtures.mjs'); const CHROME_EXECUTABLE_CANDIDATES = [ process.env.PUPPETEER_EXECUTABLE_PATH, @@ -158,11 +159,6 @@ async function main() { // Chrome minimized. Use browser focus emulation, not patched DOM getters. const focusSession = await page.createCDPSession(); await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); - const onFixture = (request) => { - const response = fixtureResponse(request); - if (response) void request.respond(response); else void request.continue(); - }; - if (FIXTURES) { await page.setCacheEnabled(false); await page.setRequestInterception(true); page.on('request', onFixture); } await page.setViewport({ width: 1440, height: 900 }); // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). @@ -172,6 +168,7 @@ async function main() { }); const trafficLogs = []; const pageErrors = []; + if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); page.on('console', (msg) => { @@ -210,7 +207,7 @@ async function main() { `coverage=${mumbai.flowCoveragePct}% tiles=${mumbai.tilesFetched}`); if (mumbai.mode !== 'live' || !(mumbai.count > 0) || !(colored > 0)) exitCode = 1; await sleep(1200); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-flow.png') }, { headful: HEADFUL }); } // ── (iv) uncovered-roads param — hide vs sim ───────────────────────────── @@ -243,7 +240,7 @@ async function main() { (hid.flowBuckets?.sim || 0) === 0 && hid.count > 0, `count=${hid.count} sim=${hid.flowBuckets?.sim}`); if ((hid.flowBuckets?.sim || 0) !== 0) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-live-hide-mode.png') }, { headful: HEADFUL }); } else { record('PARAM: hide mode renders zero sim (white) dots', null, 'view had 100% coverage (no sim dots to hide) — inconclusive here, covered by unit tests'); @@ -265,7 +262,7 @@ async function main() { } record('C4: oblique fetch box centers on the look-at point', ok, detail); if (!ok) exitCode = 1; - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-c4-oblique.png') }, { headful: HEADFUL }); } // ── (iii) budget honesty ───────────────────────────────────────────────── @@ -290,23 +287,11 @@ async function main() { await keylessFocus.send('Emulation.setFocusEmulationEnabled', { enabled: true }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - await page.setCacheEnabled(false); - await page.setRequestInterception(true); const keylessFlowReqs = []; - page.on('request', (req) => { - const url = req.url(); - if (url.includes('/api/tomtom/status')) { - req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ hasKey: false }) }); - return; - } - if (url.includes('/api/tomtom/flow/')) { - keylessFlowReqs.push(url); - req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) }); - return; - } - const response = fixtureResponse(req); - if (response) { void req.respond(response); return; } - try { req.continue(); } catch { /* already handled */ } + await installTrafficRoutes(page, { + origin: appUrl.origin, fixtures: FIXTURES, keyless: true, + onFlow: url => keylessFlowReqs.push(url), + onError: error => pageErrors.push(error.message), }); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForFunction( @@ -338,7 +323,7 @@ async function main() { exitCode = 1; } await sleep(1000); - await page.screenshot({ path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }); + await captureQaScreenshot(page, { path: path.join(SHOTS_DIR, 'traffic-sim-keyless.png') }, { headful: HEADFUL }); } record('BROWSER: no uncaught page exceptions', pageErrors.length === 0, pageErrors.join('; ')); } catch (e) { diff --git a/scripts/traffic-fixtures.mjs b/scripts/traffic-fixtures.mjs index 2f94993cc..0ec8cdacd 100644 --- a/scripts/traffic-fixtures.mjs +++ b/scripts/traffic-fixtures.mjs @@ -117,3 +117,59 @@ export function trafficFixtureResponse(request) { body: trafficFixtureTile(Number(road[1])), }; } + +/** Intercept provider URLs only; Cesium worker/module loading stays untouched. */ +export async function installTrafficRoutes( + page, + { + origin, + fixtures = false, + keyless = false, + onFlow = () => {}, + onError = () => {}, + }, +) { + const session = await page.createCDPSession(); + const patterns = [ + `${origin}/api/tomtom/status*`, + `${origin}/api/tomtom/flow/*`, + ...(fixtures + ? ['https://tiles.openfreemap.org/*', 'https://terrain.reearth.land/*'] + : []), + ].map((urlPattern) => ({ urlPattern, requestStage: 'Request' })); + session.on('Fetch.requestPaused', async ({ requestId, request }) => { + try { + const url = new URL(request.url); + let response; + if (keyless && url.pathname === '/api/tomtom/status') { + response = json({ hasKey: false }); + } else if (keyless && url.pathname.startsWith('/api/tomtom/flow/')) { + onFlow(request.url); + response = { ...json({ error: 'no_key' }), status: 503 }; + } else if (fixtures) { + response = trafficFixtureResponse({ url: () => request.url }); + } + if (!response) { + await session.send('Fetch.continueRequest', { requestId }); + return; + } + await session.send('Fetch.fulfillRequest', { + requestId, + responseCode: response.status, + responseHeaders: Object.entries({ + 'content-type': response.contentType, + ...response.headers, + }).map(([name, value]) => ({ name, value })), + body: Buffer.from(response.body).toString('base64'), + }); + } catch (error) { + if (page.isClosed()) return; + onError(error); + await session + .send('Fetch.failRequest', { requestId, errorReason: 'Failed' }) + .catch(() => {}); + } + }); + await session.send('Fetch.enable', { patterns }); + return session; +} 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', + ); +}); diff --git a/src/tooling/trafficFixtures.test.mjs b/src/tooling/trafficFixtures.test.mjs index e02b959e7..b5f6c6de4 100644 --- a/src/tooling/trafficFixtures.test.mjs +++ b/src/tooling/trafficFixtures.test.mjs @@ -90,3 +90,81 @@ test('flat terrain fixture decodes through Cesium and advertises no network chil await new Promise((resolve) => server.close(resolve)); } }); + +test('fixture interception targets provider URLs and leaves app workers alone', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { origin: 'http://localhost:4173', fixtures: true }, + ); + const patterns = sent[0].args.patterns.map((p) => p.urlPattern); + assert.deepEqual(patterns, [ + 'http://localhost:4173/api/tomtom/status*', + 'http://localhost:4173/api/tomtom/flow/*', + 'https://tiles.openfreemap.org/*', + 'https://terrain.reearth.land/*', + ]); + await paused({ + requestId: 'terrain', + request: { url: 'https://terrain.reearth.land/layer.json' }, + }); + const reply = sent.at(-1); + assert.equal(reply.name, 'Fetch.fulfillRequest'); + assert.equal( + JSON.parse(Buffer.from(reply.args.body, 'base64')).format, + 'heightmap-1.0', + ); + assert.ok( + reply.args.responseHeaders.some( + (h) => h.name === 'access-control-allow-origin' && h.value === '*', + ), + ); +}); + +test('keyless interception refuses and records unexpected flow requests', async () => { + const { installTrafficRoutes } = + await import('../../scripts/traffic-fixtures.mjs'); + const sent = [], + flows = []; + let paused; + const session = { + on: (_, handler) => { + paused = handler; + }, + send: async (name, args) => { + sent.push({ name, args }); + }, + }; + await installTrafficRoutes( + { createCDPSession: async () => session, isClosed: () => false }, + { + origin: 'http://localhost:4173', + keyless: true, + onFlow: (url) => flows.push(url), + }, + ); + await paused({ + requestId: 'status', + request: { url: 'http://localhost:4173/api/tomtom/status' }, + }); + assert.deepEqual(JSON.parse(Buffer.from(sent.at(-1).args.body, 'base64')), { + hasKey: false, + }); + await paused({ + requestId: 'flow', + request: { url: 'http://localhost:4173/api/tomtom/flow/12/1/1.pbf' }, + }); + assert.equal(sent.at(-1).args.responseCode, 503); + assert.equal(flows.length, 1); +}); From 56be69d065c4e77d46ec034075327155eb520940 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:33 +0100 Subject: [PATCH 052/178] 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 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 053/178] 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 3eb585fe49ab340cdd7e068a4afefcc474a84a3d Mon Sep 17 00:00:00 2001 From: Enzo Abyie Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 054/178] i18n(zh-CN): Simplified Chinese strings from #914 The Simplified Chinese locale by abiyeenzo (#914), converted from its namespaced JS catalogs to the English-keyed zh-CN.json: 891 entries whose English still matches the current UI. --- src/i18n/locales/zh-CN.json | 907 ++++++++++++++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 src/i18n/locales/zh-CN.json diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json new file mode 100644 index 000000000..cbedad420 --- /dev/null +++ b/src/i18n/locales/zh-CN.json @@ -0,0 +1,907 @@ +{ + "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", + "Restoring shared view...": "正在恢复共享视图…", + "Configuring viewer...": "正在配置视图…", + "Loading Google 3D Tiles...": "正在加载 Google 3D Tiles…", + "Loading the keyless globe...": "正在加载免密钥地球…", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加载免密钥地球…", + "Initializing systems...": "正在初始化系统…", + "Close data attribution": "关闭数据来源说明", + "UNAVAILABLE": "不可用", + "SOURCE": "来源", + "Overpass rate-limited": "Overpass 已限流", + "Overpass timed out": "Overpass 超时", + "Overpass could not complete the query": "Overpass 无法完成查询", + "Overpass temporarily unavailable": "Overpass 暂时不可用", + "Retrying mapped sites…": "正在重试地图站点…", + "Fetching mapped sites…": "正在获取地图站点…", + "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒后重试", + "{reason} — retry pending": "{reason}——等待重试", + "Zoom in to search mapped installations": "请放大地图以搜索地图标注的设施", + "Showing cached mapped sites": "正在显示缓存的地图站点", + "Mapped sites not loaded": "地图站点未加载", + "Mapped sites loaded": "已加载地图站点", + "Mapped installation": "地图标注设施", + "military flight": "军用航班", + "feed unavailable": "数据源不可用", + "feed stale": "数据源过期", + "observed or mapped nearby context": "附近有观测或地图标注的上下文", + "no observed or mapped objects in current feeds": "当前数据源中没有观测或地图标注的对象", + "STATION": "电台", + "Crewed stations and their visiting vehicles": "载人空间站及其访问飞行器", + "NAV": "导航", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 导航:GPS、GLONASS、Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "地球静止轨道带:通信和气象,固定在赤道上空", + "VISUAL": "可见", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", + "COMMS": "通信", + "Broadband constellation shell — shown only in DENSE mode": "宽带星座:仅在“密集”模式下显示", + "FISHING": "渔船", + "TOWING": "拖带", + "DREDGER": "挖泥船", + "DIVE OPS": "潜水作业", + "MILITARY": "军用", + "SAILING": "帆船", + "PLEASURE": "游艇", + "PILOT": "引航", + "SAR": "搜救", + "TUG": "拖轮", + "PORT TENDER": "港口供应船", + "ANTI-POLLUTION": "防污染", + "LAW ENFORCE": "执法", + "MEDICAL": "医疗", + "HIGH-SPEED": "高速", + "PASSENGER": "客船", + "CARGO": "货船", + "TANKER": "油轮", + "OTHER": "其他", + "ENVIRONMENTAL": "环境", + "EARTH WATCH": "地球监测", + "ACTIVE EVENTS": "活跃事件", + "Starting live contacts…": "正在启动实时目标…", + "Opening space missions…": "正在打开太空任务…", + "Scanning active events…": "正在扫描活跃事件…", + "Live earthquakes and active fires, from USGS and NASA": "来自 USGS 和 NASA 的实时地震与活跃火点", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "它像一座禁地座舱——然后你会发现,数据来源都是公开的,数据都是真实的。", + "Working…": "处理中…", + "Could not open that mission{detail}. Retry or explore manually.": "无法打开该任务{detail}。请重试或手动探索。", + "This browser is blocking storage, so that could not be saved.": "此浏览器阻止了存储,因此无法保存。", + "NORMAL": "常规", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "ANIME": "动漫", + "NOIR": "黑色电影", + "SNOW": "雪景", + "LOADING": "加载中", + "DEGRADED": "降级", + "STALE": "过期", + "PARTIAL": "部分", + "FALLBACK": "备用", + "{state} {layers}": "{state} {layers}", + "SUMMARY": "摘要", + "Awaiting telemetry...": "等待遥测数据…", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "other": "启动 · {count} 个密钥待配置" + }, + "POWERED UP": "已启动", + "Save failed ({status}).": "保存失败({status})。", + "REMOVE": "移除", + "Remove {title} from this app's saved keys": "从本应用已保存的密钥中移除 {title}", + "your app configuration": "你的应用配置", + "your local .env": "你的本地 .env", + "Saving…": "正在保存…", + "Save failed: {detail}": "保存失败:{detail}", + "Paste at least one key first.": "请先至少粘贴一个密钥。", + "Saved to {store}. Restarting — this page reloads itself.": "已保存到{store}。正在重启——本页面将自动重新加载。", + "Remove this key from your saved configuration?": "要从已保存的配置中移除此密钥吗?", + "Removed from {store}. Restarting — this page reloads itself.": "已从{store}中移除。正在重启——本页面将自动重新加载。", + "Places context + Street View fallback; optional separate key": "地点上下文 + 街景备用;可选的独立密钥", + "Live ships, worldwide": "全球实时船舶", + "Live active-fire detections": "实时活跃火点探测", + "Real live traffic (keyless runs a simulation)": "真实实时路况(无密钥时为模拟)", + "Bing imagery map stacks + world terrain": "Bing 影像地图组合 + 全球地形", + "More flight-polling credits (anonymous works without)": "更多航班轮询额度(匿名也可使用)", + "CONTEXT READY": "上下文就绪", + "GLOBAL CONTEXT OFF": "全局上下文已关闭", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择一个航班、船舶或地图标注设施", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "启用以加载观测 / 地图标注的邻近信息", + "Unavailable": "不可用", + "Focus {label}": "聚焦 {label}", + "Global Context navigation": "全局上下文导航", + "Previous — prior visited contact in the 250 km window": "上一个:250 km 范围内先前访问过的目标", + "PREVIOUS": "上一个", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", + "NEXT": "下一个", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测上下文。缺失的广播、未加载的地图区域或未标注的站点不能作为不存在的证据。", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "仅限当前视口", + "WITHIN {km} KM": "{km} KM 以内", + "Flights": "航班", + "Military flights": "军用航班", + "AIS vessels": "AIS 船舶", + "Mapped installations": "地图标注设施", + "syncing {count} city feeds...": "正在同步 {count} 个城市数据源…", + "scanning nearby systems...": "正在扫描附近系统…", + "Station {id}": "站点 {id}", + "Station": "电台", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 车桩 {docks} · 容量 {capacity}", + "⚠️ Not installed": "⚠️ 未安装", + "⚠️ Not renting": "⚠️ 暂停租车", + "⚠️ Not returning": "⚠️ 暂停还车", + "HDG {value}° (ESTIMATED)": "HDG {value}°(估算)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 个摄像头待命 · 未选择摄像头 · 点击摄像头以激活", + "No cameras available in catalog.": "目录中没有可用摄像头。", + "{city} CCTV": "{city} CCTV", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "覆盖 {value}km²", + "OVERLAP {count} cams": "重叠 {count} 个摄像头", + "ISOLATED VIEW": "孤立视角", + "PROJ MONITOR": "投影 监视器", + "PROJ OFF": "投影 关", + "VIEWSHED": "可视域", + "CAL {value}": "CAL {value}", + "SRC {value}": "SRC {value}", + "{value} CONTEXT": "{value} 上下文", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", + "{value} conf": "置信度 {value}", + "{value} ago": "{value} 前", + "sensor n/a": "传感器 n/a", + "FIRE · {frp} MW": "火点 · {frp} MW", + "NIGHT": "夜间", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "火点", + "FIRES": "火点", + "max {value} MW": "最大 {value} MW", + "new {value}": "新增 {value}", + "just now": "刚刚", + "<1m ago": "不到 1 分钟前", + "{count}m ago": "{count} 分钟前", + "{count}h ago": "{count} 小时前", + "STALE · cached {age}": "过期 · 缓存于 {age}", + "refreshing...": "刷新中…", + "loading...": "加载中…", + "KEY REQUIRED": "需要密钥", + "LIVE · updated {age}": "实时 · 更新于 {age}", + "loading mapped installation context": "正在加载地图标注设施上下文", + "MAPPED INSTALLATION": "地图标注设施", + "Unknown": "未知", + "LAUNCH SITE · {site}": "发射场 · {site}", + "LAUNCH SITE": "发射场", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "发射待命", + "LIFTOFF · {mission}": "点火升空 · {mission}", + "ASCENT REPLAY · {mission}": "上升回放 · {mission}", + "ASCENT ESTIMATE · {mission}": "上升估算 · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "级段再入 / 回收 · {mission}", + "ORBIT REPLAY · {mission}": "轨道回放 · {mission}", + "PAUSED · {title}": "已暂停 · {title}", + "UNSPECIFIED": "未指明", + "+{count} additional payload records": "另有 {count} 条有效载荷记录", + "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", + "{value} KM DOWNRANGE": "下射程 {value} KM", + "POSITION UNAVAILABLE": "位置不可用", + "FLIGHT {count}": "第 {count} 次飞行", + "REUSED": "复用", + "NO STAGE RE-ENTRY / RECOVERY DATA": "无级段再入 / 回收数据", + "{count} / 30D": "{count} / 30 天", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "当前 30 天窗口内没有可用任务", + "DATE UNAVAILABLE": "日期不可用", + "UNSPECIFIED OPERATOR": "运营方未指明", + "Select {mission}": "选择 {mission}", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "Selected Space Mission": "已选太空任务", + "SELECTED SPACE MISSION": "已选太空任务", + "Show all missions": "显示所有任务", + "Deselect mission": "取消选择任务", + "MISSION": "任务", + "STATUS · ": "状态 · ", + "LAUNCH SITE · ": "发射场 · ", + "LAUNCH TIME · ": "发射时间 · ", + "ORBIT · ": "轨道 · ", + "ASCENT PATH · ": "上升轨迹 · ", + "CURRENT DISTANCE FROM EARTH · ": "当前距地球距离 · ", + "SATELLITE SPEED · ": "卫星速度 · ", + "PAYLOAD": "有效载荷", + "NAME": "名称", + "TYPE": "类型", + "DESTINATION": "目的地", + "STAGE / RE-ENTRY / RECOVERY": "级段 / 再入 / 回收", + "STAGE": "级段", + "STATUS": "状态", + "FINAL POSITION": "最终位置", + "REPLAY SPEED": "回放速度", + "Replay speed multiplier": "回放速度倍率", + "REPLAY ASCENT": "回放上升过程", + "Previous mission": "上一个任务", + "PREV": "上一个", + "Next mission": "下一个任务", + "SHOW ALL / DESELECT": "显示全部 / 取消选择", + "Pause replay": "暂停回放", + "Cancel replay": "取消回放", + "EST. DOWNRANGE": "预估下射程", + "PLANNED · ": "计划 · ", + "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", + "RECONSTRUCTED ESTIMATE": "重建估算", + "Replay the estimated ascent with a following camera": "以跟随相机回放估算的上升过程", + "Resume replay": "继续回放", + "T minus {seconds}": "T−{seconds}", + "Preparing launch site": "正在准备发射场", + "Liftoff": "点火升空", + "Ascent replay": "上升回放", + "Orbit replay": "轨道回放", + ", paused": ",已暂停", + "listening": "聆听中", + "Alt unknown": "高度未知", + "Type unknown": "类型未知", + "Reg unknown": "注册号未知", + "Operator unknown": "运营方未知", + "News": "新闻", + "Talk": "谈话", + "Weather / Emergency": "天气 / 应急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空 / 海事", + "Traffic / Transit": "交通 / 公交", + "Music": "音乐", + "Other": "其他", + "Alternative": "另类", + "Ambient": "氛围", + "Blues": "蓝调", + "Classical": "古典", + "Country": "乡村", + "Dance": "舞曲", + "Electronic": "电子", + "Folk": "民谣", + "Funk": "放克", + "Hip-Hop": "嘻哈", + "House": "House", + "Indie": "独立音乐", + "Jazz": "爵士", + "Latin": "拉丁", + "Metal": "金属乐", + "Oldies": "怀旧金曲", + "Pop": "流行", + "Punk": "朋克", + "R&B": "R&B", + "Reggae": "雷鬼", + "Rock": "摇滚", + "Soul": "灵魂乐", + "Techno": "Techno", + "Trance": "Trance", + "World": "世界音乐", + "cloud unknown": "云量未知", + "Add the full Starlink broadband shell (thousands of extra points)": "添加完整的 Starlink 宽带星座(数千个额外点位)", + "Loading the Starlink shell…": "正在加载 Starlink 星座…", + "Starlink {detail} — click to retry": "Starlink {detail}——点击重试", + "load failed": "加载失败", + "Showing the full Starlink shell — click for the core catalog only": "正在显示完整的 Starlink 星座——点击仅显示核心目录", + "DENSE ···": "密集 ···", + "DENSE ✕": "密集 ✕", + "DENSE": "密集", + "Selected": "所选", + "Syncing flow · Roads: {source}": "正在同步流量 · 道路:{source}", + "LIVE · Roads: {source} · Roads without flow hidden": "实时 · 道路:{source} · 已隐藏无流量道路", + "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内无流量道路", + "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 流量 {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 流量 {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步流量 · 道路:OpenStreetMap · 流量:TomTom · 未匹配:模拟", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 流量:TomTom · 覆盖 {percent}% · 未匹配:模拟", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 流量:TomTom · 覆盖 {percent}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 流量:TomTom(无匹配)", + "SIMULATED — traffic service unreachable": "模拟——交通服务无法连接", + "SIMULATED — add TomTom key for live": "模拟——添加 TomTom 密钥以获取实时数据", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "船舶", + "POS: LIVE": "位置:实时", + "POS: {time}Z": "位置:{time}Z", + "awaiting first AIS position…": "等待首个 AIS 位置…", + "awaiting usable AIS positions…": "等待可用的 AIS 位置…", + "awaiting first AIS message…": "等待首条 AIS 消息…", + "{minutes}m ago": "{minutes} 分钟前", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "{state}: {time}": "{state}:{time}", + "loading": "加载中", + "Wind": "风", + "Resume": "继续", + "Pause": "暂停", + "Error: {message}": "错误:{message}", + "Needs {envVars} — add it in Provider Settings": "需要 {envVars},请在服务商设置中添加", + "{label} is unavailable": "{label} 不可用", + "This map stack": "此地图组合", + "{label} unavailable: {hint}": "{label} 不可用:{hint}", + "Visible map targets": "可见的地图目标", + "Source": "来源", + "Ready": "就绪", + "Loaded: {scene} / {shot}": "已加载:{scene} / {shot}", + "Cannot capture shot: camera not ready": "无法截取镜头:相机未就绪", + "Captured: {scene} / {shot}": "已截取:{scene} / {shot}", + "Select a shot first": "请先选择一个镜头", + "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", + "Camera unavailable — exit cockpit first": "相机不可用——请先退出座舱", + "No shots to run": "没有可运行的镜头", + "Project exported": "项目已导出", + "Could not exit {mode} — scene layers may be refused": "无法退出 {mode}——场景图层可能被拒绝", + "Running {index}/{total}: {scene} / {shot}": "正在运行 {index}/{total}:{scene} / {shot}", + "Scene run complete": "场景运行完成", + "Global Flights Radar": "全球航班雷达", + "Orbital Watch": "轨道监视", + "Thermal Threat Board": "热源威胁看板", + "City Overload": "城市过载", + "Omniscience Pullback": "全知拉远", + "Shot {n}": "镜头 {n}", + "Density": "密度", + "Bloom": "泛光", + "VISUAL PRESETS": "视觉预设", + "LOCATION": "位置", + "CONTACTS — nearest planes · vessels · sites": "目标:最近的飞机 · 船舶 · 站点", + "SPACE MISSIONS — launches & orbital assets": "太空任务:发射与在轨资产", + "LIVE SIGNALS": "实时信号", + "CCTV layer unavailable": "CCTV 图层不可用", + "Link copied!": "链接已复制!", + "Copy failed": "复制失败", + "CCTV calibration saved": "CCTV 校准已保存", + "CCTV calibration reset": "CCTV 校准已重置", + "ADJUST ON": "调整开", + "ADJUST": "调整", + "UNKNOWN": "未知", + "ERROR": "错误", + "SOURCE · UNKNOWN": "来源 · 未知", + "FRAME · LOADING": "画面 · 加载中", + "FRAME · UNAVAILABLE": "画面 · 不可用", + "CALIBRATED": "已校准", + "CURATED": "已整理", + "RAW PRIOR": "原始默认", + "CCTV ON": "CCTV 开", + "CCTV OFF": "CCTV 关", + "VIEWSHED ON": "可视域开", + "COVERAGE ON": "覆盖范围开", + "COVERAGE OFF": "覆盖范围关", + "AUTO HOP ON": "自动跳转开", + "AUTO HOP OFF": "自动跳转关", + "PROJECTION ON": "投影开", + "PROJECTION OFF": "投影关", + "CAL · EDITED (UNSAVED)": "CAL · 已编辑(未保存)", + "CAL · {badge}": "CAL · {badge}", + "MONITOR": "监视器", + "OFF": "关", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 范围 {range}m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "other": "已加载 {count} 个摄像头 · 点击摄像头以激活" + }, + "{count} cameras loaded · enable CCTV to activate": { + "other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活" + }, + "Enable CCTV to load camera intersections": "启用 CCTV 以加载摄像头路口", + "Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。", + "No summary available.": "暂无摘要。", + "loading frames": "正在加载画面", + "camera grid ready": "摄像头网格就绪", + "Clearing selected data layers": "正在清除已选数据图层", + "Clear selected data layers": "清除已选数据图层", + "CONDITIONS UNKNOWN": "天气状况未知", + "CLEAR": "晴", + "PARTLY CLOUDY": "多云", + "OVERCAST": "阴", + "FOG": "雾", + "DRIZZLE": "毛毛雨", + "RAIN": "雨", + "RAIN SHOWERS": "阵雨", + "SNOW SHOWERS": "阵雪", + "THUNDERSTORM": "雷暴", + "MIXED CONDITIONS": "混合天气", + "CYCLE ON": "轮换开", + "CYCLE OFF": "轮换关", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及 SIG/NEWS/LOCAL 标签仍可使用。", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自动轮换简报页面(信号 → 新闻 → 本地)。悬停或聚焦面板时暂停。无论是否轮换,实时信号数据都会持续刷新。", + "ACQUIRING REGIONAL NEWS": "正在获取地区新闻", + "REGIONAL NEWS UNAVAILABLE": "地区新闻不可用", + "RESOLVING REGION": "正在解析区域", + "REGION UNAVAILABLE": "区域不可用", + "NO RECENT LOCATION MATCHES": "暂无近期位置匹配结果", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "云量 {pct}%", + "CLOUD UNKNOWN": "云量未知", + "{source} · LOCATION QUERY": "{source} · 位置查询", + "REGIONAL NEWS": "地区新闻", + "CONTEXT STANDBY": "上下文待机", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局上下文以获取邻近 PING", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", + "CONTACT LOST · {subject}": "目标丢失 · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目标已脱离其数据源 · 读数保持最后已知值", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "无可用示例", + "{cohort}, Unavailable": "{cohort},不可用", + "AHEAD": "正前方", + "{side} {angle}": "{side} {angle}", + "L": "左", + "R": "右", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "other": "{count} 项输入未知 · 不代表安全无虞" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用输入均为最新 · 不代表安全无虞", + "AIRCRAFT": "飞机", + "ACQUIRING SURFACE": "正在获取地面数据", + "SURFACE FALLBACK": "地面数据备用", + "STALE FEED": "数据源过期", + "LIVE TRACK": "实时航迹", + "COMMERCIAL": "商用", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向对准", + "ARROW · ESTIMATED DIRECTION": "箭头 · 预估方向", + "ROUTE DATA UNAVAILABLE": "航线数据不可用", + "DEST {bearing}": "目的地 {bearing}", + "Disable cockpit weather effects": "停用座舱天气效果", + "Enable cockpit weather effects": "启用座舱天气效果", + "ON": "开", + "Normal": "常规", + "Night vision": "夜视", + "Thermal": "热成像", + "Anime": "动漫", + "Noir": "黑色电影", + "Snow": "雪景", + "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。激活可切换到下一个样式。", + "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", + "Collapse Contact panel": "收起目标面板", + "Expand Contact panel": "展开目标面板", + "Collapse contact panel": "收起目标面板", + "Expand contact panel": "展开目标面板", + "Collapse cockpit briefing panel": "收起座舱简报面板", + "Expand cockpit briefing panel": "展开座舱简报面板", + "Collapse briefing panel": "收起简报面板", + "Expand briefing panel": "展开简报面板", + "OBSERVED / MAPPED PINGS": "观测 / 地图标注 PING", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻", + "LATEST LOCATION-MATCHED REPORTING": "最新的位置匹配报道", + "LOCAL INFO": "本地信息", + "PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置", + "TIME UNKNOWN": "时间未知", + "{count}M AGO": "{count} 分钟前", + "{count}H AGO": "{count} 小时前", + "{count}D AGO": "{count} 天前", + "DIR UNKNOWN": "风向未知", + "Select flight {title}": "选择航班 {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · 当前", + "MILITARY FLIGHT": "军用航班", + "COMMERCIAL FLIGHT": "商用航班", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "距离未知", + "{count} INPUTS UNKNOWN": { + "other": "{count} 项输入未知" + }, + "SOURCE STATUS UNAVAILABLE": "数据源状态不可用", + "Restore real aircraft": "恢复真实飞机", + "Reclassify as TR-3B": "重新归类为 TR-3B", + "TRACK ACQUIRED": "已锁定航迹", + "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "Context could not restore every layer; try again": "上下文未能恢复所有图层,请重试", + "No selected data layers": "没有已选数据图层", + "{count} data layers could not be cleared": { + "other": "{count} 个数据图层无法清除" + }, + "Cleared {count} data layers": { + "other": "已清除 {count} 个数据图层" + }, + "Selected data layers could not be cleared": "无法清除已选数据图层", + "Contacts could not complete the requested transition; try again": "目标未能完成所请求的切换,请重试", + "Space Missions could not complete the requested transition; try again": "太空任务未能完成所请求的切换,请重试", + "Nearby installations refreshed": "附近设施已刷新", + "Nearby installations could not be refreshed; try again": "无法刷新附近设施,请重试", + "Space Missions cancellation could not restore the previous layer state": "取消太空任务后无法恢复先前的图层状态", + "That layer is unavailable in the current Context mode": "该图层在当前上下文模式下不可用", + "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", + "start": "启动", + "stop": "停止", + "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", + "Space Missions": "太空任务", + "Context": "上下文", + "Live Flights": "实时航班", + "Military Flights": "军用航班", + "Earthquakes (24h)": "地震(24 小时)", + "Satellites": "卫星", + "Space Missions (30d)": "太空任务(30 天)", + "Street Traffic": "街道交通", + "CCTV": "CCTV", + "Radio": "电台", + "Bikeshare": "共享单车", + "Live AIS Vessels": "实时 AIS 船舶", + "Mapped Installations": "地图标注设施", + "Global Context": "全局上下文", + "Datacenters": "数据中心", + "Dams": "水坝", + "Submarine Cables": "海底电缆", + "FIRMS Active Fires": "FIRMS 活跃火点", + "Transit": "交通", + "ENABLING": "启用中", + "DISABLING": "停用中", + "Weather": "天气", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒后重试", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "从未", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 条记录", + "incomplete snapshot": "快照不完整", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒后重试", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "不确定", + "{name}: {state}": "{name}:{state}", + "{count}s ago": "{count} 秒前", + "CONNECTING": "连接中", + "📍 Location: --": "📍 位置:--", + "Landmark: --": "地标:--", + "Searched location": "已搜索的位置", + "ORBIT": "环绕", + "Location not found": "未找到位置", + "Search failed": "搜索失败", + "Fly to a POI first": "请先飞往一个 POI", + "Reset to full globe view": "重置为完整地球视图", + "Reset cockpit to full globe view": "将座舱重置为完整地球视图", + "Resetting to full globe view": "正在重置为完整地球视图", + "Resetting cockpit to full globe view": "正在将座舱重置为完整地球视图", + "MAP": "地图", + "panel": "面板", + "Expand {name}": "展开{name}", + "Collapse {name}": "收起{name}", + "Expand Radio": "展开电台", + "Collapse Radio": "收起电台", + "Expand Radio section": "展开电台区域", + "Collapse Radio section": "收起电台区域", + "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", + "Collapse Cockpit display options": "收起座舱显示选项", + "Expand Cockpit display options": "展开座舱显示选项", + "Collapse Cockpit Radio controls": "收起座舱电台控件", + "Expand Cockpit Radio controls": "展开座舱电台控件", + "NO STATIONS": "无电台", + "NO STATION AVAILABLE": "没有可用电台", + "{name}, station {index} of {total}": "{name},第 {index} 个电台,共 {total} 个", + "No station available": "没有可用电台", + "OFF AIR": "停播", + "STATION UNAVAILABLE": "电台不可用", + "Radio could not {action} cleanly": "电台未能正常{action}", + "Close compact Radio controls": "关闭精简电台控件", + "Open compact Radio controls": "打开精简电台控件", + "SYNC": "同步", + "RECONCILE": "核对", + "DISABLE": "停用", + "ENABLE": "启用", + "Reconcile Radio — lifecycle uncertain": "核对电台——生命周期状态不确定", + "Disable Radio": "停用电台", + "Enable Radio": "启用电台", + "DIRECTORY BAND": "目录频段", + "{category} BAND": "{category}频段", + "NO STATION SELECTED": "未选择电台", + "Directory metadata only": "仅目录元数据", + "Loading station directory…": "正在加载电台目录…", + "Choose a globe marker or use next.": "请选择地球上的标记或使用“下一个”。", + "TAGS · {tags}": "标签 · {tags}", + "Play": "播放", + "PAUSE": "暂停", + "RESUME": "继续", + "PLAY": "播放", + "selected": "所选", + "nearest": "最近", + "{action} {target} radio station": "{action}{target}电台", + "RADIO STATE UNCERTAIN": "电台状态不确定", + "SYNCING DIRECTORY": "正在同步目录", + "RADIO READY": "电台就绪", + "SYNCING": "同步中", + "READY": "就绪", + "station": "电台", + " · stale/degraded directory": " · 目录过期/降级", + " · degraded directory": " · 目录降级", + " · stale directory": " · 目录过期", + " · outside current filter": " · 不在当前筛选范围内", + "Ready — playback starts only from your action": "就绪——仅在你操作后才开始播放", + "Radio off": "电台已关闭", + "Connecting directly to broadcaster…": "正在直接连接广播方…", + "Buffering broadcaster stream…": "正在缓冲广播流…", + "Playing {station}": "正在播放 {station}", + "Paused {station}": "已暂停 {station}", + "Broadcaster stream unavailable": "广播流不可用", + " · muted during voice interaction": " · 语音交互期间已静音", + " · restoring volume after voice": " · 语音结束后正在恢复音量", + " · static indicates no broadcaster audio": " · 杂音表示广播方没有音频", + " · tuning static until broadcaster starts": " · 广播方启动前为调谐杂音", + "Station unavailable after directory refresh — choose another channel": "目录刷新后电台不可用——请选择其他频道", + "Radio is enabling…": "电台正在启用…", + "Radio is disabling…": "电台正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", + "Loading": "加载中", + "unknown": "未知", + "Scene action failed": "场景操作失败", + "Delete shot \"{shot}\"?": "删除镜头“{shot}”?", + "SPARSE": "稀疏", + "BALANCED": "均衡", + "DEL": "删除", + "entity": "实体", + "Shared {subject} follow expired": "共享的 {subject} 跟随已过期", + "Shared {subject} could not be restored — feed unavailable": "无法恢复共享的 {subject}——数据源不可用", + "Shared {subject} is unavailable": "共享的 {subject} 不可用", + "ACQUIRING": "获取中", + "SHARED {subject}": "共享 {subject}", + "LOADING LIVE DATA": "正在加载实时数据", + "LOAD COMPLETE": "加载完成", + "LOAD CANCELLED": "加载已取消", + "LOAD FAILED": "加载失败", + "LIVE DATA OFF": "实时数据已关闭", + "MAPPED SITES LOADED": "已加载地图站点", + "RETRYING MAPPED SITES": "正在重试地图站点", + "FETCHING MAPPED SITES": "正在获取地图站点", + "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", + "OVERPASS RATE-LIMITED": "Overpass 已限流", + "OVERPASS TIMED OUT": "Overpass 超时", + "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 无法完成查询", + "TURNING OFF LIVE DATA": "正在关闭实时数据", + "REFRESHING LIVE DATA": "正在刷新实时数据", + "syncing road network": "正在同步路网", + "retry pending": "等待重试", + "retrying in {seconds}s": "{seconds} 秒后重试", + "Celestial ring — reveal the full globe": "天球环——显示完整地球", + "Celestial ring — available in Normal style": "天球环——仅在常规样式下可用", + "Detection overlay: {mode}": "探测叠加层:{mode}", + "Detection overlay: off": "探测叠加层:关闭", + "DETECT": "探测", + "AI AGENT": "AI 代理", + "Voice model tier — applies next session": "语音模型档位——下次会话生效", + "Estimated session cost": "预估会话费用", + "Voice control — activate to toggle voice; hold Space to speak": "语音控制——激活可切换语音;按住空格键说话", + "VOICE STANDBY": "语音待机", + "VOICE CONTROL": "语音控制", + "Hold Space to speak · tap Space to activate focused controls": "按住空格键说话 · 轻按空格键激活当前聚焦的控件", + "VOICE SYSTEM ERROR": "语音系统错误", + "DISMISS": "关闭", + "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", + "WebRTC microphone support unavailable": "不支持 WebRTC 麦克风", + "Requesting microphone": "正在请求麦克风", + "Release Space to send": "松开空格键发送", + "Hold Space to talk": "按住空格键说话", + "Ask or command": "提问或下达指令", + "Voice off": "语音已关闭", + "VOICE UNAVAILABLE": "语音不可用", + "VOICE ACTIVE": "语音已启用", + "Voice session could not be started.": "无法启动语音会话。", + "Next session: {pendingId} — this session stays on {modelId}": "下次会话:{pendingId}——本次会话仍使用 {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "语音模型:{pendingId}——点击切换到 {target};下次会话生效", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的预估会话费用——{responses} 次响应。达到 {warn} 时警告,达到 {cap} 时结束会话。", + "{tier} applies next session": "{tier} 将在下次会话生效", + "Session ended — cost cap {cost}": "会话已结束——已达费用上限 {cost}", + "Radio did not start": "电台未能启动", + "Running command": "正在执行指令", + "{items} {scope}": "{items} {scope}", + "degraded": "降级", + "stale": "过期", + "partial": "部分", + "fallback": "备用", + "unavailable": "不可用", + "Voice active": "语音已启用", + "Aircraft cockpit view": "飞机座舱视图", + "LEVEL": "等级", + "Estimated destination direction": "预估目的地方向", + "DEST ---°": "目的地 ---°", + "OPTICAL PLANE · 01": "光学平面 · 01", + "VISOR LOCK · ACTIVE": "面罩锁定 · 激活", + "GROUND SPEED · KTS": "地速 · KTS", + "ALTITUDE · FT": "高度 · FT", + "FIRST PERSON": "第一人称", + "LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准", + "Cockpit vision style": "座舱视觉样式", + "Previous cockpit vision style": "上一个座舱视觉样式", + "Previous vision style": "上一个视觉样式", + "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。激活可切换到下一个样式。", + "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", + "CURRENT": "当前", + "Next cockpit vision style": "下一个座舱视觉样式", + "Next vision style": "下一个视觉样式", + "Current aircraft heading": "飞机当前航向", + "GROUND SPEED": "地速", + "ALTITUDE": "高度", + "Contact cockpit summary": "目标座舱摘要", + "CONTACT": "目标", + "CONTACTS · 250 KM": "目标 · 250 KM", + "Contact navigation": "目标导航", + "CONTEXT ONLY": "仅供参考", + "Nearby cohort counts": "附近各类目标数量", + "NEAREST OBSERVED / MAPPED": "最近的观测 / 地图标注目标", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "仅限可用输入 · 不代表安全无虞", + "Cockpit briefing carousel": "座舱简报轮播", + "Estimated flight plan": "预估飞行计划", + "ESTIMATED FLIGHT PLAN": "预估飞行计划", + "FROM": "出发", + "TO": "到达", + "Cockpit briefing controls": "座舱简报控件", + "Previous briefing page": "上一页简报", + "Next briefing page": "下一页简报", + "Live signals": "实时信号", + "Latest regional news": "最新地区新闻", + "Location-based information": "基于位置的信息", + "TEMP": "气温", + "WIND": "风", + "SKY": "天空", + "PRECIP": "降水", + "Show Live Signals": "显示实时信号", + "NEWS": "新闻", + "Show Regional News": "显示地区新闻", + "Show Local Info": "显示本地信息", + "Cockpit display and Radio controls": "座舱显示与电台控件", + "DISPLAY": "显示", + "Cockpit display options": "座舱显示选项", + "RADIO": "电台", + "Cockpit compact Radio controls": "座舱精简电台控件", + "Previous filtered radio station": "上一个已筛选电台", + "Play selected radio station": "播放所选电台", + "Next filtered radio station": "下一个已筛选电台", + "VOLUME": "音量", + "Cockpit Radio volume": "座舱电台音量", + "View switcher": "视图切换", + "Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图", + "RESET": "重置", + "Exit cockpit view": "退出座舱视图", + "EXIT COCKPIT": "退出座舱", + "Navigation, voice, and visual preset controls": "导航、语音和视觉预设控件", + "Expand Visual Presets": "展开视觉预设", + "Pin visual presets": "固定视觉预设", + "Keep visual presets open": "保持视觉预设展开", + "Show the globe without a visual filter.": "显示不带视觉滤镜的地球。", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "模拟带扫描线和屏幕弧度的绿色荧光 CRT。", + "Simulate night-vision goggles with green intensification and a tube vignette.": "模拟夜视仪,带绿色增强和镜筒暗角。", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。", + "Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。", + "Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白黑色电影调色。", + "Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的雪天白茫茫效果。", + "MAP SOURCE": "地图源", + "Map source": "地图源", + "Style": "样式", + "Expand LOCATION": "展开位置", + "Collapse panel": "收起面板", + "Pin location tray": "固定位置托盘", + "Keep location tray open": "保持位置托盘展开", + "Search any location": "搜索任意位置", + "Search location by name or coordinates": "按名称或坐标搜索位置", + "Search any location...": "搜索任意位置…", + "WEATHER": "天气", + "CONTEXT": "上下文", + "Compact Radio controls": "精简电台控件", + "Open detailed Radio controls": "打开详细电台控件", + "Previous station": "上一个电台", + "Next station": "下一个电台", + "Compact Radio volume": "精简电台音量", + "Expand panel": "展开面板", + "Context mode": "上下文模式", + "CONTACTS": "目标", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。", + "SPACE MISSIONS": "太空任务", + "SELECT CONTEXT": "选择上下文", + "Contact Context actions": "目标上下文操作", + "COCKPIT": "座舱", + "SEARCH NEARBY SITES": "搜索附近站点", + "Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B", + "CONTACTS CONTEXT OFF": "目标上下文已关闭", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "选择目标以加载观测 / 地图标注的邻近信息", + "Available Space Missions": "可用太空任务", + "AVAILABLE MISSIONS": "可用任务", + "SELECT A MISSION TO INSPECT": "选择一项任务以查看", + "LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择", + "Internet radio and local SDR": "网络电台与本地 SDR", + "STATION TAG": "电台标签", + "Filter stations by station tag": "按电台标签筛选电台", + "All": "全部", + "Enable Radio, then choose a globe marker or use next.": "请先启用电台,然后选择地球上的标记或使用“下一个”。", + "DRAG TO TUNE": "拖动调谐", + "ALL · DRAG THE NEEDLE": "全部 · 拖动指针", + "SNAPS TO AVAILABLE STATIONS": "自动吸附到可用电台", + "Radio playback": "电台播放", + "Previous filtered station": "上一个已筛选电台", + "Play selected station": "播放所选电台", + "Next filtered station": "下一个已筛选电台", + "STOP": "停止", + "Stop radio playback": "停止电台播放", + "Radio volume": "电台音量", + "STATION SITE": "电台网站", + "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。", + "Intelligence HUD (H)": "情报 HUD(H)", + "Layout": "布局", + "HUD layout": "HUD 布局", + "Tactical": "战术", + "Operator": "操作员", + "Minimal": "精简", + "Detection Overlay (D)": "探测叠加层(D)", + "Detection overlay": "探测叠加层", + "Detection label density": "探测标签密度", + "Allocation": "分配", + "Detection label allocation": "探测标签分配方式", + "Elastic": "弹性", + "Weighted": "加权", + "Fade": "渐隐", + "Detection fade distance": "探测渐隐距离", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "锁孔之外的世界叠加层渐隐距离,以其半径的百分比表示", + "Outside": "外部", + "Detection opacity outside the keyhole": "锁孔之外的探测不透明度", + "World-overlay label and card opacity beyond the fade distance": "超出渐隐距离后世界叠加层标签和卡片的不透明度", + "PARAMETERS": "参数", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飞机——拉远显示平面图标,拉近显示 3D 模型", + "Models": "模型", + "3D model coverage": "3D 模型覆盖范围", + "Proximity": "邻近", + "Scope — the circular viewport mask": "视域——圆形视口遮罩", + "Scope": "视域", + "Feather": "羽化", + "Scope edge feather": "视域边缘羽化", + "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以锁孔半径的百分比表示", + "Clear": "晴", + "Import": "导入", + "Celestial": "天球", + "Hide UI chrome": "隐藏界面控件", + "Clean UI": "纯净界面", + "Bloom / Glow": "泛光 / 辉光", + "Bloom intensity": "泛光强度", + "Sharpening": "锐化", + "Sharpen": "锐化", + "Sharpen intensity": "锐化强度", + "EXIT CLEAN VIEW": "退出纯净视图", + "Return UI controls": "恢复界面控件", + "Initializing photorealistic world...": "正在初始化逼真世界…", + "DATA LAYERS": "数据图层", + "NEAREST": "最近", + "CCTV camera": "CCTV 摄像头", + "CALIBRATION": "校准", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖动摄像头:圆环旋转,箭头平移,控制点设置范围/FOV", + "Camera pose — click a value to type": "摄像头姿态——点击数值可输入", + "Heading (compass °) — click to type": "航向(罗盘 °)——点击输入", + "Pitch (° up/down) — click to type": "俯仰(° 上/下)——点击输入", + "Horizontal FOV (°) — click to type": "水平 FOV(°)——点击输入", + "Range / monitor-plane distance (m) — click to type": "范围 / 监视器平面距离(m)——点击输入", + "Mount height above ground (m) — click to type": "安装高度(距地面,m)——点击输入", + "North offset from catalog position (m) — click to type": "相对目录位置的北向偏移(m)——点击输入", + "East offset from catalog position (m) — click to type": "相对目录位置的东向偏移(m)——点击输入", + "SAVE CAL": "保存校准", + "RESET CAL": "重置校准", + "SCENE SUMMARY": "场景摘要", + "ALL": "全部", + "SCENES": "场景", + "Scene recipe": "场景方案", + "NEW": "新建", + "CAPTURE SHOT": "截取镜头", + "UPDATE SHOT": "更新镜头", + "START": "启动", + "EXPORT PRESETS": "导出预设", + "IMPORT": "导入", + "RUN LOG": "运行日志", + "POWER UP": "启动", + "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服务商设置", + "Close key setup": "关闭密钥设置", + "Power up the globe": "启动地球", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球无需密钥即可运行。下面的每个密钥都会启用一个新的真实数据源:粘贴后会保存到本应用的本地配置,随后服务器自动重启。服务端密钥只保存在本机;Google Maps 和 Cesium ion 在浏览器中运行,必须设置服务商限制。你在其他位置配置的密钥会显示,但不会被改动。", + "SAVE KEYS": "保存密钥", + "ESC to close": "按 ESC 关闭", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供逼真的地球,其余数据源都叠加在其上。", + "NO PLACE LEFT BEHIND": "不遗漏任何地方", + "ACTIVE STYLE": "当前样式", + "Globe actions": "地球操作", + "Turn off all selected data layers": "关闭所有已选数据图层", + "Copy share link": "复制分享链接", + "Reset camera and return to full globe view": "重置相机并返回完整地球视图", + "MISSION CONTROL · FIRST LAUNCH": "任务控制 · 首次启动", + "Choose your first view": "选择你的首个视图", + "LIVE CONTACTS": "实时目标", + "Aircraft, vessels and nearby intelligence": "飞机、船舶及周边情报", + "Launches, spacecraft and orbital context": "发射、航天器与轨道态势", + "EXPLORE MANUALLY": "手动探索", + "Begin with a clean globe": "从干净的地球开始", + "Don't show this again": "不再显示", + "ESC to dismiss": "按 ESC 关闭", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:停靠栏中的 GEV MIC 按钮可让你用语音操控地图。" +} From db16d8ed4cbb35717d9d971b203073bfd9b5a3c0 Mon Sep 17 00:00:00 2001 From: EDIT-Aeterna <73235923+EDIT-Aeterna@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 055/178] i18n(zh-CN): Simplified Chinese strings from #979 The Simplified Chinese localization by EDIT-Aeterna (#979), converted to the English-keyed catalog: 823 more entries for UI text that #914 did not cover. --- src/i18n/locales/zh-CN.json | 861 ++++++++++++++++++++++++++++++++++++ 1 file changed, 861 insertions(+) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cbedad420..3a5972058 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1,4 +1,18 @@ { + "Pick a shape, then click the map.": "先选择形状,再在地图上点击。", + "Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。", + "Click where the pin goes.": "点击图钉的放置位置。", + "Click {count} more points.": { + "other": "再点击 {count} 个点。" + }, + "Those points are in a line — move one off it to enclose an area.": "这些点都在同一条直线上——请移开其中一个点才能围出面。", + "That line has no length — click somewhere further away.": "这条线没有长度——请在更远处点击。", + "That shape already has {count} points — finish it or press Backspace.": "该形状已有 {count} 个点——请完成绘制或按 Backspace。", + "That point is off the globe — click on the world.": "该点在地球之外——请点击地球上的位置。", + "That shape could not be placed.": "该形状无法放置。", + "Could not place the shape: {error}": "无法放置形状:{error}", + "Board cleared.": "画板已清除。", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指针——请先关闭它。", "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", "Restoring shared view...": "正在恢复共享视图…", "Configuring viewer...": "正在配置视图…", @@ -7,11 +21,86 @@ "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加载免密钥地球…", "Initializing systems...": "正在初始化系统…", "Close data attribution": "关闭数据来源说明", + "Light aircraft": "轻型飞机", + "Glider": "滑翔机", + "Turboprop": "涡桨飞机", + "Airliner": "喷气客机", + "Widebody": "宽体机", + "Four-engine jet": "四发喷气机", + "Helicopter": "直升机", + "Fast jet": "喷气战机", + "Business jet": "公务机", + "Drone": "无人机", "UNAVAILABLE": "不可用", "SOURCE": "来源", + "EVIDENCE": "证据", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "other": "{count} 个地理定位来源点位" + }, + "NO PREVIEW": "无预览", + "SOURCE LINK": "来源链接", + "{publisher} / OPEN": "{publisher} / 打开", + "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重构控制", + "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重构 · 2026-08-26", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 冰湖溃决洪水", + "Close event layer": "关闭事件图层", + "OBSERVED IMAGERY": "实测影像", + "SCHEMATIC CORRIDOR": "示意走廊", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年历史参考 / 2026 年灾后", + "Historical reference and post-event image split": "历史参考与灾后影像的分割", + "RECONSTRUCTION CLOCK": "重构时钟", + "Schematic downstream progression": "下游演进示意", + "Story beat navigation": "故事节拍导航", + "Previous story beat": "上一个故事节拍", + "CAUSE": "成因", + "CAPTURE TIME UNVERIFIED": "拍摄时间未核实", + "Next story beat": "下一个故事节拍", + "▶ PLAY": "▶ 播放", + "Play the next shot and continue through this scene": "播放下一个镜头并继续整个场景", + "▶ PLAY SCENE": "▶ 播放场景", + "◉ CINEMATIC": "◉ 运镜", + "↺ FULL STORY": "↺ 完整故事", + "↗ OPEN ORIGINAL": "↗ 打开原始来源", + "⌖ LOWER GORGE": "⌖ 下游峡谷", + "FIELD REPORTS · {count}": "现场报道 · {count}", + "↗ OPEN PUBLIC MAP": "↗ 打开公共地图", + "Clouds are preserved from the source imagery.": " 云层按源影像原样保留。", + "Rasuwagadhi witness": "Rasuwagadhi 目击", + "Open original source for {source}; capture time unverified": "打开 {source} 的原始来源;拍摄时间未核实", + "Historical reference and post-event image divider": "历史参考与灾后影像的分割线", + "A historical reference {before} percent, B post-event {after} percent": "历史参考 {before}%,灾后 {after}%", + "■ RELEASE CAMERA": "■ 释放相机", + "Seek Nepal scene clock": "定位尼泊尔场景时钟", + "NEPAL SCENE CLOCK": "尼泊尔场景时钟", + "… PLAYING SHOT": "… 镜头播放中", + "▶ PLAY SHOT": "▶ 播放镜头", + "Ⅱ PAUSE": "Ⅱ 暂停", + "↺ REPLAY CINEMATIC": "↺ 重播运镜", + "↺ REPLAY": "↺ 重播", + "… PLAYING SCENE": "… 场景播放中", + "Continue {scene} from next shot": "从下一镜头继续播放 {scene}", + "current scene": "当前场景", + "Replay the full reconstruction": "重播完整重构", + "SCENE SHOT": "场景镜头", + "TIME UNVERIFIED": "拍摄时间未核实", + "{count} SOURCES": { + "other": "{count} 个来源" + }, + "OUTSIDE IMAGERY SWIPE": "影像范围外滑动", + "{scene} / AUTHORED SHOT": "{scene} / 排定镜头", + "NEPAL FLOOD INCIDENT": "尼泊尔洪水事件", + "Play {shot}": "播放 {shot}", + "current scene shot": "当前场景镜头", + "Play reconstruction": "播放重构", + "Load previous scene shot": "载入上一个场景镜头", + "Load next scene shot": "载入下一个场景镜头", + "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", + "Event reconstruction": "事件重构", "Overpass rate-limited": "Overpass 已限流", "Overpass timed out": "Overpass 超时", "Overpass could not complete the query": "Overpass 无法完成查询", + "Map tiles temporarily unavailable": "地图瓦片暂时不可用", + "Mapped names temporarily unavailable": "地点名称暂时不可用", "Overpass temporarily unavailable": "Overpass 暂时不可用", "Retrying mapped sites…": "正在重试地图站点…", "Fetching mapped sites…": "正在获取地图站点…", @@ -27,6 +116,9 @@ "feed stale": "数据源过期", "observed or mapped nearby context": "附近有观测或地图标注的上下文", "no observed or mapped objects in current feeds": "当前数据源中没有观测或地图标注的对象", + "Draw": "绘制", + "Directions": "路线导航", + "Recent imagery": "近期影像", "STATION": "电台", "Crewed stations and their visiting vehicles": "载人空间站及其访问飞行器", "NAV": "导航", @@ -56,6 +148,10 @@ "CARGO": "货船", "TANKER": "油轮", "OTHER": "其他", + "Included in bundle": "已包含在资源包中", + "Missing bundle file — reimport its bundle": "缺少资源包文件——请重新导入其资源包", + "Source configured; file checked when loaded": "数据源已配置;文件在加载时校验", + "Source unavailable": "数据源不可用", "ENVIRONMENTAL": "环境", "EARTH WATCH": "地球监测", "ACTIVE EVENTS": "活跃事件", @@ -74,6 +170,16 @@ "ANIME": "动漫", "NOIR": "黑色电影", "SNOW": "雪景", + "ARCTIC": "北极", + "ANTARCTIC": "南极洲", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "欧洲", + "AFRICA": "非洲", + "ASIA": "亚洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋网格", + "SOUTHERN OCEANIC GRID": "南方海洋网格", "LOADING": "加载中", "DEGRADED": "降级", "STALE": "过期", @@ -84,27 +190,77 @@ "Awaiting telemetry...": "等待遥测数据…", "REC": "REC", "AIS: --": "AIS: --", + "SECTOR {lat} {lon}": "扇区 {lat} {lon}", "POWER UP · {count} KEYS WAITING": { "other": "启动 · {count} 个密钥待配置" }, "POWERED UP": "已启动", + "Invalid JSON": "无效的 JSON", "Save failed ({status}).": "保存失败({status})。", + "OAuth check failed: {detail}": "OAuth 检查失败:{detail}", + "Could not check ChatGPT sign-in. Try again.": "无法检查 ChatGPT 登录状态,请重试。", + "VOICE AUTH · CHATGPT OAUTH": "语音认证 · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "语音认证 · API 密钥", + "USE API KEY": "使用 API 密钥", + "USE CHATGPT OAUTH": "使用 CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "下次云语音会话使用 OPENAI_API_KEY", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次云语音会话使用本机已登录的 ChatGPT/Codex OAuth 会话", + "Metered — a billing-enabled account": "按量计费——需要启用结算的账号", + "Free key — register, paste, done": "免费密钥——注册、粘贴,即可使用", + "browser-side": "浏览器端", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密钥按设计在浏览器中运行——请在服务商处设置限制(见 SECURITY.md)", + "configured externally": "由外部配置", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的环境、钥匙串或启动器提供——请在原设置处修改", + "MANAGE ↗": "管理 ↗", + "GET KEY ↗": "获取密钥 ↗", + "{envVar} saved — paste to replace": "{envVar} 已保存——粘贴可替换", + "paste {envVar}": "粘贴 {envVar}", "REMOVE": "移除", "Remove {title} from this app's saved keys": "从本应用已保存的密钥中移除 {title}", + "Project keys: {status}": "项目密钥:{status}", "your app configuration": "你的应用配置", "your local .env": "你的本地 .env", "Saving…": "正在保存…", "Save failed: {detail}": "保存失败:{detail}", "Paste at least one key first.": "请先至少粘贴一个密钥。", "Saved to {store}. Restarting — this page reloads itself.": "已保存到{store}。正在重启——本页面将自动重新加载。", + "Cloud voice will use OPENAI_API_KEY on the next session.": "云语音将在下次会话使用 OPENAI_API_KEY。", + "Checking local ChatGPT OAuth sign-in…": "正在检查本机 ChatGPT OAuth 登录…", + "Could not check ChatGPT sign-in.": "无法检查 ChatGPT 登录状态。", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已为云语音选择 ChatGPT OAuth。你的 API 密钥仍会保留并可用。", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登录已过期。正在重新打开登录…", + "Opening ChatGPT sign-in in your browser…": "正在在你的浏览器中打开 ChatGPT 登录…", + "Could not start ChatGPT sign-in on this machine.": "无法在此机器上启动 ChatGPT 登录。", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "请在浏览器中完成 ChatGPT 登录,正在等待其完成…", + "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登录超时。点击“{button}”重试。", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登录完成。下次云语音会话将使用 OAuth。", "Remove this key from your saved configuration?": "要从已保存的配置中移除此密钥吗?", "Removed from {store}. Restarting — this page reloads itself.": "已从{store}中移除。正在重启——本页面将自动重新加载。", "Places context + Street View fallback; optional separate key": "地点上下文 + 街景备用;可选的独立密钥", + "Voice control — API key or ChatGPT OAuth": "语音控制——API 密钥或 ChatGPT OAuth", "Live ships, worldwide": "全球实时船舶", "Live active-fire detections": "实时活跃火点探测", "Real live traffic (keyless runs a simulation)": "真实实时路况(无密钥时为模拟)", "Bing imagery map stacks + world terrain": "Bing 影像地图组合 + 全球地形", "More flight-polling credits (anonymous works without)": "更多航班轮询额度(匿名也可使用)", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像与街景图层中的覆盖范围。免费:在 Mapillary 开发者控制台注册一个应用,并粘贴其 Client Token", + "Low": "低", + "High": "高", + "SHOW NEAREST": "最近摄像头", + "Stop following the current object before navigating to a camera": "请先停止跟踪当前目标,再前往摄像头", + "Move to the nearest loaded camera and show its details": "前往最近已加载的摄像头并显示详情", + "Camera badges": "摄像头徽标", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "选中后青色摄像头变为珊瑚色。扇形仅示意已标绘方向,并非实测覆盖范围。附近的摄像头可能不在屏幕内。", + "retrying mapped ALPR cameras": "正在重试加载 ALPR 摄像头", + "loading mapped ALPR cameras": "正在加载 ALPR 摄像头", + "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据 —— 仅覆盖美国和加拿大", + "Zoom in to load mapped cameras": "放大以加载标绘摄像头", + "Showing cached locations": "正在显示缓存位置", + "Coverage limited — zoom in": "覆盖受限 —— 请放大", + "No ALPR data for this area": "此区域没有 ALPR 数据", + "None on screen — nearby cameras are outside the view": "屏幕上没有摄像头 —— 附近的摄像头在视野之外", + "ALPR camera": "ALPR 摄像头", + "At least {count}": "至少 {count}", "CONTEXT READY": "上下文就绪", "GLOBAL CONTEXT OFF": "全局上下文已关闭", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择一个航班、船舶或地图标注设施", @@ -117,6 +273,7 @@ "FOCUS": "聚焦", "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", "NEXT": "下一个", + "Named areas ({n})": "指定区域({n})", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测上下文。缺失的广播、未加载的地图区域或未标注的站点不能作为不存在的证据。", "HDG {value}°": "HDG {value}°", "BRG {value}°": "BRG {value}°", @@ -150,8 +307,83 @@ "CAL {value}": "CAL {value}", "SRC {value}": "SRC {value}", "{value} CONTEXT": "{value} 上下文", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "覆盖大西洋及北太平洋东部/中部;不含全球气旋。", + "Cyclone advisories unavailable": "气旋公告不可用", + "Potential tropical cyclone": "潜在热带气旋", + "Hurricane": "飓风", + "Tropical storm": "热带风暴", + "Tropical depression": "热带低压", + "Subtropical storm": "亚热带风暴", + "Subtropical depression": "亚热带低压", + "Track and cone match this advisory": "路径与锥体与本份公告一致", + "Track/cone awaiting advisory {number}": "路径/锥体等待第 {number} 号公告", + "Track/cone unavailable": "路径/锥体不可用", + "Cached advisory · stale source": "缓存公告 · 数据源已过期", + "Loading advisories…": "正在加载公告…", + "{count} active storms": { + "other": "{count} 个活跃气旋" + }, + "Position as of {time}": "位置(截至 {time})", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持续风速:{wind} · 气压:{pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 号公告 · 发布于 {time}", + "No active NHC/CPHC systems": "当前没有活跃的 NHC/CPHC 气旋", + "Advisories unavailable": "公告不可用", + "Cyclones · NHC / CPHC": "气旋 · NHC / CPHC", + "Atlantic · E/C Pacific": "大西洋 · 东/中太平洋", + "Official advisory ↗": "官方公告 ↗", + "Active NHC and CPHC cyclone advisories": "活跃的 NHC 与 CPHC 气旋公告", + "Wind unavailable": "风场数据不可用", + "Advisory center / forecast track": "公告中心 / 预报路径", + "Center-track uncertainty cone": "中心路径不确定锥", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公报。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "DRIVE": "驾车", + "Drive": "驾车", + "WALK": "步行", + "Walk": "步行", + "BIKE": "骑行", + "Bike": "骑行", + "{error} — try again in a moment": "{error} — 请稍后重试", + "Routing is rate limited — try again in a moment": "路线请求过于频繁 — 请稍后重试", + "Routing unavailable (HTTP {status})": "路线服务不可用(HTTP {status})", + "Routing unavailable": "路线规划不可用", + "Head out on {road}": "沿{road}出发", + "Head out": "出发", + "Arrive at your destination": "到达目的地", + "{mode} — reroute for {modeLower}": "{mode} — 按{modeLower}重新规划路线", + "CLICK MAP": "点击地图", + "SET A": "设置 A", + "Click a spot on the globe to place A (click again to cancel)": "点击地球上的位置放置 A(再次点击取消)", + "Then click the globe to place the start": "然后点击地球放置起点", + "SET B": "设置 B", + "Click a spot on the globe to place B (click again to cancel)": "点击地球上的位置放置 B(再次点击取消)", + "Then click the globe to place the destination": "然后点击地球放置终点", + "Swap A and B": "交换 A 和 B", + "FLY ···": "飞行 ···", + "FLYING": "飞行中", + "FLY": "飞行", + "Fly the camera along the route": "沿路线飞行相机", + "Place A and B first": "请先放置 A 和 B", + "Remove the route and both markers": "移除路线和两个标记点", + "press Escape twice to leave Draw": "按两次 Escape 退出绘图", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用 — {leave},然后设置 {endpoint}", + "Only the first {count} turns of this route are shown": "此路线仅显示前 {count} 个转向", + "Turn-by-turn directions": "逐向导航路线", + "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击 — 关闭它,然后重新设置 A", + "Routing…": "正在规划路线…", + "No route found": "未找到路线", "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "点击地球放置 {endpoint}", + "SET B, then click the globe": "设置 B,然后点击地球", + "SET A, then click the globe": "设置 A,然后点击地球", + "Step {step} of {total}": "第 {step} 步,共 {total} 步", + "Then: {instruction}": "接下来:{instruction}", + "No route found between A and B": "在 A 和 B 之间未找到路线", + "Routing failed: {error}": "路线规划失败:{error}", + "Routing timed out": "路线规划超时", + "Routing unavailable — no response from the routing service": "路线服务无响应 — 路线规划不可用", + "Earthquake source unavailable": "地震数据源不可用", "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", + "live feed unavailable": "实时数据不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value} 前", "sensor n/a": "传感器 n/a", @@ -166,12 +398,15 @@ "<1m ago": "不到 1 分钟前", "{count}m ago": "{count} 分钟前", "{count}h ago": "{count} 小时前", + "Fire · FRP {frp} MW": "火点 · FRP {frp} MW", "STALE · cached {age}": "过期 · 缓存于 {age}", "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", "LIVE · updated {age}": "实时 · 更新于 {age}", "loading mapped installation context": "正在加载地图标注设施上下文", + "Too many mapped sites in view to list them all": "视野内标绘地点过多,无法全部列出", + "Installation context unavailable": "设施背景信息不可用", "MAPPED INSTALLATION": "地图标注设施", "Unknown": "未知", "LAUNCH SITE · {site}": "发射场 · {site}", @@ -240,11 +475,50 @@ "Ascent replay": "上升回放", "Orbit replay": "轨道回放", ", paused": ",已暂停", + "Heard by your receiver": "由你的接收机接收", + "browser SDR": "浏览器 SDR", + "decoder feed": "解码器数据源", + "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收机和解码器数据源", + "Your decoder feed": "你的解码器数据源", + "Your RTL-SDR receiver": "你的 RTL-SDR 接收机", + "NO CALLSIGN": "无呼号", + "Decoder feeds": "解码器数据源", + "WebUSB + decoder feeds": "WebUSB + 解码器数据源", + "opening receiver": "正在打开接收机", + "checking decoder feeds": "正在检查解码器数据源", + "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "connect a receiver in Radio": "在电台面板连接接收机", + "receiver is in FM mode": "接收机处于 FM 模式", "listening": "聆听中", + "USB error": "USB 错误", "Alt unknown": "高度未知", "Type unknown": "类型未知", "Reg unknown": "注册号未知", "Operator unknown": "运营方未知", + "{acres} ac": { + "other": "{acres} 英亩" + }, + "{percent}% contained": "已控制 {percent}%", + "containment unknown": "控制比例未知", + "{cause} cause": "起火原因:{cause}", + "{personnel} personnel": { + "other": "{personnel} 名人员" + }, + "{county} County": "{county} 县", + "{cost} to date": "迄今损失 {cost}", + "discovered {age} ago": "发现于 {age}前", + "updated {age} ago": "更新于 {age}前", + "part of {complex}": "属于 {complex} 复合火场", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 点击卡片打开", + "FIRE · {name}": "火场 · {name}", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上打开 {title}", + "Perimeter source unavailable": "周界数据源不可用", + "Not contained or unknown": "未控制或未知", + "Under 50% contained": "控制低于 50%", + "50–99% contained": "控制 50–99%", + "Fully contained": "已全部控制", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "颜色表示报告的控制比例。周界简化至约 100 米。", "News": "新闻", "Talk": "谈话", "Weather / Emergency": "天气 / 应急", @@ -253,6 +527,10 @@ "Traffic / Transit": "交通 / 公交", "Music": "音乐", "Other": "其他", + "Directory refresh degraded; showing the previous station catalog.": "目录刷新降级;正在显示上一次的电台目录。", + "Radio directory coverage is degraded.": "电台目录覆盖已降级。", + "Directory refresh failed; showing the previous station catalog.": "目录刷新失败;正在显示上一次的电台目录。", + "Radio directory is temporarily unavailable.": "电台目录暂时不可用。", "Alternative": "另类", "Ambient": "氛围", "Blues": "蓝调", @@ -287,7 +565,22 @@ "DENSE ···": "密集 ···", "DENSE ✕": "密集 ✕", "DENSE": "密集", + "DOCKED · {name}": "对接 · {name}", + "KEY REJECTED": "密钥被拒绝", + "loading coverage...": "正在加载覆盖范围...", "Selected": "所选", + "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", + "Roads: {source}": "道路:{source}", + "TomTom roads unavailable while the traffic service is unreachable": "交通服务不可达期间 TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服务不可达期间混合模式不可用", + "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密钥", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 密钥", + "Local surface still loading": "本地路面仍在加载", + "Partial coverage": "部分覆盖", + "Detailed roads unavailable": "详细道路不可用", + "Reduced detail coverage": "详细覆盖范围受限", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用时匹配 TomTom 路况", + "SIMULATED — {reason}": "模拟 — {reason}", "Syncing flow · Roads: {source}": "正在同步流量 · 道路:{source}", "LIVE · Roads: {source} · Roads without flow hidden": "实时 · 道路:{source} · 已隐藏无流量道路", "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内无流量道路", @@ -299,6 +592,7 @@ "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 流量:TomTom(无匹配)", "SIMULATED — traffic service unreachable": "模拟——交通服务无法连接", "SIMULATED — add TomTom key for live": "模拟——添加 TomTom 密钥以获取实时数据", + "Route {route}": "航线{route}", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", @@ -307,39 +601,222 @@ "awaiting first AIS position…": "等待首个 AIS 位置…", "awaiting usable AIS positions…": "等待可用的 AIS 位置…", "awaiting first AIS message…": "等待首条 AIS 消息…", + "AISSTREAM_API_KEY not set": "未设置 AISSTREAM_API_KEY", + "live feed unsupported": "不支持实时数据源", + "connecting to feed…": "正在连接数据源…", + "feed disconnected": "数据源已断开", + "feed down": "数据源中断", + "feed idle": "数据源空闲", + "API key rejected — check AISSTREAM_API_KEY": "API 密钥被拒绝 — 请检查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "数据源已静默 {seconds} 秒 — 无 AIS 数据", + "feed silent — no AIS data": "数据源已静默 — 无 AIS 数据", + "Hidden by this map source · choose a globe map": "已被当前地图源隐藏 · 请选择球体地图", + "Frame unavailable; previous observation retained": "帧不可用;保留上一帧观测", + "Weather imagery unavailable": "天气影像不可用", + "Weather source unavailable; previous observation retained": "天气数据源不可用;保留上一帧观测", + "Weather unavailable": "天气数据不可用", + "{hours}h {minutes}m ago": "{hours} 小时 {minutes} 分前", "{minutes}m ago": "{minutes} 分钟前", "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "观测", + "History": "历史", + "Observation unavailable": "观测不可用", + "Waiting for observation": "等待观测数据", + "RADAR REFLECTIVITY · dBZ": "雷达反射率 · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "闪电密度 · 15 分钟累计", + "GLOBAL INFRARED · hourly": "全球红外 · 每小时", + "GOES INFRARED · ~5 min": "GOES 红外 · 约 5 分钟", "{state}: {time}": "{state}:{time}", + "Latest observation": "最新观测", "loading": "加载中", + "Observation: unavailable": "观测:不可用", + "Contiguous US · gaps ≠ no rain": "美国本土 · 无回波 ≠ 无降雨", + "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", + "Color: strikes/km²/min ×10³": "颜色:次闪/km²/分钟 ×10³", + "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", + "North America · infrared imagery": "北美洲 · 红外影像", + "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", + "Reduced motion · manual history available": "减弱动效 · 可手动浏览历史", + "Rain radar · US": "降雨雷达 · 美国", + "Lightning density · 15 min": "闪电密度 · 15 分钟", + "Satellite clouds": "卫星云图", + "Americas + Pacific": "美洲与太平洋", + "Global · 60°S–60°N": "全球 · 60°S–60°N", + "North America": "北美洲", + "Source observations delayed": "数据源观测延迟", + "Stale source": "数据源已过期", + "Loading next frame…": "正在加载下一帧…", + "Map center outside coverage": "地图中心在覆盖范围之外", + "N. America": "北美", + "Global": "全球", + "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球拼图;通常延迟 2–3 小时", + "GOES regional clouds; approximately 5-minute updates": "GOES 区域云图;约 5 分钟更新", + "Clouds only": "仅云区", + "Full": "完整", + "Soft": "柔和", + "Vivid": "鲜明", + "Image opacity; does not alter the observed values": "图像不透明度;不改变观测值", + "View US radar": "查看美国雷达", + "View Americas & Pacific": "查看美洲与太平洋", + "View coverage": "查看覆盖范围", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次闪/km²/分钟 ×10³(15 分钟密度)", + "{value} dBZ radar reflectivity": "{value} dBZ 雷达反射率", + "STALE · cached source metadata": "已过期 · 数据源元数据为缓存", + "Map center is outside source coverage": "地图中心在数据源覆盖范围之外", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分钟闪电密度,来源为 Vaisala NLDN/GLD360。覆盖自 110°E 经太平洋/美洲至 0°,25°S–80°N。不是实时闪击计数、不是全球覆盖,也不构成安全警示。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据约 1 公里分辨率;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段长波红外区域图;NESDIS 全球长波拼图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", + "REGION": "区域", + "IMAGE": "图像", + "OPACITY": "不透明度", + "Weather tiles unavailable · previous frame retained": "天气瓦片不可用 · 保留上一帧", + "Some weather tiles unavailable": "部分天气瓦片不可用", + "Temperature field unavailable": "温度字段不可用", "Wind": "风", + "Wind source unavailable": "风场数据源不可用", + "Forecast · does not follow history": "预报 · 不随观测历史变化", + "Wind motion": "风场运动", + "Wind speed": "风速", + "Sea-level pressure": "海平面气压", + "Air temperature · 2 m": "2 米气温", + "Global · 1° grid": "全球 · 1° 网格", + "{model} forecast · {time}": "{model} 预报 · {time}", + "Loading forecast": "正在加载预报", + "Preparing flow": "正在准备流场", + "Selected field unavailable": "所选字段不可用", + "Cached forecast · stale": "缓存预报 · 已过期", + "ECMWF IFS surface forecast": "ECMWF IFS 地面预报", + "NOAA GFS surface forecast": "NOAA GFS 地面预报", + "Reduced motion": "减弱动效", "Resume": "继续", "Pause": "暂停", + "Pause visual flow; forecast time does not advance with animation": "暂停流动动画;预报时次不会随动画推进", + "None": "无", + "Speed": "风速", + "Pressure": "气压", + "Temperature": "气温", + "Wind trails with no field shading": "仅风流线,不叠加字段填色", + "How hard the surface wind is blowing": "地面风有多大", + "Sea-level air pressure: broad highs and lows": "海平面气压:大范围的高压与低压", + "Air temperature two meters above the surface": "地面以上两米的气温", + "Wind speed units": "风速单位", + "Read wind at map center": "读取地图中心风速", + "Read the forecast at the center of the map without changing selection": "读取地图中心的预报值,不改变当前选择", + "{model} forecast · {field} ({unit})": "{model} 预报 · {field}({unit})", + "Valid: {time}": "生效:{time}", + "Issued: {time}": "发布:{time}", + "Selected field unavailable · wind remains visible": "所选字段不可用 · 风场仍可见", + "{error} · wind remains visible": "{error} · 风场仍可见", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 米高度的地表风。约 1° 全球网格。流线沿 10 米风场绘制,为便于观看抬升至 12 公里;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。颜色字段贴附于球体底图或当前的照片级 3D Tiles。", + "MODEL": "模型", + "FIELD": "字段", + "UNITS": "单位", + "MOTION": "运动", + "Forecast": "预报", + "No surface reading": "暂无地表读数", + "Aim the map center at Earth": "请将地图中心对准地球", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置读数需要已加载的预报,且地图中心对准地球。", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按约 1° 网格插值的模式预报。反映大范围天气形势,不是街道级实测。", + " from {direction}": "从{direction}出发", + "WIND AT {coordinates}": "{coordinates} 的风", + "{model} · valid {time}": "{model} · 生效 {time}", + "Globe imagery unavailable": "球体影像不可用", + "Globe field image unavailable": "球体字段图像不可用", "Error: {message}": "错误:{message}", "Needs {envVars} — add it in Provider Settings": "需要 {envVars},请在服务商设置中添加", "{label} is unavailable": "{label} 不可用", "This map stack": "此地图组合", "{label} unavailable: {hint}": "{label} 不可用:{hint}", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用 — 请检查密钥的 API 限制、配额或网络", "Visible map targets": "可见的地图目标", "Source": "来源", + "Camera ownership changed": "相机所有权已变更", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的项目无法读取;存储已保留。请导入有效文件以恢复保存。", "Ready": "就绪", + "Scene layer turned off": "场景图层已关闭", + "Stopped": "已停止", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "场景未保存——已保存的项目无法读取。请先导出修改或导入有效文件。", + "Scene not saved — {reason}": "场景未保存——{reason}", + "Scene not saved — browser storage unavailable": "场景未保存——浏览器存储不可用", "Loaded: {scene} / {shot}": "已加载:{scene} / {shot}", + "Cannot update {scene}: shot inventory changed": "无法更新 {scene}:镜头清单已变化", + "Cannot update {scene}: evidence beats changed": "无法更新 {scene}:证据节拍已变化", + "Cannot update {scene}: shot bindings are incomplete": "无法更新 {scene}:镜头绑定不完整", + "Updated {count} shots: {scene}": { + "other": "已更新 {count} 个镜头:{scene}" + }, + "Appended {count} shots: {scene}": { + "other": "已追加 {count} 个镜头:{scene}" + }, "Cannot capture shot: camera not ready": "无法截取镜头:相机未就绪", "Captured: {scene} / {shot}": "已截取:{scene} / {shot}", "Select a shot first": "请先选择一个镜头", "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", + "Could not leave scene: {scene}": "无法离开场景:{scene}", + "Seeked: {scene} / {shot}": "已定位:{scene} / {shot}", + "Seeking scene clock": "正在定位场景时钟", "Camera unavailable — exit cockpit first": "相机不可用——请先退出座舱", "No shots to run": "没有可运行的镜头", + "Scene transition limit reached — load a shot to reset": "已达到场景切换上限——请载入一个镜头以重置", + "Export failed: {error}": "导出失败:{error}", "Project exported": "项目已导出", + "Importing project": "正在导入项目", + "Imported {file}": "已导入 {file}", + "Import failed: {error}": "导入失败:{error}", + "Import failed (could not read JSON file)": "导入失败(无法读取 JSON 文件)", + "Layers refused: {layers}": "被拒绝的图层:{layers}", "Could not exit {mode} — scene layers may be refused": "无法退出 {mode}——场景图层可能被拒绝", + "Camera move interrupted": "相机移动被中断", + "Scene media timed out": "场景媒体播放超时", + "Action complete": "动作已完成", + "Action unavailable or cancelled": "动作不可用或已取消", + "Scene actions": "场景动作", + "Select a feature or use Tab and Enter to choose an action.": "选择一个要素,或使用 Tab 和 Enter 选择动作。", + "Selected feature: {feature}. Choose an action.": "已选要素:{feature}。请选择动作。", "Running {index}/{total}: {scene} / {shot}": "正在运行 {index}/{total}:{scene} / {shot}", "Scene run complete": "场景运行完成", + "Bhote Koshi Evidence Sequence": "Bhote Koshi 证据序列", + "Nepal Flood Incident": "尼泊尔洪水事件", "Global Flights Radar": "全球航班雷达", "Orbital Watch": "轨道监视", "Thermal Threat Board": "热源威胁看板", "City Overload": "城市过载", "Omniscience Pullback": "全知拉远", + "Scene {n}": "场景 {n}", "Shot {n}": "镜头 {n}", + "Could not complete this action. Check the file, references and selected assets.": "无法完成此操作。请检查文件、引用和所选资源。", + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", + "Unavailable layers: {layers}": "不可用图层:{layers}", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校验 {bytes} 个打包字节。文件仅保留在本次会话的内存中。重新加载应用后请再次导入资源包。", + "Review scene import": "审查场景导入", + "Nothing is loaded or changed until you apply this file.": "在你应用此文件之前,不会载入或更改任何内容。", + "Ready to import": "可以导入", + "Apply replaces the current project. Export your current project first if you want to keep both.": "应用后将替换当前项目。如需同时保留两者,请先导出当前项目。", + "Apply import": "应用导入", + "Import was not applied. The current project may have changed.": "导入未应用。当前项目可能已发生变化。", + "Select a scene and shot first": "请先选择场景和镜头", + "Edit scene details": "编辑场景详情", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相机位置使用度与椭球面上的米数。草稿在替换已保存场景之前会先进行校验。", + "Anchors and data packs": "锚点与数据包", + "Shot camera, timing, packs and actions": "镜头相机、时序、数据包与动作", + "New anchor ID": "新锚点 ID", + "Capture camera as anchor": "将当前相机采集为锚点", + "Anchor added to draft": "锚点已加入草稿", + "Set move start to current camera": "将运动起点设为当前相机", + "Move added to draft; destination remains the shot camera": "运动已加入草稿;终点仍为镜头相机", + "Use ordinary flight": "使用普通飞行", + "Invalid shot JSON": "镜头 JSON 无效", + "Apply details": "应用详情", + "Select a scene first": "请先选择一个场景", + "Share selected scene": "分享所选场景", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "场景 JSON 保留创作设置与署名信息。它不包含资源文件。资源包仅包含你在此选择的包文件或先前导入的资源包文件。", + "Download scene JSON": "下载场景 JSON", + "Choose data-pack files": "选择数据包文件", + "Or choose a data-pack folder": "或选择数据包文件夹", + "Download asset bundle": "下载资源包", + "Asset bundle downloaded": "资源包已下载", "Density": "密度", + "Gain": "增益", "Bloom": "泛光", "VISUAL PRESETS": "视觉预设", "LOCATION": "位置", @@ -349,12 +826,18 @@ "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", "Copy failed": "复制失败", + "Tilt map to oblique view": "将地图倾斜至斜视角", "CCTV calibration saved": "CCTV 校准已保存", + "RANGE": "范围", + "{value}m": "{value}分钟", "CCTV calibration reset": "CCTV 校准已重置", "ADJUST ON": "调整开", "ADJUST": "调整", + "LIVE": "实时", "UNKNOWN": "未知", "ERROR": "错误", + "Configured source": "已配置数据源", + "Configured Source": "已配置数据源", "SOURCE · UNKNOWN": "来源 · 未知", "FRAME · LOADING": "画面 · 加载中", "FRAME · UNAVAILABLE": "画面 · 不可用", @@ -504,6 +987,11 @@ "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", "Space Missions": "太空任务", "Context": "上下文", + "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", + "Show {name}": "显示 {name}", + "Fly to {name}": "飞往{name}", + "Press on the ground, not the sky": "请按在地面,而不是天空", + "Drag to size the box": "拖动以调整框选大小", "Live Flights": "实时航班", "Military Flights": "军用航班", "Earthquakes (24h)": "地震(24 小时)", @@ -520,10 +1008,32 @@ "Dams": "水坝", "Submarine Cables": "海底电缆", "FIRMS Active Fires": "FIRMS 活跃火点", + "Local ADS-B": "本地 ADS-B", "Transit": "交通", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火场周界", + "Rain radar": "降雨雷达", + "Lightning density": "闪电密度", + "Cyclone advisories": "气旋公告", + "ALPR Cameras": "ALPR 摄像头", + "Live Vessels": "实时船舶", + "Bike Share": "公共自行车", + "Cameras": "相机", + "Street Level": "街景", + "Mapped ALPR Cameras": "ALPR 摄像头", + "Data Centers": "数据中心", + "Active Fires": "活跃火点", + "Military aircraft": "军用飞机", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 预报", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 观测", "ENABLING": "启用中", "DISABLING": "停用中", + "Movement": "移动", + "Infrastructure": "基础设施", + "Events": "事件", "Weather": "天气", + "Utilities": "工具", + "Other layers": "其他图层", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒后重试", @@ -537,15 +1047,39 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "不确定", + "Data source unavailable": "数据源不可用", "{name}: {state}": "{name}:{state}", "{count}s ago": "{count} 秒前", "CONNECTING": "连接中", + "TUNING": "调谐中", + "STREAMING": "正在流式接收", + "waiting for IQ": "等待 IQ 数据", + "{count} messages": { + "other": "{count} 条消息" + }, + "DSP {count} blocks": { + "other": "DSP {count} 个模块" + }, + "DSP waiting": "DSP 等待中", + "audio signal --": "音频信号 --", + "audio {state}": "音频 {state}", + "{count} HEARD": { + "other": "已收到 {count} 架" + }, + "DISCONNECT": "断开连接", + "CONNECT": "连接", + "LOCATING…": "定位中…", + "LOCATED": "已定位", + "LOCATE": "定位", + " · read while Local ADS-B is on": " · 仅在本地 ADS-B 开启时读取", + "Interface language": "界面语言", "📍 Location: --": "📍 位置:--", "Landmark: --": "地标:--", "Searched location": "已搜索的位置", "ORBIT": "环绕", "Location not found": "未找到位置", "Search failed": "搜索失败", + "Detailed outline unavailable": "无法获取详细轮廓", "Fly to a POI first": "请先飞往一个 POI", "Reset to full globe view": "重置为完整地球视图", "Reset cockpit to full globe view": "将座舱重置为完整地球视图", @@ -560,17 +1094,30 @@ "Expand Radio section": "展开电台区域", "Collapse Radio section": "收起电台区域", "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", + "Austin": "奥斯汀", + "San Francisco": "旧金山", + "New York": "纽约", + "Tokyo": "东京", + "London": "伦敦", + "Paris": "巴黎", + "Dubai": "迪拜", + "Washington DC": "华盛顿特区", + "Tallinn": "塔林", "Collapse Cockpit display options": "收起座舱显示选项", "Expand Cockpit display options": "展开座舱显示选项", "Collapse Cockpit Radio controls": "收起座舱电台控件", "Expand Cockpit Radio controls": "展开座舱电台控件", + "CH {index} / {total}": "频道 {index} / {total}", "NO STATIONS": "无电台", "NO STATION AVAILABLE": "没有可用电台", "{name}, station {index} of {total}": "{name},第 {index} 个电台,共 {total} 个", "No station available": "没有可用电台", "OFF AIR": "停播", "STATION UNAVAILABLE": "电台不可用", + "Station unavailable after directory refresh": "目录刷新后该电台不可用", "Radio could not {action} cleanly": "电台未能正常{action}", + "Go to expanded Radio section": "前往展开的电台分区", + "Expand Radio section in Context": "在态势面板中展开电台分区", "Close compact Radio controls": "关闭精简电台控件", "Open compact Radio controls": "打开精简电台控件", "SYNC": "同步", @@ -619,19 +1166,119 @@ "Radio is enabling…": "电台正在启用…", "Radio is disabling…": "电台正在停用…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", + "Clear reading": "清除读数", + "Earlier observation": "早前观测", + "Observed history": "观测历史", + "Later observation": "稍后观测", + "Latest": "最新", + "Newest frame per product": "每个产品的最新一帧", + "Replay observed history": "回放观测历史", + "Imagery on Esri · Google 3D returns when cleared": "影像来自 Esri · 清除后恢复 Google 3D", + "{owner} is using the pointer — close it first": "{owner} 正在使用指针——请先将其关闭", + "VS BASEMAP": "对比底图", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "此框选最新的低云日 · 云量为场景云量,而非框内云量", + "Newest day covering this box · cloudier than 20%": "覆盖此框选的最新一天 · 云量高于 20%", + "Newest day with imagery for this box · partial coverage": "此框选有影像的最新一天 · 部分覆盖", + "Newest daily overview for this box": "此框选的最新每日概览", + "newest clear day": "最新晴天", + "newest day (cloudy)": "最新一天(多云)", + "newest day (partial coverage)": "最新一天(部分覆盖)", + "newest overview": "最新概览", + "No imagery": "无影像", "Loading": "加载中", "unknown": "未知", + "no imagery": "无影像", + "{percent}% cloud": "{percent}% 云量", + "{min}–{max}% cloud": "{min}–{max}% 云量", + "NO BOX": "无框选", + "SEARCHING": "搜索中", + "{count} DAYS": { + "other": "{count} 天" + }, + "Zoom in or draw a smaller box": "放大或绘制更小的框选", + "Select a box or use the view": "框选区域,或使用当前视野", + "Searching the last 30 days": "正在搜索最近 30 天", + "No days to show for this box": "此框选没有可显示的日期", + "Drag the divider · SWAP trades sides": "拖动分割线 · “交换”互换两侧", + "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 钉住聚焦日", + "← → preview the other side · A or B pins it": "← → 预览另一侧 · A 或 B 将其钉住", + "S on another day replaces it · × unpins": "在其他日期按 S 替换 · × 取消钉住", + "← → preview · S shows the focused day": "← → 预览 · S 显示聚焦日", + "ZOOM IN": "放大", + "Fly in until the view fits the 1,000 km limit": "拉近直到视野满足 1,000 km 限制", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 钉住", + "On the map": "地图上", + "Mode": "模式", + "Imagery opacity": "影像不透明度", + "SWAP": "交换", + "Trade the two sides of the divider": "互换分割线两侧", + "SELECT BOX": "框选区域", + "Drag a box on the map (Esc cancels)": "在地图上拖出框选(Esc 取消)", + "USE VIEW": "使用当前视野", + "Use the current view as the box": "以当前视野作为框选", + "Forget the box and its images": "清除框选及其影像", + "START HERE": "从此开始", + "Checking": "检查中", + "PREVIEW": "预览", + "Newest day with imagery for this box": "此框选有影像的最新一天", + "SHOW": "显示", + "Pin {day} as {slot}": "将 {day} 钉为 {slot}", + "Show {day}": "显示 {day}", + "preview": "预览", + "start here": "从此开始", + "No box": "无框选", + "Searching": "搜索中", + "Every day is empty here": "此处每天都为空", + "Sources off": "数据源未开启", + "Not set": "未设置", + "Basemap": "底图", + "Unpin {slot}": "取消钉住 {slot}", + "Unpin the image": "取消钉住图像", + "EXPORT {slot}": "导出 {slot}", + "EXPORT": "导出", + "Download {slot} as a PNG": "将 {slot} 下载为 PNG", + "Box {width} × {height} km": "框选 {width} × {height} km", + "Acquired {time}": "获取于 {time}", + "Coverage {coverage}": "覆盖 {coverage}", + "START HERE · {reason}": "从此开始 · {reason}", + "Daily overview shows little detail in a box this small": "框选这么小时,每日概览几乎看不出细节", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 与 Worldview · HLS(Sentinel-2、Landsat 8/9)与 VIIRS", + "Details": "详情", + "Times, coverage, sources": "时间、覆盖与数据源", + "HIDE EMPTY DAYS": "隐藏空白日", + "SHOW EMPTY DAYS · {count}": "显示空白日 · {count}", + "Days the probe found empty in this box": "探测发现此框选内为空的日期", + "image": "图像", + "BASEMAP": "底图", + "The basemap": "底图", + "basemap": "底图", + "Recent imagery against the basemap divider": "近期影像与底图对比分割线", + "Recent imagery A and B divider": "近期影像 A / B 分割线", + "{before} {left} percent, {after} {right} percent": "{before} {left}%,{after} {right}%", + "Export failed · {error}": "导出失败 · {error}", "Scene action failed": "场景操作失败", + "New scene name": "新场景名称", + "Delete scene \"{scene}\" and all shots?": "删除场景“{scene}”及其全部镜头?", "Delete shot \"{shot}\"?": "删除镜头“{shot}”?", + "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", "BALANCED": "均衡", + "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“采集镜头”保存当前画面。", + "Double-click to rename": "双击重命名", + "Shot name": "镜头名称", + "LOAD": "载入", "DEL": "删除", + "Cancel": "取消", + "EDIT DETAILS": "编辑详情", + "SHARE SCENE": "分享场景", "entity": "实体", "Shared {subject} follow expired": "共享的 {subject} 跟随已过期", "Shared {subject} could not be restored — feed unavailable": "无法恢复共享的 {subject}——数据源不可用", "Shared {subject} is unavailable": "共享的 {subject} 不可用", "ACQUIRING": "获取中", "SHARED {subject}": "共享 {subject}", + "subject": "目标", "LOADING LIVE DATA": "正在加载实时数据", "LOAD COMPLETE": "加载完成", "LOAD CANCELLED": "加载已取消", @@ -640,27 +1287,86 @@ "MAPPED SITES LOADED": "已加载地图站点", "RETRYING MAPPED SITES": "正在重试地图站点", "FETCHING MAPPED SITES": "正在获取地图站点", + "RETRYING ALPR CAMERAS": "正在重试 ALPR 摄像头", + "FETCHING ALPR CAMERAS": "正在获取 ALPR 摄像头", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", "OVERPASS RATE-LIMITED": "Overpass 已限流", "OVERPASS TIMED OUT": "Overpass 超时", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 无法完成查询", + "MAP TILES TEMPORARILY UNAVAILABLE": "地图瓦片暂时不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地点名称暂时不可用", "TURNING OFF LIVE DATA": "正在关闭实时数据", "REFRESHING LIVE DATA": "正在刷新实时数据", "syncing road network": "正在同步路网", "retry pending": "等待重试", "retrying in {seconds}s": "{seconds} 秒后重试", + "ALPR cameras": "ALPR 摄像头", + "Loading {feeds}": "正在加载 {feeds}", + "{count} heard": { + "other": "已收到 {count} 架" + }, + "{rate} msg/s": "{rate} msg/s", + "Street Level could not start": "街景未能启动", + "Street Level toggle failed": "街景切换失败", + "Street-level image": "街景图像", + "SHRINK": "缩小", + "EXPAND": "展开", + "Shrink": "缩小", + "Expand": "展开", + "ANY DATE": "任意日期", + "LAST 10 YEARS": "最近 10 年", + "LAST 5 YEARS": "最近 5 年", + "LAST 3 YEARS": "最近 3 年", + "LAST 2 YEARS": "最近 2 年", + "LAST YEAR": "最近一年", + "LAST 6 MONTHS": "最近 6 个月", + "LAST 3 MONTHS": "最近 3 个月", + "LAST MONTH": "最近一个月", + "LAST {count} DAYS": { + "other": "最近 {count} 天" + }, + "{window} · SINCE {date}": "{window} · 自 {date}", + "Turn Street Level off": "关闭街景", + "Turn Street Level on": "开启街景", + "{name} imagery on": "{name} 影像已开启", + "{name} imagery off": "{name} 影像已关闭", + "Image by {creator}": "图像作者:{creator}", + "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", + "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在地图源(MAP SOURCE)中选择 Google 3D", + "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", + "Loading this sequence…": "正在加载此序列…", + "{n} images in this sequence · Esc clears": { + "other": "此序列共 {n} 张图像 · Esc 清除" + }, + "{n} sequences in view · click a line for its photos": { + "other": "视野内共 {n} 条序列 · 点击线段查看其照片" + }, "Celestial ring — reveal the full globe": "天球环——显示完整地球", "Celestial ring — available in Normal style": "天球环——仅在常规样式下可用", "Detection overlay: {mode}": "探测叠加层:{mode}", "Detection overlay: off": "探测叠加层:关闭", "DETECT": "探测", + "{time} · {minutes} min ago": "{time} · {minutes} 分钟前", + "Active weather": "活跃天气", + "LATEST · newest per product": "最新 · 每个产品的最新一帧", + "Forecast · valid {valid} · issued {issued}": "预报 · 生效 {valid} · 发布 {issued}", + "Forecast · valid {valid}": "预报 · 生效 {valid}", "AI AGENT": "AI 代理", "Voice model tier — applies next session": "语音模型档位——下次会话生效", "Estimated session cost": "预估会话费用", "Voice control — activate to toggle voice; hold Space to speak": "语音控制——激活可切换语音;按住空格键说话", + "ON/OFF": "开/关", "VOICE STANDBY": "语音待机", "VOICE CONTROL": "语音控制", "Hold Space to speak · tap Space to activate focused controls": "按住空格键说话 · 轻按空格键激活当前聚焦的控件", + "Voice assistant": "语音助手", + "VOICE": "语音", + "Dismiss voice card": "关闭语音卡片", + "YOU": "用户", + "THIS": "此目标", + "Plan": "计划", + "Referenced items": "引用条目", + "NOTES & SOURCES": "笔记与来源", "VOICE SYSTEM ERROR": "语音系统错误", "DISMISS": "关闭", "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", @@ -675,18 +1381,89 @@ "Voice session could not be started.": "无法启动语音会话。", "Next session: {pendingId} — this session stays on {modelId}": "下次会话:{pendingId}——本次会话仍使用 {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "语音模型:{pendingId}——点击切换到 {target};下次会话生效", + "COST UNKNOWN": "费用未知", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 会话:已报告 {responses} 个响应、{input} 个输入 token、{output} 个输出 token、{captions} 次字幕转写。", + "USD cost and the API spend cap are unavailable for this auth mode.": "此认证模式下无法提供美元费用与 API 消费上限。", + "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因为会话结束时仍有响应在处理中。", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的预估会话费用——{responses} 次响应。达到 {warn} 时警告,达到 {cap} 时结束会话。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整 — 会话结束时仍有响应在处理中,因此其用量从未上报。", "{tier} applies next session": "{tier} 将在下次会话生效", "Session ended — cost cap {cost}": "会话已结束——已达费用上限 {cost}", "Radio did not start": "电台未能启动", "Running command": "正在执行指令", + "{n} fires": { + "other": "{n} 处火点" + }, + "anywhere in the loaded data": "已加载数据中的任意位置", + "in view": "视野内", + "over {region}": "{region} 范围内", + "At least {items}": "至少 {items}", "{items} {scope}": "{items} {scope}", + "{label} off": "{label} 已关闭", + "counted the first {returned} loaded records": "已统计最先加载的 {returned} 条记录", + "counted {returned} of {total} loaded records": "已统计已加载 {total} 条记录中的 {returned} 条", + "Fly below {km} km to a covered region": "请飞行至 {km} 公里以下的覆盖区域", + "No feed here yet · {n} regions available": { + "other": "此区域暂无线路 · 有 {n} 个覆盖区域" + }, "degraded": "降级", "stale": "过期", "partial": "部分", "fallback": "备用", "unavailable": "不可用", + "Not answered: {layers}": "未回答:{layers}", + "Toggle voice control": "切换语音控制", + "Activate to toggle voice": "点击切换语音", "Voice active": "语音已启用", + "Voice could not be started.": "无法启动语音。", + "{value} feet": "{value} 英尺", + "nominal": "正常", + "{label} on": "{label} 已开启", + "Layer": "图层", + "there": "那里", + "Camera range {range}": "相机距离{range}", + "Within {radius} of the view": "距当前视野{radius}内", + "Labels on": "标签已开启", + "Framing uses the nearest 80 loaded contacts": "取景仅使用已加载的最近 80 个目标", + "{distance} from {location}": "{distance} 自 {location}", + "Altitude {altitude}": "高度{altitude}", + "{origin} to {destination}": "{origin} 至 {destination}", + "from {origin}": "从{origin}出发", + "to {destination}": "前往{destination}", + "Registration {registration}": "注册号{registration}", + "View state": "视野状态", + "Marked {count} places": { + "other": "已标记 {count} 个地点" + }, + "Nothing marked": "没有标记", + "Not found: {places}": "未找到:{places}", + "Tracing outlines": "正在描画边界", + "Frame {target}": "取景{target}", + "Mark the map": "在地图上标注", + "Fly to {place}": "飞往{place}", + "Nearest aircraft to {place}": "距离{place}最近的飞机", + "Zoom in": "放大", + "Read the view": "读取视野", + "Check view state": "检查视野状态", + "Action": "操作", + "Finding places": "正在查找地点", + "Tracing outline": "正在描画边界", + "Looking up place": "正在查询地点", + "Flying": "飞行中", + "Turning on layer": "正在开启图层", + "Loading aircraft": "正在加载飞机", + "Picking nearest": "正在选取最近目标", + "Working": "处理中", + "in progress": "进行中", + "done": "完成", + "failed": "失败", + "cancelled": "已取消", + "Outline traced": "边界描画完成", + "Outline unavailable": "边界不可用", + "Source: {label}": "来源:{label}", + "WORKING": "处理中", + "DONE": "完成", + "HERE": "此处", "Aircraft cockpit view": "飞机座舱视图", "LEVEL": "等级", "Estimated destination direction": "预估目的地方向", @@ -731,9 +1508,13 @@ "WIND": "风", "SKY": "天空", "PRECIP": "降水", + "Weather data by Open-Meteo.com": "天气数据由 Open-Meteo.com 提供", + "Cockpit briefing pages": "驾驶舱简报页面", + "SIG": "信号", "Show Live Signals": "显示实时信号", "NEWS": "新闻", "Show Regional News": "显示地区新闻", + "LOCAL": "本地", "Show Local Info": "显示本地信息", "Cockpit display and Radio controls": "座舱显示与电台控件", "DISPLAY": "显示", @@ -745,6 +1526,8 @@ "Next filtered radio station": "下一个已筛选电台", "VOLUME": "音量", "Cockpit Radio volume": "座舱电台音量", + "ESC EXIT": "ESC 退出", + "C TOGGLE": "C 切换", "View switcher": "视图切换", "Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图", "RESET": "重置", @@ -772,6 +1555,9 @@ "Search location by name or coordinates": "按名称或坐标搜索位置", "Search any location...": "搜索任意位置…", "WEATHER": "天气", + "Active weather products": "活跃天气产品", + "RECENT IMAGERY": "近期影像", + "Imagery days in the box": "影像盒内的天数", "CONTEXT": "上下文", "Compact Radio controls": "精简电台控件", "Open detailed Radio controls": "打开详细电台控件", @@ -801,6 +1587,7 @@ "All": "全部", "Enable Radio, then choose a globe marker or use next.": "请先启用电台,然后选择地球上的标记或使用“下一个”。", "DRAG TO TUNE": "拖动调谐", + "Tune available internet radio stations": "调谐可用的网络电台", "ALL · DRAG THE NEEDLE": "全部 · 拖动指针", "SNAPS TO AVAILABLE STATIONS": "自动吸附到可用电台", "Radio playback": "电台播放", @@ -811,13 +1598,48 @@ "Stop radio playback": "停止电台播放", "Radio volume": "电台音量", "STATION SITE": "电台网站", + "DIRECTORY: RADIO BROWSER": "目录:RADIO BROWSER", "Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "点击播放后,音频将直接连接到广播方。广播方可以看到你的 IP。", + "Local RTL-SDR receiver": "本地 RTL-SDR 接收机", + "LOCAL RTL-SDR": "本地 RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。", + "Use your location for faster ADS-B position decoding": "使用你的位置以更快解码 ADS-B 位置", + "CHANGE DEVICE": "更换设备", + "Choose a different RTL-SDR or receiver channel": "选择其他 RTL-SDR 或接收通道", + "RTL-SDR receiver mode": "RTL-SDR 接收模式", + "GAIN": "增益", + "RTL-SDR tuner gain for the current mode": "当前模式下的 RTL-SDR 调谐器增益", + "AUTO": "自动", + "MSG/S": "消息/秒", + "HEARD": "已收到", + "POSITIONED": "已定位", + "FM MHZ": "FM MHz", + "TUNE": "调谐", + "Seek broadcast FM stations": "搜索广播 FM 电台", + "◀ SEEK": "◀ 搜台", + "SEEK ▶": "搜台 ▶", + "SDR VOL": "SDR 音量", + "Local SDR volume": "本地 SDR 音量", "Intelligence HUD (H)": "情报 HUD(H)", "Layout": "布局", "HUD layout": "HUD 布局", "Tactical": "战术", "Operator": "操作员", "Minimal": "精简", + "Cyber": "赛博", + "Sonar": "声呐", + "Cyber sonar contact scanner": "赛博声呐接触扫描器", + "Rings": "圈数", + "Sonar ring count": "声呐圈数", + "Range": "范围", + "Sonar ring range": "声呐圈范围", + "Power": "强度", + "Sonar intensity": "声呐强度", + "Opacity": "不透明度", + "Cyber contact and label opacity": "赛博接触与标注不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "声呐扫描期间地图点、图标、模型、括标与标注的最低不透明度", + "Sector": "扇区", + "Sonar sector width": "声呐扇区宽度", "Detection Overlay (D)": "探测叠加层(D)", "Detection overlay": "探测叠加层", "Detection label density": "探测标签密度", @@ -841,7 +1663,22 @@ "Feather": "羽化", "Scope edge feather": "视域边缘羽化", "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以锁孔半径的百分比表示", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上绘制 — 单击顶点,双击或按 Enter 完成,按 Esc 取消", + "Shape": "形状", + "Shape to draw": "要绘制的形状", + "Area": "面", + "Line": "线", + "Pin": "标记", + "Label (optional)": "标签(可选)", + "Label for the drawn shape": "绘制形状的标签", + "Colour of the drawn shape": "绘制形状的颜色", + "Primary": "主色", + "Amber": "琥珀", + "Cyan": "青色", + "Green": "绿色", + "Red": "红色", "Clear": "晴", + "Remove every mark from the board": "清除图板上的所有标记", "Import": "导入", "Celestial": "天球", "Hide UI chrome": "隐藏界面控件", @@ -870,7 +1707,28 @@ "SAVE CAL": "保存校准", "RESET CAL": "重置校准", "SCENE SUMMARY": "场景摘要", + "STREET LEVEL": "街景", + "Expand the street-level view (Esc shrinks it)": "放大街景视图(Esc 缩小)", + "Image fit": "图像适配", + "FIT": "适应", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "适应:显示整张照片,比例与画框不同处留黑边", + "FILL": "填充", + "Fill: fill the frame edge to edge, cropping the photo": "填充:图像铺满画框并裁剪照片", + "Camera follows view": "相机跟随视野", + "FOLLOW": "跟随", + "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中", + "Close the street-level image": "关闭街景图像", + "Street-level viewer": "街景查看器", + "Open this image on the provider's site": "在提供商网站上打开此图像", + "Imagery providers": "影像提供商", + "PROVIDERS": "提供商", + "Imagery filters": "影像筛选", + "IMAGERY": "影像", + "Panorama filter": "全景筛选", "ALL": "全部", + "FLAT": "平面", + "SINCE": "时间范围", + "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -893,6 +1751,9 @@ "Globe actions": "地球操作", "Turn off all selected data layers": "关闭所有已选数据图层", "Copy share link": "复制分享链接", + "Toggle straight-down and tilted map views": "在垂直俯视与倾斜视角之间切换", + "Reset map to north up": "将地图重置为朝北", + "Reset map bearing to north": "重置地图方位为正北", "Reset camera and return to full globe view": "重置相机并返回完整地球视图", "MISSION CONTROL · FIRST LAUNCH": "任务控制 · 首次启动", "Choose your first view": "选择你的首个视图", From c4cc499d581650a5d3a95c7f08cb9b54db575e49 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:52:34 +0400 Subject: [PATCH 056/178] i18n(zh-CN): complete and ship Simplified Chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate the remaining 910 strings and review the whole catalog (meaning, terms, punctuation, label length) against the screens. - Register zh-CN: any zh tag maps to it except Traditional ones (Hant, TW, HK, MO), which keep English until a Traditional catalog ships. - Hemisphere letters get their own context so the HUD sector line reads 21.33N, not a compass word; the Bhote Koshi imagery dates follow year-first languages (2021年10月16日). - cjk.css: tight tracking for Chinese text and a 9px floor on the dock voice labels and layer buttons; Latin HUD readouts keep their look. - docs/translations/zh-CN.md: style, plurals and glossary. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-CN.md | 59 ++ scripts/package-boundaries.json | 89 ++- src/data/bhoteKoshiEvent.js | 10 +- src/hudLocalityFormat.js | 13 +- src/i18n/i18n.test.mjs | 6 + src/i18n/index.js | 3 +- src/i18n/locale.js | 13 +- src/i18n/locales/es.json | 8 +- src/i18n/locales/ru.json | 8 +- src/i18n/locales/zh-CN.json | 1306 +++++++++++++++++++++++++++---- src/i18n/same-as-english.json | 58 ++ src/ui/localePicker.test.mjs | 8 +- src/ui/styles/cjk.css | 29 + style.css | 1 + 16 files changed, 1422 insertions(+), 199 deletions(-) create mode 100644 docs/translations/zh-CN.md create mode 100644 src/ui/styles/cjk.css diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 045d6e26b..68dcb858b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3369,7 +3369,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3380,7 +3380,9 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Any `zh` tag maps to `zh-CN` except + Traditional ones (`Hant`, `TW`, `HK`, `MO`), which fall through until a + Traditional catalog ships. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 2131ab7a3..538f8c50e 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -1,6 +1,6 @@ # Translating God's Eye View -Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). ## How it works @@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth ## Language notes -Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one. +Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-CN.md b/docs/translations/zh-CN.md new file mode 100644 index 000000000..9b779e0b7 --- /dev/null +++ b/docs/translations/zh-CN.md @@ -0,0 +1,59 @@ +# Simplified Chinese (`zh-CN`) + +Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +The catalog started from the Chinese locale by abiyeenzo (#914) and the +Simplified Chinese localization by EDIT-Aeterna (#979); the rest was translated +for the current English and reviewed against both. + +## Style + +- Mainland usage and Simplified characters. Address the user neutrally, without 您/你 where a plain imperative works: 「点击地图」, 「重试」. +- Chinese has no case: an UPPERCASE English HUD label becomes the same short Chinese label. Keep labels as short as the English: 「实时」, 「过期」, 「关」. +- Full-width punctuation in Chinese text: ,。:;?!()“”. A dash is 「——」 without spaces; keep `·` as the HUD separator and `…` for ellipsis. +- One space between Chinese and Latin letters or digits: 「AIS 船舶」, 「24 小时」, 「{count} 个」. No space next to full-width punctuation. +- Units: km, m, km/h, m/s, kn, ft, MW, °, % stay as written; time words are 小时, 分钟, 秒 after a number (「5 分钟前」). +- Product names, provider names and codes stay as they are: Google, Cesium, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, CONUS. + +## Plurals + +`Intl.PluralRules('zh-CN')` has one category, `other`: a `tn()` entry has only that form. Use a measure word next to the number where Chinese needs one: 「{count} 个密钥」, 「{count} 架飞机」, 「{count} 艘船」. + +## Glossary + +| English | 中文 | +| ------------------------------------- | --------------------------- | +| layer / data layer | 图层 / 数据图层 | +| feed | 数据源 | +| source / data source | 来源 / 数据源 | +| LIVE (status) / live data | 实时 / 实时数据 | +| SIMULATED | 模拟 | +| stale / degraded / unavailable | 过期 / 降级 / 不可用 | +| retry | 重试 | +| key (API key) / POWER UP | 密钥(API 密钥)/ 增强 | +| Provider Settings | 服务商设置 | +| globe / map / map source / basemap | 地球 / 地图 / 地图源 / 底图 | +| aircraft / flight / military flights | 飞机 / 航班 / 军用航班 | +| vessel, ship | 船舶 | +| satellite | 卫星 | +| launch (rocket) / space mission | 发射 / 太空任务 | +| contact (tracked object) | 目标 | +| installation, site | 设施 | +| camera / CCTV camera | 摄像头 / CCTV 摄像头 | +| earthquake / active fire | 地震 / 活跃火点 | +| scene / shot | 场景 / 镜头 | +| briefing / panel / dock | 简报 / 面板 / 停靠栏 | +| preset / visual preset | 预设 / 视觉预设 | +| Display / HUD layout | 显示 / HUD 布局 | +| Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 | +| Scope (circular viewport mask) | 视域 | +| Clean UI | 纯净界面 | +| cockpit | 座舱 | +| heading / altitude / ground speed | 航向 / 高度 / 地速 | +| Street Level / Street View (Google) | 街景 / 街景(Google) | +| Recent Imagery | 近期影像 | +| Global Context / Context (panel) | 全局态势 / 态势 | +| radio (station) | 电台 | +| transit / bikeshare | 公共交通 / 共享单车 | +| Previous / Next | 上一个 / 下一个 | +| compass points (N, NNE, …) | 北, 北东北, … | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 264f58440..bbbc39574 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -426,7 +426,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -451,7 +452,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -467,7 +469,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -495,6 +498,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js" @@ -513,7 +517,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -528,7 +533,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -546,6 +552,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": [] @@ -570,6 +577,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/layerNames.js" ], "external": [] @@ -603,6 +611,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -617,6 +626,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": [] @@ -765,6 +775,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -882,6 +893,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -947,6 +959,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -992,7 +1005,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1052,7 +1066,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1081,6 +1096,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1109,7 +1125,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1140,6 +1157,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1171,7 +1189,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1213,6 +1232,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1247,6 +1267,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1295,7 +1316,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1323,7 +1345,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1370,7 +1393,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["hls.js", "cesium"] }, @@ -1435,7 +1459,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -1462,7 +1487,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1501,7 +1527,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1522,7 +1549,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1577,7 +1605,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -1633,6 +1662,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js" ], "external": [] @@ -2486,6 +2516,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3059,6 +3090,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3164,6 +3196,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3184,7 +3217,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -3216,7 +3250,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -3240,7 +3275,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": [] }, @@ -3331,6 +3367,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3385,6 +3422,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js" ], "external": [] @@ -3636,7 +3674,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3678,7 +3717,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium", "pbf"] }, @@ -3730,7 +3770,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" ], "external": ["cesium"] }, @@ -3746,6 +3787,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -3938,6 +3980,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 2baaf7031..434aff6d0 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -412,14 +412,18 @@ const IMAGERY_LABEL_NAMES = Object.freeze([ [/^(\d{4}) POST-EVENT$/, msg('{year} POST-EVENT')], ]); -/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021. */ +/** Day, short month and year in the active language: 16 OCT 2021, 16 ОКТ. 2021, 2021年10月16日. */ function imageryDateText(observedAt) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(observedAt || '')); if (!match) return ''; const date = new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])); if (Number.isNaN(date.getTime())) return ''; const part = (options) => formatDate(date, { timeZone: 'UTC', ...options }); - return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${part({ year: 'numeric' })}`.toUpperCase(); + const year = part({ year: 'numeric' }); + // Year-first languages (2021年10月16日) keep their own order and units. + const whole = part({ day: 'numeric', month: 'short', year: 'numeric' }); + if (whole.startsWith(year)) return whole; + return `${part({ day: 'numeric' })} ${part({ month: 'short' })} ${year}`.toUpperCase(); } /** @@ -1029,7 +1033,7 @@ function createPanel(event, handlers) { ${escapeMarkup(t('GEOLOCATIONS · {credit}', { credit: 'GEO GEORGE SHADRACH' }))} -

${escapeMarkup(t('Clouds are preserved from the source imagery.'))}

+

${escapeMarkup(t(' Clouds are preserved from the source imagery.'))}

`; const refs = collectPanelReferences(panel); diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 4a82fdbb3..84278564b 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -35,7 +35,16 @@ export const LOCALIZED_LOCALITY = Object.freeze({ }), sector: ({ latDeg, lonDeg }) => t('SECTOR {lat} {lon}', { - lat: hudCoordinate(latDeg, t('N'), t('S')), - lon: hudCoordinate(lonDeg, t('E'), t('W')), + // Hemisphere letters, not compass words: 21.33N, never 21.33北. + lat: hudCoordinate( + latDeg, + t('N', { context: 'hemisphere' }), + t('S', { context: 'hemisphere' }), + ), + lon: hudCoordinate( + lonDeg, + t('E', { context: 'hemisphere' }), + t('W', { context: 'hemisphere' }), + ), }), }); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index a662130a1..70eaf7e86 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('xx'), null); + // Simplified Chinese by default; Traditional tags wait for zh-TW. + assert.equal(normalizeLocale('zh'), 'zh-CN'); + assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); + assert.equal(normalizeLocale('zh-TW'), null); + assert.equal(normalizeLocale('zh-Hant-HK'), null); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index ccc54a5e6..d5698aa5b 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -18,8 +18,9 @@ import { } from './locale.js'; import es from './locales/es.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' }; +import zhCN from './locales/zh-CN.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 626303ad7..fab95e5cc 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ -export const LOCALES = Object.freeze(['en', 'es', 'ru']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', + 'zh-CN': '简体中文', }); /** Right-to-left locales. */ const RTL_LOCALES = Object.freeze([]); /** - * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'. + * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. * @returns {string|null} */ export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; - const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; + const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); + // Traditional Chinese readers are not given Simplified: no zh-TW yet. + if (primary === 'zh') + return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) + ? null + : 'zh-CN'; return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f93f42070..b0d4dbef5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1096,7 +1096,7 @@ "FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}", "GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}", "↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO", - "Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.", + " Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.", "Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi", "CITIES": "CIUDADES", @@ -2890,5 +2890,9 @@ "USB work abandoned after a stalled transfer": "Trabajo USB cancelado tras una transferencia bloqueada", "SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido", "SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido", - "SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido" + "SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "O" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6a58ab901..cce27c3d4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1110,7 +1110,7 @@ "FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}", "GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}", "↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ", - "Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.", + " Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.", "Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны", "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi", "CITIES": "ГОРОДА", @@ -2957,5 +2957,9 @@ "USB work abandoned after a stalled transfer": "Работа с USB прервана: передача данных зависла", "SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен", "SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен", - "SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен" + "SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен", + "hemisphere|N": "С", + "hemisphere|S": "Ю", + "hemisphere|E": "В", + "hemisphere|W": "З" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3a5972058..0a56bc76a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -7,12 +7,16 @@ }, "Those points are in a line — move one off it to enclose an area.": "这些点都在同一条直线上——请移开其中一个点才能围出面。", "That line has no length — click somewhere further away.": "这条线没有长度——请在更远处点击。", + " · {max}-point limit reached": " · 已达 {max} 个点上限", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 双击或按 Enter 完成,按 Backspace 撤销,按 Esc 取消。{full}", + "drawings on the map": "地图上的绘图", "That shape already has {count} points — finish it or press Backspace.": "该形状已有 {count} 个点——请完成绘制或按 Backspace。", "That point is off the globe — click on the world.": "该点在地球之外——请点击地球上的位置。", "That shape could not be placed.": "该形状无法放置。", "Could not place the shape: {error}": "无法放置形状:{error}", "Board cleared.": "画板已清除。", "{owner} is using the pointer — close it first.": "{owner} 正在使用指针——请先关闭它。", + "{label} · Detailed outline unavailable": "{label} · 无法获取详细轮廓", "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", "Restoring shared view...": "正在恢复共享视图…", "Configuring viewer...": "正在配置视图…", @@ -20,6 +24,9 @@ "Loading the keyless globe...": "正在加载免密钥地球…", "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles 不可用({detail})。正在加载免密钥地球…", "Initializing systems...": "正在初始化系统…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任务模式会隔离回放数据。退出该模式后才能启用“{layer}”。", + "Data attribution": "数据来源说明", + "Data provided by:": "数据提供方:", "Close data attribution": "关闭数据来源说明", "Light aircraft": "轻型飞机", "Glider": "滑翔机", @@ -31,18 +38,86 @@ "Fast jet": "喷气战机", "Business jet": "公务机", "Drone": "无人机", + "General emergency": "一般紧急情况", + "Unlawful interference": "非法干扰", + "Radio failure": "无线电失效", + "Downed aircraft": "飞机坠毁", + "Minimum fuel": "最低油量", + "Lifeguard (medical priority)": "救护飞行(医疗优先)", + "Rasuwagadhi witness video": "Rasuwagadhi 目击视频", + "Bhote Koshi valley footage": "Bhote Koshi 河谷画面", + "Flood footage": "洪水画面", + "Compilation and casualty claims": "视频合集与伤亡说法", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "来源已地理核实 · 已标定于地图", + "SOURCE CREDITED · LOCATION UNRESOLVED": "来源已署名 · 位置未确定", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "仅供参考 · 说法未核实", + "SOURCE GEOCONFIRMED": "来源已地理核实", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年历史参考影像与 2026 年灾后影像对比;变化为累计结果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演进为示意,并非模拟的到达时间;也不是官方灾害模型。", + "Immediate collapse viewpoint": "近距离崩塌观察点", + "Collapse viewpoint": "崩塌观察点", + "Debris-dammed lake": "碎屑堰塞湖", + "Dammed lake": "堰塞湖", + "Second landslide": "第二次滑坡", + "Gyirong border gate": "Gyirong 边境口岸", + "Border gate": "边境口岸", + "Timure evidence cluster": "Timure 证据群", + "Mailung / Upper Trishuli bridge": "Mailung / Upper Trishuli 桥", + "Dandagaun viewpoint restaurant": "Dandagaun 观景餐厅", + "Dandagaun viewpoint": "Dandagaun 观景点", + "Bidur - Trishuli bridge": "Bidur - Trishuli 桥", + "Bidur bridge": "Bidur 桥", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola 桥", + "Devighat bridge": "Devighat 桥", + "Facebook player API did not initialize.": "Facebook 播放器 API 未能初始化。", + "Facebook player API could not load.": "无法加载 Facebook 播放器 API。", + "Facebook player API timed out.": "Facebook 播放器 API 超时。", + "X embed API did not initialize.": "X 嵌入 API 未能初始化。", + "X embed could not load.": "无法加载 X 嵌入内容。", + "X embed timed out.": "X 嵌入内容超时。", "UNAVAILABLE": "不可用", + "TIMEOUT": "超时", + "BLOCKED": "被拦截", "SOURCE": "来源", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook 嵌入内容不可用。该帖子可能为私密、已删除、受地区限制或被拦截。", + "Preloading {provider} media": "正在预加载 {provider} 媒体", + "LOADING X POST…": "正在加载 X 帖子…", + "The X post is unavailable or cannot be embedded.": "该 X 帖子不可用或无法嵌入。", + "X POST · PROVIDER CONTROLS": "X 帖子 · 平台控件", + "{reason} OPEN ORIGINAL.": "{reason} 请打开原始来源。", + "X embed unavailable": "X 嵌入内容不可用。", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · 平台控件", + "Facebook embed unavailable": "Facebook 嵌入内容不可用。", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自动播放被拦截 · 请使用平台控件", + "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒体", + "witness source": "目击来源", + "Witness source": "目击来源", + "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入来源", + "OPEN ORIGINAL ↗": "打开原始来源 ↗", + "{provider} media: {source}": "{provider} 媒体:{source}", + "{provider} · PROVIDER CONTROLS": "{provider} · 平台控件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打开原始来源", + "INITIATION": "起始", + "DAMMING": "堰塞", + "SECONDARY FAILURE": "二次溃决", + "BORDER ARRIVAL": "抵达边境", + "PROPAGATION": "演进", + "DOWNSTREAM PASSAGE": "向下游推进", + "CONSEQUENCE": "后果", + "DOWNSTREAM CONSEQUENCE": "下游后果", "EVIDENCE": "证据", + "{year} HISTORICAL REFERENCE": "{year} 年历史参考", + "{year} POST-EVENT": "{year} 年灾后", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "other": "{count} 个地理定位来源点位" }, "NO PREVIEW": "无预览", + "YouTube source": "YouTube 来源", + "Facebook source": "Facebook 来源", "SOURCE LINK": "来源链接", "{publisher} / OPEN": "{publisher} / 打开", "Bhote Koshi flood reconstruction controls": "Bhote Koshi 洪水重构控制", "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重构 · 2026-08-26", - "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 冰湖溃决洪水", + "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 溃决洪水", "Close event layer": "关闭事件图层", "OBSERVED IMAGERY": "实测影像", "SCHEMATIC CORRIDOR": "示意走廊", @@ -63,12 +138,13 @@ "↗ OPEN ORIGINAL": "↗ 打开原始来源", "⌖ LOWER GORGE": "⌖ 下游峡谷", "FIELD REPORTS · {count}": "现场报道 · {count}", + "GEOLOCATIONS · {credit}": "地理定位 · {credit}", "↗ OPEN PUBLIC MAP": "↗ 打开公共地图", - "Clouds are preserved from the source imagery.": " 云层按源影像原样保留。", + " Clouds are preserved from the source imagery.": "云层按源影像原样保留。", "Rasuwagadhi witness": "Rasuwagadhi 目击", "Open original source for {source}; capture time unverified": "打开 {source} 的原始来源;拍摄时间未核实", "Historical reference and post-event image divider": "历史参考与灾后影像的分割线", - "A historical reference {before} percent, B post-event {after} percent": "历史参考 {before}%,灾后 {after}%", + "A historical reference {before} percent, B post-event {after} percent": "A 历史参考 {before}%,B 灾后 {after}%", "■ RELEASE CAMERA": "■ 释放相机", "Seek Nepal scene clock": "定位尼泊尔场景时钟", "NEPAL SCENE CLOCK": "尼泊尔场景时钟", @@ -86,7 +162,7 @@ "{count} SOURCES": { "other": "{count} 个来源" }, - "OUTSIDE IMAGERY SWIPE": "影像范围外滑动", + "OUTSIDE IMAGERY SWIPE": "不在影像对比范围内", "{scene} / AUTHORED SHOT": "{scene} / 排定镜头", "NEPAL FLOOD INCIDENT": "尼泊尔洪水事件", "Play {shot}": "播放 {shot}", @@ -94,38 +170,103 @@ "Play reconstruction": "播放重构", "Load previous scene shot": "载入上一个场景镜头", "Load next scene shot": "载入下一个场景镜头", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,并非模拟的到达时间。", "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", "Event reconstruction": "事件重构", + "BHOTE KOSHI FLOOD INCIDENT": "BHOTE KOSHI 洪水事件", + "NEPAL · 26 AUG 2026": "尼泊尔 · 2026-08-26", + "USGS INITIAL REPORT": "USGS 初始报告", + "26 AUG 2026 · 08:37 NPT": "2026-08-26 · 08:37 NPT", + "28.2710° N · 85.5150° E": "北纬 28.2710° · 东经 85.5150°", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "初报 M4.4 · 重新归类为 M5.2 滑坡", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS 事件 us7000tbwb · 打开来源", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "打开 USGS 关于 2026 年 8 月 26 日尼泊尔滑坡的初始报告", + "Show the Nepal context callout without a border or country fill": "显示尼泊尔背景标注,不绘制边界和国家填充", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "介绍尼泊尔,绘制其边界,再展示 Bhote Koshi 事件", + "CITIES": "城市", + "Keep the regional incident context and introduce nearby city labels": "保留区域事件背景,并加入附近城市标注", + "PLACES": "地点", + "Introduce the reconstruction scene key places from trigger to downstream": "依次介绍重构场景的关键地点,从触发点到下游", + "PATH": "路径", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "以动画展示有来源依据的青色河道中线路径,直至 Trishuli Bazaar", + "OVERVIEW": "总览", + "Keep the completed flood path and location labels visible": "保持完整的洪水路径和地点标注可见", + "RECORD": "记录", + "Reveal the trigger record while making a slow camera approach": "镜头缓慢推近,同时展示触发事件记录", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "该文件不是有效的 JSON。", + "That JSON file is not GeoJSON.": "该 JSON 文件不是 GeoJSON。", + "That FeatureCollection has no features list.": "该 FeatureCollection 没有 features 列表。", + "Every GeoJSON feature must have type Feature.": "每个 GeoJSON 要素的类型都必须是 Feature。", + "That GeoJSON has invalid geometries.": "该 GeoJSON 包含无效的几何图形。", + "That GeoJSON contains an unsupported geometry.": "该 GeoJSON 包含不受支持的几何图形。", + "That GeoJSON has invalid coordinates.": "该 GeoJSON 包含无效坐标。", + "That file creates more than {limit} map objects (the limit).": "该文件生成的地图对象超过上限 {limit} 个。", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "清理后,该文件的样式定义和图像链接仍超过上限 {limit} 个。", + "That file has more than {limit} features (the limit).": "该文件的要素超过上限 {limit} 个。", + "That file has more than {limit} points (the limit) across {features} features.": "该文件的点数超过上限 {limit} 个(共 {features} 个要素)。", + "That file has more than {limit} points (the limit).": "该文件的点数超过上限 {limit} 个。", + "That KMZ unpacks to more than the import limit.": "该 KMZ 解压后超过导入上限。", + "That KMZ is damaged and could not be unpacked.": "该 KMZ 已损坏,无法解压。", + "That KMZ is not a readable zip archive.": "该 KMZ 不是可读取的 zip 压缩包。", + "That KMZ uses a zip format this import does not read.": "该 KMZ 使用的 zip 格式无法导入。", + "That KMZ is encrypted.": "该 KMZ 已加密。", + "That KMZ uses a compression method this import does not read.": "该 KMZ 使用的压缩方式无法导入。", + "That KML has invalid geometry bounds.": "该 KML 的几何范围无效。", + "That KML has invalid coordinates.": "该 KML 包含无效坐标。", + "That GPX file has no document.": "该 GPX 文件没有 文档。", + "That {format} file is not valid XML.": "该 {format} 文件不是有效的 XML。", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "该文件包含无法读取的图像,或图像超过 1600 万像素上限。", + "That KML could not be read.": "无法读取该 KML。", + "That file has invalid point coordinates.": "该文件的点坐标无效。", + "Ground heights are not available for that file. Try again when the map has loaded.": "该文件无法获取地面高度。请在地图加载完成后重试。", + "That file took too long to read.": "读取该文件耗时过长。", + "Nothing to import.": "没有可导入的内容。", + "That file is larger than {size} MiB.": "该文件大于 {size} MiB。", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "只能导入 GeoJSON、KML、KMZ 和 GPX 文件。", + "That file has nothing to draw.": "该文件没有可在地图上绘制的内容。", + "That KMZ holds no KML document.": "该 KMZ 中没有 KML 文档。", + "That {format} file could not be read.": "无法读取该 {format} 文件。", + "That file has {count} map objects; the limit is {limit}.": "该文件有 {count} 个地图对象,上限为 {limit} 个。", + "Ground heights could not be read. Try that file again.": "无法读取地面高度。请重新导入该文件。", + "That file has invalid point graphics.": "该文件的点样式无效。", "Overpass rate-limited": "Overpass 已限流", "Overpass timed out": "Overpass 超时", "Overpass could not complete the query": "Overpass 无法完成查询", "Map tiles temporarily unavailable": "地图瓦片暂时不可用", "Mapped names temporarily unavailable": "地点名称暂时不可用", "Overpass temporarily unavailable": "Overpass 暂时不可用", - "Retrying mapped sites…": "正在重试地图站点…", - "Fetching mapped sites…": "正在获取地图站点…", + "Retrying mapped sites…": "正在重试加载地图标注设施…", + "Fetching mapped sites…": "正在获取地图标注设施…", "{reason} — retrying in {seconds}s": "{reason}——{seconds} 秒后重试", "{reason} — retry pending": "{reason}——等待重试", "Zoom in to search mapped installations": "请放大地图以搜索地图标注的设施", - "Showing cached mapped sites": "正在显示缓存的地图站点", - "Mapped sites not loaded": "地图站点未加载", - "Mapped sites loaded": "已加载地图站点", + "Showing cached mapped sites": "正在显示缓存的地图标注设施", + "Mapped sites not loaded": "地图标注设施未加载", + "Mapped sites loaded": "已加载地图标注设施", + "Military airfield": "军用机场", + "Naval base": "海军基地", + "Firing range": "靶场", + "Military land": "军事用地", "Mapped installation": "地图标注设施", + "Military area": "军事区域", + "flight": "航班", "military flight": "军用航班", + "satellite": "卫星", "feed unavailable": "数据源不可用", "feed stale": "数据源过期", - "observed or mapped nearby context": "附近有观测或地图标注的上下文", + "observed or mapped nearby context": "附近的观测或地图标注态势", "no observed or mapped objects in current feeds": "当前数据源中没有观测或地图标注的对象", "Draw": "绘制", "Directions": "路线导航", "Recent imagery": "近期影像", - "STATION": "电台", + "STATION": "空间站", "Crewed stations and their visiting vehicles": "载人空间站及其访问飞行器", "NAV": "导航", "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 导航:GPS、GLONASS、Galileo", "GEO": "GEO", "Geostationary belt — comms and weather, fixed over the equator": "地球静止轨道带:通信和气象,固定在赤道上空", - "VISUAL": "可见", + "VISUAL": "目视可见", "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", "COMMS": "通信", "Broadband constellation shell — shown only in DENSE mode": "宽带星座:仅在“密集”模式下显示", @@ -168,10 +309,10 @@ "NVG": "NVG", "FLIR": "FLIR", "ANIME": "动漫", - "NOIR": "黑色电影", + "NOIR": "黑白", "SNOW": "雪景", "ARCTIC": "北极", - "ANTARCTIC": "南极洲", + "ANTARCTIC": "南极", "NORTH AMERICA": "北美洲", "SOUTH AMERICA": "南美洲", "EUROPE": "欧洲", @@ -186,16 +327,51 @@ "PARTIAL": "部分", "FALLBACK": "备用", "{state} {layers}": "{state} {layers}", + "view band|STREET": "街道", + "view band|CITY": "城市", + "view band|METRO": "都市圈", + "view band|REGIONAL": "区域", + "view band|GLOBAL": "全球", "SUMMARY": "摘要", "Awaiting telemetry...": "等待遥测数据…", "REC": "REC", "AIS: --": "AIS: --", + "N/A": "无", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 视窗 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "邻近 {poi}({city}){distance}", "SECTOR {lat} {lon}": "扇区 {lat} {lon}", + "N": "北", + "S": "南", + "E": "东", + "W": "西", "POWER UP · {count} KEYS WAITING": { - "other": "启动 · {count} 个密钥待配置" + "other": "增强 · {count} 个密钥待配置" }, - "POWERED UP": "已启动", + "POWERED UP": "已增强", + "Provider Settings does not answer proxied requests": "服务商设置不响应经代理的请求", + "Provider Settings is disabled while sharing is enabled": "启用共享时,服务商设置不可用", + "Provider Settings answers only the machine running the server": "服务商设置只响应运行服务器的本机", + "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", + "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", + "Unrecognized Origin refused": "已拒绝无法识别的 Origin", + "Cross-origin requests are refused": "拒绝跨源请求", + "Content-Type must be application/json": "Content-Type 必须为 application/json", + "Method not allowed": "不允许的方法", + "Request too large": "请求过大", "Invalid JSON": "无效的 JSON", + "Body must be a JSON object of {ENV_VAR: value}": "请求体必须是 {ENV_VAR: value} 形式的 JSON 对象", + "No keys provided": "未提供密钥", + "At most {limit} keys per save": "每次最多保存 {limit} 个密钥", + "Unknown key: {name}": "未知密钥:{name}", + "{name} must be a string": "{name} 必须是字符串", + "{name} is empty": "{name} 为空", + "{name} is longer than any real key ({limit} max)": "{name} 比任何真实密钥都长(最多 {limit} 个字符)", + "{name} may only contain printable characters with no spaces": "{name} 只能包含可打印字符,且不能有空格", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} 包含密钥中不允许的字符(#、引号、$、\\ 或反引号)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服务商设置之外配置的,只能在原设置处修改", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "密钥未保存:无法将凭据文件限制为仅你的账户可访问;未保存任何内容", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "密钥未保存:无法读取现有配置,因此未做任何更改", + "Could not write the {store} store": "无法写入{store}存储", "Save failed ({status}).": "保存失败({status})。", "OAuth check failed: {detail}": "OAuth 检查失败:{detail}", "Could not check ChatGPT sign-in. Try again.": "无法检查 ChatGPT 登录状态,请重试。", @@ -206,6 +382,7 @@ "Use OPENAI_API_KEY for the next cloud voice session": "下次云语音会话使用 OPENAI_API_KEY", "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "下次云语音会话使用本机已登录的 ChatGPT/Codex OAuth 会话", "Metered — a billing-enabled account": "按量计费——需要启用结算的账号", + "Optional paid token — public access works without one": "可选的付费令牌——无需令牌也可公开访问", "Free key — register, paste, done": "免费密钥——注册、粘贴,即可使用", "browser-side": "浏览器端", "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密钥按设计在浏览器中运行——请在服务商处设置限制(见 SECURITY.md)", @@ -217,6 +394,7 @@ "paste {envVar}": "粘贴 {envVar}", "REMOVE": "移除", "Remove {title} from this app's saved keys": "从本应用已保存的密钥中移除 {title}", + "unsaved provider keys": "未保存的服务商密钥", "Project keys: {status}": "项目密钥:{status}", "your app configuration": "你的应用配置", "your local .env": "你的本地 .env", @@ -229,14 +407,15 @@ "Could not check ChatGPT sign-in.": "无法检查 ChatGPT 登录状态。", "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已为云语音选择 ChatGPT OAuth。你的 API 密钥仍会保留并可用。", "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登录已过期。正在重新打开登录…", - "Opening ChatGPT sign-in in your browser…": "正在在你的浏览器中打开 ChatGPT 登录…", + "Opening ChatGPT sign-in in your browser…": "正在浏览器中打开 ChatGPT 登录…", "Could not start ChatGPT sign-in on this machine.": "无法在此机器上启动 ChatGPT 登录。", "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "请在浏览器中完成 ChatGPT 登录,正在等待其完成…", "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登录超时。点击“{button}”重试。", "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登录完成。下次云语音会话将使用 OAuth。", "Remove this key from your saved configuration?": "要从已保存的配置中移除此密钥吗?", "Removed from {store}. Restarting — this page reloads itself.": "已从{store}中移除。正在重启——本页面将自动重新加载。", - "Places context + Street View fallback; optional separate key": "地点上下文 + 街景备用;可选的独立密钥", + "The photorealistic 3D planet, place search + Street View": "照片级 3D 地球、地点搜索 + 街景", + "Places context + Street View fallback; optional separate key": "地点信息 + 街景备用;可选的独立密钥", "Voice control — API key or ChatGPT OAuth": "语音控制——API 密钥或 ChatGPT OAuth", "Live ships, worldwide": "全球实时船舶", "Live active-fire detections": "实时活跃火点探测", @@ -244,51 +423,88 @@ "Bing imagery map stacks + world terrain": "Bing 影像地图组合 + 全球地形", "More flight-polling credits (anonymous works without)": "更多航班轮询额度(匿名也可使用)", "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像与街景图层中的覆盖范围。免费:在 Mapillary 开发者控制台注册一个应用,并粘贴其 Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公开的太空任务数据无需密钥。常规使用请留空;付费令牌只会提高请求上限。", + "Air quality request failed": "空气质量请求失败", + "No readings available": "暂无读数", + "No current readings": "暂无当前读数", + "{count} stations reporting": { + "other": "{count} 个站点上报数据" + }, + "Highest air-quality readings in view": "视野内空气质量读数最高的站点", "Low": "低", + "Low health risk": "低健康风险", + "Moderate": "中等", + "Moderate health risk": "中等健康风险", "High": "高", + "High health risk": "高健康风险", + "Very High": "极高", + "Very High health risk": "极高健康风险", + "Air quality": "空气质量", + "Canada only · ECCC AQHI network": "仅限加拿大 · ECCC AQHI 站网", + "ECCC request timed out": "ECCC 请求超时", + "Malformed AQHI station response": "AQHI 站点响应格式错误", + "No AQHI stations available": "没有可用的 AQHI 站点", + "Malformed AQHI observation response": "AQHI 观测响应格式错误", + "Camera source temporarily unavailable": "摄像头数据源暂时不可用", + "Mapped camera locations": "地图标注的摄像头位置", "SHOW NEAREST": "最近摄像头", "Stop following the current object before navigating to a camera": "请先停止跟踪当前目标,再前往摄像头", "Move to the nearest loaded camera and show its details": "前往最近已加载的摄像头并显示详情", "Camera badges": "摄像头徽标", "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "选中后青色摄像头变为珊瑚色。扇形仅示意已标绘方向,并非实测覆盖范围。附近的摄像头可能不在屏幕内。", + "{count} nearby · {onScreen} on screen": { + "other": "附近 {count} 个 · 屏幕上 {onScreen} 个" + }, + "{count} nearby": { + "other": "附近 {count} 个" + }, "retrying mapped ALPR cameras": "正在重试加载 ALPR 摄像头", "loading mapped ALPR cameras": "正在加载 ALPR 摄像头", - "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据 —— 仅覆盖美国和加拿大", - "Zoom in to load mapped cameras": "放大以加载标绘摄像头", + "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据——仅覆盖美国和加拿大", + "Zoom in to load mapped cameras": "放大以加载地图标注的摄像头", "Showing cached locations": "正在显示缓存位置", - "Coverage limited — zoom in": "覆盖受限 —— 请放大", + "Coverage limited — zoom in": "覆盖受限——请放大", "No ALPR data for this area": "此区域没有 ALPR 数据", - "None on screen — nearby cameras are outside the view": "屏幕上没有摄像头 —— 附近的摄像头在视野之外", + "None on screen — nearby cameras are outside the view": "屏幕上没有摄像头——附近的摄像头在视野之外", "ALPR camera": "ALPR 摄像头", + "Camera coverage unavailable": "摄像头覆盖不可用", + "OpenStreetMap · community mapped": "OpenStreetMap · 社区标注", + "Overpass returned an incomplete camera response": "Overpass 返回的摄像头数据不完整", "At least {count}": "至少 {count}", - "CONTEXT READY": "上下文就绪", - "GLOBAL CONTEXT OFF": "全局上下文已关闭", + "CONTEXT READY": "态势就绪", + "GLOBAL CONTEXT OFF": "全局态势已关闭", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择一个航班、船舶或地图标注设施", "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "启用以加载观测 / 地图标注的邻近信息", "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", - "Global Context navigation": "全局上下文导航", + "Global Context navigation": "全局态势导航", "Previous — prior visited contact in the 250 km window": "上一个:250 km 范围内先前访问过的目标", "PREVIOUS": "上一个", "FOCUS": "聚焦", "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", "NEXT": "下一个", - "Named areas ({n})": "指定区域({n})", - "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测上下文。缺失的广播、未加载的地图区域或未标注的站点不能作为不存在的证据。", + "Named areas ({n})": "已命名区域({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶范围", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测态势信息。缺失的广播、未加载的地图区域或未标注的设施不能作为不存在的证据。", + "mapped matches within {km} km of the subject": "所选对象 {km} km 内的地图标注匹配", + "none mapped within {km} km; not a complete 250 km survey": "{km} km 内无地图标注;并非完整的 250 km 普查", + "mapped matches from the loaded viewport": "来自已加载视口的地图标注匹配", + "viewport feed is not a complete 250 km survey": "视口数据源并非完整的 250 km 普查", "HDG {value}°": "HDG {value}°", "BRG {value}°": "BRG {value}°", "BRG —": "BRG —", "CRS {value}°": "CRS {value}°", "CURRENT VIEWPORT ONLY": "仅限当前视口", - "WITHIN {km} KM": "{km} KM 以内", + "WITHIN {km} KM": "{km} km 以内", "Flights": "航班", "Military flights": "军用航班", "AIS vessels": "AIS 船舶", "Mapped installations": "地图标注设施", "syncing {count} city feeds...": "正在同步 {count} 个城市数据源…", "scanning nearby systems...": "正在扫描附近系统…", + "GBFS fetch error": "GBFS 获取错误", "Station {id}": "站点 {id}", - "Station": "电台", + "Station": "站点", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 车桩 {docks} · 容量 {capacity}", "⚠️ Not installed": "⚠️ 未安装", "⚠️ Not renting": "⚠️ 暂停租车", @@ -297,25 +513,29 @@ "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} 个摄像头待命 · 未选择摄像头 · 点击摄像头以激活", "No cameras available in catalog.": "目录中没有可用摄像头。", "{city} CCTV": "{city} CCTV", + "FOV {value}° (DATASHEET)": "FOV {value}°(规格书)", "FOV {value}°": "FOV {value}°", + "SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)", "COVERAGE {value}km²": "覆盖 {value}km²", "OVERLAP {count} cams": "重叠 {count} 个摄像头", "ISOLATED VIEW": "孤立视角", - "PROJ MONITOR": "投影 监视器", - "PROJ OFF": "投影 关", + "PROJ MONITOR": "投影:监视器", + "PROJ OFF": "投影:关", "VIEWSHED": "可视域", "CAL {value}": "CAL {value}", "SRC {value}": "SRC {value}", - "{value} CONTEXT": "{value} 上下文", + "{value} CONTEXT": "{value} 态势", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 规格:CCTV-DATABASE.COM", "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "覆盖大西洋及北太平洋东部/中部;不含全球气旋。", "Cyclone advisories unavailable": "气旋公告不可用", + "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", "Subtropical storm": "亚热带风暴", "Subtropical depression": "亚热带低压", - "Track and cone match this advisory": "路径与锥体与本份公告一致", + "Track and cone match this advisory": "路径和锥体与本期公告一致", "Track/cone awaiting advisory {number}": "路径/锥体等待第 {number} 号公告", "Track/cone unavailable": "路径/锥体不可用", "Cached advisory · stale source": "缓存公告 · 数据源已过期", @@ -330,26 +550,103 @@ "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "气旋 · NHC / CPHC", "Atlantic · E/C Pacific": "大西洋 · 东/中太平洋", + "{storms} · {name} selected": "{storms} · 已选择 {name}", "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活跃的 NHC 与 CPHC 气旋公告", "Wind unavailable": "风场数据不可用", "Advisory center / forecast track": "公告中心 / 预报路径", "Center-track uncertainty cone": "中心路径不确定锥", - "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公报。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公告。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", "DRIVE": "驾车", "Drive": "驾车", "WALK": "步行", "Walk": "步行", "BIKE": "骑行", "Bike": "骑行", - "{error} — try again in a moment": "{error} — 请稍后重试", - "Routing is rate limited — try again in a moment": "路线请求过于频繁 — 请稍后重试", + "rate limited": "已限流", + "routing service is rate limited": "路线服务已限流", + "route leg too long": "路段过长", + "route too long": "路线过长", + "{error} — try again in a moment": "{error}——请稍后重试", + "Routing is rate limited — try again in a moment": "路线请求过于频繁——请稍后重试", "Routing unavailable (HTTP {status})": "路线服务不可用(HTTP {status})", "Routing unavailable": "路线规划不可用", - "Head out on {road}": "沿{road}出发", + "direction|left": "向左", + "direction|right": "向右", + "direction|slightly left": "向左前方", + "direction|slightly right": "向右前方", + "direction|sharply left": "向左后方", + "direction|sharply right": "向右后方", + "direction|straight": "向前", + "direction|around": "向后", + "fork direction|left": "靠左行驶", + "fork direction|right": "靠右行驶", + "fork direction|slightly left": "稍靠左行驶", + "fork direction|slightly right": "稍靠右行驶", + "fork direction|sharply left": "向左后方行驶", + "fork direction|sharply right": "向右后方行驶", + "fork direction|straight": "直行", + "lane direction|left": "左转", + "lane direction|right": "右转", + "lane direction|slightly left": "稍左转", + "lane direction|slightly right": "稍右转", + "lane direction|sharply left": "急左转", + "lane direction|sharply right": "急右转", + "lane direction|straight": "直行", + "ordinal|{number}st": "第 {number} 个", + "ordinal|{number}nd": "第 {number} 个", + "ordinal|{number}rd": "第 {number} 个", + "ordinal|{number}th": "第 {number} 个", + "Head out on {road}": "沿 {road} 出发", "Head out": "出发", + "Arrive at your destination, on the left": "到达目的地,目的地在左侧", + "Arrive at your destination, on the right": "到达目的地,目的地在右侧", "Arrive at your destination": "到达目的地", - "{mode} — reroute for {modeLower}": "{mode} — 按{modeLower}重新规划路线", + "Make a U-turn onto {road}": "掉头进入 {road}", + "Make a U-turn": "掉头", + "Continue straight onto {road}": "直行进入 {road}", + "Continue straight": "继续直行", + "Turn {direction} onto {road}": "{direction}转,进入 {road}", + "Turn {direction}": "{direction}转", + "Turn onto {road}": "转弯进入 {road}", + "maneuver|Turn": "转弯", + "Continue {direction} onto {road}": "继续{direction}行驶,进入 {road}", + "Continue {direction}": "继续{direction}行驶", + "At the end of the road, turn {direction} onto {road}": "在道路尽头{direction}转,进入 {road}", + "At the end of the road, turn {direction}": "在道路尽头{direction}转", + "At the end of the road, continue onto {road}": "在道路尽头继续行驶,进入 {road}", + "At the end of the road, continue": "在道路尽头继续行驶", + "Keep {direction} at the fork onto {road}": "在岔路口{direction},进入 {road}", + "Keep {direction} at the fork": "在岔路口{direction}", + "Continue at the fork onto {road}": "在岔路口继续行驶,进入 {road}", + "Continue at the fork": "在岔路口继续行驶", + "Merge {direction} onto {road}": "{direction}并道,进入 {road}", + "Merge {direction}": "{direction}并道", + "Merge onto {road}": "并入 {road}", + "maneuver|Merge": "并道", + "Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}", + "Take the ramp on the {direction}": "{direction}驶入匝道", + "Take the ramp onto {road}": "驶入匝道,进入 {road}", + "Take the ramp": "驶入匝道", + "Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}", + "Take the exit on the {direction}": "从出口{direction}驶出", + "Take the exit onto {road}": "从出口驶出,进入 {road}", + "Take the exit": "从出口驶出", + "At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}", + "At the roundabout, take the {exit} exit": "在环岛从{exit}出口驶出", + "Enter the roundabout onto {road}": "驶入环岛,进入 {road}", + "Enter the roundabout": "驶入环岛", + "At the roundabout, turn {direction} onto {road}": "在环岛{direction}转,进入 {road}", + "At the roundabout, turn {direction}": "在环岛{direction}转", + "At the roundabout, continue onto {road}": "在环岛继续行驶,进入 {road}", + "At the roundabout, continue": "在环岛继续行驶", + "Exit the roundabout onto {road}": "驶出环岛,进入 {road}", + "Exit the roundabout": "驶出环岛", + "Use the {direction} lane onto {road}": "使用{direction}车道,进入 {road}", + "Use the {direction} lane": "使用{direction}车道", + "Continue onto {road}": "继续行驶,进入 {road}", + "maneuver|Continue": "继续行驶", + "{mode} — reroute for {modeLower}": "{mode}——按{modeLower}重新规划路线", "CLICK MAP": "点击地图", "SET A": "设置 A", "Click a spot on the globe to place A (click again to cancel)": "点击地球上的位置放置 A(再次点击取消)", @@ -363,29 +660,33 @@ "FLY": "飞行", "Fly the camera along the route": "沿路线飞行相机", "Place A and B first": "请先放置 A 和 B", + "action|CLEAR": "清除", "Remove the route and both markers": "移除路线和两个标记点", - "press Escape twice to leave Draw": "按两次 Escape 退出绘图", - "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用 — {leave},然后设置 {endpoint}", + "press Escape twice to leave Draw": "按两次 Escape 退出“绘制”", + "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——请先关闭 {name},再设置 {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——请先{leave},再设置 {endpoint}", "Only the first {count} turns of this route are shown": "此路线仅显示前 {count} 个转向", "Turn-by-turn directions": "逐向导航路线", - "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击 — 关闭它,然后重新设置 A", + "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击——请先关闭它,再重新设置 A", "Routing…": "正在规划路线…", "No route found": "未找到路线", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 个转向", "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", "Click the globe to place {endpoint}": "点击地球放置 {endpoint}", "SET B, then click the globe": "设置 B,然后点击地球", "SET A, then click the globe": "设置 A,然后点击地球", + "Step {step} of {total} · then {leg}": "第 {step}/{total} 步 · 随后 {leg}", "Step {step} of {total}": "第 {step} 步,共 {total} 步", "Then: {instruction}": "接下来:{instruction}", "No route found between A and B": "在 A 和 B 之间未找到路线", "Routing failed: {error}": "路线规划失败:{error}", "Routing timed out": "路线规划超时", - "Routing unavailable — no response from the routing service": "路线服务无响应 — 路线规划不可用", + "Routing unavailable — no response from the routing service": "路线规划不可用——路线服务无响应", "Earthquake source unavailable": "地震数据源不可用", "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", "live feed unavailable": "实时数据不可用", "{value} conf": "置信度 {value}", - "{value} ago": "{value} 前", + "{value} ago": "{value}前", "sensor n/a": "传感器 n/a", "FIRE · {frp} MW": "火点 · {frp} MW", "NIGHT": "夜间", @@ -393,20 +694,62 @@ "FIRE": "火点", "FIRES": "火点", "max {value} MW": "最大 {value} MW", - "new {value}": "新增 {value}", + "new {value}": "最新 {value}前", + "{value}°N": "{value}°N", + "{value}°S": "{value}°S", + "{value}°E": "{value}°E", + "{value}°W": "{value}°W", "just now": "刚刚", "<1m ago": "不到 1 分钟前", "{count}m ago": "{count} 分钟前", "{count}h ago": "{count} 小时前", + "fire confidence|high": "高", + "fire confidence|nominal": "中", + "fire confidence|low": "低", + "age|<1h": "<1 小时", + "age|{count}h": "{count} 小时", + "age|{count}d": "{count} 天", "Fire · FRP {frp} MW": "火点 · FRP {frp} MW", - "STALE · cached {age}": "过期 · 缓存于 {age}", + "STALE · cached {age}": "过期 · {age}前缓存", "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", "LIVE · updated {age}": "实时 · 更新于 {age}", - "loading mapped installation context": "正在加载地图标注设施上下文", - "Too many mapped sites in view to list them all": "视野内标绘地点过多,无法全部列出", - "Installation context unavailable": "设施背景信息不可用", + "Source snapshot time unavailable": "来源快照时间不可用", + "Live data unavailable": "实时数据不可用", + "Low-accuracy score < 2%": "低精度评分 < 2%", + "Low-accuracy score 2–10%": "低精度评分 2–10%", + "Low-accuracy score > 10%": "低精度评分 > 10%", + "GNSS integrity source unavailable": "GNSS 完好性数据源不可用", + "Regional sample · 250 nm radius · cells from visited views over 30 min": "区域采样 · 半径 250 海里 · 30 分钟内已访问视图中的网格", + "Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试", + "Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视图中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", + "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度评分 = 100 × max(0, bad − 1) / total:扣除一架飞机,因此并非原始占比。每个网格需在 {minutes} 分钟内有 {aircraft} 架飞机。导航完好性异常不等于检测到干扰或欺骗。", + "GNSS snapshot needs a view anchor": "GNSS 快照需要视图锚点", + "GNSS feed rate limited (HTTP 429)": "GNSS 数据源已限流(HTTP 429)", + "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式错误", + "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少观测时间", + "installation class|AIRFIELD": "机场", + "installation class|NAVAL BASE": "海军基地", + "installation class|RANGE": "靶场", + "installation class|MILITARY LAND": "军事用地", + "installation class|BASE": "基地", + "installation class|BARRACKS": "营房", + "installation class|TRAINING AREA": "训练区", + "installation class|PLACES CANDIDATE": "Places 候选", + "installation class|INSTALLATION": "设施", + "No mapped sites within {km} km of the contact": "目标 {km} km 内无地图标注设施", + "{count} mapped sites within {km} km of the contact": { + "other": "目标 {km} km 内有 {count} 个地图标注设施" + }, + "No mapped sites in view": "视野内无地图标注设施", + "{count} mapped sites in view": { + "other": "视野内有 {count} 个地图标注设施" + }, + "loading mapped installation context": "正在加载地图标注设施信息", + "Too many mapped sites in view to list them all": "视野内地图标注设施过多,无法全部列出", + "Installation context unavailable": "设施信息不可用", "MAPPED INSTALLATION": "地图标注设施", "Unknown": "未知", "LAUNCH SITE · {site}": "发射场 · {site}", @@ -422,7 +765,7 @@ "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "下射程 {value} KM", + "{value} KM DOWNRANGE": "射程 {value} km", "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飞行", "REUSED": "复用", @@ -432,8 +775,8 @@ "DATE UNAVAILABLE": "日期不可用", "UNSPECIFIED OPERATOR": "运营方未指明", "Select {mission}": "选择 {mission}", - "{value} KM": "{value} KM", - "{value} KM/S": "{value} KM/S", + "{value} KM": "{value} km", + "{value} KM/S": "{value} km/s", "Selected Space Mission": "已选太空任务", "SELECTED SPACE MISSION": "已选太空任务", "Show all missions": "显示所有任务", @@ -463,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估下射程", + "EST. DOWNRANGE": "预估射程", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -475,13 +818,24 @@ "Ascent replay": "上升回放", "Orbit replay": "轨道回放", ", paused": ",已暂停", + "Malformed launch snapshot": "发射快照格式错误", "Heard by your receiver": "由你的接收机接收", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", "browser SDR": "浏览器 SDR", "decoder feed": "解码器数据源", "Your RTL-SDR receiver and decoder feed": "你的 RTL-SDR 接收机和解码器数据源", "Your decoder feed": "你的解码器数据源", "Your RTL-SDR receiver": "你的 RTL-SDR 接收机", "NO CALLSIGN": "无呼号", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} FT · 地速 {speed} KT · 航迹 {track}", + "V/S {rate}": "升降 {rate}", + "V/S {rate} FPM": "升降 {rate} FPM", + "POSITION {age}": "位置 {age}", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 条消息" + }, "Decoder feeds": "解码器数据源", "WebUSB + decoder feeds": "WebUSB + 解码器数据源", "opening receiver": "正在打开接收机", @@ -489,12 +843,25 @@ "WebUSB needs desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", "connect a receiver in Radio": "在电台面板连接接收机", "receiver is in FM mode": "接收机处于 FM 模式", - "listening": "聆听中", + "listening": "监听中", "USB error": "USB 错误", "Alt unknown": "高度未知", "Type unknown": "类型未知", "Reg unknown": "注册号未知", "Operator unknown": "运营方未知", + "${amount}K": "{amount} 千美元", + "${amount}M": "{amount} 百万美元", + "${amount}B": "{amount} 十亿美元", + "fire cause|Human": "人为", + "fire cause|Natural": "自然", + "fire cause|Undetermined": "待定", + "fire cause|Unknown": "未知", + "fire behavior|Minimal": "微弱", + "fire behavior|Moderate": "中等", + "fire behavior|Active": "活跃", + "fire behavior|Extreme": "极端", + "Type {level} Incident": "{level} 类事件", + "age|{count}m": "{count} 分钟", "{acres} ac": { "other": "{acres} 英亩" }, @@ -505,7 +872,7 @@ "other": "{personnel} 名人员" }, "{county} County": "{county} 县", - "{cost} to date": "迄今损失 {cost}", + "{cost} to date": "迄今扑救费用 {cost}", "discovered {age} ago": "发现于 {age}前", "updated {age} ago": "更新于 {age}前", "part of {complex}": "属于 {complex} 复合火场", @@ -518,19 +885,32 @@ "Under 50% contained": "控制低于 50%", "50–99% contained": "控制 50–99%", "Fully contained": "已全部控制", - "Colour shows reported containment. Perimeters are simplified to about 100 m.": "颜色表示报告的控制比例。周界简化至约 100 米。", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "颜色表示报告的控制比例。周界简化至约 100 m。", + "Malformed perimeter snapshot": "周界快照格式错误", + "radio tag|All": "全部", "News": "新闻", - "Talk": "谈话", + "Talk": "访谈", "Weather / Emergency": "天气 / 应急", "Public Safety": "公共安全", "Aviation / Marine": "航空 / 海事", - "Traffic / Transit": "交通 / 公交", + "Traffic / Transit": "交通 / 公共交通", "Music": "音乐", "Other": "其他", "Directory refresh degraded; showing the previous station catalog.": "目录刷新降级;正在显示上一次的电台目录。", "Radio directory coverage is degraded.": "电台目录覆盖已降级。", "Directory refresh failed; showing the previous station catalog.": "目录刷新失败;正在显示上一次的电台目录。", "Radio directory is temporarily unavailable.": "电台目录暂时不可用。", + "radio|NEWS": "新闻", + "radio|TALK": "访谈", + "radio|WEATHER": "天气", + "radio|SAFETY": "安全", + "radio|AIR / SEA": "航空 / 海事", + "radio|TRANSIT": "公共交通", + "radio|MUSIC": "音乐", + "radio|OTHER": "其他", + "Broadcaster stream is unavailable or blocked by the browser.": "广播流不可用,或被浏览器阻止。", + "Playback requires a direct click or tap.": "需直接点击或轻触才能播放。", + "Broadcaster stream could not be started.": "无法启动广播流。", "Alternative": "另类", "Ambient": "氛围", "Blues": "蓝调", @@ -556,7 +936,32 @@ "Techno": "Techno", "Trance": "Trance", "World": "世界音乐", + "overview": "概览", "cloud unknown": "云量未知", + "{percent}% scene cloud": "场景云量 {percent}%", + "{min}–{max}% scene cloud": "场景云量 {min}–{max}%", + "Comparison in use by another scene": "对比功能正被另一个场景占用", + "Esri map unavailable · no swipe": "Esri 地图不可用 · 无法滑动对比", + "Swipe needs a globe map": "滑动对比需要球体地图", + "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除后改用 Google 3D", + "Imagery catalog unavailable": "影像目录不可用", + "Catalog truncated · a newer clear day may exist": "目录已截断 · 可能还有更新的晴天", + "{label} catalog unavailable · {detail}": "{label} 目录不可用 · {detail}", + "Daily overview for today may still be empty until the pass": "卫星过境前,今天的每日概览可能仍为空", + "Box and images cleared": "框选及影像已清除", + "MORE DETAIL · 30 m": "更多细节 · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 与 Landsat,经 HLS,30 m,每 2–6 天", + "DAILY OVERVIEW · 250 m": "每日概览 · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日拼图,250 m,反映大范围变化;25 km 以下几乎没有细节", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2(经 HLS)", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9(经 HLS)", + "Daily overview": "每日概览", + "Box edges must be finite": "框选边界必须是有限值", + "Longitudes must be within ±180°": "经度必须在 ±180° 以内", + "Select one side of the dateline": "请只选择日界线的一侧", + "Box must enclose an area": "框选必须包含一定面积", "Add the full Starlink broadband shell (thousands of extra points)": "添加完整的 Starlink 宽带星座(数千个额外点位)", "Loading the Starlink shell…": "正在加载 Starlink 星座…", "Starlink {detail} — click to retry": "Starlink {detail}——点击重试", @@ -565,10 +970,66 @@ "DENSE ···": "密集 ···", "DENSE ✕": "密集 ✕", "DENSE": "密集", + "CelesTrak unreachable": "CelesTrak 无法连接", + "DOCKED · {name} · +{extra}": "对接 · {name} · +{extra}", "DOCKED · {name}": "对接 · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} 需要 Google 3D 地图:请在地图源(MAP SOURCE)中选择", + "zoom in to a street to open {name}": "放大到街道以打开 {name}", "KEY REJECTED": "密钥被拒绝", - "loading coverage...": "正在加载覆盖范围...", + "loading coverage...": "正在加载覆盖范围…", + "No street-level imagery within {radius} m of that point": "该点 {radius} m 范围内没有街景影像", + "No street-level imagery within {radius} m of the view centre": "视野中心 {radius} m 范围内没有街景影像", + "Coverage tile failed": "覆盖瓦片加载失败", + "Point the camera at the globe for street-level coverage": "将相机对准地球以查看街景覆盖范围", + "The photo could not be loaded": "照片无法加载", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切换。在 360° 照片上,拖动或使用 Shift+← →、↑ ↓ 环视,+ − 缩放", + "{name} street-level photo": "{name} 街景照片", + "Previous photo in this sequence (←)": "此序列中的上一张照片(←)", + "Next photo in this sequence (→)": "此序列中的下一张照片(→)", + "Aim the middle of the view at a street to look for imagery there": "将视野中心对准街道以查找该处影像", + "GOOGLE_MAPS_API_KEY is not set": "未设置 GOOGLE_MAPS_API_KEY", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 无法加载", + "Google Street View": "Google 街景", + "GOOGLE STREET VIEW": "Google 街景", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒绝了用于街景的 GOOGLE_MAPS_API_KEY——请检查该密钥已启用 Maps JavaScript API,且本站点在允许的引荐来源(referrer)中", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景无响应——请检查网络,并确认 GOOGLE_MAPS_API_KEY 已启用 Maps JavaScript API", + "click a street to open Street View": "点击街道以打开街景", + "Street View has no imagery for this place": "街景没有此地的影像", + "KartaView has no position for this photo": "KartaView 没有此照片的位置", + "The KartaView photo could not be loaded": "KartaView 照片无法加载", + "zoom in to see KartaView coverage": "放大以查看 KartaView 覆盖范围", + "no KartaView photos in view": "视野内没有 KartaView 照片", + "KartaView is rate-limiting requests — they resume on their own": "KartaView 正在限制请求频率——稍后会自动恢复", + "KartaView’s photos here are archived and cannot be shown": "KartaView 在此处的照片已归档,无法显示", + "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求", + "None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选", + "KartaView did not answer in time — try again": "KartaView 未及时响应——请重试", + "KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络", + "KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取", + "KartaView is rate-limiting requests": "KartaView 正在限制请求频率", + "KartaView sent too much data": "KartaView 返回的数据过多", + "KartaView is rate-limiting requests — try again in a moment": "KartaView 正在限制请求频率——请稍后重试", + "KartaView has no such photo": "KartaView 没有这张照片", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒绝了 MAPILLARY_CLIENT_TOKEN——请在服务商设置中更换", + "Coverage requests are being rate-limited — they resume on their own": "覆盖范围请求受到频率限制——稍后会自动恢复", + "The Panoramax photo could not be loaded": "Panoramax 照片无法加载", + "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆盖范围稍后会自动恢复", + "no Panoramax photos in view": "视野内没有 Panoramax 照片", + "no Panoramax photos here match the filters": "此处没有符合筛选条件的 Panoramax 照片", + "Panoramax sent a map tile that could not be read": "Panoramax 返回的地图瓦片无法读取", + "This Panoramax photo is no longer available": "这张 Panoramax 照片已不可用", + "zoom in to see Panoramax coverage": "放大以查看 Panoramax 覆盖范围", + "Panoramax did not answer in time — try again": "Panoramax 未及时响应——请重试", + "Panoramax could not be reached — check the connection": "无法连接 Panoramax——请检查网络连接", + "Panoramax sent too much data": "Panoramax 返回的数据过多", + "Panoramax is busy": "Panoramax 繁忙", + "Panoramax sent an unreadable answer": "Panoramax 返回的响应无法读取", + "No street-level imagery providers configured": "未配置街景影像提供商", + "{names}: by upload date": "{names}:按上传日期", "Selected": "所选", + "Open the Street Level panel to view imagery": "打开街景面板以查看影像", + "Image could not be opened": "图像无法打开", + "TeleGeography load failed": "TeleGeography 加载失败", "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", "Roads: {source}": "道路:{source}", "TomTom roads unavailable while the traffic service is unreachable": "交通服务不可达期间 TomTom 道路不可用", @@ -579,25 +1040,71 @@ "Partial coverage": "部分覆盖", "Detailed roads unavailable": "详细道路不可用", "Reduced detail coverage": "详细覆盖范围受限", + "TomTom roads only, each with its live flow": "仅 TomTom 道路,各自带实时路况", + "TomTom roads only, each with its live flow (needs a TomTom key)": "仅 TomTom 道路,各自带实时路况(需要 TomTom 密钥)", "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用时匹配 TomTom 路况", - "SIMULATED — {reason}": "模拟 — {reason}", - "Syncing flow · Roads: {source}": "正在同步流量 · 道路:{source}", - "LIVE · Roads: {source} · Roads without flow hidden": "实时 · 道路:{source} · 已隐藏无流量道路", - "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内无流量道路", - "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 流量 {percent}%", - "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 流量 {percent}%", - "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步流量 · 道路:OpenStreetMap · 流量:TomTom · 未匹配:模拟", - "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 流量:TomTom · 覆盖 {percent}% · 未匹配:模拟", - "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 流量:TomTom · 覆盖 {percent}%", - "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 流量:TomTom(无匹配)", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先 TomTom 道路;其余由 OpenStreetMap 补足,路况为模拟", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先 TomTom 道路;其余由 OpenStreetMap 补足,路况为模拟(需要 TomTom 密钥)", + "Hybrid": "混合", + "TomTom key unavailable": "TomTom 密钥不可用", + "TomTom daily budget reached": "已达 TomTom 每日配额", + "TomTom upstream unreachable": "TomTom 上游服务无法连接", + "TomTom flow unavailable": "TomTom 路况不可用", + "SIMULATED — {reason}": "模拟——{reason}", + "Syncing flow · Roads: {source}": "正在同步路况 · 道路:{source}", + "LIVE · Roads: {source} · Roads without flow hidden": "实时 · 道路:{source} · 已隐藏无路况道路", + "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内无路况道路", + "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%", + "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 路况:TomTom(无匹配)", "SIMULATED — traffic service unreachable": "模拟——交通服务无法连接", "SIMULATED — add TomTom key for live": "模拟——添加 TomTom 密钥以获取实时数据", - "Route {route}": "航线{route}", + "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片受到频率限制", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超时", + "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", + "occupancy|empty": "空车", + "occupancy|many seats available": "空座较多", + "occupancy|few seats available": "空座较少", + "occupancy|standing room only": "仅剩站位", + "occupancy|crushed standing room only": "拥挤,仅剩站位", + "occupancy|full": "已满", + "occupancy|not accepting passengers": "不载客", + "occupancy|not boardable": "无法上车", + "Route {route}": "线路 {route}", + "Vehicle {vehicle}": "车辆 {vehicle}", + "vehicle motion|Stopped": "停驶", + "No recent fix": "无近期定位", + "Waiting for update": "等待更新", + "Moving": "行驶中", + "reported {speed} km/h": "上报 {speed} km/h", + "reported hdg {heading}°": "上报航向 {heading}°", + "Last report: stopped at stop {stop}": "最近上报:停靠站点 {stop}", + "Last report: arriving at stop {stop}": "最近上报:即将到达站点 {stop}", + "Last report: next stop {stop}": "最近上报:下一站 {stop}", + "Shown at street level · depth not in the feed": "按地面高度显示 · 数据源未提供深度", + "Reported {seconds} s ago": "{seconds} 秒前上报", + "Reported {minutes} min ago": "{minutes} 分钟前上报", + "shown {seconds} s behind": "显示滞后 {seconds} 秒", + "{reported} (feed time)": "{reported}(数据源时间)", + "Available trail: {minutes} min (limited)": "可用轨迹:{minutes} 分钟(受限)", + "Available trail: {minutes} min": "可用轨迹:{minutes} 分钟", + "Bus": "公交车", + "Tram": "有轨电车", + "Subway": "地铁", + "Train": "火车", + "Ferry": "渡轮", + "Transit vehicle": "公共交通车辆", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", - "POS: LIVE": "位置:实时", - "POS: {time}Z": "位置:{time}Z", + "{speed}KT": "{speed} kn", + "{heading}DEG": "{heading}°", + "POS: LIVE": "POS:实时", + "POS: {time}Z": "POS:{time}Z", + "AIS live load failed": "AIS 实时数据加载失败", "awaiting first AIS position…": "等待首个 AIS 位置…", "awaiting usable AIS positions…": "等待可用的 AIS 位置…", "awaiting first AIS message…": "等待首条 AIS 消息…", @@ -607,32 +1114,44 @@ "feed disconnected": "数据源已断开", "feed down": "数据源中断", "feed idle": "数据源空闲", - "API key rejected — check AISSTREAM_API_KEY": "API 密钥被拒绝 — 请检查 AISSTREAM_API_KEY", - "feed silent {seconds}s — no AIS data": "数据源已静默 {seconds} 秒 — 无 AIS 数据", - "feed silent — no AIS data": "数据源已静默 — 无 AIS 数据", + "API key rejected — check AISSTREAM_API_KEY": "API 密钥被拒绝——请检查 AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "数据源已静默 {seconds} 秒——无 AIS 数据", + "feed silent — no AIS data": "数据源已静默——无 AIS 数据", + "feed down — retrying slowly (attempt {attempt})": "数据源中断——正在低频重试(第 {attempt} 次)", + "reconnecting to feed… (attempt {attempt})": "正在重新连接数据源…(第 {attempt} 次)", + "feed down — retrying slowly": "数据源中断——正在低频重试", + "reconnecting to feed…": "正在重新连接数据源…", + "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", "Hidden by this map source · choose a globe map": "已被当前地图源隐藏 · 请选择球体地图", + "Weather image HTTP {status}": "天气图像 HTTP {status}", + "Weather HTTP {status}": "天气 HTTP {status}", "Frame unavailable; previous observation retained": "帧不可用;保留上一帧观测", "Weather imagery unavailable": "天气影像不可用", "Weather source unavailable; previous observation retained": "天气数据源不可用;保留上一帧观测", "Weather unavailable": "天气数据不可用", - "{hours}h {minutes}m ago": "{hours} 小时 {minutes} 分前", + "No frame within {gap} min of {time}": "{time} 前后 {gap} 分钟内没有帧", + "No frame within {gap} h of {time}": "{time} 前后 {gap} 小时内没有帧", + "{hours}h {minutes}m ago": "{hours} 小时 {minutes} 分钟前", "{minutes}m ago": "{minutes} 分钟前", "{state} · {time} · {lag}": "{state} · {time} · {lag}", "Observed": "观测", "History": "历史", "Observation unavailable": "观测不可用", "Waiting for observation": "等待观测数据", + "{detail} · synced": "{detail} · 已同步", + "{detail} · nearest": "{detail} · 最近", "RADAR REFLECTIVITY · dBZ": "雷达反射率 · dBZ", "LIGHTNING DENSITY · 15 min accumulation": "闪电密度 · 15 分钟累计", "GLOBAL INFRARED · hourly": "全球红外 · 每小时", "GOES INFRARED · ~5 min": "GOES 红外 · 约 5 分钟", "{state}: {time}": "{state}:{time}", "Latest observation": "最新观测", + "frame {index}/{total}": "第 {index}/{total} 帧", "loading": "加载中", "Observation: unavailable": "观测:不可用", "Contiguous US · gaps ≠ no rain": "美国本土 · 无回波 ≠ 无降雨", "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", - "Color: strikes/km²/min ×10³": "颜色:次闪/km²/分钟 ×10³", + "Color: strikes/km²/min ×10³": "颜色:次闪击/km²/分钟 ×10³", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "North America · infrared imagery": "北美洲 · 红外影像", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", @@ -647,38 +1166,50 @@ "Stale source": "数据源已过期", "Loading next frame…": "正在加载下一帧…", "Map center outside coverage": "地图中心在覆盖范围之外", + "strikes/km²/min ×10³": "次闪击/km²/分钟 ×10³", "N. America": "北美", "Global": "全球", "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球拼图;通常延迟 2–3 小时", "GOES regional clouds; approximately 5-minute updates": "GOES 区域云图;约 5 分钟更新", "Clouds only": "仅云区", + "Apply the full cloud brightness key; 100% filter strength": "应用完整的云层亮度映射;滤镜强度 100%", "Full": "完整", + "Show the complete infrared image; 0% filter strength": "显示完整红外图像;滤镜强度 0%", "Soft": "柔和", "Vivid": "鲜明", "Image opacity; does not alter the observed values": "图像不透明度;不改变观测值", "View US radar": "查看美国雷达", "View Americas & Pacific": "查看美洲与太平洋", "View coverage": "查看覆盖范围", - "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次闪/km²/分钟 ×10³(15 分钟密度)", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次闪击/km²/分钟 ×10³(15 分钟密度)", "{value} dBZ radar reflectivity": "{value} dBZ 雷达反射率", "STALE · cached source metadata": "已过期 · 数据源元数据为缓存", "Map center is outside source coverage": "地图中心在数据源覆盖范围之外", "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分钟闪电密度,来源为 Vaisala NLDN/GLD360。覆盖自 110°E 经太平洋/美洲至 0°,25°S–80°N。不是实时闪击计数、不是全球覆盖,也不构成安全警示。", - "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据约 1 公里分辨率;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据分辨率约 1 km;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段长波红外区域图;NESDIS 全球长波拼图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", "REGION": "区域", "IMAGE": "图像", + "FILTER": "滤镜", + "Cloud filter strength": "云层滤镜强度", "OPACITY": "不透明度", + "Infrared decode failed": "红外图像解码失败", + "Weather prefetch unavailable": "天气预取不可用", "Weather tiles unavailable · previous frame retained": "天气瓦片不可用 · 保留上一帧", "Some weather tiles unavailable": "部分天气瓦片不可用", + "Malformed weather manifest": "天气清单格式错误", + "Weather request timed out": "天气请求超时", "Temperature field unavailable": "温度字段不可用", + "Mean sea level pressure field unavailable": "平均海平面气压场不可用", + "Wind upstream unavailable": "风场上游数据源不可用", + "{source} HTTP {status}": "{source} HTTP {status}", "Wind": "风", "Wind source unavailable": "风场数据源不可用", "Forecast · does not follow history": "预报 · 不随观测历史变化", "Wind motion": "风场运动", "Wind speed": "风速", "Sea-level pressure": "海平面气压", - "Air temperature · 2 m": "2 米气温", + "Air temperature · 2 m": "气温 · 2 m", "Global · 1° grid": "全球 · 1° 网格", "{model} forecast · {time}": "{model} 预报 · {time}", "Loading forecast": "正在加载预报", @@ -703,32 +1234,69 @@ "Read wind at map center": "读取地图中心风速", "Read the forecast at the center of the map without changing selection": "读取地图中心的预报值,不改变当前选择", "{model} forecast · {field} ({unit})": "{model} 预报 · {field}({unit})", - "Valid: {time}": "生效:{time}", + "Valid: {time}": "有效时间:{time}", + "preparing": "准备中", "Issued: {time}": "发布:{time}", "Selected field unavailable · wind remains visible": "所选字段不可用 · 风场仍可见", "{error} · wind remains visible": "{error} · 风场仍可见", - "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 米高度的地表风。约 1° 全球网格。流线沿 10 米风场绘制,为便于观看抬升至 12 公里;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。颜色字段贴附于球体底图或当前的照片级 3D Tiles。", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。颜色字段贴附于球体底图或当前的照片级 3D Tiles。", "MODEL": "模型", "FIELD": "字段", "UNITS": "单位", "MOTION": "运动", "Forecast": "预报", + "NNE": "北东北", + "NE": "东北", + "ENE": "东东北", + "ESE": "东东南", + "SE": "东南", + "SSE": "南东南", + "SSW": "南西南", + "SW": "西南", + "WSW": "西西南", + "WNW": "西西北", + "NW": "西北", + "NNW": "北西北", "No surface reading": "暂无地表读数", "Aim the map center at Earth": "请将地图中心对准地球", "A location reading needs a loaded forecast and the Earth at the center of the view.": "位置读数需要已加载的预报,且地图中心对准地球。", "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "按约 1° 网格插值的模式预报。反映大范围天气形势,不是街道级实测。", - " from {direction}": "从{direction}出发", + " · calm": " · 静风", + " from {direction}": ",{direction}风", "WIND AT {coordinates}": "{coordinates} 的风", - "{model} · valid {time}": "{model} · 生效 {time}", + "{model} · valid {time}": "{model} · 有效时间 {time}", + "Wind speed field unavailable": "风速场不可用", "Globe imagery unavailable": "球体影像不可用", "Globe field image unavailable": "球体字段图像不可用", + "Unknown wind model": "未知风场模式", + "Unknown weather overlay": "未知天气叠加层", + "Wind request timed out": "风场请求超时", + "Malformed wind manifest": "风场清单格式错误", + "Malformed wind grid": "风场网格格式错误", "Error: {message}": "错误:{message}", "Needs {envVars} — add it in Provider Settings": "需要 {envVars},请在服务商设置中添加", "{label} is unavailable": "{label} 不可用", "This map stack": "此地图组合", "{label} unavailable: {hint}": "{label} 不可用:{hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用 — 请检查密钥的 API 限制、配额或网络", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D 瓦片不可用——请检查密钥的 API 限制、配额或网络", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "需要 {envVars}(请在服务商设置中添加),或使用 Cesium ion 令牌走 ion 托管通道", + "Exit cockpit to fly to a camera": "请先退出座舱,再飞往摄像头", + "Exit cockpit to fly to a location": "请先退出座舱,再飞往该地点", + "Exit cockpit to fly to a photo": "请先退出座舱,再飞往照片", + "Exit cockpit to fly to a shared view": "请先退出座舱,再飞往分享的视图", + "Exit cockpit to fly to a target": "请先退出座舱,再飞往目标", + "Exit cockpit to fly to a vessel": "请先退出座舱,再飞往船舶", + "Exit cockpit to fly to a fire": "请先退出座舱,再飞往火点", + "Exit cockpit to fly to a route": "请先退出座舱,再飞往路线", + "Exit cockpit to fly to a file": "请先退出座舱,再飞往文件位置", + "Exit cockpit to fly to a scene": "请先退出座舱,再飞往场景", + "Exit cockpit to view weather coverage": "请先退出座舱,再查看天气覆盖范围", + "Exit cockpit to fly to a cyclone": "请先退出座舱,再飞往气旋", + "Exit cockpit to fly to an aircraft": "请先退出座舱,再飞往飞机", + "Exit cockpit to fly to a satellite": "请先退出座舱,再飞往卫星", + "Exit cockpit to frame contacts": "请先退出座舱,再取景目标", "Visible map targets": "可见的地图目标", + "Focusing {label}": "正在聚焦 {label}", "Source": "来源", "Camera ownership changed": "相机所有权已变更", "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的项目无法读取;存储已保留。请导入有效文件以恢复保存。", @@ -782,11 +1350,41 @@ "Thermal Threat Board": "热源威胁看板", "City Overload": "城市过载", "Omniscience Pullback": "全知拉远", + "Global Incident Context": "全球事件背景", + "Nepal-Focused Globe Rotation": "聚焦尼泊尔的地球旋转", + "Bhote Koshi Regional Approach": "Bhote Koshi 区域接近", + "Bhote Koshi Nearby Cities": "Bhote Koshi 周边城市", + "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", + "Bhote Koshi Flood Path": "Bhote Koshi 洪水路径", + "Bhote Koshi Corridor Overview": "Bhote Koshi 走廊概览", + "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", + "Upper Valley Collapse": "上游河谷崩塌", + "Debris-Dammed Lake": "碎屑堰塞湖", + "Nepal-China Border Gate": "尼中边境口岸", + "Timure Evidence Cluster": "Timure 证据群", + "Syabru Besi Passage": "Syabru Besi 通道", + "Bidur / Trishuli Consequence": "Bidur / Trishuli 后果", + "Final view": "最终视图", "Scene {n}": "场景 {n}", "Shot {n}": "镜头 {n}", + "the title of scene {scene} is empty or longer than {max} characters": "场景 {scene} 的标题为空或超过 {max} 个字符", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "场景 {scene} 中镜头 {shot} 的标题为空或超过 {max} 个字符", + "the project is larger than 5 MiB": "项目大于 5 MiB", + "the project has too many shots": "项目中的镜头过多", + "the scene data failed validation": "场景数据未通过校验", "Could not complete this action. Check the file, references and selected assets.": "无法完成此操作。请检查文件、引用和所选资源。", + "{count} scenes": { + "other": "{count} 个场景" + }, + "{count} shots": { + "other": "{count} 个镜头" + }, + "{count} data packs": { + "other": "{count} 个数据包" + }, "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", "Unavailable layers: {layers}": "不可用图层:{layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已注册内容或链接媒体。这些外部文件不包含在场景资源包中。其原始声明仍然适用。", "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校验 {bytes} 个打包字节。文件仅保留在本次会话的内存中。重新加载应用后请再次导入资源包。", "Review scene import": "审查场景导入", "Nothing is loaded or changed until you apply this file.": "在你应用此文件之前,不会载入或更改任何内容。", @@ -815,35 +1413,115 @@ "Or choose a data-pack folder": "或选择数据包文件夹", "Download asset bundle": "下载资源包", "Asset bundle downloaded": "资源包已下载", + "No RTL-SDR device selected": "未选择 RTL-SDR 设备", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB 需要在 localhost/HTTPS 上使用桌面版 Chrome 或 Edge", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "RTL-SDR 访问失败;请关闭其他 SDR 应用并检查 USB 权限", + "RTL-SDR connection failed": "RTL-SDR 连接失败", + "Receiving 1090 MHz ADS-B": "正在接收 1090 MHz ADS-B", + "Receiving broadcast FM": "正在接收广播 FM", + "Connect an RTL-SDR to begin.": "连接 RTL-SDR 即可开始。", + "WebUSB is unavailable in this browser": "此浏览器不支持 WebUSB", + "USB work abandoned after a stalled transfer": "USB 传输停滞,已放弃操作", + "SDR decoder worker failed": "SDR 解码器工作线程失败", + "SDR decoder did not start; receiver stopped": "SDR 解码器未能启动;接收机已停止", + "SDR decoder stopped responding; receiver stopped": "SDR 解码器停止响应;接收机已停止", + "SDR decoder fell behind; receiver stopped": "SDR 解码器处理滞后;接收机已停止", + "WebUSB requires desktop Chrome or Edge": "WebUSB 需要桌面版 Chrome 或 Edge", + "Waiting for an RTL-SDR device…": "正在等待 RTL-SDR 设备…", + "Browser audio is suspended; press Connect again to resume it": "浏览器音频已挂起;再次按“连接”以恢复", + "RTL-SDR sample stream stopped": "RTL-SDR 采样流已停止", + "Switching to {mode}…": "正在切换到 {mode}…", + "Browser audio is suspended; select FM again to resume it": "浏览器音频已挂起;再次选择 FM 以恢复", + "RTL-SDR gain change failed": "RTL-SDR 增益调整失败", + "Tuned to {mhz} MHz FM": "已调谐到 FM {mhz} MHz", + "Seeking up…": "正在向上搜台…", + "Seeking down…": "正在向下搜台…", + "Seek stopped": "搜台已停止", + "Scanning {mhz} MHz…": "正在扫描 {mhz} MHz…", + "Found {mhz} MHz · {snr} dB": "找到 {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "在 {mhz} MHz 找到 FM 信号", + "No FM signal found": "未找到 FM 信号", + "Browser location is unavailable": "浏览器位置不可用", + "Receiver location was not granted": "未授予接收机位置权限", + "RTL-SDR disconnected": "RTL-SDR 已断开", + "Upstream response too large": "上游响应过大", + "Vector tile request timed out": "矢量瓦片请求超时", + "Vector tiles unavailable": "矢量瓦片不可用", + "Vector tile metadata unavailable": "矢量瓦片元数据不可用", + "Zoom in for vector tile coverage": "放大以查看矢量瓦片覆盖", + "Saturation": "饱和度", + "Edge Thickness": "描边粗细", + "Contrast": "对比度", + "Grain": "颗粒", + "Vignette": "暗角", + "Pixelation": "像素化", + "Distortion": "畸变", + "Instability": "不稳定", "Density": "密度", "Gain": "增益", "Bloom": "泛光", + "Scanlines": "扫描线", + "Sensitivity": "灵敏度", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "unsaved CCTV calibration": "未保存的 CCTV 校准", + "the open cockpit": "当前打开的座舱", + "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切换语言会重新加载页面。以下内容将会丢失:{items}。仍要切换吗?", "VISUAL PRESETS": "视觉预设", "LOCATION": "位置", - "CONTACTS — nearest planes · vessels · sites": "目标:最近的飞机 · 船舶 · 站点", + "CONTACTS — nearest planes · vessels · sites": "目标:最近的飞机 · 船舶 · 设施", "SPACE MISSIONS — launches & orbital assets": "太空任务:发射与在轨资产", "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", "Copy failed": "复制失败", + "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", + "Reset map to north up. Current heading {heading} degrees": "将地图重置为朝北。当前航向 {heading} 度", + "Tilted view": "倾斜视角", + "Straight-down view": "垂直俯视", + "North up": "朝北", "CCTV calibration saved": "CCTV 校准已保存", + "HDG": "HDG", + "PITCH": "俯仰", + "FOV": "FOV", "RANGE": "范围", - "{value}m": "{value}分钟", + "HGT": "高度", + "ΔN": "ΔN", + "ΔE": "ΔE", + "{value}m": "{value} m", "CCTV calibration reset": "CCTV 校准已重置", "ADJUST ON": "调整开", "ADJUST": "调整", + "SNAPSHOT": "快照", "LIVE": "实时", + "UPSTREAM": "上游", + "SYNTHETIC": "合成", + "CONFIGURED": "已配置", + "SEED": "种子", "UNKNOWN": "未知", "ERROR": "错误", + "Live HLS connected": "实时 HLS 已连接", + "Live HLS unavailable": "实时 HLS 不可用", + "No stream URL configured": "未配置流 URL", + "Live stream connected": "实时流已连接", + "Snapshot feed connected": "快照数据源已连接", + "Media fetch failed": "媒体获取失败", + "Upstream snapshot active": "上游快照已启用", + "Fallback Street View frame": "备用街景画面", + "Upstream unavailable": "上游不可用", + "No source configured": "未配置来源", "Configured source": "已配置数据源", "Configured Source": "已配置数据源", + "Synthetic fallback": "合成备用", + "Upstream HTTP {status}": "上游 HTTP {status}", + "Unexpected media type {type}": "意外的媒体类型 {type}", "SOURCE · UNKNOWN": "来源 · 未知", "FRAME · LOADING": "画面 · 加载中", "FRAME · UNAVAILABLE": "画面 · 不可用", "CALIBRATED": "已校准", "CURATED": "已整理", - "RAW PRIOR": "原始默认", + "RAW PRIOR": "初始估计", "CCTV ON": "CCTV 开", "CCTV OFF": "CCTV 关", "VIEWSHED ON": "可视域开", @@ -857,14 +1535,14 @@ "CAL · {badge}": "CAL · {badge}", "MONITOR": "监视器", "OFF": "关", - "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 范围 {range}m · {projection}{calBadge} · {provider}{status}", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · 范围 {range} m · {projection}{calBadge} · {provider}{status}", "{count} cameras loaded · click a camera to activate": { "other": "已加载 {count} 个摄像头 · 点击摄像头以激活" }, "{count} cameras loaded · enable CCTV to activate": { "other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活" }, - "Enable CCTV to load camera intersections": "启用 CCTV 以加载摄像头路口", + "Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头", "Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。", "No summary available.": "暂无摘要。", "loading frames": "正在加载画面", @@ -884,7 +1562,7 @@ "MIXED CONDITIONS": "混合天气", "CYCLE ON": "轮换开", "CYCLE OFF": "轮换关", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及 SIG/NEWS/LOCAL 标签仍可使用。", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及“信号”“新闻”“本地”标签仍可使用。", "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自动轮换简报页面(信号 → 新闻 → 本地)。悬停或聚焦面板时暂停。无论是否轮换,实时信号数据都会持续刷新。", "ACQUIRING REGIONAL NEWS": "正在获取地区新闻", "REGIONAL NEWS UNAVAILABLE": "地区新闻不可用", @@ -892,12 +1570,15 @@ "REGION UNAVAILABLE": "区域不可用", "NO RECENT LOCATION MATCHES": "暂无近期位置匹配结果", "{domain} · {age}": "{domain} · {age}", + "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "云量 {pct}%", "CLOUD UNKNOWN": "云量未知", "{source} · LOCATION QUERY": "{source} · 位置查询", "REGIONAL NEWS": "地区新闻", - "CONTEXT STANDBY": "上下文待机", - "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局上下文以获取邻近 PING", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空中 / 海上范围", + "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", + "CONTEXT STANDBY": "态势待机", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以获取邻近提示", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", "CONTACT LOST · {subject}": "目标丢失 · {subject}", "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目标已脱离其数据源 · 读数保持最后已知值", @@ -912,6 +1593,9 @@ "other": "{count} 项输入未知 · 不代表安全无虞" }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用输入均为最新 · 不代表安全无虞", + "SQUAWK {code} · {status}": "应答码 {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 优先 · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B 紧急 · {status}", "AIRCRAFT": "飞机", "ACQUIRING SURFACE": "正在获取地面数据", "SURFACE FALLBACK": "地面数据备用", @@ -930,7 +1614,7 @@ "Night vision": "夜视", "Thermal": "热成像", "Anime": "动漫", - "Noir": "黑色电影", + "Noir": "黑白", "Snow": "雪景", "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。激活可切换到下一个样式。", "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", @@ -942,9 +1626,10 @@ "Expand cockpit briefing panel": "展开座舱简报面板", "Collapse briefing panel": "收起简报面板", "Expand briefing panel": "展开简报面板", - "OBSERVED / MAPPED PINGS": "观测 / 地图标注 PING", + "OBSERVED / MAPPED PINGS": "观测 / 地图标注提示", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻", "LATEST LOCATION-MATCHED REPORTING": "最新的位置匹配报道", + "{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期", "LOCAL INFO": "本地信息", "PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置", "TIME UNKNOWN": "时间未知", @@ -966,7 +1651,7 @@ "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已锁定航迹", "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", - "Context could not restore every layer; try again": "上下文未能恢复所有图层,请重试", + "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { "other": "{count} 个数据图层无法清除" @@ -980,18 +1665,40 @@ "Nearby installations refreshed": "附近设施已刷新", "Nearby installations could not be refreshed; try again": "无法刷新附近设施,请重试", "Space Missions cancellation could not restore the previous layer state": "取消太空任务后无法恢复先前的图层状态", - "That layer is unavailable in the current Context mode": "该图层在当前上下文模式下不可用", + "That layer is unavailable in the current Context mode": "该图层在当前态势模式下不可用", "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", "start": "启动", "stop": "停止", "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", "Space Missions": "太空任务", - "Context": "上下文", + "Context": "态势", "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", + "imported map files": "已导入的地图文件", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "无法刷新地面高度。请在地图加载完成后重新尝试该文件。", + "{count} map objects": { + "other": "{count} 个地图对象" + }, + ", drawn with its own styles": ",使用其自带样式绘制", + "Hide {name}": "隐藏 {name}", "Show {name}": "显示 {name}", - "Fly to {name}": "飞往{name}", + "Fly to {name}": "飞往 {name}", + "Remove {name}": "移除 {name}", + "At most {count} files at once — remove one first.": "一次最多 {count} 个文件——请先移除一个。", + "Reading {name}…": "正在读取 {name}…", + "That file could not be read.": "无法读取该文件。", + "Added {count} files.": { + "other": "已添加 {count} 个文件。" + }, + "{count} files could not be added ({name}: {error})": { + "other": "{count} 个文件无法添加({name}:{error})" + }, + "Some KML content was left out.": "部分 KML 内容未被导入。", "Press on the ground, not the sky": "请按在地面,而不是天空", "Drag to size the box": "拖动以调整框选大小", + "light {source} to use your {name} key": "开启 {source} 以使用你的 {name} 密钥", + "light {source}": "开启 {source}", + ", or ": ",或", + "{requirement}, or {offer}": "{requirement},或{offer}", "Live Flights": "实时航班", "Military Flights": "军用航班", "Earthquakes (24h)": "地震(24 小时)", @@ -1003,32 +1710,39 @@ "Bikeshare": "共享单车", "Live AIS Vessels": "实时 AIS 船舶", "Mapped Installations": "地图标注设施", - "Global Context": "全局上下文", + "Global Context": "全局态势", "Datacenters": "数据中心", "Dams": "水坝", "Submarine Cables": "海底电缆", "FIRMS Active Fires": "FIRMS 活跃火点", "Local ADS-B": "本地 ADS-B", - "Transit": "交通", + "Transit": "公共交通", "Recent Imagery": "近期影像", "Fire Perimeters": "火场周界", + "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", "Cyclone advisories": "气旋公告", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", - "Bike Share": "公共自行车", - "Cameras": "相机", + "Bike Share": "共享单车", + "Cameras": "摄像头", "Street Level": "街景", "Mapped ALPR Cameras": "ALPR 摄像头", "Data Centers": "数据中心", "Active Fires": "活跃火点", "Military aircraft": "军用飞机", + "Vessels": "船舶", + "CCTV + Street View fallback": "CCTV + 街景备用", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + 可选 Google Maps Places", "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · 预报", "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · 观测", + "NASA FIRMS · LIVE": "NASA FIRMS · 实时", + "OSM routing": "OSM 路线规划", + "data source|Local": "本地", "ENABLING": "启用中", "DISABLING": "停用中", - "Movement": "移动", + "Movement": "动态目标", "Infrastructure": "基础设施", "Events": "事件", "Weather": "天气", @@ -1038,7 +1752,7 @@ "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不确定 · {source} · 生命周期状态需要核对", "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · {seconds} 秒后重试", "{state} · {source} · {detail}": "{state} · {source} · {detail}", - "never": "从未", + "never": "未更新", "{source} · {loadingLabel}": "{source} · {loadingLabel}", "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 条记录", "incomplete snapshot": "快照不完整", @@ -1047,20 +1761,33 @@ "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", "UNCERTAIN": "不确定", + "status chip|UNAVAILABLE": "不可用", "Data source unavailable": "数据源不可用", "{name}: {state}": "{name}:{state}", "{count}s ago": "{count} 秒前", + "Local FM stopped: internet radio started": "本地 FM 已停止:网络电台已开始播放", + "IDLE": "空闲", "CONNECTING": "连接中", "TUNING": "调谐中", - "STREAMING": "正在流式接收", + "STREAMING": "接收中", + "UNSUPPORTED": "不支持", + "idle": "空闲", + "running": "运行中", + "suspended": "已挂起", + "closed": "已关闭", + "feed status|live": "实时", + "feed status|stale": "过期", + "feed status|unreachable": "无法访问", + "feed status|invalid": "无效", "waiting for IQ": "等待 IQ 数据", "{count} messages": { "other": "{count} 条消息" }, "DSP {count} blocks": { - "other": "DSP {count} 个模块" + "other": "DSP {count} 个数据块" }, "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音频 {level} dBFS", "audio signal --": "音频信号 --", "audio {state}": "音频 {state}", "{count} HEARD": { @@ -1072,7 +1799,12 @@ "LOCATED": "已定位", "LOCATE": "定位", " · read while Local ADS-B is on": " · 仅在本地 ADS-B 开启时读取", + "Decoder feeds: {feeds}": "解码器数据源:{feeds}", + "Language: {language}": "语言:{language}", + "Change the interface language": "更改界面语言", "Interface language": "界面语言", + "Search languages": "搜索语言", + "No matching languages": "没有匹配的语言", "📍 Location: --": "📍 位置:--", "Landmark: --": "地标:--", "Searched location": "已搜索的位置", @@ -1093,7 +1825,9 @@ "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", "Collapse Radio section": "收起电台区域", + "Double-click the header to snap the panel back": "双击标题栏可将面板复位", "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", + "Drag to resize · double-click the header to snap back": "拖动以调整大小 · 双击标题栏复位", "Austin": "奥斯汀", "San Francisco": "旧金山", "New York": "纽约", @@ -1103,6 +1837,7 @@ "Dubai": "迪拜", "Washington DC": "华盛顿特区", "Tallinn": "塔林", + "Los Angeles": "洛杉矶", "Collapse Cockpit display options": "收起座舱显示选项", "Expand Cockpit display options": "展开座舱显示选项", "Collapse Cockpit Radio controls": "收起座舱电台控件", @@ -1116,8 +1851,8 @@ "STATION UNAVAILABLE": "电台不可用", "Station unavailable after directory refresh": "目录刷新后该电台不可用", "Radio could not {action} cleanly": "电台未能正常{action}", - "Go to expanded Radio section": "前往展开的电台分区", - "Expand Radio section in Context": "在态势面板中展开电台分区", + "Go to expanded Radio section": "前往展开的电台区域", + "Expand Radio section in Context": "在态势面板中展开电台区域", "Close compact Radio controls": "关闭精简电台控件", "Open compact Radio controls": "打开精简电台控件", "SYNC": "同步", @@ -1128,7 +1863,7 @@ "Disable Radio": "停用电台", "Enable Radio": "启用电台", "DIRECTORY BAND": "目录频段", - "{category} BAND": "{category}频段", + "{category} BAND": "{category} 频段", "NO STATION SELECTED": "未选择电台", "Directory metadata only": "仅目录元数据", "Loading station directory…": "正在加载电台目录…", @@ -1174,7 +1909,12 @@ "Newest frame per product": "每个产品的最新一帧", "Replay observed history": "回放观测历史", "Imagery on Esri · Google 3D returns when cleared": "影像来自 Esri · 清除后恢复 Google 3D", + "Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野", + "That point cannot anchor a box": "该点无法作为框选锚点", + "Imagery stops at ±{limit}° latitude": "影像仅覆盖到纬度 ±{limit}°", "{owner} is using the pointer — close it first": "{owner} 正在使用指针——请先将其关闭", + "Box is {size} wide · limit {limit}": "框选宽 {size} · 上限 {limit}", + "View is {size} wide · limit {limit}": "视野宽 {size} · 上限 {limit}", "VS BASEMAP": "对比底图", "A / B": "A / B", "Newest low-cloud day for this box · scene cloud, not box cloud": "此框选最新的低云日 · 云量为场景云量,而非框内云量", @@ -1187,6 +1927,8 @@ "newest overview": "最新概览", "No imagery": "无影像", "Loading": "加载中", + "coverage|full": "完整", + "coverage|partial": "部分", "unknown": "未知", "no imagery": "无影像", "{percent}% cloud": "{percent}% 云量", @@ -1215,7 +1957,7 @@ "Trade the two sides of the divider": "互换分割线两侧", "SELECT BOX": "框选区域", "Drag a box on the map (Esc cancels)": "在地图上拖出框选(Esc 取消)", - "USE VIEW": "使用当前视野", + "USE VIEW": "使用视野", "Use the current view as the box": "以当前视野作为框选", "Forget the box and its images": "清除框选及其影像", "START HERE": "从此开始", @@ -1225,19 +1967,24 @@ "SHOW": "显示", "Pin {day} as {slot}": "将 {day} 钉为 {slot}", "Show {day}": "显示 {day}", + "pinned {slot}": "已钉为 {slot}", + "shown": "显示中", "preview": "预览", "start here": "从此开始", "No box": "无框选", "Searching": "搜索中", "Every day is empty here": "此处每天都为空", "Sources off": "数据源未开启", + "VS": "对比", "Not set": "未设置", "Basemap": "底图", + "Source off": "数据源已关", "Unpin {slot}": "取消钉住 {slot}", "Unpin the image": "取消钉住图像", "EXPORT {slot}": "导出 {slot}", "EXPORT": "导出", "Download {slot} as a PNG": "将 {slot} 下载为 PNG", + "Download the image as a PNG": "将图像下载为 PNG", "Box {width} × {height} km": "框选 {width} × {height} km", "Acquired {time}": "获取于 {time}", "Coverage {coverage}": "覆盖 {coverage}", @@ -1264,14 +2011,29 @@ "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", "BALANCED": "均衡", - "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“采集镜头”保存当前画面。", + "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“截取镜头”保存当前画面。", "Double-click to rename": "双击重命名", "Shot name": "镜头名称", "LOAD": "载入", "DEL": "删除", + "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", + "Choose files": "选择文件", + "{count} files selected": { + "other": "已选择 {count} 个文件" + }, + "No file chosen": "未选择文件", "Cancel": "取消", "EDIT DETAILS": "编辑详情", "SHARE SCENE": "分享场景", + "Shared flight follow expired": "共享的航班跟随已过期", + "Shared military flight follow expired": "共享的军用航班跟随已过期", + "Shared satellite follow expired": "共享的卫星跟随已过期", + "Shared flight could not be restored — feed unavailable": "无法恢复共享的航班——数据源不可用", + "Shared military flight could not be restored — feed unavailable": "无法恢复共享的军用航班——数据源不可用", + "Shared satellite could not be restored — feed unavailable": "无法恢复共享的卫星——数据源不可用", + "Shared flight is unavailable": "共享的航班不可用", + "Shared military flight is unavailable": "共享的军用航班不可用", + "Shared satellite is unavailable": "共享的卫星不可用", "entity": "实体", "Shared {subject} follow expired": "共享的 {subject} 跟随已过期", "Shared {subject} could not be restored — feed unavailable": "无法恢复共享的 {subject}——数据源不可用", @@ -1284,9 +2046,9 @@ "LOAD CANCELLED": "加载已取消", "LOAD FAILED": "加载失败", "LIVE DATA OFF": "实时数据已关闭", - "MAPPED SITES LOADED": "已加载地图站点", - "RETRYING MAPPED SITES": "正在重试地图站点", - "FETCHING MAPPED SITES": "正在获取地图站点", + "MAPPED SITES LOADED": "已加载地图标注设施", + "RETRYING MAPPED SITES": "正在重试地图标注设施", + "FETCHING MAPPED SITES": "正在获取地图标注设施", "RETRYING ALPR CAMERAS": "正在重试 ALPR 摄像头", "FETCHING ALPR CAMERAS": "正在获取 ALPR 摄像头", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", @@ -1301,11 +2063,48 @@ "retry pending": "等待重试", "retrying in {seconds}s": "{seconds} 秒后重试", "ALPR cameras": "ALPR 摄像头", + "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客户端缺失/无效", + "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒绝了凭据", + "OpenSky username/password rejected": "OpenSky 用户名/密码被拒绝", + "OpenSky auth missing": "OpenSky 缺少认证", + "OpenSky auth required": "OpenSky 需要认证", + "OpenSky auth invalid": "OpenSky 认证无效", + "OpenSky auth failed": "OpenSky 认证失败", + "KartaView answered HTTP {status}": "KartaView 返回 HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax 返回 HTTP {status}", + "KartaView refused the request (code {code})": "KartaView 拒绝了请求(代码 {code})", + "Vector tiles unavailable (HTTP {status})": "矢量瓦片不可用(HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS 数据源不可用(HTTP {status})", + "{count} CelesTrak groups unavailable": { + "other": "{count} 个 CelesTrak 分组不可用" + }, + "{feed} feed unavailable": "{feed} 数据源不可用", + "Source snapshot {minutes} min old": "数据源快照:{minutes} 分钟前", + "{source} rate limited (anonymous)": "{source} 已限流(匿名)", + "{source} rate limited": "{source} 已限流", + "{source} network error": "{source} 网络错误", + "Malformed {source} aircraft rows": "{source} 飞机数据行格式错误", + "Malformed {source} response": "{source} 响应格式错误", + "entry {n}": "条目 {n}", + "feeds {names} stale": { + "other": "数据源 {names} 过期" + }, + "feeds {names} unreachable": { + "other": "数据源 {names} 无法连接" + }, + "feeds {names} invalid": { + "other": "数据源 {names} 无效" + }, "Loading {feeds}": "正在加载 {feeds}", "{count} heard": { "other": "已收到 {count} 架" }, + "USB {rate} msg/s": "USB {rate} msg/s", "{rate} msg/s": "{rate} msg/s", + "{count} feeds live": { + "other": "实时数据源 {count} 个" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", "Street Level could not start": "街景未能启动", "Street Level toggle failed": "街景切换失败", "Street-level image": "街景图像", @@ -1332,15 +2131,38 @@ "{name} imagery off": "{name} 影像已关闭", "Image by {creator}": "图像作者:{creator}", "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", - "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在地图源(MAP SOURCE)中选择 Google 3D", + "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在“地图源”中选择 Google 3D", + " or ": " 或 ", + "OPEN {labels}": "打开 {labels}", + "Open {names} at the centre of the view": "在视野中心打开 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此处打开 {names}", "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", "Loading this sequence…": "正在加载此序列…", "{n} images in this sequence · Esc clears": { "other": "此序列共 {n} 张图像 · Esc 清除" }, + "{n} streets in view · click a line for its nearest photo": { + "other": "视野内共 {n} 条街道 · 点击线段查看最近的照片" + }, + "{n} sequences in view · click a line for its nearest photo": { + "other": "视野内共 {n} 条序列 · 点击线段查看最近的照片" + }, "{n} sequences in view · click a line for its photos": { "other": "视野内共 {n} 条序列 · 点击线段查看其照片" }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "小时", + "min": "分钟", + "s": "秒", + "dB": "dB", "Celestial ring — reveal the full globe": "天球环——显示完整地球", "Celestial ring — available in Normal style": "天球环——仅在常规样式下可用", "Detection overlay: {mode}": "探测叠加层:{mode}", @@ -1350,9 +2172,16 @@ "Active weather": "活跃天气", "LATEST · newest per product": "最新 · 每个产品的最新一帧", "Forecast · valid {valid} · issued {issued}": "预报 · 生效 {valid} · 发布 {issued}", + "{detail} · Does not follow history": "{detail} · 不随观测历史变化", "Forecast · valid {valid}": "预报 · 生效 {valid}", + "voice|OFF": "关", + "voice|CONNECTING": "连接中", + "voice|LISTENING": "聆听中", + "voice|EXECUTING": "执行中", + "voice|ERROR": "错误", "AI AGENT": "AI 代理", "Voice model tier — applies next session": "语音模型档位——下次会话生效", + "STD": "标准", "Estimated session cost": "预估会话费用", "Voice control — activate to toggle voice; hold Space to speak": "语音控制——激活可切换语音;按住空格键说话", "ON/OFF": "开/关", @@ -1363,13 +2192,14 @@ "VOICE": "语音", "Dismiss voice card": "关闭语音卡片", "YOU": "用户", - "THIS": "此目标", + "THIS": "目标", "Plan": "计划", "Referenced items": "引用条目", "NOTES & SOURCES": "笔记与来源", "VOICE SYSTEM ERROR": "语音系统错误", "DISMISS": "关闭", "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", + "Stopped by voice": "已通过语音停止", "WebRTC microphone support unavailable": "不支持 WebRTC 麦克风", "Requesting microphone": "正在请求麦克风", "Release Space to send": "松开空格键发送", @@ -1379,32 +2209,99 @@ "VOICE UNAVAILABLE": "语音不可用", "VOICE ACTIVE": "语音已启用", "Voice session could not be started.": "无法启动语音会话。", + "MINI": "迷你", "Next session: {pendingId} — this session stays on {modelId}": "下次会话:{pendingId}——本次会话仍使用 {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "语音模型:{pendingId}——点击切换到 {target};下次会话生效", + "standard": "标准", + "mini": "迷你", "COST UNKNOWN": "费用未知", "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 会话:已报告 {responses} 个响应、{input} 个输入 token、{output} 个输出 token、{captions} 次字幕转写。", "USD cost and the API spend cap are unavailable for this auth mode.": "此认证模式下无法提供美元费用与 API 消费上限。", "Usage is incomplete because a response was still in flight when the session ended.": "用量不完整,因为会话结束时仍有响应在处理中。", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "{modelId} 的预估会话费用——{responses} 次响应。达到 {warn} 时警告,达到 {cap} 时结束会话。", - "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整 — 会话结束时仍有响应在处理中,因此其用量从未上报。", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "估算不完整——会话结束时仍有响应在处理中,因此其用量从未上报。", + "STANDARD": "标准", "{tier} applies next session": "{tier} 将在下次会话生效", "Session ended — cost cap {cost}": "会话已结束——已达费用上限 {cost}", + "MIC": "麦克风", "Radio did not start": "电台未能启动", "Running command": "正在执行指令", + "{n} aircraft": { + "other": "{n} 架飞机" + }, + "{n} military aircraft": { + "other": "{n} 架军用飞机" + }, + "{n} ships": { + "other": "{n} 艘船舶" + }, + "{n} satellites": { + "other": "{n} 颗卫星" + }, + "{n} launches": { + "other": "{n} 次发射" + }, + "{n} earthquakes": { + "other": "{n} 次地震" + }, "{n} fires": { "other": "{n} 处火点" }, + "{n} fire perimeters": { + "other": "{n} 处火场周界" + }, + "{n} cyclones": { + "other": "{n} 个气旋" + }, + "{n} buses": { + "other": "{n} 辆公交车" + }, + "{n} bike share": { + "other": "{n} 个共享单车站点" + }, + "{n} flock cameras": { + "other": "{n} 个 Flock 摄像头" + }, + "{n} military bases": { + "other": "{n} 处军事基地" + }, + "{n} datacenters": { + "other": "{n} 个数据中心" + }, + "{n} dams": { + "other": "{n} 座水坝" + }, + "{n} air quality": { + "other": "{n} 个空气质量站点" + }, + "{n} results": { + "other": "{n} 条结果" + }, "anywhere in the loaded data": "已加载数据中的任意位置", "in view": "视野内", + "within {radius} of the view centre": "视野中心 {radius} 范围内", + "within {radius} of {place} (Contacts window)": "{place} {radius} 范围内(目标窗口)", + "within {radius} of {place}": "{place} {radius} 范围内", + "within {radius}": "{radius} 范围内", "over {region}": "{region} 范围内", "At least {items}": "至少 {items}", "{items} {scope}": "{items} {scope}", + "last 24 h": "过去 24 小时", + "past 30 d and upcoming": "过去 30 天及未来", + "the flights layer loads by viewport": "航班图层按视口加载", + "distance is ground distance, not overhead": "距离为地面距离,而非空中直线距离", + "the count is a lower bound because at least one layer reached its retained limit.": "该计数为下限,因为至少一个图层已达到其保留上限。", "{label} off": "{label} 已关闭", + "{label} loading": "{label} 加载中", + "{label} error": "{label} 出错", "counted the first {returned} loaded records": "已统计最先加载的 {returned} 条记录", "counted {returned} of {total} loaded records": "已统计已加载 {total} 条记录中的 {returned} 条", - "Fly below {km} km to a covered region": "请飞行至 {km} 公里以下的覆盖区域", + "{layers} just turned on — still loading, counts will rise": "{layers} 刚开启——仍在加载,计数还会增加", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目标面板快照({time})——卡片、语音播报和后续追问记忆均来自同一组保留目标", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目标面板快照——卡片、语音播报和后续追问记忆均来自同一组保留目标", + "Fly below {km} km to a covered region": "请降至 {km} km 以下并飞往有覆盖的区域", "No feed here yet · {n} regions available": { - "other": "此区域暂无线路 · 有 {n} 个覆盖区域" + "other": "此处暂无数据源 · 有 {n} 个覆盖区域" }, "degraded": "降级", "stale": "过期", @@ -1416,21 +2313,72 @@ "Activate to toggle voice": "点击切换语音", "Voice active": "语音已启用", "Voice could not be started.": "无法启动语音。", + "the active voice session": "当前语音会话", "{value} feet": "{value} 英尺", + "The flights layer is not enabled": "航班图层未开启", + "The military layer is not enabled": "军用航班图层未开启", + "The satellites layer is not enabled": "卫星图层未开启", + "The ais-live-vessels layer is not enabled": "ais-live-vessels 图层未开启", + "Could not enable the requested layer": "无法开启所请求的图层", + "Could not disable the requested layer": "无法关闭所请求的图层", + "Radio layer unavailable": "电台图层不可用", + "Camera navigation is unavailable in the current view": "当前视图下无法进行相机导航", + "Camera navigation policy unavailable": "相机导航策略不可用", + "Nearest-aircraft selection supports Flights or Military Flights only": "最近飞机选择仅支持航班或军用航班", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "最近飞机选择需要预设、地名或经纬度", + "Could not arrive at the requested place": "无法抵达所请求的地点", + "The nearest airborne aircraft could not be selected": "无法选中最近的空中飞机", + "No flights within {radius} of the current view": "当前视野 {radius} 范围内没有航班", + "No military within {radius} of the current view": "当前视野 {radius} 范围内没有军用航班", + "No satellites within {radius} of the current view": "当前视野 {radius} 范围内没有卫星", + "No vessels within {radius} of the current view": "当前视野 {radius} 范围内没有船舶", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已在 {source} 数据源上开启,但 {place} 视野中尚未加载空中飞机", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "{layer} 已开启,但 {place} 视野中尚未加载空中飞机", + "{layer} is enabled, but its aircraft feed is unavailable": "{layer} 已开启,但其飞机数据源不可用", + "{layer} is enabled, but its destination refresh did not complete": "{layer} 已开启,但目的地刷新未完成", + "{layer} is enabled, but {source} is unavailable": "{layer} 已开启,但 {source} 不可用", + "Could not arrive at {place}": "无法抵达 {place}", + "{name} is no longer loaded": "{name} 已不在加载数据中", + "Unknown target layer: {target}": "未知目标图层:{target}", "nominal": "正常", "{label} on": "{label} 已开启", + "Frame aircraft": "取景飞机", + "Frame military aircraft": "取景军用飞机", + "Frame ships": "取景船舶", + "Frame satellites": "取景卫星", + "Frame contacts": "取景目标", + "{count} aircraft in frame": { + "other": "画面中 {count} 架飞机" + }, + "{count} military aircraft in frame": { + "other": "画面中 {count} 架军用飞机" + }, + "{count} ships in frame": { + "other": "画面中 {count} 艘船舶" + }, + "{count} satellites in frame": { + "other": "画面中 {count} 颗卫星" + }, + "{count} contacts in frame": { + "other": "画面中 {count} 个目标" + }, + "Nearest military aircraft": "最近的军用飞机", + "Nearest ships": "最近的船舶", + "Nearest satellites": "最近的卫星", + "Nearest aircraft": "最近的飞机", + "{first} and {second}": "{first}和{second}", "Layer": "图层", "there": "那里", - "Camera range {range}": "相机距离{range}", - "Within {radius} of the view": "距当前视野{radius}内", + "Camera range {range}": "相机距离 {range}", + "Within {radius} of the view": "距当前视野 {radius} 内", "Labels on": "标签已开启", "Framing uses the nearest 80 loaded contacts": "取景仅使用已加载的最近 80 个目标", - "{distance} from {location}": "{distance} 自 {location}", - "Altitude {altitude}": "高度{altitude}", + "{distance} from {location}": "距 {location} {distance}", + "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", - "from {origin}": "从{origin}出发", - "to {destination}": "前往{destination}", - "Registration {registration}": "注册号{registration}", + "from {origin}": "从 {origin} 出发", + "to {destination}": "前往 {destination}", + "Registration {registration}": "注册号 {registration}", "View state": "视野状态", "Marked {count} places": { "other": "已标记 {count} 个地点" @@ -1438,10 +2386,42 @@ "Nothing marked": "没有标记", "Not found: {places}": "未找到:{places}", "Tracing outlines": "正在描画边界", - "Frame {target}": "取景{target}", + "Frame flights": "取景航班", + "Frame military": "取景军用航班", + "Frame vessels": "取景船舶", + "Frame {target}": "取景 {target}", + "Zoom out to globe": "缩小至地球视图", + "Open data layers": "打开数据图层", + "Open or close a panel": "打开或关闭面板", + "Change context mode": "切换态势模式", + "Control cockpit": "控制座舱", + "Change visual style": "切换视觉样式", + "Adjust HUD": "调整 HUD", + "Adjust cyber sonar": "调整赛博声呐", + "Adjust detection overlay": "调整探测叠加层", + "Change map stack": "切换地图组合", + "Adjust post-processing": "调整后期处理", + "Control scene": "控制场景", + "Control CCTV": "控制 CCTV", + "Control radio": "控制电台", + "Track contact": "跟踪目标", + "Stop tracking": "停止跟踪", + "Clear marks": "清除标注", + "Move camera": "移动相机", + "Fly route": "沿路线飞行", + "Analyst query": "分析查询", + "Next ISS pass": "下一次 ISS 过境", + "Next satellite pass": "下一次卫星过境", "Mark the map": "在地图上标注", - "Fly to {place}": "飞往{place}", - "Nearest aircraft to {place}": "距离{place}最近的飞机", + "Mark {targets} +{more}": "标注 {targets} +{more}", + "Mark {targets}": "标注 {targets}", + "Fly to {place}": "飞往 {place}", + "Fly to Location": "飞往地点", + "Nearest aircraft to {place}": "距离 {place} 最近的飞机", + "Nearest aircraft to Here": "距离此处最近的飞机", + "Layer on": "图层已开启", + "Layer off": "图层已关闭", + "Zoom out": "缩小", "Zoom in": "放大", "Read the view": "读取视野", "Check view state": "检查视野状态", @@ -1465,13 +2445,13 @@ "DONE": "完成", "HERE": "此处", "Aircraft cockpit view": "飞机座舱视图", - "LEVEL": "等级", + "LEVEL": "水平", "Estimated destination direction": "预估目的地方向", "DEST ---°": "目的地 ---°", "OPTICAL PLANE · 01": "光学平面 · 01", "VISOR LOCK · ACTIVE": "面罩锁定 · 激活", - "GROUND SPEED · KTS": "地速 · KTS", - "ALTITUDE · FT": "高度 · FT", + "GROUND SPEED · KTS": "地速 · kn", + "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人称", "LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准", "Cockpit vision style": "座舱视觉样式", @@ -1484,15 +2464,21 @@ "Next vision style": "下一个视觉样式", "Current aircraft heading": "飞机当前航向", "GROUND SPEED": "地速", + "KTS": "kn", "ALTITUDE": "高度", + "FT": "ft", "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", - "CONTACTS · 250 KM": "目标 · 250 KM", + "CONTACTS · 250 KM": "目标 · 250 km", "Contact navigation": "目标导航", "CONTEXT ONLY": "仅供参考", "Nearby cohort counts": "附近各类目标数量", + "FLT": "航班", + "MIL": "军用", + "SITE": "设施", "NEAREST OBSERVED / MAPPED": "最近的观测 / 地图标注目标", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "仅限可用输入 · 不代表安全无虞", + "WX": "天气", "Cockpit briefing carousel": "座舱简报轮播", "Estimated flight plan": "预估飞行计划", "ESTIMATED FLIGHT PLAN": "预估飞行计划", @@ -1508,8 +2494,9 @@ "WIND": "风", "SKY": "天空", "PRECIP": "降水", + "MM": "mm", "Weather data by Open-Meteo.com": "天气数据由 Open-Meteo.com 提供", - "Cockpit briefing pages": "驾驶舱简报页面", + "Cockpit briefing pages": "座舱简报页面", "SIG": "信号", "Show Live Signals": "显示实时信号", "NEWS": "新闻", @@ -1542,7 +2529,7 @@ "Simulate night-vision goggles with green intensification and a tube vignette.": "模拟夜视仪,带绿色增强和镜筒暗角。", "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。", "Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。", - "Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白黑色电影调色。", + "Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白电影风格调色。", "Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的雪天白茫茫效果。", "MAP SOURCE": "地图源", "Map source": "地图源", @@ -1557,24 +2544,24 @@ "WEATHER": "天气", "Active weather products": "活跃天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "影像盒内的天数", - "CONTEXT": "上下文", + "Imagery days in the box": "框选内的影像日期", + "CONTEXT": "态势", "Compact Radio controls": "精简电台控件", "Open detailed Radio controls": "打开详细电台控件", "Previous station": "上一个电台", "Next station": "下一个电台", "Compact Radio volume": "精简电台音量", "Expand panel": "展开面板", - "Context mode": "上下文模式", + "Context mode": "态势模式", "CONTACTS": "目标", "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "循环切换你所选类型中最近的目标,包括飞机、船舶、设施。卫星独立跟踪。", "SPACE MISSIONS": "太空任务", - "SELECT CONTEXT": "选择上下文", - "Contact Context actions": "目标上下文操作", + "SELECT CONTEXT": "选择态势", + "Contact Context actions": "目标态势操作", "COCKPIT": "座舱", - "SEARCH NEARBY SITES": "搜索附近站点", + "SEARCH NEARBY SITES": "搜索附近设施", "Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B", - "CONTACTS CONTEXT OFF": "目标上下文已关闭", + "CONTACTS CONTEXT OFF": "目标态势已关闭", "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "选择目标以加载观测 / 地图标注的邻近信息", "Available Space Missions": "可用太空任务", "AVAILABLE MISSIONS": "可用任务", @@ -1610,7 +2597,7 @@ "GAIN": "增益", "RTL-SDR tuner gain for the current mode": "当前模式下的 RTL-SDR 调谐器增益", "AUTO": "自动", - "MSG/S": "消息/秒", + "MSG/S": "msg/s", "HEARD": "已收到", "POSITIONED": "已定位", "FM MHZ": "FM MHz", @@ -1628,7 +2615,7 @@ "Minimal": "精简", "Cyber": "赛博", "Sonar": "声呐", - "Cyber sonar contact scanner": "赛博声呐接触扫描器", + "Cyber sonar contact scanner": "赛博声呐目标扫描器", "Rings": "圈数", "Sonar ring count": "声呐圈数", "Range": "范围", @@ -1636,8 +2623,8 @@ "Power": "强度", "Sonar intensity": "声呐强度", "Opacity": "不透明度", - "Cyber contact and label opacity": "赛博接触与标注不透明度", - "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "声呐扫描期间地图点、图标、模型、括标与标注的最低不透明度", + "Cyber contact and label opacity": "赛博目标与标签不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "两次声呐扫描之间地图点、图标、模型、括标与标签的最低不透明度", "Sector": "扇区", "Sonar sector width": "声呐扇区宽度", "Detection Overlay (D)": "探测叠加层(D)", @@ -1663,23 +2650,26 @@ "Feather": "羽化", "Scope edge feather": "视域边缘羽化", "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以锁孔半径的百分比表示", - "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上绘制 — 单击顶点,双击或按 Enter 完成,按 Esc 取消", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击顶点,双击或按 Enter 完成,按 Esc 取消", "Shape": "形状", "Shape to draw": "要绘制的形状", "Area": "面", "Line": "线", - "Pin": "标记", + "Pin": "图钉", "Label (optional)": "标签(可选)", "Label for the drawn shape": "绘制形状的标签", "Colour of the drawn shape": "绘制形状的颜色", - "Primary": "主色", - "Amber": "琥珀", + "Primary": "主题色", + "Amber": "琥珀色", "Cyan": "青色", "Green": "绿色", "Red": "红色", - "Clear": "晴", + "Clear": "清除", "Remove every mark from the board": "清除图板上的所有标记", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上显示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件内容仅保留在本设备上;叠加层可能出现在共享给语音的地图截图中。", "Import": "导入", + "Choose GeoJSON, KML, KMZ or GPX files": "选择 GeoJSON、KML、KMZ 或 GPX 文件", + "Imported files": "已导入的文件", "Celestial": "天球", "Hide UI chrome": "隐藏界面控件", "Clean UI": "纯净界面", @@ -1692,6 +2682,7 @@ "Return UI controls": "恢复界面控件", "Initializing photorealistic world...": "正在初始化逼真世界…", "DATA LAYERS": "数据图层", + "ACQUIRING FRAME": "正在获取画面", "NEAREST": "最近", "CCTV camera": "CCTV 摄像头", "CALIBRATION": "校准", @@ -1708,6 +2699,7 @@ "RESET CAL": "重置校准", "SCENE SUMMARY": "场景摘要", "STREET LEVEL": "街景", + "Turn Street Level on or off": "开启或关闭街景", "Expand the street-level view (Esc shrinks it)": "放大街景视图(Esc 缩小)", "Image fit": "图像适配", "FIT": "适应", @@ -1727,7 +2719,7 @@ "Panorama filter": "全景筛选", "ALL": "全部", "FLAT": "平面", - "SINCE": "时间范围", + "SINCE": "时间", "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", @@ -1738,10 +2730,10 @@ "EXPORT PRESETS": "导出预设", "IMPORT": "导入", "RUN LOG": "运行日志", - "POWER UP": "启动", + "POWER UP": "增强", "GROUND STATION · PROVIDER SETTINGS": "地面站 · 服务商设置", "Close key setup": "关闭密钥设置", - "Power up the globe": "启动地球", + "Power up the globe": "增强地球", "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球无需密钥即可运行。下面的每个密钥都会启用一个新的真实数据源:粘贴后会保存到本应用的本地配置,随后服务器自动重启。服务端密钥只保存在本机;Google Maps 和 Cesium ion 在浏览器中运行,必须设置服务商限制。你在其他位置配置的密钥会显示,但不会被改动。", "SAVE KEYS": "保存密钥", "ESC to close": "按 ESC 关闭", @@ -1764,5 +2756,9 @@ "Begin with a clean globe": "从干净的地球开始", "Don't show this again": "不再显示", "ESC to dismiss": "按 ESC 关闭", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:停靠栏中的 GEV MIC 按钮可让你用语音操控地图。" + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:停靠栏中的 GEV 麦克风按钮可让你用语音操控地图。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "hemisphere|W": "W" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..821b154b9 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -98,6 +98,9 @@ "fire confidence|nominal", "ft", "hPa", + "hemisphere|E", + "hemisphere|N", + "hemisphere|S", "installation class|BASE", "km", "km/h", @@ -133,5 +136,60 @@ "REC", "VS", "WHOT/BHOT" + ], + "zh-CN": [ + "1090 MHz", + "978 MHz UAT", + "AIS: --", + "AIS: {name}", + "BRG {value}°", + "BRG —", + "CAL {value}", + "CAL · {badge}", + "CCTV", + "CRS {value}°", + "CRT", + "FLIR", + "FOV", + "FOV {value}°", + "FT", + "GEO", + "HDG", + "HDG {value}°", + "House", + "Ironbow", + "KTS", + "Landsat 8/9", + "MM", + "NVG", + "REC", + "SRC {value}", + "Sentinel-2", + "Techno", + "Trance", + "USB {rate} msg/s", + "WHOT/BHOT", + "dB", + "ft", + "hPa", + "hemisphere|E", + "hemisphere|N", + "hemisphere|S", + "hemisphere|W", + "km", + "km/h", + "kt", + "kts", + "mph", + "{city} CCTV", + "{rate} msg/s", + "{source} HTTP {status}", + "{speed} kn", + "{type} SPD: {speed} HDG: {heading}", + "{value} KM", + "{value} KM/S", + "ΔE", + "ΔN", + "▲ {frp} MW" ] } diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 35b473e42..7f6d11f9b 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['ru', 'en', 'es'], + ['ru', 'en', 'es', 'zh-CN'], ); assert.deepEqual( codes( @@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['es', 'en', 'ru'], + ['es', 'en', 'ru', 'zh-CN'], ); assert.deepEqual( codes( @@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => { nativeNames: LOCALE_NAMES, }), ), - ['en', 'es', 'ru'], + ['en', 'es', 'ru', 'zh-CN'], ); const rows = buildLocaleOptions({ locales: MANY, @@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () => assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.equal(model.activeOption.code, 'en'); model.keydown({ key: 'End' }); - assert.equal(model.activeOption.code, 'es'); + assert.equal(model.activeOption.code, 'zh-CN'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css new file mode 100644 index 000000000..c44431520 --- /dev/null +++ b/src/ui/styles/cjk.css @@ -0,0 +1,29 @@ +/* Chinese text. The UI sets Latin capitals with wide tracking; Chinese set + that way falls apart into single glyphs, so its tracking stays tight. The + intel HUD readouts (codes, coordinates) and text kept in English on purpose + (translate="no", and whatever holds it) keep the Latin look. */ +html:lang(zh) + body + :not( + #intel-hud, + #intel-hud *, + [translate='no'], + [translate='no'] *, + :has([translate='no']) + ) { + letter-spacing: 0.02em !important; +} + +/* Below about 9px a Chinese glyph blurs: lift the dock's voice labels and the + panel layer buttons to that floor. */ +html:lang(zh) #command-dock .gev-mic-label, +html:lang(zh) #command-dock #gev-voice-detail, +html:lang(zh) .gev-voice-tier-btn, +html:lang(zh) .panel-layer-toggle { + font-size: 9px; +} + +/* A two-glyph button label must not break between its glyphs. */ +html:lang(zh) .pp-mode-btn { + white-space: nowrap; +} diff --git a/style.css b/style.css index 44b7e07bc..8c4599e89 100644 --- a/style.css +++ b/style.css @@ -27,4 +27,5 @@ @import './src/ui/styles/weather.css'; @import './src/ui/styles/panel-surfaces.css'; +@import './src/ui/styles/cjk.css'; @import './src/ui/styles/cyber.css'; From f3fa1fa0227b7519c29834d42d3e79ae202e77d2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:54:55 +0400 Subject: [PATCH 057/178] i18n(zh-CN): translate the strings from the latest main Gemini Live voice and the street-level coverage hints; drop the entries the code no longer uses. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-CN.json | 73 ++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0a56bc76a..a83d35cc0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -980,7 +980,6 @@ "No street-level imagery within {radius} m of that point": "该点 {radius} m 范围内没有街景影像", "No street-level imagery within {radius} m of the view centre": "视野中心 {radius} m 范围内没有街景影像", "Coverage tile failed": "覆盖瓦片加载失败", - "Point the camera at the globe for street-level coverage": "将相机对准地球以查看街景覆盖范围", "The photo could not be loaded": "照片无法加载", "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name} 照片。← 和 → 沿序列切换。在 360° 照片上,拖动或使用 Shift+← →、↑ ↓ 环视,+ − 缩放", "{name} street-level photo": "{name} 街景照片", @@ -1026,7 +1025,6 @@ "Panoramax sent an unreadable answer": "Panoramax 返回的响应无法读取", "No street-level imagery providers configured": "未配置街景影像提供商", "{names}: by upload date": "{names}:按上传日期", - "Selected": "所选", "Open the Street Level panel to view imagery": "打开街景面板以查看影像", "Image could not be opened": "图像无法打开", "TeleGeography load failed": "TeleGeography 加载失败", @@ -1825,9 +1823,7 @@ "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", "Collapse Radio section": "收起电台区域", - "Double-click the header to snap the panel back": "双击标题栏可将面板复位", "Panel layout updated — positions reset to new defaults": "面板布局已更新——位置已重置为新的默认值", - "Drag to resize · double-click the header to snap back": "拖动以调整大小 · 双击标题栏复位", "Austin": "奥斯汀", "San Francisco": "旧金山", "New York": "纽约", @@ -2107,7 +2103,6 @@ "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", "Street Level could not start": "街景未能启动", "Street Level toggle failed": "街景切换失败", - "Street-level image": "街景图像", "SHRINK": "缩小", "EXPAND": "展开", "Shrink": "缩小", @@ -2130,13 +2125,10 @@ "{name} imagery on": "{name} 影像已开启", "{name} imagery off": "{name} 影像已关闭", "Image by {creator}": "图像作者:{creator}", - "Camera follows view: move the globe camera wherever the street-level view looks": "相机跟随视野:街景看向哪里,地球相机就移到哪里", - "Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "相机跟随需要 Google 3D 地图:请在“地图源”中选择 Google 3D", " or ": " 或 ", "OPEN {labels}": "打开 {labels}", "Open {names} at the centre of the view": "在视野中心打开 {names}", "Zoom in to a street to open {names} here": "放大到街道以在此处打开 {names}", - "Switch a provider on to draw its coverage.": "开启一个提供商以绘制其覆盖范围。", "Loading this sequence…": "正在加载此序列…", "{n} images in this sequence · Esc clears": { "other": "此序列共 {n} 张图像 · Esc 清除" @@ -2700,14 +2692,11 @@ "SCENE SUMMARY": "场景摘要", "STREET LEVEL": "街景", "Turn Street Level on or off": "开启或关闭街景", - "Expand the street-level view (Esc shrinks it)": "放大街景视图(Esc 缩小)", "Image fit": "图像适配", "FIT": "适应", "Fit: show the whole photo, with dark bars where its shape differs from the frame": "适应:显示整张照片,比例与画框不同处留黑边", "FILL": "填充", "Fill: fill the frame edge to edge, cropping the photo": "填充:图像铺满画框并裁剪照片", - "Camera follows view": "相机跟随视野", - "FOLLOW": "跟随", "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中", "Close the street-level image": "关闭街景图像", "Street-level viewer": "街景查看器", @@ -2720,7 +2709,6 @@ "ALL": "全部", "FLAT": "平面", "SINCE": "时间", - "Coverage colours": "覆盖图例", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -2760,5 +2748,64 @@ "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", - "hemisphere|W": "W" + "hemisphere|W": "W", + "Gemini Live voice control": "Gemini Live 语音控制", + "Zoom in to see street-level coverage": "放大地图以查看街景覆盖范围", + "Point the camera at the ground for street-level coverage": "将相机朝向地面以查看街景覆盖范围", + "Sequence images unavailable": "序列影像不可用", + "Switch Street Level on to draw its coverage.": "开启“街景”以绘制其覆盖范围。", + "Every provider is switched off: light a chip to draw its coverage.": "所有服务商均已关闭:点亮一个标签即可绘制其覆盖范围。", + "No sequences in view match the filter": "视野内没有符合筛选条件的序列", + "No coverage in view": "视野内没有覆盖", + "Voice provider": "语音服务商", + "Changing provider reconnects voice if it is on.": "如语音已开启,切换服务商会重新连接语音。", + "Gemini returned an oversized audio chunk": "Gemini 返回的音频块过大", + "Gemini returned an unsupported audio format": "Gemini 返回了不支持的音频格式", + "Gemini returned an invalid audio sample rate": "Gemini 返回了无效的音频采样率", + "Gemini returned incomplete PCM audio": "Gemini 返回的 PCM 音频不完整", + "This browser does not support Gemini microphone audio": "当前浏览器不支持 Gemini 麦克风音频", + "Microphone audio processing failed": "麦克风音频处理失败", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini 音频播放滞后。请重新连接以继续。", + "Gemini token response did not include an ephemeral credential": "Gemini 令牌响应中没有临时凭据", + "Invalid Gemini connection endpoint": "Gemini 连接端点无效", + "Gemini voice could not connect. Try again.": "Gemini 语音无法连接。请重试。", + "Gemini voice requires a same-origin app request": "Gemini 语音需要来自本应用同源的请求", + "Add a Gemini API key in Provider Settings": "请在服务商设置中添加 Gemini API 密钥", + "The Gemini Live model configuration is invalid": "Gemini Live 模型配置无效", + "Gemini voice is starting too often. Try again shortly.": "Gemini 语音启动过于频繁。请稍后重试。", + "Gemini quota is unavailable. Try again later.": "Gemini 配额不可用。请稍后重试。", + "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 令牌请求", + "Voice command context is too large": "语音命令上下文过大", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini 可靠传输超出了队列上限。请重新启动语音。", + "Gemini reliable transport did not drain. Start voice again.": "Gemini 可靠传输的队列未能清空。请重新启动语音。", + "Gemini token request failed (HTTP {status})": "Gemini 令牌请求失败(HTTP {status})", + "Gemini token response did not include a valid model": "Gemini 令牌响应中没有有效的模型", + "Gemini token response did not include session settings": "Gemini 令牌响应中没有会话设置", + "Gemini session settings do not match the input mode": "Gemini 会话设置与输入模式不匹配", + "Gemini could not submit this input. Start voice again.": "Gemini 无法提交此输入。请重新启动语音。", + "Connecting to Gemini": "正在连接 Gemini", + "Gemini returned an oversized message": "Gemini 返回的消息过大", + "Gemini Live connection failed. Check network access, then start voice again.": "Gemini Live 连接失败。请检查网络连接,然后重新启动语音。", + "Gemini Live connection ended. Start voice again for a new conversation.": "Gemini Live 连接已结束。请重新启动语音以开始新对话。", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live 无法启动。请重新启动语音以申请新的会话。", + "Start voice again for a fresh token and a new conversation.": "请重新启动语音,以获取新令牌并开始新对话。", + "Start voice again to request a fresh session credential.": "请重新启动语音以申请新的会话凭据。", + "Microphone permission was denied. Allow microphone access and try again.": "麦克风权限被拒绝。请允许访问麦克风后重试。", + "Gemini connection timed out. Try again.": "Gemini 连接超时。请重试。", + "Gemini voice could not start": "Gemini 语音无法启动", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini 未能完成被打断的回复。请重新启动语音。", + "Gemini session expiring; finishing the current reply": "Gemini 会话即将到期,正在完成当前回复", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini 拒绝了语音会话。请重新启动语音以申请新的会话。", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "请重新启动语音以获取新的会话凭据。如问题重复出现,请检查 Gemini 模型和 API 访问权限。", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini 操作历史已满。请重新启动语音以开始新的会话。", + "Gemini requested too many simultaneous actions": "Gemini 请求的同时操作过多", + "Voice text command is too long": "语音文本命令过长", + "Gemini voice is not connected": "Gemini 语音未连接", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打断的输入超出了缓冲区或等待上限。请重新启动语音。", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 连接即将到期。请重新启动语音以开始新对话。", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。", + "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)" } From da1dd928efc5bb35cd3e4da9740bb93d6fe98088 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 12:55:21 +0400 Subject: [PATCH 058/178] docs(changelog): Simplified Chinese (#1298) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1298-simplified-chinese.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1298-simplified-chinese.md diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md new file mode 100644 index 000000000..ffae0cd41 --- /dev/null +++ b/changelog.d/1298-simplified-chinese.md @@ -0,0 +1,6 @@ +- Add Simplified Chinese (zh-CN) as an interface language, selectable from + the globe picker. The catalog builds on the Chinese translations by + abiyeenzo (#914) and EDIT-Aeterna (#979); the rest was translated and the + whole catalog reviewed against the screens. Chinese text gets tight tracking + and readable minimum sizes, and year-first dates read 2021年10月16日 + (German, @gekh, #1298). From 6a40b30d12ce506e47a229d76f5d05f7534ff611 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:02:34 +0400 Subject: [PATCH 059/178] docs(changelog): credit the zh-CN translators by GitHub handle Enzo's commit email is not linked to his GitHub account, so credit his #914 translation here as well. Co-authored-by: Enzo Abyie <93532759+abiyeenzo@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- changelog.d/1298-simplified-chinese.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md index ffae0cd41..e6850e964 100644 --- a/changelog.d/1298-simplified-chinese.md +++ b/changelog.d/1298-simplified-chinese.md @@ -1,6 +1,6 @@ - Add Simplified Chinese (zh-CN) as an interface language, selectable from the globe picker. The catalog builds on the Chinese translations by - abiyeenzo (#914) and EDIT-Aeterna (#979); the rest was translated and the + @abiyeenzo (#914) and @EDIT-Aeterna (#979); the rest was translated and the whole catalog reviewed against the screens. Chinese text gets tight tracking and readable minimum sizes, and year-first dates read 2021年10月16日 (German, @gekh, #1298). From d6c4d58a2da252c1ebeca2f682c1164e2d57255a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:35:52 +0400 Subject: [PATCH 060/178] i18n(zh-CN): apply the language review Correct weather, imagery, scene, voice, and status wording after the Chinese UI review. Align the translation glossary with the revised terminology. Co-Authored-By: Codex (GPT-6) --- docs/translations/zh-CN.md | 22 ++- src/i18n/locales/zh-CN.json | 332 ++++++++++++++++++------------------ 2 files changed, 187 insertions(+), 167 deletions(-) diff --git a/docs/translations/zh-CN.md b/docs/translations/zh-CN.md index 9b779e0b7..33f7c7251 100644 --- a/docs/translations/zh-CN.md +++ b/docs/translations/zh-CN.md @@ -46,10 +46,11 @@ for the current English and reviewed against both. | preset / visual preset | 预设 / 视觉预设 | | Display / HUD layout | 显示 / HUD 布局 | | Tactical / Operator / Minimal / Cyber | 战术 / 操作员 / 精简 / 赛博 | -| Scope (circular viewport mask) | 视域 | +| Scope (circular viewport mask) / keyhole | 视域 | | Clean UI | 纯净界面 | | cockpit | 座舱 | | heading / altitude / ground speed | 航向 / 高度 / 地速 | +| map heading / North up | 方位 / 正北朝上 | | Street Level / Street View (Google) | 街景 / 街景(Google) | | Recent Imagery | 近期影像 | | Global Context / Context (panel) | 全局态势 / 态势 | @@ -57,3 +58,22 @@ for the current English and reviewed against both. | transit / bikeshare | 公共交通 / 共享单车 | | Previous / Next | 上一个 / 下一个 | | compass points (N, NNE, …) | 北, 北东北, … | +| weather field / FIELD (selector) | 气象场 / 要素 | +| weather model / forecast lead hours | 模式 / 预报时效 | +| valid (forecast time) | 有效时间 | +| scene cloud / mosaic | 整景云量 / 镶嵌图 | +| pin (Recent Imagery) | 固定 | +| THIS (voice pointer) | 所指 | +| outline / trace an outline | 轮廓 / 绘制轮廓 | +| mark (map annotation) | 标注 | +| camera badge | 摄像头标记 | +| provider / embedded provider controls | 服务商 / 平台控件 | +| share (a view or link) | 分享 | +| AI agent | AI 智能体 | +| stage (rocket) / downrange | 子级 / 航程 | +| capture shot / camera move | 保存镜头 / 运镜 | +| story beat / evidence beat | 事件节点 / 证据节点 | +| camera pose | 位姿 | +| forecast uncertainty cone | 预报不确定范围 | +| seed (catalog data) | 预置 | +| frame (bring into view) | 将…纳入视野 | diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a83d35cc0..8b5240e12 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -18,7 +18,7 @@ "{owner} is using the pointer — close it first.": "{owner} 正在使用指针——请先关闭它。", "{label} · Detailed outline unavailable": "{label} · 无法获取详细轮廓", "Flying to Austin, TX...": "正在飞往德克萨斯州奥斯汀…", - "Restoring shared view...": "正在恢复共享视图…", + "Restoring shared view...": "正在恢复分享视图…", "Configuring viewer...": "正在配置视图…", "Loading Google 3D Tiles...": "正在加载 Google 3D Tiles…", "Loading the keyless globe...": "正在加载免密钥地球…", @@ -31,7 +31,7 @@ "Light aircraft": "轻型飞机", "Glider": "滑翔机", "Turboprop": "涡桨飞机", - "Airliner": "喷气客机", + "Airliner": "民航客机", "Widebody": "宽体机", "Four-engine jet": "四发喷气机", "Helicopter": "直升机", @@ -48,10 +48,10 @@ "Bhote Koshi valley footage": "Bhote Koshi 河谷画面", "Flood footage": "洪水画面", "Compilation and casualty claims": "视频合集与伤亡说法", - "SOURCE GEOCONFIRMED · WORLD-PINNED": "来源已地理核实 · 已标定于地图", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "来源位置已核实 · 已在地图上标注", "SOURCE CREDITED · LOCATION UNRESOLVED": "来源已署名 · 位置未确定", "REFERENCE ONLY · CLAIMS UNVERIFIED": "仅供参考 · 说法未核实", - "SOURCE GEOCONFIRMED": "来源已地理核实", + "SOURCE GEOCONFIRMED": "来源位置已核实", "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "2021 年历史参考影像与 2026 年灾后影像对比;变化为累计结果,包含 2025 年 7 月的 Rasuwagadhi 洪水。下游演进为示意,并非模拟的到达时间;也不是官方灾害模型。", "Immediate collapse viewpoint": "近距离崩塌观察点", "Collapse viewpoint": "崩塌观察点", @@ -98,7 +98,7 @@ "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打开原始来源", "INITIATION": "起始", "DAMMING": "堰塞", - "SECONDARY FAILURE": "二次溃决", + "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵达边境", "PROPAGATION": "演进", "DOWNSTREAM PASSAGE": "向下游推进", @@ -125,11 +125,11 @@ "Historical reference and post-event image split": "历史参考与灾后影像的分割", "RECONSTRUCTION CLOCK": "重构时钟", "Schematic downstream progression": "下游演进示意", - "Story beat navigation": "故事节拍导航", - "Previous story beat": "上一个故事节拍", + "Story beat navigation": "事件节点导航", + "Previous story beat": "上一个事件节点", "CAUSE": "成因", "CAPTURE TIME UNVERIFIED": "拍摄时间未核实", - "Next story beat": "下一个故事节拍", + "Next story beat": "下一个事件节点", "▶ PLAY": "▶ 播放", "Play the next shot and continue through this scene": "播放下一个镜头并继续整个场景", "▶ PLAY SCENE": "▶ 播放场景", @@ -300,7 +300,7 @@ "Opening space missions…": "正在打开太空任务…", "Scanning active events…": "正在扫描活跃事件…", "Live earthquakes and active fires, from USGS and NASA": "来自 USGS 和 NASA 的实时地震与活跃火点", - "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "它像一座禁地座舱——然后你会发现,数据来源都是公开的,数据都是真实的。", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "仿佛闯进了一间机密座舱——随后你会发现,数据来源全部公开,数据全部真实。", "Working…": "处理中…", "Could not open that mission{detail}. Retry or explore manually.": "无法打开该任务{detail}。请重试或手动探索。", "This browser is blocking storage, so that could not be saved.": "此浏览器阻止了存储,因此无法保存。", @@ -338,7 +338,7 @@ "AIS: --": "AIS: --", "N/A": "无", "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 视窗 {window} | SUN {sun}° | ONA {ona}° | {zone}", - "NEAR {poi} ({city}) {distance}": "邻近 {poi}({city}){distance}", + "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", "SECTOR {lat} {lon}": "扇区 {lat} {lon}", "N": "北", "S": "南", @@ -349,7 +349,7 @@ }, "POWERED UP": "已增强", "Provider Settings does not answer proxied requests": "服务商设置不响应经代理的请求", - "Provider Settings is disabled while sharing is enabled": "启用共享时,服务商设置不可用", + "Provider Settings is disabled while sharing is enabled": "启用分享时,服务商设置不可用", "Provider Settings answers only the machine running the server": "服务商设置只响应运行服务器的本机", "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", @@ -371,7 +371,7 @@ "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} 是在服务商设置之外配置的,只能在原设置处修改", "The key was not saved: could not restrict the credential file to your account; nothing was saved": "密钥未保存:无法将凭据文件限制为仅你的账户可访问;未保存任何内容", "The key was not saved: the existing configuration could not be read, so nothing was changed": "密钥未保存:无法读取现有配置,因此未做任何更改", - "Could not write the {store} store": "无法写入{store}存储", + "Could not write the {store} store": "无法写入 {store} 存储", "Save failed ({status}).": "保存失败({status})。", "OAuth check failed: {detail}": "OAuth 检查失败:{detail}", "Could not check ChatGPT sign-in. Try again.": "无法检查 ChatGPT 登录状态,请重试。", @@ -450,7 +450,7 @@ "SHOW NEAREST": "最近摄像头", "Stop following the current object before navigating to a camera": "请先停止跟踪当前目标,再前往摄像头", "Move to the nearest loaded camera and show its details": "前往最近已加载的摄像头并显示详情", - "Camera badges": "摄像头徽标", + "Camera badges": "摄像头标记", "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "选中后青色摄像头变为珊瑚色。扇形仅示意已标绘方向,并非实测覆盖范围。附近的摄像头可能不在屏幕内。", "{count} nearby · {onScreen} on screen": { "other": "附近 {count} 个 · 屏幕上 {onScreen} 个" @@ -526,7 +526,7 @@ "SRC {value}": "SRC {value}", "{value} CONTEXT": "{value} 态势", "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · 规格:CCTV-DATABASE.COM", - "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "覆盖大西洋及北太平洋东部/中部;不含全球气旋。", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "仅覆盖大西洋及北太平洋东部和中部,并非全球气旋覆盖。", "Cyclone advisories unavailable": "气旋公告不可用", "Cyclone data unavailable": "气旋数据不可用", "Potential tropical cyclone": "潜在热带气旋", @@ -535,9 +535,9 @@ "Tropical depression": "热带低压", "Subtropical storm": "亚热带风暴", "Subtropical depression": "亚热带低压", - "Track and cone match this advisory": "路径和锥体与本期公告一致", - "Track/cone awaiting advisory {number}": "路径/锥体等待第 {number} 号公告", - "Track/cone unavailable": "路径/锥体不可用", + "Track and cone match this advisory": "路径和预报不确定范围与本期公告一致", + "Track/cone awaiting advisory {number}": "路径/预报不确定范围等待第 {number} 号公告", + "Track/cone unavailable": "路径/预报不确定范围不可用", "Cached advisory · stale source": "缓存公告 · 数据源已过期", "Loading advisories…": "正在加载公告…", "{count} active storms": { @@ -554,9 +554,9 @@ "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活跃的 NHC 与 CPHC 气旋公告", "Wind unavailable": "风场数据不可用", - "Advisory center / forecast track": "公告中心 / 预报路径", - "Center-track uncertainty cone": "中心路径不确定锥", - "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公告。锥体描述的是预报中心路径的不确定范围,不代表气旋尺寸或全部灾害区域。预报点标注的是数据源的前导小时数,不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "Advisory center / forecast track": "公告中的气旋中心 / 预报路径", + "Center-track uncertainty cone": "中心路径预报不确定范围", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公告。该区域表示气旋中心路径预报的不确定范围,不代表气旋大小或全部灾害影响范围。预报点标注的是数据源的预报时效(小时),不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", "DRIVE": "驾车", "Drive": "驾车", "WALK": "步行", @@ -658,15 +658,15 @@ "FLY ···": "飞行 ···", "FLYING": "飞行中", "FLY": "飞行", - "Fly the camera along the route": "沿路线飞行相机", + "Fly the camera along the route": "让相机沿路线飞行", "Place A and B first": "请先放置 A 和 B", "action|CLEAR": "清除", - "Remove the route and both markers": "移除路线和两个标记点", + "Remove the route and both markers": "移除路线和两个标注点", "press Escape twice to leave Draw": "按两次 Escape 退出“绘制”", "{name} is active — turn {name} off, then set {endpoint}": "{name} 正在使用——请先关闭 {name},再设置 {endpoint}", "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——请先{leave},再设置 {endpoint}", "Only the first {count} turns of this route are shown": "此路线仅显示前 {count} 个转向", - "Turn-by-turn directions": "逐向导航路线", + "Turn-by-turn directions": "分步导航", "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击——请先关闭它,再重新设置 A", "Routing…": "正在规划路线…", "No route found": "未找到路线", @@ -706,7 +706,7 @@ "fire confidence|high": "高", "fire confidence|nominal": "中", "fire confidence|low": "低", - "age|<1h": "<1 小时", + "age|<1h": "不到 1 小时", "age|{count}h": "{count} 小时", "age|{count}d": "{count} 天", "Fire · FRP {frp} MW": "火点 · FRP {frp} MW", @@ -759,17 +759,17 @@ "LIFTOFF · {mission}": "点火升空 · {mission}", "ASCENT REPLAY · {mission}": "上升回放 · {mission}", "ASCENT ESTIMATE · {mission}": "上升估算 · {mission}", - "STAGE RE-ENTRY / RECOVERY · {mission}": "级段再入 / 回收 · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "子级再入 / 回收 · {mission}", "ORBIT REPLAY · {mission}": "轨道回放 · {mission}", "PAUSED · {title}": "已暂停 · {title}", "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "射程 {value} km", + "{value} KM DOWNRANGE": "航程 {value} km", "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飞行", "REUSED": "复用", - "NO STAGE RE-ENTRY / RECOVERY DATA": "无级段再入 / 回收数据", + "NO STAGE RE-ENTRY / RECOVERY DATA": "无子级再入 / 回收数据", "{count} / 30D": "{count} / 30 天", "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "当前 30 天窗口内没有可用任务", "DATE UNAVAILABLE": "日期不可用", @@ -793,8 +793,8 @@ "NAME": "名称", "TYPE": "类型", "DESTINATION": "目的地", - "STAGE / RE-ENTRY / RECOVERY": "级段 / 再入 / 回收", - "STAGE": "级段", + "STAGE / RE-ENTRY / RECOVERY": "子级 / 再入 / 回收", + "STAGE": "子级", "STATUS": "状态", "FINAL POSITION": "最终位置", "REPLAY SPEED": "回放速度", @@ -806,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估射程", + "EST. DOWNRANGE": "预估航程", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -828,9 +828,9 @@ "Your decoder feed": "你的解码器数据源", "Your RTL-SDR receiver": "你的 RTL-SDR 接收机", "NO CALLSIGN": "无呼号", - "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} FT · 地速 {speed} KT · 航迹 {track}", - "V/S {rate}": "升降 {rate}", - "V/S {rate} FPM": "升降 {rate} FPM", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航迹角 {track}", + "V/S {rate}": "升降率 {rate}", + "V/S {rate} FPM": "升降率 {rate} ft/min", "POSITION {age}": "位置 {age}", "POSITION {seconds} S AGO": "位置 {seconds} 秒前", "{count} MSGS": { @@ -938,21 +938,21 @@ "World": "世界音乐", "overview": "概览", "cloud unknown": "云量未知", - "{percent}% scene cloud": "场景云量 {percent}%", - "{min}–{max}% scene cloud": "场景云量 {min}–{max}%", + "{percent}% scene cloud": "整景云量 {percent}%", + "{min}–{max}% scene cloud": "整景云量 {min}–{max}%", "Comparison in use by another scene": "对比功能正被另一个场景占用", "Esri map unavailable · no swipe": "Esri 地图不可用 · 无法滑动对比", "Swipe needs a globe map": "滑动对比需要球体地图", "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除后改用 Google 3D", "Imagery catalog unavailable": "影像目录不可用", - "Catalog truncated · a newer clear day may exist": "目录已截断 · 可能还有更新的晴天", + "Catalog truncated · a newer clear day may exist": "目录已截断 · 可能还有更近的少云日期", "{label} catalog unavailable · {detail}": "{label} 目录不可用 · {detail}", "Daily overview for today may still be empty until the pass": "卫星过境前,今天的每日概览可能仍为空", "Box and images cleared": "框选及影像已清除", "MORE DETAIL · 30 m": "更多细节 · 30 m", "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 与 Landsat,经 HLS,30 m,每 2–6 天", "DAILY OVERVIEW · 250 m": "每日概览 · 250 m", - "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日拼图,250 m,反映大范围变化;25 km 以下几乎没有细节", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS 每日镶嵌图,250 m,反映大范围变化;25 km 以下几乎没有细节", "Sentinel-2": "Sentinel-2", "Sentinel-2 via HLS": "Sentinel-2(经 HLS)", "Landsat 8/9": "Landsat 8/9", @@ -1023,15 +1023,15 @@ "Panoramax sent too much data": "Panoramax 返回的数据过多", "Panoramax is busy": "Panoramax 繁忙", "Panoramax sent an unreadable answer": "Panoramax 返回的响应无法读取", - "No street-level imagery providers configured": "未配置街景影像提供商", + "No street-level imagery providers configured": "未配置街景影像服务商", "{names}: by upload date": "{names}:按上传日期", "Open the Street Level panel to view imagery": "打开街景面板以查看影像", "Image could not be opened": "图像无法打开", "TeleGeography load failed": "TeleGeography 加载失败", "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", "Roads: {source}": "道路:{source}", - "TomTom roads unavailable while the traffic service is unreachable": "交通服务不可达期间 TomTom 道路不可用", - "Hybrid unavailable while the traffic service is unreachable": "交通服务不可达期间混合模式不可用", + "TomTom roads unavailable while the traffic service is unreachable": "无法连接交通服务期间 TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "无法连接交通服务期间混合模式不可用", "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密钥", "Hybrid needs a TomTom key": "混合模式需要 TomTom 密钥", "Local surface still loading": "本地路面仍在加载", @@ -1041,8 +1041,8 @@ "TomTom roads only, each with its live flow": "仅 TomTom 道路,各自带实时路况", "TomTom roads only, each with its live flow (needs a TomTom key)": "仅 TomTom 道路,各自带实时路况(需要 TomTom 密钥)", "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap 道路,可用时匹配 TomTom 路况", - "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先 TomTom 道路;其余由 OpenStreetMap 补足,路况为模拟", - "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先 TomTom 道路;其余由 OpenStreetMap 补足,路况为模拟(需要 TomTom 密钥)", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)", "Hybrid": "混合", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", @@ -1051,7 +1051,7 @@ "SIMULATED — {reason}": "模拟——{reason}", "Syncing flow · Roads: {source}": "正在同步路况 · 道路:{source}", "LIVE · Roads: {source} · Roads without flow hidden": "实时 · 道路:{source} · 已隐藏无路况道路", - "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内无路况道路", + "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路", "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟", @@ -1073,7 +1073,7 @@ "occupancy|not boardable": "无法上车", "Route {route}": "线路 {route}", "Vehicle {vehicle}": "车辆 {vehicle}", - "vehicle motion|Stopped": "停驶", + "vehicle motion|Stopped": "已停车", "No recent fix": "无近期定位", "Waiting for update": "等待更新", "Moving": "行驶中", @@ -1147,7 +1147,7 @@ "frame {index}/{total}": "第 {index}/{total} 帧", "loading": "加载中", "Observation: unavailable": "观测:不可用", - "Contiguous US · gaps ≠ no rain": "美国本土 · 无回波 ≠ 无降雨", + "Contiguous US · gaps ≠ no rain": "美国本土连续区域 · 数据缺失 ≠ 无降雨", "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", "Color: strikes/km²/min ×10³": "颜色:次闪击/km²/分钟 ×10³", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", @@ -1167,7 +1167,7 @@ "strikes/km²/min ×10³": "次闪击/km²/分钟 ×10³", "N. America": "北美", "Global": "全球", - "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球拼图;通常延迟 2–3 小时", + "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球镶嵌图;通常延迟 2–3 小时", "GOES regional clouds; approximately 5-minute updates": "GOES 区域云图;约 5 分钟更新", "Clouds only": "仅云区", "Apply the full cloud brightness key; 100% filter strength": "应用完整的云层亮度映射;滤镜强度 100%", @@ -1185,7 +1185,7 @@ "Map center is outside source coverage": "地图中心在数据源覆盖范围之外", "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分钟闪电密度,来源为 Vaisala NLDN/GLD360。覆盖自 110°E 经太平洋/美洲至 0°,25°S–80°N。不是实时闪击计数、不是全球覆盖,也不构成安全警示。", "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据分辨率约 1 km;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", - "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段长波红外区域图;NESDIS 全球长波拼图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段长波红外区域图;NESDIS 全球长波镶嵌图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", "REGION": "区域", "IMAGE": "图像", "FILTER": "滤镜", @@ -1197,7 +1197,7 @@ "Some weather tiles unavailable": "部分天气瓦片不可用", "Malformed weather manifest": "天气清单格式错误", "Weather request timed out": "天气请求超时", - "Temperature field unavailable": "温度字段不可用", + "Temperature field unavailable": "温度场不可用", "Mean sea level pressure field unavailable": "平均海平面气压场不可用", "Wind upstream unavailable": "风场上游数据源不可用", "{source} HTTP {status}": "{source} HTTP {status}", @@ -1212,7 +1212,7 @@ "{model} forecast · {time}": "{model} 预报 · {time}", "Loading forecast": "正在加载预报", "Preparing flow": "正在准备流场", - "Selected field unavailable": "所选字段不可用", + "Selected field unavailable": "所选气象场不可用", "Cached forecast · stale": "缓存预报 · 已过期", "ECMWF IFS surface forecast": "ECMWF IFS 地面预报", "NOAA GFS surface forecast": "NOAA GFS 地面预报", @@ -1224,22 +1224,22 @@ "Speed": "风速", "Pressure": "气压", "Temperature": "气温", - "Wind trails with no field shading": "仅风流线,不叠加字段填色", + "Wind trails with no field shading": "仅显示风流线,不叠加气象场填色", "How hard the surface wind is blowing": "地面风有多大", "Sea-level air pressure: broad highs and lows": "海平面气压:大范围的高压与低压", "Air temperature two meters above the surface": "地面以上两米的气温", "Wind speed units": "风速单位", - "Read wind at map center": "读取地图中心风速", + "Read wind at map center": "读取地图中心气象数据", "Read the forecast at the center of the map without changing selection": "读取地图中心的预报值,不改变当前选择", "{model} forecast · {field} ({unit})": "{model} 预报 · {field}({unit})", "Valid: {time}": "有效时间:{time}", "preparing": "准备中", "Issued: {time}": "发布:{time}", - "Selected field unavailable · wind remains visible": "所选字段不可用 · 风场仍可见", + "Selected field unavailable · wind remains visible": "所选气象场不可用 · 风场仍可见", "{error} · wind remains visible": "{error} · 风场仍可见", - "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。颜色字段贴附于球体底图或当前的照片级 3D Tiles。", - "MODEL": "模型", - "FIELD": "字段", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。气象场填色叠加于地球底图或当前的照片级 3D Tiles。", + "MODEL": "模式", + "FIELD": "要素", "UNITS": "单位", "MOTION": "运动", "Forecast": "预报", @@ -1265,7 +1265,7 @@ "{model} · valid {time}": "{model} · 有效时间 {time}", "Wind speed field unavailable": "风速场不可用", "Globe imagery unavailable": "球体影像不可用", - "Globe field image unavailable": "球体字段图像不可用", + "Globe field image unavailable": "地球气象场图像不可用", "Unknown wind model": "未知风场模式", "Unknown weather overlay": "未知天气叠加层", "Wind request timed out": "风场请求超时", @@ -1292,11 +1292,11 @@ "Exit cockpit to fly to a cyclone": "请先退出座舱,再飞往气旋", "Exit cockpit to fly to an aircraft": "请先退出座舱,再飞往飞机", "Exit cockpit to fly to a satellite": "请先退出座舱,再飞往卫星", - "Exit cockpit to frame contacts": "请先退出座舱,再取景目标", + "Exit cockpit to frame contacts": "请先退出座舱,再将目标纳入视野", "Visible map targets": "可见的地图目标", "Focusing {label}": "正在聚焦 {label}", "Source": "来源", - "Camera ownership changed": "相机所有权已变更", + "Camera ownership changed": "相机控制权已变更", "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "已保存的项目无法读取;存储已保留。请导入有效文件以恢复保存。", "Ready": "就绪", "Scene layer turned off": "场景图层已关闭", @@ -1306,7 +1306,7 @@ "Scene not saved — browser storage unavailable": "场景未保存——浏览器存储不可用", "Loaded: {scene} / {shot}": "已加载:{scene} / {shot}", "Cannot update {scene}: shot inventory changed": "无法更新 {scene}:镜头清单已变化", - "Cannot update {scene}: evidence beats changed": "无法更新 {scene}:证据节拍已变化", + "Cannot update {scene}: evidence beats changed": "无法更新 {scene}:证据节点已变化", "Cannot update {scene}: shot bindings are incomplete": "无法更新 {scene}:镜头绑定不完整", "Updated {count} shots: {scene}": { "other": "已更新 {count} 个镜头:{scene}" @@ -1314,8 +1314,8 @@ "Appended {count} shots: {scene}": { "other": "已追加 {count} 个镜头:{scene}" }, - "Cannot capture shot: camera not ready": "无法截取镜头:相机未就绪", - "Captured: {scene} / {shot}": "已截取:{scene} / {shot}", + "Cannot capture shot: camera not ready": "无法保存镜头:相机未就绪", + "Captured: {scene} / {shot}": "已保存:{scene} / {shot}", "Select a shot first": "请先选择一个镜头", "Updated: {scene} / {shot}": "已更新:{scene} / {shot}", "Could not leave scene: {scene}": "无法离开场景:{scene}", @@ -1383,23 +1383,23 @@ "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。文件:{path}。{attribution} · {license}", "Unavailable layers: {layers}": "不可用图层:{layers}", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已注册内容或链接媒体。这些外部文件不包含在场景资源包中。其原始声明仍然适用。", - "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校验 {bytes} 个打包字节。文件仅保留在本次会话的内存中。重新加载应用后请再次导入资源包。", - "Review scene import": "审查场景导入", - "Nothing is loaded or changed until you apply this file.": "在你应用此文件之前,不会载入或更改任何内容。", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校验打包内容 {bytes} 字节。文件仅保留在本次会话的内存中。重新加载应用后请再次导入资源包。", + "Review scene import": "场景导入预览", + "Nothing is loaded or changed until you apply this file.": "确认导入此文件前,不会载入或更改任何内容。", "Ready to import": "可以导入", "Apply replaces the current project. Export your current project first if you want to keep both.": "应用后将替换当前项目。如需同时保留两者,请先导出当前项目。", - "Apply import": "应用导入", - "Import was not applied. The current project may have changed.": "导入未应用。当前项目可能已发生变化。", + "Apply import": "确认导入", + "Import was not applied. The current project may have changed.": "未执行导入。当前项目可能已发生变化。", "Select a scene and shot first": "请先选择场景和镜头", "Edit scene details": "编辑场景详情", - "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相机位置使用度与椭球面上的米数。草稿在替换已保存场景之前会先进行校验。", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相机经纬度以度为单位,高度为相对椭球面的高度,以米为单位。草稿通过校验后才会替换已保存的场景。", "Anchors and data packs": "锚点与数据包", "Shot camera, timing, packs and actions": "镜头相机、时序、数据包与动作", "New anchor ID": "新锚点 ID", - "Capture camera as anchor": "将当前相机采集为锚点", + "Capture camera as anchor": "将当前相机位置设为锚点", "Anchor added to draft": "锚点已加入草稿", - "Set move start to current camera": "将运动起点设为当前相机", - "Move added to draft; destination remains the shot camera": "运动已加入草稿;终点仍为镜头相机", + "Set move start to current camera": "将运镜起点设为当前相机位置", + "Move added to draft; destination remains the shot camera": "运镜已加入草稿;终点仍为该镜头的相机位置", "Use ordinary flight": "使用普通飞行", "Invalid shot JSON": "镜头 JSON 无效", "Apply details": "应用详情", @@ -1454,7 +1454,7 @@ "Vignette": "暗角", "Pixelation": "像素化", "Distortion": "畸变", - "Instability": "不稳定", + "Instability": "抖动", "Density": "密度", "Gain": "增益", "Bloom": "泛光", @@ -1475,10 +1475,10 @@ "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", - "Reset map to north up. Current heading {heading} degrees": "将地图重置为朝北。当前航向 {heading} 度", + "Reset map to north up. Current heading {heading} degrees": "将地图重置为正北朝上。当前方位 {heading} 度", "Tilted view": "倾斜视角", "Straight-down view": "垂直俯视", - "North up": "朝北", + "North up": "正北朝上", "CCTV calibration saved": "CCTV 校准已保存", "HDG": "HDG", "PITCH": "俯仰", @@ -1496,7 +1496,7 @@ "UPSTREAM": "上游", "SYNTHETIC": "合成", "CONFIGURED": "已配置", - "SEED": "种子", + "SEED": "预置", "UNKNOWN": "未知", "ERROR": "错误", "Live HLS connected": "实时 HLS 已连接", @@ -1573,10 +1573,10 @@ "CLOUD UNKNOWN": "云量未知", "{source} · LOCATION QUERY": "{source} · 位置查询", "REGIONAL NEWS": "地区新闻", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空中 / 海上范围", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 航班 / 船舶范围", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "CONTEXT STANDBY": "态势待机", - "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以获取邻近提示", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以获取邻近目标", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", "CONTACT LOST · {subject}": "目标丢失 · {subject}", "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目标已脱离其数据源 · 读数保持最后已知值", @@ -1592,7 +1592,7 @@ }, "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "可用输入均为最新 · 不代表安全无虞", "SQUAWK {code} · {status}": "应答码 {code} · {status}", - "ADS-B PRIORITY · {status}": "ADS-B 优先 · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 优先级 · {status}", "ADS-B EMERGENCY · {status}": "ADS-B 紧急 · {status}", "AIRCRAFT": "飞机", "ACQUIRING SURFACE": "正在获取地面数据", @@ -1614,7 +1614,7 @@ "Anime": "动漫", "Noir": "黑白", "Snow": "雪景", - "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。激活可切换到下一个样式。", + "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。", "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", "Collapse Contact panel": "收起目标面板", "Expand Contact panel": "展开目标面板", @@ -1624,9 +1624,9 @@ "Expand cockpit briefing panel": "展开座舱简报面板", "Collapse briefing panel": "收起简报面板", "Expand briefing panel": "展开简报面板", - "OBSERVED / MAPPED PINGS": "观测 / 地图标注提示", + "OBSERVED / MAPPED PINGS": "观测 / 地图标注目标", "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "来源可查的事件 · 无合成新闻", - "LATEST LOCATION-MATCHED REPORTING": "最新的位置匹配报道", + "LATEST LOCATION-MATCHED REPORTING": "当前地区最新报道", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查询 · 近期", "LOCAL INFO": "本地信息", "PLACE / CONDITIONS / POSITION": "地点 / 状况 / 位置", @@ -1775,7 +1775,7 @@ "closed": "已关闭", "feed status|live": "实时", "feed status|stale": "过期", - "feed status|unreachable": "无法访问", + "feed status|unreachable": "无法连接", "feed status|invalid": "无效", "waiting for IQ": "等待 IQ 数据", "{count} messages": { @@ -1789,7 +1789,7 @@ "audio signal --": "音频信号 --", "audio {state}": "音频 {state}", "{count} HEARD": { - "other": "已收到 {count} 架" + "other": "已接收 {count} 架飞机的信号" }, "DISCONNECT": "断开连接", "CONNECT": "连接", @@ -1817,8 +1817,8 @@ "Resetting cockpit to full globe view": "正在将座舱重置为完整地球视图", "MAP": "地图", "panel": "面板", - "Expand {name}": "展开{name}", - "Collapse {name}": "收起{name}", + "Expand {name}": "展开 {name}", + "Collapse {name}": "收起 {name}", "Expand Radio": "展开电台", "Collapse Radio": "收起电台", "Expand Radio section": "展开电台区域", @@ -1863,7 +1863,7 @@ "NO STATION SELECTED": "未选择电台", "Directory metadata only": "仅目录元数据", "Loading station directory…": "正在加载电台目录…", - "Choose a globe marker or use next.": "请选择地球上的标记或使用“下一个”。", + "Choose a globe marker or use next.": "请选择地球上的标注或使用“下一个”。", "TAGS · {tags}": "标签 · {tags}", "Play": "播放", "PAUSE": "暂停", @@ -1882,7 +1882,7 @@ " · degraded directory": " · 目录降级", " · stale directory": " · 目录过期", " · outside current filter": " · 不在当前筛选范围内", - "Ready — playback starts only from your action": "就绪——仅在你操作后才开始播放", + "Ready — playback starts only from your action": "就绪——手动操作后开始播放", "Radio off": "电台已关闭", "Connecting directly to broadcaster…": "正在直接连接广播方…", "Buffering broadcaster stream…": "正在缓冲广播流…", @@ -1904,7 +1904,7 @@ "Latest": "最新", "Newest frame per product": "每个产品的最新一帧", "Replay observed history": "回放观测历史", - "Imagery on Esri · Google 3D returns when cleared": "影像来自 Esri · 清除后恢复 Google 3D", + "Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D", "Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野", "That point cannot anchor a box": "该点无法作为框选锚点", "Imagery stops at ±{limit}° latitude": "影像仅覆盖到纬度 ±{limit}°", @@ -1913,11 +1913,11 @@ "View is {size} wide · limit {limit}": "视野宽 {size} · 上限 {limit}", "VS BASEMAP": "对比底图", "A / B": "A / B", - "Newest low-cloud day for this box · scene cloud, not box cloud": "此框选最新的低云日 · 云量为场景云量,而非框内云量", + "Newest low-cloud day for this box · scene cloud, not box cloud": "框选区域内最近的少云日期 · 云量指整景影像,并非框选区域", "Newest day covering this box · cloudier than 20%": "覆盖此框选的最新一天 · 云量高于 20%", "Newest day with imagery for this box · partial coverage": "此框选有影像的最新一天 · 部分覆盖", "Newest daily overview for this box": "此框选的最新每日概览", - "newest clear day": "最新晴天", + "newest clear day": "最近少云日期", "newest day (cloudy)": "最新一天(多云)", "newest day (partial coverage)": "最新一天(部分覆盖)", "newest overview": "最新概览", @@ -1939,13 +1939,13 @@ "Searching the last 30 days": "正在搜索最近 30 天", "No days to show for this box": "此框选没有可显示的日期", "Drag the divider · SWAP trades sides": "拖动分割线 · “交换”互换两侧", - "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 钉住聚焦日", - "← → preview the other side · A or B pins it": "← → 预览另一侧 · A 或 B 将其钉住", - "S on another day replaces it · × unpins": "在其他日期按 S 替换 · × 取消钉住", - "← → preview · S shows the focused day": "← → 预览 · S 显示聚焦日", + "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 固定当前日期", + "← → preview the other side · A or B pins it": "← → 预览另一侧 · A 或 B 将其固定", + "S on another day replaces it · × unpins": "在其他日期按 S 替换 · × 取消固定", + "← → preview · S shows the focused day": "← → 预览 · S 显示当前日期", "ZOOM IN": "放大", "Fly in until the view fits the 1,000 km limit": "拉近直到视野满足 1,000 km 限制", - "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 钉住", + "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 固定", "On the map": "地图上", "Mode": "模式", "Imagery opacity": "影像不透明度", @@ -1961,22 +1961,22 @@ "PREVIEW": "预览", "Newest day with imagery for this box": "此框选有影像的最新一天", "SHOW": "显示", - "Pin {day} as {slot}": "将 {day} 钉为 {slot}", + "Pin {day} as {slot}": "将 {day} 固定为 {slot}", "Show {day}": "显示 {day}", - "pinned {slot}": "已钉为 {slot}", + "pinned {slot}": "已固定为 {slot}", "shown": "显示中", "preview": "预览", "start here": "从此开始", "No box": "无框选", "Searching": "搜索中", "Every day is empty here": "此处每天都为空", - "Sources off": "数据源未开启", + "Sources off": "数据源已关闭", "VS": "对比", "Not set": "未设置", "Basemap": "底图", - "Source off": "数据源已关", - "Unpin {slot}": "取消钉住 {slot}", - "Unpin the image": "取消钉住图像", + "Source off": "数据源已关闭", + "Unpin {slot}": "取消固定 {slot}", + "Unpin the image": "取消固定图像", "EXPORT {slot}": "导出 {slot}", "EXPORT": "导出", "Download {slot} as a PNG": "将 {slot} 下载为 PNG", @@ -2007,7 +2007,7 @@ "Stopped (Esc)": "已停止(Esc)", "SPARSE": "稀疏", "BALANCED": "均衡", - "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“截取镜头”保存当前画面。", + "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“保存镜头”保存当前视角及设置。", "Double-click to rename": "双击重命名", "Shot name": "镜头名称", "LOAD": "载入", @@ -2021,21 +2021,21 @@ "Cancel": "取消", "EDIT DETAILS": "编辑详情", "SHARE SCENE": "分享场景", - "Shared flight follow expired": "共享的航班跟随已过期", - "Shared military flight follow expired": "共享的军用航班跟随已过期", - "Shared satellite follow expired": "共享的卫星跟随已过期", - "Shared flight could not be restored — feed unavailable": "无法恢复共享的航班——数据源不可用", - "Shared military flight could not be restored — feed unavailable": "无法恢复共享的军用航班——数据源不可用", - "Shared satellite could not be restored — feed unavailable": "无法恢复共享的卫星——数据源不可用", - "Shared flight is unavailable": "共享的航班不可用", - "Shared military flight is unavailable": "共享的军用航班不可用", - "Shared satellite is unavailable": "共享的卫星不可用", + "Shared flight follow expired": "分享的航班跟随已过期", + "Shared military flight follow expired": "分享的军用航班跟随已过期", + "Shared satellite follow expired": "分享的卫星跟随已过期", + "Shared flight could not be restored — feed unavailable": "无法恢复分享的航班——数据源不可用", + "Shared military flight could not be restored — feed unavailable": "无法恢复分享的军用航班——数据源不可用", + "Shared satellite could not be restored — feed unavailable": "无法恢复分享的卫星——数据源不可用", + "Shared flight is unavailable": "分享的航班不可用", + "Shared military flight is unavailable": "分享的军用航班不可用", + "Shared satellite is unavailable": "分享的卫星不可用", "entity": "实体", - "Shared {subject} follow expired": "共享的 {subject} 跟随已过期", - "Shared {subject} could not be restored — feed unavailable": "无法恢复共享的 {subject}——数据源不可用", - "Shared {subject} is unavailable": "共享的 {subject} 不可用", + "Shared {subject} follow expired": "分享的 {subject} 跟随已过期", + "Shared {subject} could not be restored — feed unavailable": "无法恢复分享的 {subject}——数据源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", "ACQUIRING": "获取中", - "SHARED {subject}": "共享 {subject}", + "SHARED {subject}": "分享 {subject}", "subject": "目标", "LOADING LIVE DATA": "正在加载实时数据", "LOAD COMPLETE": "加载完成", @@ -2093,7 +2093,7 @@ }, "Loading {feeds}": "正在加载 {feeds}", "{count} heard": { - "other": "已收到 {count} 架" + "other": "已接收 {count} 架飞机的信号" }, "USB {rate} msg/s": "USB {rate} msg/s", "{rate} msg/s": "{rate} msg/s", @@ -2161,21 +2161,21 @@ "Detection overlay: off": "探测叠加层:关闭", "DETECT": "探测", "{time} · {minutes} min ago": "{time} · {minutes} 分钟前", - "Active weather": "活跃天气", + "Active weather": "已启用的天气图层", "LATEST · newest per product": "最新 · 每个产品的最新一帧", - "Forecast · valid {valid} · issued {issued}": "预报 · 生效 {valid} · 发布 {issued}", + "Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布 {issued}", "{detail} · Does not follow history": "{detail} · 不随观测历史变化", - "Forecast · valid {valid}": "预报 · 生效 {valid}", + "Forecast · valid {valid}": "预报 · 有效时间 {valid}", "voice|OFF": "关", "voice|CONNECTING": "连接中", "voice|LISTENING": "聆听中", "voice|EXECUTING": "执行中", "voice|ERROR": "错误", - "AI AGENT": "AI 代理", + "AI AGENT": "AI 智能体", "Voice model tier — applies next session": "语音模型档位——下次会话生效", "STD": "标准", "Estimated session cost": "预估会话费用", - "Voice control — activate to toggle voice; hold Space to speak": "语音控制——激活可切换语音;按住空格键说话", + "Voice control — activate to toggle voice; hold Space to speak": "语音控制——点击可切换语音;按住空格键说话", "ON/OFF": "开/关", "VOICE STANDBY": "语音待机", "VOICE CONTROL": "语音控制", @@ -2183,16 +2183,16 @@ "Voice assistant": "语音助手", "VOICE": "语音", "Dismiss voice card": "关闭语音卡片", - "YOU": "用户", - "THIS": "目标", + "YOU": "你", + "THIS": "所指", "Plan": "计划", "Referenced items": "引用条目", - "NOTES & SOURCES": "笔记与来源", + "NOTES & SOURCES": "说明与来源", "VOICE SYSTEM ERROR": "语音系统错误", "DISMISS": "关闭", "Check microphone permission and network access, then try again.": "请检查麦克风权限和网络连接,然后重试。", "Stopped by voice": "已通过语音停止", - "WebRTC microphone support unavailable": "不支持 WebRTC 麦克风", + "WebRTC microphone support unavailable": "当前浏览器不支持通过 WebRTC 使用麦克风", "Requesting microphone": "正在请求麦克风", "Release Space to send": "松开空格键发送", "Hold Space to talk": "按住空格键说话", @@ -2201,11 +2201,11 @@ "VOICE UNAVAILABLE": "语音不可用", "VOICE ACTIVE": "语音已启用", "Voice session could not be started.": "无法启动语音会话。", - "MINI": "迷你", + "MINI": "轻量", "Next session: {pendingId} — this session stays on {modelId}": "下次会话:{pendingId}——本次会话仍使用 {modelId}", "Voice model: {pendingId} — click to switch to {target}; applies next session": "语音模型:{pendingId}——点击切换到 {target};下次会话生效", "standard": "标准", - "mini": "迷你", + "mini": "轻量", "COST UNKNOWN": "费用未知", "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT OAuth 会话:已报告 {responses} 个响应、{input} 个输入 token、{output} 个输出 token、{captions} 次字幕转写。", "USD cost and the API spend cap are unavailable for this auth mode.": "此认证模式下无法提供美元费用与 API 消费上限。", @@ -2300,7 +2300,7 @@ "partial": "部分", "fallback": "备用", "unavailable": "不可用", - "Not answered: {layers}": "未回答:{layers}", + "Not answered: {layers}": "未响应:{layers}", "Toggle voice control": "切换语音控制", "Activate to toggle voice": "点击切换语音", "Voice active": "语音已启用", @@ -2310,7 +2310,7 @@ "The flights layer is not enabled": "航班图层未开启", "The military layer is not enabled": "军用航班图层未开启", "The satellites layer is not enabled": "卫星图层未开启", - "The ais-live-vessels layer is not enabled": "ais-live-vessels 图层未开启", + "The ais-live-vessels layer is not enabled": "实时 AIS 船舶图层未开启", "Could not enable the requested layer": "无法开启所请求的图层", "Could not disable the requested layer": "无法关闭所请求的图层", "Radio layer unavailable": "电台图层不可用", @@ -2334,11 +2334,11 @@ "Unknown target layer: {target}": "未知目标图层:{target}", "nominal": "正常", "{label} on": "{label} 已开启", - "Frame aircraft": "取景飞机", - "Frame military aircraft": "取景军用飞机", - "Frame ships": "取景船舶", - "Frame satellites": "取景卫星", - "Frame contacts": "取景目标", + "Frame aircraft": "将飞机纳入视野", + "Frame military aircraft": "将军用飞机纳入视野", + "Frame ships": "将船舶纳入视野", + "Frame satellites": "将卫星纳入视野", + "Frame contacts": "将目标纳入视野", "{count} aircraft in frame": { "other": "画面中 {count} 架飞机" }, @@ -2358,13 +2358,13 @@ "Nearest ships": "最近的船舶", "Nearest satellites": "最近的卫星", "Nearest aircraft": "最近的飞机", - "{first} and {second}": "{first}和{second}", + "{first} and {second}": "{first} 和 {second}", "Layer": "图层", "there": "那里", "Camera range {range}": "相机距离 {range}", "Within {radius} of the view": "距当前视野 {radius} 内", "Labels on": "标签已开启", - "Framing uses the nearest 80 loaded contacts": "取景仅使用已加载的最近 80 个目标", + "Framing uses the nearest 80 loaded contacts": "调整视野时仅包含已加载的最近 80 个目标", "{distance} from {location}": "距 {location} {distance}", "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", @@ -2373,15 +2373,15 @@ "Registration {registration}": "注册号 {registration}", "View state": "视野状态", "Marked {count} places": { - "other": "已标记 {count} 个地点" + "other": "已标注 {count} 个地点" }, - "Nothing marked": "没有标记", + "Nothing marked": "没有标注", "Not found: {places}": "未找到:{places}", - "Tracing outlines": "正在描画边界", - "Frame flights": "取景航班", - "Frame military": "取景军用航班", - "Frame vessels": "取景船舶", - "Frame {target}": "取景 {target}", + "Tracing outlines": "正在绘制轮廓", + "Frame flights": "将航班纳入视野", + "Frame military": "将军用航班纳入视野", + "Frame vessels": "将船舶纳入视野", + "Frame {target}": "将 {target} 纳入视野", "Zoom out to globe": "缩小至地球视图", "Open data layers": "打开数据图层", "Open or close a panel": "打开或关闭面板", @@ -2419,7 +2419,7 @@ "Check view state": "检查视野状态", "Action": "操作", "Finding places": "正在查找地点", - "Tracing outline": "正在描画边界", + "Tracing outline": "正在绘制轮廓", "Looking up place": "正在查询地点", "Flying": "飞行中", "Turning on layer": "正在开启图层", @@ -2430,8 +2430,8 @@ "done": "完成", "failed": "失败", "cancelled": "已取消", - "Outline traced": "边界描画完成", - "Outline unavailable": "边界不可用", + "Outline traced": "轮廓已绘制", + "Outline unavailable": "轮廓不可用", "Source: {label}": "来源:{label}", "WORKING": "处理中", "DONE": "完成", @@ -2449,7 +2449,7 @@ "Cockpit vision style": "座舱视觉样式", "Previous cockpit vision style": "上一个座舱视觉样式", "Previous vision style": "上一个视觉样式", - "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。激活可切换到下一个样式。", + "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。", "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", "CURRENT": "当前", "Next cockpit vision style": "下一个座舱视觉样式", @@ -2534,7 +2534,7 @@ "Search location by name or coordinates": "按名称或坐标搜索位置", "Search any location...": "搜索任意位置…", "WEATHER": "天气", - "Active weather products": "活跃天气产品", + "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", "Imagery days in the box": "框选内的影像日期", "CONTEXT": "态势", @@ -2564,7 +2564,7 @@ "STATION TAG": "电台标签", "Filter stations by station tag": "按电台标签筛选电台", "All": "全部", - "Enable Radio, then choose a globe marker or use next.": "请先启用电台,然后选择地球上的标记或使用“下一个”。", + "Enable Radio, then choose a globe marker or use next.": "请先启用电台,然后选择地球上的标注或使用“下一个”。", "DRAG TO TUNE": "拖动调谐", "Tune available internet radio stations": "调谐可用的网络电台", "ALL · DRAG THE NEEDLE": "全部 · 拖动指针", @@ -2590,7 +2590,7 @@ "RTL-SDR tuner gain for the current mode": "当前模式下的 RTL-SDR 调谐器增益", "AUTO": "自动", "MSG/S": "msg/s", - "HEARD": "已收到", + "HEARD": "已接收信号", "POSITIONED": "已定位", "FM MHZ": "FM MHz", "TUNE": "调谐", @@ -2628,20 +2628,20 @@ "Weighted": "加权", "Fade": "渐隐", "Detection fade distance": "探测渐隐距离", - "World-overlay fade distance outside the keyhole as a percentage of its radius": "锁孔之外的世界叠加层渐隐距离,以其半径的百分比表示", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "视域之外的世界叠加层渐隐距离,以其半径的百分比表示", "Outside": "外部", - "Detection opacity outside the keyhole": "锁孔之外的探测不透明度", + "Detection opacity outside the keyhole": "视域之外的探测不透明度", "World-overlay label and card opacity beyond the fade distance": "超出渐隐距离后世界叠加层标签和卡片的不透明度", "PARAMETERS": "参数", "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飞机——拉远显示平面图标,拉近显示 3D 模型", "Models": "模型", "3D model coverage": "3D 模型覆盖范围", - "Proximity": "邻近", + "Proximity": "近处", "Scope — the circular viewport mask": "视域——圆形视口遮罩", "Scope": "视域", "Feather": "羽化", "Scope edge feather": "视域边缘羽化", - "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以锁孔半径的百分比表示", + "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示", "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击顶点,双击或按 Enter 完成,按 Esc 取消", "Shape": "形状", "Shape to draw": "要绘制的形状", @@ -2657,7 +2657,7 @@ "Green": "绿色", "Red": "红色", "Clear": "清除", - "Remove every mark from the board": "清除图板上的所有标记", + "Remove every mark from the board": "清除画板上的所有标注", "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上显示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件内容仅保留在本设备上;叠加层可能出现在共享给语音的地图截图中。", "Import": "导入", "Choose GeoJSON, KML, KMZ or GPX files": "选择 GeoJSON、KML、KMZ 或 GPX 文件", @@ -2670,16 +2670,16 @@ "Sharpening": "锐化", "Sharpen": "锐化", "Sharpen intensity": "锐化强度", - "EXIT CLEAN VIEW": "退出纯净视图", + "EXIT CLEAN VIEW": "退出纯净界面", "Return UI controls": "恢复界面控件", - "Initializing photorealistic world...": "正在初始化逼真世界…", + "Initializing photorealistic world...": "正在初始化照片级真实世界…", "DATA LAYERS": "数据图层", "ACQUIRING FRAME": "正在获取画面", "NEAREST": "最近", "CCTV camera": "CCTV 摄像头", "CALIBRATION": "校准", "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖动摄像头:圆环旋转,箭头平移,控制点设置范围/FOV", - "Camera pose — click a value to type": "摄像头姿态——点击数值可输入", + "Camera pose — click a value to type": "摄像头位姿——点击数值可输入", "Heading (compass °) — click to type": "航向(罗盘 °)——点击输入", "Pitch (° up/down) — click to type": "俯仰(° 上/下)——点击输入", "Horizontal FOV (°) — click to type": "水平 FOV(°)——点击输入", @@ -2700,9 +2700,9 @@ "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中", "Close the street-level image": "关闭街景图像", "Street-level viewer": "街景查看器", - "Open this image on the provider's site": "在提供商网站上打开此图像", - "Imagery providers": "影像提供商", - "PROVIDERS": "提供商", + "Open this image on the provider's site": "在服务商网站上打开此图像", + "Imagery providers": "影像服务商", + "PROVIDERS": "服务商", "Imagery filters": "影像筛选", "IMAGERY": "影像", "Panorama filter": "全景筛选", @@ -2712,7 +2712,7 @@ "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", - "CAPTURE SHOT": "截取镜头", + "CAPTURE SHOT": "保存镜头", "UPDATE SHOT": "更新镜头", "START": "启动", "EXPORT PRESETS": "导出预设", @@ -2725,26 +2725,26 @@ "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球无需密钥即可运行。下面的每个密钥都会启用一个新的真实数据源:粘贴后会保存到本应用的本地配置,随后服务器自动重启。服务端密钥只保存在本机;Google Maps 和 Cesium ion 在浏览器中运行,必须设置服务商限制。你在其他位置配置的密钥会显示,但不会被改动。", "SAVE KEYS": "保存密钥", "ESC to close": "按 ESC 关闭", - "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供逼真的地球,其余数据源都叠加在其上。", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供照片级 3D 地球,其余数据源都叠加在其上。", "NO PLACE LEFT BEHIND": "不遗漏任何地方", "ACTIVE STYLE": "当前样式", "Globe actions": "地球操作", "Turn off all selected data layers": "关闭所有已选数据图层", "Copy share link": "复制分享链接", "Toggle straight-down and tilted map views": "在垂直俯视与倾斜视角之间切换", - "Reset map to north up": "将地图重置为朝北", + "Reset map to north up": "将地图重置为正北朝上", "Reset map bearing to north": "重置地图方位为正北", "Reset camera and return to full globe view": "重置相机并返回完整地球视图", "MISSION CONTROL · FIRST LAUNCH": "任务控制 · 首次启动", - "Choose your first view": "选择你的首个视图", + "Choose your first view": "选择初始视图", "LIVE CONTACTS": "实时目标", "Aircraft, vessels and nearby intelligence": "飞机、船舶及周边情报", "Launches, spacecraft and orbital context": "发射、航天器与轨道态势", "EXPLORE MANUALLY": "手动探索", - "Begin with a clean globe": "从干净的地球开始", + "Begin with a clean globe": "从未叠加数据图层的地球开始", "Don't show this again": "不再显示", "ESC to dismiss": "按 ESC 关闭", - "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:停靠栏中的 GEV 麦克风按钮可让你用语音操控地图。", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通过停靠栏中的 GEV 麦克风按钮用语音操控地图。", "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", From fd78bc38845c7fbc6fc8bd82c6eac1e6b0048155 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:36:38 +0400 Subject: [PATCH 061/178] fix(zh-CN): localize readouts and keep short labels intact Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English. Co-Authored-By: Codex (GPT-6) --- src/hudLocalityFormat.js | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/same-as-english.json | 1 + src/i18n/zh-CN-display.test.mjs | 106 ++++++++++++++++++++++++++++ src/layers/firms/model.js | 1 + src/layers/firms/queries.js | 3 +- src/layers/recentImagery/display.js | 12 +++- src/ui/cctvFrames.js | 3 +- src/ui/cockpitContext.js | 4 +- src/ui/cockpitPresentation.js | 7 +- src/ui/radioPresentation.js | 11 +-- src/ui/styles/cjk.css | 25 +++++++ 14 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 src/i18n/zh-CN-display.test.mjs diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 84278564b..b508f8d59 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return `${number}${unitLabel(unit).toUpperCase()}`; + const label = unitLabel(unit); + if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + return `${number}${label.toUpperCase()}`; } /** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */ diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index cbba42e0e..c26430fe7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2941,5 +2941,7 @@ "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", - "hemisphere|W": "O" + "hemisphere|W": "O", + "OK": "OK", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dcd6ef20b..dadea1a42 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3008,5 +3008,7 @@ "hemisphere|N": "С", "hemisphere|S": "Ю", "hemisphere|E": "В", - "hemisphere|W": "З" + "hemisphere|W": "З", + "OK": "НОРМА", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8b5240e12..6b815f545 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2807,5 +2807,7 @@ "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打断的输入超出了缓冲区或等待上限。请重新启动语音。", "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 连接即将到期。请重新启动语音以开始新对话。", "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。", - "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)" + "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)", + "OK": "正常", + "{category} ({count})": "{category}({count})" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 821b154b9..c331cd070 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -63,6 +63,7 @@ "NVG", "Noir", "Normal", + "OK", "POS: {time}Z", "Plan", "Pop", diff --git a/src/i18n/zh-CN-display.test.mjs b/src/i18n/zh-CN-display.test.mjs new file mode 100644 index 000000000..b9e55058d --- /dev/null +++ b/src/i18n/zh-CN-display.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t } from './index.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { cctvSourceStatusLabel } from '../ui/cctvFrames.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { createModel } from '../layers/firms/model.js'; +import { createQueries } from '../layers/firms/queries.js'; +import { renderRadioState } from '../ui/radioPresentation.js'; + +test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => { + try { + setLocale('zh-CN'); + assert.equal(hudQuantity(0, 'km'), '0 km'); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km'); + assert.equal(formatCockpitDistance(14000), '14\u00a0km'); + assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }), + '扇区 21.33N 77.50W'); + setLocale('en'); + assert.equal(hudQuantity(0, 'km'), '0KM'); + assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM'); + assert.equal(formatCockpitDistance(14000), '14\u00a0KM'); + } finally { + setLocale('en'); + } +}); + +test('FIRMS stale cache age uses localized durations while English stays compact', () => { + const now = Date.now(); + const layerState = { _stale: true }; + const components = { + model: createModel({ services: { anchors: {} } }), + }; + const { methods } = createQueries({ layerState, components, config: {} }); + try { + for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) { + layerState._lastUpdate = now - hours * 3600000; + setLocale('zh-CN'); + const expected = t('STALE · cached {age}', { age: zh }); + assert.equal(methods.getStats().loadingLabel, expected); + assert.equal(methods.getStats().error, expected); + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`); + } + } finally { + setLocale('en'); + } +}); + +test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => { + try { + setLocale('zh-CN'); + assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); + assert.equal(READOUT_WORDS.daysAgo(0), '今天'); + assert.equal(READOUT_WORDS.daysAgo(1), '昨天'); + setLocale('en'); + assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago'); + assert.equal(READOUT_WORDS.daysAgo(0), 'today'); + } finally { + setLocale('en'); + } +}); + +test('CCTV translates the healthy status and preserves unknown provider codes', () => { + try { + for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) { + setLocale(locale); + assert.equal(cctvSourceStatusLabel('ok'), expected); + assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM'); + } + } finally { + setLocale('en'); + } +}); + +test('radio option text and accessible name use the same localized parentheses', () => { + const originalDocument = globalThis.document; + const element = () => ({ + style: {}, dataset: {}, attributes: {}, + classList: { contains: () => true, toggle() {} }, + setAttribute(name, value) { this.attributes[name] = value; }, + replaceChildren(...children) { this.children = children; }, + }); + globalThis.document = { createElement: element, getElementById: () => null }; + try { + for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) { + setLocale(locale); + const host = { + _radioPanel: element(), _radioFilter: element(), + _syncContextRadioLauncherState() {}, + actions: { getLifecycle: () => null, scheduleLayout() {} }, + }; + renderRadioState.call(host, { + enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }], + filter: 'all', filteredCount: 750, stationCount: 750, volume: 1, + }); + const option = host._radioFilter.children[0]; + assert.equal(option.textContent, `● ${expected}`); + assert.equal(option.attributes['aria-label'], expected); + } + } finally { + globalThis.document = originalDocument; + setLocale('en'); + } +}); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 3a1df327b..226c32286 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -591,6 +591,7 @@ export function createModel({ formatLatLon, formatFrp, formatAge, + formatAgeLabel, formatAgoMinutes, confidenceBucket, }; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..c4a793f9d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -64,7 +64,8 @@ export function createQueries({ const staleText = layerState._lastUpdate ? t('STALE · cached {age}', { age: - components.model.formatAge(now - layerState._lastUpdate) || '<1h', + components.model.formatAgeLabel(now - layerState._lastUpdate) || + t('<1h', { context: 'age' }), }) : t('STALE'); let loadingLabel = ''; diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5b2b5ce39..5c18d8b3f 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -3,7 +3,12 @@ * day's readout. The model (`model.js`) stays English and i18n-free because AI * tool services import it outside the browser; this module is page-only. */ -import { formatDate, formatRelativeTime, t } from '../../i18n/index.js'; +import { + formatDate, + formatRelativeTime, + getLocale, + t, +} from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { dayTime } from './model.js'; @@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm'); /** The readout's words in the page's language; the model keeps them English. */ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), - daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), + daysAgo: (delta) => { + const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); + return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + }, // "Sentinel-2" never breaks at its hyphen: a word joiner follows it // (JetBrains Mono has no U+2011 non-breaking hyphen). sensor: (sensor) => diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index 7976d46c8..22f4f0dce 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -19,6 +19,7 @@ const SOURCE_KIND_LABELS = Object.freeze({ }); const SOURCE_STATUS_LABELS = Object.freeze({ + ok: msg('OK'), degraded: msg('DEGRADED'), error: msg('ERROR'), unknown: msg('UNKNOWN'), @@ -51,7 +52,7 @@ export function cctvSourceKindLabel(kind) { : key.toUpperCase(); } -/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */ +/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */ export function cctvSourceStatusLabel(status) { const key = String(status || 'unknown').toLowerCase(); return SOURCE_STATUS_LABELS[key] diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 57db349ee..852cc32a8 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,6 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; -import { formatNumber, t, tn } from '../i18n/index.js'; +import { formatNumber, getLocale, t, tn } from '../i18n/index.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ window: ({ label, radiusKm }) => t('{label} · {radius} AIR/SEA WINDOW', { label, - radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, }), installations: ({ scope, coverage }) => t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index f8930d4e0..8285b74b3 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,5 +1,5 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ -import { formatNumber, t, msg } from '../i18n/index.js'; +import { formatNumber, getLocale, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) { */ export function formatCockpitDistance(distanceM) { const digits = distanceM < 10000 ? 1 : 0; - return formatQuantity(distanceM / 1000, 'km', { + const distance = formatQuantity(distanceM / 1000, 'km', { minimumFractionDigits: digits, maximumFractionDigits: digits, - }).toUpperCase(); + }); + return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 94431fdec..54ee379e8 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -114,13 +114,14 @@ export function renderRadioState(state) { ...state.categories.map((category) => { const option = document.createElement('option'); option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; + const label = t('{category} ({count})', { + category: category.label, + count: category.count, + }); + option.textContent = `● ${label}`; option.dataset.radioColor = category.color; option.style.color = category.color; - option.setAttribute( - 'aria-label', - `${category.label} (${category.count})`, - ); + option.setAttribute('aria-label', label); return option; }), ); diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css index c44431520..9ecfa0ce3 100644 --- a/src/ui/styles/cjk.css +++ b/src/ui/styles/cjk.css @@ -27,3 +27,28 @@ html:lang(zh) .panel-layer-toggle { html:lang(zh) .pp-mode-btn { white-space: nowrap; } + +/* Keep unit symbols in their catalog case, including the cockpit readouts. */ +html:lang(zh) #intel-hud *, +html:lang(zh) #cockpit-hud * { + text-transform: none; +} + +/* Move short travel modes (步行 / 骑行) to the next line as whole words. */ +html:lang(zh) [data-layer-id='directions'] .data-toggle-meta { + word-break: keep-all; + overflow-wrap: break-word; +} + +/* Leave room for the complete sensor name and resolution on two lines. */ +html:lang(zh) .recent-imagery-readout .ri-strip { + height: 131px; +} +html:lang(zh) .recent-imagery-readout .ri-card { + height: 123px; + grid-template-rows: 42px 13px 22px 11px 18px; +} +html:lang(zh) .recent-imagery-readout .ri-card-sensor { + white-space: normal; + text-overflow: clip; +} 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 062/178] 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 063/178] 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 82c722e6afa4605c2bb1d70f1a3c490b3f6b7ae8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 21 Oct 2026 17:40:49 +0100 Subject: [PATCH 064/178] chore(format): keep format-scope in step with main Co-Authored-By: Claude Opus 5.5 --- scripts/format-scope.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index fb291362d..fd710642d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1078,6 +1078,5 @@ "src/ui/statusBadge.test.mjs", "src/ui/cctvBadges.test.mjs", "scripts/qa-status-badges.mjs", - "src/data/routeSegment.test.mjs", "src/dockerTrustedPeers.test.mjs" ] From f6517691106751238c7eb2250a666a973d524188 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 12:12:39 +0400 Subject: [PATCH 065/178] i18n(zh-CN): apply the catalog review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of all 2,677 zh-CN entries (scripted checks + full meaning review) confirmed the catalog fully Simplified and mechanically clean. 83 fixes in two passes: Meaning and terms — PARTLY CLOUDY lost 'partly' (多云 → 晴间多云); AIR/SEA WINDOW said scheduled flights (航班 → 空/海监视窗口); Mapped ALPR Cameras collided with ALPR Cameras; globe terms unified on 地球 (球体地图/球体影像); later/earlier observation buttons read as future actions (稍后/早前 → 较晚/较早); 从未叠加… misread as 'never'; 风流线 → 风场流线; 访问飞行器 → 来访航天器; 目击 → 目击者; downrange is 落点距离 not 航程. Polish — 加载 over 载入; 预警 for advisories; {amount}K/M/B 美元; 空载; 窗口 over 视窗; 分享的 {subject} matches its siblings; missing CJK↔Latin spaces restored around {store} and {mode}; 拦截点击, 已被限流, 精选, 逼真世界, and other one-word mainland-usage fixes. --- src/i18n/locales/zh-CN.json | 166 ++++++++++++++++++------------------ 1 file changed, 83 insertions(+), 83 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6b815f545..ad75de5d4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -5,7 +5,7 @@ "Click {count} more points.": { "other": "再点击 {count} 个点。" }, - "Those points are in a line — move one off it to enclose an area.": "这些点都在同一条直线上——请移开其中一个点才能围出面。", + "Those points are in a line — move one off it to enclose an area.": "这些点都在同一条直线上——请移开其中一个点才能围出区域。", "That line has no length — click somewhere further away.": "这条线没有长度——请在更远处点击。", " · {max}-point limit reached": " · 已达 {max} 个点上限", "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · 双击或按 Enter 完成,按 Backspace 撤销,按 Esc 取消。{full}", @@ -119,7 +119,7 @@ "EVENT RECONSTRUCTION · 26 AUG 2026": "事件重构 · 2026-08-26", "BHOTE KOSHI OUTBURST FLOOD": "BHOTE KOSHI 溃决洪水", "Close event layer": "关闭事件图层", - "OBSERVED IMAGERY": "实测影像", + "OBSERVED IMAGERY": "观测影像", "SCHEMATIC CORRIDOR": "示意走廊", "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年历史参考 / 2026 年灾后", "Historical reference and post-event image split": "历史参考与灾后影像的分割", @@ -131,7 +131,7 @@ "CAPTURE TIME UNVERIFIED": "拍摄时间未核实", "Next story beat": "下一个事件节点", "▶ PLAY": "▶ 播放", - "Play the next shot and continue through this scene": "播放下一个镜头并继续整个场景", + "Play the next shot and continue through this scene": "播放下一个镜头,继续播放整个场景", "▶ PLAY SCENE": "▶ 播放场景", "◉ CINEMATIC": "◉ 运镜", "↺ FULL STORY": "↺ 完整故事", @@ -141,7 +141,7 @@ "GEOLOCATIONS · {credit}": "地理定位 · {credit}", "↗ OPEN PUBLIC MAP": "↗ 打开公共地图", " Clouds are preserved from the source imagery.": "云层按源影像原样保留。", - "Rasuwagadhi witness": "Rasuwagadhi 目击", + "Rasuwagadhi witness": "Rasuwagadhi 目击者", "Open original source for {source}; capture time unverified": "打开 {source} 的原始来源;拍摄时间未核实", "Historical reference and post-event image divider": "历史参考与灾后影像的分割线", "A historical reference {before} percent, B post-event {after} percent": "A 历史参考 {before}%,B 灾后 {after}%", @@ -163,13 +163,13 @@ "other": "{count} 个来源" }, "OUTSIDE IMAGERY SWIPE": "不在影像对比范围内", - "{scene} / AUTHORED SHOT": "{scene} / 排定镜头", + "{scene} / AUTHORED SHOT": "{scene} / 编排镜头", "NEPAL FLOOD INCIDENT": "尼泊尔洪水事件", "Play {shot}": "播放 {shot}", "current scene shot": "当前场景镜头", "Play reconstruction": "播放重构", - "Load previous scene shot": "载入上一个场景镜头", - "Load next scene shot": "载入下一个场景镜头", + "Load previous scene shot": "加载上一个场景镜头", + "Load next scene shot": "加载下一个场景镜头", "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "示意走廊,并非模拟的到达时间。", "Rasuwa · 26 Aug 2026": "Rasuwa · 2026-08-26", "Event reconstruction": "事件重构", @@ -261,7 +261,7 @@ "Directions": "路线导航", "Recent imagery": "近期影像", "STATION": "空间站", - "Crewed stations and their visiting vehicles": "载人空间站及其访问飞行器", + "Crewed stations and their visiting vehicles": "载人空间站及其来访航天器", "NAV": "导航", "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 导航:GPS、GLONASS、Galileo", "GEO": "GEO", @@ -296,11 +296,11 @@ "ENVIRONMENTAL": "环境", "EARTH WATCH": "地球监测", "ACTIVE EVENTS": "活跃事件", - "Starting live contacts…": "正在启动实时目标…", + "Starting live contacts…": "正在接入实时目标…", "Opening space missions…": "正在打开太空任务…", "Scanning active events…": "正在扫描活跃事件…", "Live earthquakes and active fires, from USGS and NASA": "来自 USGS 和 NASA 的实时地震与活跃火点", - "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "仿佛闯进了一间机密座舱——随后你会发现,数据来源全部公开,数据全部真实。", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "仿佛闯进了一个机密座舱——随后你会发现,数据来源全部公开,数据全部真实。", "Working…": "处理中…", "Could not open that mission{detail}. Retry or explore manually.": "无法打开该任务{detail}。请重试或手动探索。", "This browser is blocking storage, so that could not be saved.": "此浏览器阻止了存储,因此无法保存。", @@ -337,7 +337,7 @@ "REC": "REC", "AIS: --": "AIS: --", "N/A": "无", - "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 视窗 {window} | SUN {sun}° | ONA {ona}° | {zone}", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | 窗口 {window} | SUN {sun}° | ONA {ona}° | {zone}", "NEAR {poi} ({city}) {distance}": "{poi}({city})附近 {distance}", "SECTOR {lat} {lon}": "扇区 {lat} {lon}", "N": "北", @@ -350,7 +350,7 @@ "POWERED UP": "已增强", "Provider Settings does not answer proxied requests": "服务商设置不响应经代理的请求", "Provider Settings is disabled while sharing is enabled": "启用分享时,服务商设置不可用", - "Provider Settings answers only the machine running the server": "服务商设置只响应运行服务器的本机", + "Provider Settings answers only the machine running the server": "服务商设置仅响应服务器所在的本机", "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", "Unrecognized Origin refused": "已拒绝无法识别的 Origin", @@ -385,7 +385,7 @@ "Optional paid token — public access works without one": "可选的付费令牌——无需令牌也可公开访问", "Free key — register, paste, done": "免费密钥——注册、粘贴,即可使用", "browser-side": "浏览器端", - "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密钥按设计在浏览器中运行——请在服务商处设置限制(见 SECURITY.md)", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "此密钥设计上就在浏览器中运行——请在服务商处设置限制(见 SECURITY.md)", "configured externally": "由外部配置", "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的环境、钥匙串或启动器提供——请在原设置处修改", "MANAGE ↗": "管理 ↗", @@ -400,20 +400,20 @@ "your local .env": "你的本地 .env", "Saving…": "正在保存…", "Save failed: {detail}": "保存失败:{detail}", - "Paste at least one key first.": "请先至少粘贴一个密钥。", - "Saved to {store}. Restarting — this page reloads itself.": "已保存到{store}。正在重启——本页面将自动重新加载。", + "Paste at least one key first.": "请先粘贴至少一个密钥。", + "Saved to {store}. Restarting — this page reloads itself.": "已保存到 {store}。正在重启——本页面将自动重新加载。", "Cloud voice will use OPENAI_API_KEY on the next session.": "云语音将在下次会话使用 OPENAI_API_KEY。", "Checking local ChatGPT OAuth sign-in…": "正在检查本机 ChatGPT OAuth 登录…", "Could not check ChatGPT sign-in.": "无法检查 ChatGPT 登录状态。", "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "已为云语音选择 ChatGPT OAuth。你的 API 密钥仍会保留并可用。", "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT 登录已过期。正在重新打开登录…", "Opening ChatGPT sign-in in your browser…": "正在浏览器中打开 ChatGPT 登录…", - "Could not start ChatGPT sign-in on this machine.": "无法在此机器上启动 ChatGPT 登录。", + "Could not start ChatGPT sign-in on this machine.": "无法在本机启动 ChatGPT 登录。", "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "请在浏览器中完成 ChatGPT 登录,正在等待其完成…", "ChatGPT sign-in timed out. Click {button} to try again.": "ChatGPT 登录超时。点击“{button}”重试。", "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登录完成。下次云语音会话将使用 OAuth。", "Remove this key from your saved configuration?": "要从已保存的配置中移除此密钥吗?", - "Removed from {store}. Restarting — this page reloads itself.": "已从{store}中移除。正在重启——本页面将自动重新加载。", + "Removed from {store}. Restarting — this page reloads itself.": "已从 {store} 中移除。正在重启——本页面将自动重新加载。", "The photorealistic 3D planet, place search + Street View": "照片级 3D 地球、地点搜索 + 街景", "Places context + Street View fallback; optional separate key": "地点信息 + 街景备用;可选的独立密钥", "Voice control — API key or ChatGPT OAuth": "语音控制——API 密钥或 ChatGPT OAuth", @@ -421,12 +421,12 @@ "Live active-fire detections": "实时活跃火点探测", "Real live traffic (keyless runs a simulation)": "真实实时路况(无密钥时为模拟)", "Bing imagery map stacks + world terrain": "Bing 影像地图组合 + 全球地形", - "More flight-polling credits (anonymous works without)": "更多航班轮询额度(匿名也可使用)", + "More flight-polling credits (anonymous works without)": "更多航班轮询额度(无密钥也可匿名使用)", "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像与街景图层中的覆盖范围。免费:在 Mapillary 开发者控制台注册一个应用,并粘贴其 Client Token", "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公开的太空任务数据无需密钥。常规使用请留空;付费令牌只会提高请求上限。", "Air quality request failed": "空气质量请求失败", "No readings available": "暂无读数", - "No current readings": "暂无当前读数", + "No current readings": "暂无最新读数", "{count} stations reporting": { "other": "{count} 个站点上报数据" }, @@ -447,7 +447,7 @@ "Malformed AQHI observation response": "AQHI 观测响应格式错误", "Camera source temporarily unavailable": "摄像头数据源暂时不可用", "Mapped camera locations": "地图标注的摄像头位置", - "SHOW NEAREST": "最近摄像头", + "SHOW NEAREST": "显示最近", "Stop following the current object before navigating to a camera": "请先停止跟踪当前目标,再前往摄像头", "Move to the nearest loaded camera and show its details": "前往最近已加载的摄像头并显示详情", "Camera badges": "摄像头标记", @@ -462,18 +462,18 @@ "loading mapped ALPR cameras": "正在加载 ALPR 摄像头", "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据——仅覆盖美国和加拿大", "Zoom in to load mapped cameras": "放大以加载地图标注的摄像头", - "Showing cached locations": "正在显示缓存位置", + "Showing cached locations": "当前显示的是缓存位置", "Coverage limited — zoom in": "覆盖受限——请放大", "No ALPR data for this area": "此区域没有 ALPR 数据", "None on screen — nearby cameras are outside the view": "屏幕上没有摄像头——附近的摄像头在视野之外", "ALPR camera": "ALPR 摄像头", - "Camera coverage unavailable": "摄像头覆盖不可用", + "Camera coverage unavailable": "摄像头覆盖范围不可用", "OpenStreetMap · community mapped": "OpenStreetMap · 社区标注", "Overpass returned an incomplete camera response": "Overpass 返回的摄像头数据不完整", "At least {count}": "至少 {count}", "CONTEXT READY": "态势就绪", "GLOBAL CONTEXT OFF": "全局态势已关闭", - "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择一个航班、船舶或地图标注设施", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "选择航班、船舶或地图标注设施", "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "启用以加载观测 / 地图标注的邻近信息", "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", @@ -569,7 +569,7 @@ "route too long": "路线过长", "{error} — try again in a moment": "{error}——请稍后重试", "Routing is rate limited — try again in a moment": "路线请求过于频繁——请稍后重试", - "Routing unavailable (HTTP {status})": "路线服务不可用(HTTP {status})", + "Routing unavailable (HTTP {status})": "路线规划不可用(HTTP {status})", "Routing unavailable": "路线规划不可用", "direction|left": "向左", "direction|right": "向右", @@ -667,7 +667,7 @@ "{name} is active — {leave}, then set {endpoint}": "{name} 正在使用——请先{leave},再设置 {endpoint}", "Only the first {count} turns of this route are shown": "此路线仅显示前 {count} 个转向", "Turn-by-turn directions": "分步导航", - "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在占用点击——请先关闭它,再重新设置 A", + "Another map tool is using clicks — close it, then SET A again": "另一个地图工具正在拦截点击——请先关闭它,再重新设置 A", "Routing…": "正在规划路线…", "No route found": "未找到路线", "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · 前 {count} 个转向", @@ -684,7 +684,7 @@ "Routing unavailable — no response from the routing service": "路线规划不可用——路线服务无响应", "Earthquake source unavailable": "地震数据源不可用", "Focus fire detection {title}, {details}": "聚焦火点探测 {title},{details}", - "live feed unavailable": "实时数据不可用", + "live feed unavailable": "实时数据源不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value}前", "sensor n/a": "传感器 n/a", @@ -765,7 +765,7 @@ "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 条有效载荷记录", "PAYLOAD DATA UNAVAILABLE": "有效载荷数据不可用", - "{value} KM DOWNRANGE": "航程 {value} km", + "{value} KM DOWNRANGE": "落点距离 {value} km", "POSITION UNAVAILABLE": "位置不可用", "FLIGHT {count}": "第 {count} 次飞行", "REUSED": "复用", @@ -806,7 +806,7 @@ "SHOW ALL / DESELECT": "显示全部 / 取消选择", "Pause replay": "暂停回放", "Cancel replay": "取消回放", - "EST. DOWNRANGE": "预估航程", + "EST. DOWNRANGE": "预估落点距离", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", @@ -849,9 +849,9 @@ "Type unknown": "类型未知", "Reg unknown": "注册号未知", "Operator unknown": "运营方未知", - "${amount}K": "{amount} 千美元", - "${amount}M": "{amount} 百万美元", - "${amount}B": "{amount} 十亿美元", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", "fire cause|Human": "人为", "fire cause|Natural": "自然", "fire cause|Undetermined": "待定", @@ -901,7 +901,7 @@ "Directory refresh failed; showing the previous station catalog.": "目录刷新失败;正在显示上一次的电台目录。", "Radio directory is temporarily unavailable.": "电台目录暂时不可用。", "radio|NEWS": "新闻", - "radio|TALK": "访谈", + "radio|TALK": "脱口秀", "radio|WEATHER": "天气", "radio|SAFETY": "安全", "radio|AIR / SEA": "航空 / 海事", @@ -942,7 +942,7 @@ "{min}–{max}% scene cloud": "整景云量 {min}–{max}%", "Comparison in use by another scene": "对比功能正被另一个场景占用", "Esri map unavailable · no swipe": "Esri 地图不可用 · 无法滑动对比", - "Swipe needs a globe map": "滑动对比需要球体地图", + "Swipe needs a globe map": "滑动对比需要地球地图", "Imagery stays on Esri · CLEAR to use Google 3D": "影像保留在 Esri · 清除后改用 Google 3D", "Imagery catalog unavailable": "影像目录不可用", "Catalog truncated · a newer clear day may exist": "目录已截断 · 可能还有更近的少云日期", @@ -990,7 +990,7 @@ "The Google Maps JavaScript API could not load": "Google Maps JavaScript API 无法加载", "Google Street View": "Google 街景", "GOOGLE STREET VIEW": "Google 街景", - "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒绝了用于街景的 GOOGLE_MAPS_API_KEY——请检查该密钥已启用 Maps JavaScript API,且本站点在允许的引荐来源(referrer)中", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google 拒绝了用于街景的 GOOGLE_MAPS_API_KEY——请确认已为该密钥启用 Maps JavaScript API,且本站点在允许的引荐来源(referrer)中", "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "街景无响应——请检查网络,并确认 GOOGLE_MAPS_API_KEY 已启用 Maps JavaScript API", "click a street to open Street View": "点击街道以打开街景", "Street View has no imagery for this place": "街景没有此地的影像", @@ -1030,8 +1030,8 @@ "TeleGeography load failed": "TeleGeography 加载失败", "UNAVAILABLE · {source} · Roads unavailable": "不可用 · {source} · 道路不可用", "Roads: {source}": "道路:{source}", - "TomTom roads unavailable while the traffic service is unreachable": "无法连接交通服务期间 TomTom 道路不可用", - "Hybrid unavailable while the traffic service is unreachable": "无法连接交通服务期间混合模式不可用", + "TomTom roads unavailable while the traffic service is unreachable": "交通服务无法连接期间,TomTom 道路不可用", + "Hybrid unavailable while the traffic service is unreachable": "交通服务无法连接期间,混合模式不可用", "TomTom roads need a TomTom key": "TomTom 道路需要 TomTom 密钥", "Hybrid needs a TomTom key": "混合模式需要 TomTom 密钥", "Local surface still loading": "本地路面仍在加载", @@ -1060,14 +1060,14 @@ "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "模拟 · 道路:OpenStreetMap · 路况:TomTom(无匹配)", "SIMULATED — traffic service unreachable": "模拟——交通服务无法连接", "SIMULATED — add TomTom key for live": "模拟——添加 TomTom 密钥以获取实时数据", - "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片受到频率限制", + "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超时", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", - "occupancy|empty": "空车", + "occupancy|empty": "空载", "occupancy|many seats available": "空座较多", "occupancy|few seats available": "空座较少", "occupancy|standing room only": "仅剩站位", - "occupancy|crushed standing room only": "拥挤,仅剩站位", + "occupancy|crushed standing room only": "极度拥挤,仅剩站位", "occupancy|full": "已满", "occupancy|not accepting passengers": "不载客", "occupancy|not boardable": "无法上车", @@ -1120,7 +1120,7 @@ "feed down — retrying slowly": "数据源中断——正在低频重试", "reconnecting to feed…": "正在重新连接数据源…", "Focus vessel {name}, MMSI {mmsi}": "聚焦船舶 {name},MMSI {mmsi}", - "Hidden by this map source · choose a globe map": "已被当前地图源隐藏 · 请选择球体地图", + "Hidden by this map source · choose a globe map": "已被当前地图源隐藏 · 请选择地球地图", "Weather image HTTP {status}": "天气图像 HTTP {status}", "Weather HTTP {status}": "天气 HTTP {status}", "Frame unavailable; previous observation retained": "帧不可用;保留上一帧观测", @@ -1137,7 +1137,7 @@ "Observation unavailable": "观测不可用", "Waiting for observation": "等待观测数据", "{detail} · synced": "{detail} · 已同步", - "{detail} · nearest": "{detail} · 最近", + "{detail} · nearest": "{detail} · 最接近", "RADAR REFLECTIVITY · dBZ": "雷达反射率 · dBZ", "LIGHTNING DENSITY · 15 min accumulation": "闪电密度 · 15 分钟累计", "GLOBAL INFRARED · hourly": "全球红外 · 每小时", @@ -1147,9 +1147,9 @@ "frame {index}/{total}": "第 {index}/{total} 帧", "loading": "加载中", "Observation: unavailable": "观测:不可用", - "Contiguous US · gaps ≠ no rain": "美国本土连续区域 · 数据缺失 ≠ 无降雨", + "Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨", "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", - "Color: strikes/km²/min ×10³": "颜色:次闪击/km²/分钟 ×10³", + "Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "North America · infrared imagery": "北美洲 · 红外影像", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", @@ -1164,7 +1164,7 @@ "Stale source": "数据源已过期", "Loading next frame…": "正在加载下一帧…", "Map center outside coverage": "地图中心在覆盖范围之外", - "strikes/km²/min ×10³": "次闪击/km²/分钟 ×10³", + "strikes/km²/min ×10³": "闪击次数/km²/分钟 ×10³", "N. America": "北美", "Global": "全球", "Hourly global mosaic; usually 2–3 hours delayed": "每小时全球镶嵌图;通常延迟 2–3 小时", @@ -1181,7 +1181,7 @@ "View coverage": "查看覆盖范围", "{value} strikes/km²/min ×10³ (15-minute density)": "{value} 次闪击/km²/分钟 ×10³(15 分钟密度)", "{value} dBZ radar reflectivity": "{value} dBZ 雷达反射率", - "STALE · cached source metadata": "已过期 · 数据源元数据为缓存", + "STALE · cached source metadata": "已过期 · 数据源元数据来自缓存", "Map center is outside source coverage": "地图中心在数据源覆盖范围之外", "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分钟闪电密度,来源为 Vaisala NLDN/GLD360。覆盖自 110°E 经太平洋/美洲至 0°,25°S–80°N。不是实时闪击计数、不是全球覆盖,也不构成安全警示。", "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据分辨率约 1 km;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", @@ -1224,7 +1224,7 @@ "Speed": "风速", "Pressure": "气压", "Temperature": "气温", - "Wind trails with no field shading": "仅显示风流线,不叠加气象场填色", + "Wind trails with no field shading": "仅显示风场流线,不叠加气象场填色", "How hard the surface wind is blowing": "地面风有多大", "Sea-level air pressure: broad highs and lows": "海平面气压:大范围的高压与低压", "Air temperature two meters above the surface": "地面以上两米的气温", @@ -1237,7 +1237,7 @@ "Issued: {time}": "发布:{time}", "Selected field unavailable · wind remains visible": "所选气象场不可用 · 风场仍可见", "{error} · wind remains visible": "{error} · 风场仍可见", - "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为可读性服务。动画展示的是单一固定预报内的流场,不推进时间。气象场填色叠加于地球底图或当前的照片级 3D Tiles。", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为提高可读性。动画展示的是单一固定预报内的流场,不推进时间。气象场填色叠加于地球底图或当前的照片级 3D Tiles。", "MODEL": "模式", "FIELD": "要素", "UNITS": "单位", @@ -1264,7 +1264,7 @@ "WIND AT {coordinates}": "{coordinates} 的风", "{model} · valid {time}": "{model} · 有效时间 {time}", "Wind speed field unavailable": "风速场不可用", - "Globe imagery unavailable": "球体影像不可用", + "Globe imagery unavailable": "地球影像不可用", "Globe field image unavailable": "地球气象场图像不可用", "Unknown wind model": "未知风场模式", "Unknown weather overlay": "未知天气叠加层", @@ -1323,7 +1323,7 @@ "Seeking scene clock": "正在定位场景时钟", "Camera unavailable — exit cockpit first": "相机不可用——请先退出座舱", "No shots to run": "没有可运行的镜头", - "Scene transition limit reached — load a shot to reset": "已达到场景切换上限——请载入一个镜头以重置", + "Scene transition limit reached — load a shot to reset": "已达到场景切换上限——请加载一个镜头以重置", "Export failed: {error}": "导出失败:{error}", "Project exported": "项目已导出", "Importing project": "正在导入项目", @@ -1350,7 +1350,7 @@ "Omniscience Pullback": "全知拉远", "Global Incident Context": "全球事件背景", "Nepal-Focused Globe Rotation": "聚焦尼泊尔的地球旋转", - "Bhote Koshi Regional Approach": "Bhote Koshi 区域接近", + "Bhote Koshi Regional Approach": "Bhote Koshi 区域推近", "Bhote Koshi Nearby Cities": "Bhote Koshi 周边城市", "Bhote Koshi Incident Corridor": "Bhote Koshi 事件走廊", "Bhote Koshi Flood Path": "Bhote Koshi 洪水路径", @@ -1358,7 +1358,7 @@ "Bhote Koshi Upper Valley": "Bhote Koshi 上游河谷", "Upper Valley Collapse": "上游河谷崩塌", "Debris-Dammed Lake": "碎屑堰塞湖", - "Nepal-China Border Gate": "尼中边境口岸", + "Nepal-China Border Gate": "中尼边境口岸", "Timure Evidence Cluster": "Timure 证据群", "Syabru Besi Passage": "Syabru Besi 通道", "Bidur / Trishuli Consequence": "Bidur / Trishuli 后果", @@ -1385,14 +1385,14 @@ "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "使用了已注册内容或链接媒体。这些外部文件不包含在场景资源包中。其原始声明仍然适用。", "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "已校验打包内容 {bytes} 字节。文件仅保留在本次会话的内存中。重新加载应用后请再次导入资源包。", "Review scene import": "场景导入预览", - "Nothing is loaded or changed until you apply this file.": "确认导入此文件前,不会载入或更改任何内容。", + "Nothing is loaded or changed until you apply this file.": "确认导入此文件前,不会加载或更改任何内容。", "Ready to import": "可以导入", "Apply replaces the current project. Export your current project first if you want to keep both.": "应用后将替换当前项目。如需同时保留两者,请先导出当前项目。", "Apply import": "确认导入", "Import was not applied. The current project may have changed.": "未执行导入。当前项目可能已发生变化。", "Select a scene and shot first": "请先选择场景和镜头", "Edit scene details": "编辑场景详情", - "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相机经纬度以度为单位,高度为相对椭球面的高度,以米为单位。草稿通过校验后才会替换已保存的场景。", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "相机经纬度以度为单位,高度以米为单位(相对椭球面)。草稿通过校验后才会替换已保存的场景。", "Anchors and data packs": "锚点与数据包", "Shot camera, timing, packs and actions": "镜头相机、时序、数据包与动作", "New anchor ID": "新锚点 ID", @@ -1518,7 +1518,7 @@ "FRAME · LOADING": "画面 · 加载中", "FRAME · UNAVAILABLE": "画面 · 不可用", "CALIBRATED": "已校准", - "CURATED": "已整理", + "CURATED": "精选", "RAW PRIOR": "初始估计", "CCTV ON": "CCTV 开", "CCTV OFF": "CCTV 关", @@ -1549,7 +1549,7 @@ "Clear selected data layers": "清除已选数据图层", "CONDITIONS UNKNOWN": "天气状况未知", "CLEAR": "晴", - "PARTLY CLOUDY": "多云", + "PARTLY CLOUDY": "晴间多云", "OVERCAST": "阴", "FOG": "雾", "DRIZZLE": "毛毛雨", @@ -1573,7 +1573,7 @@ "CLOUD UNKNOWN": "云量未知", "{source} · LOCATION QUERY": "{source} · 位置查询", "REGIONAL NEWS": "地区新闻", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 航班 / 船舶范围", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "CONTEXT STANDBY": "态势待机", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以获取邻近目标", @@ -1615,7 +1615,7 @@ "Noir": "黑白", "Snow": "雪景", "Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。", - "Current style: {style} — click for next": "当前样式:{style}——点击切换下一个", + "Current style: {style} — click for next": "当前样式:{style}——点击切换到下一个", "Collapse Contact panel": "收起目标面板", "Expand Contact panel": "展开目标面板", "Collapse contact panel": "收起目标面板", @@ -1667,7 +1667,7 @@ "{layerId} could not {action} cleanly": "{layerId} 未能正常{action}", "start": "启动", "stop": "停止", - "{mode} could not start because another layer did not stop cleanly": "{mode}无法启动,因为另一个图层未能正常停止", + "{mode} could not start because another layer did not stop cleanly": "{mode} 无法启动,因为另一个图层未能正常停止", "Space Missions": "太空任务", "Context": "态势", "Rendered globe frames per second · toggle with `": "每秒渲染的地球帧数 · 按 ` 键切换", @@ -1720,13 +1720,13 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", - "Cyclone advisories": "气旋公告", + "Cyclone advisories": "气旋预警", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", "Bike Share": "共享单车", "Cameras": "摄像头", "Street Level": "街景", - "Mapped ALPR Cameras": "ALPR 摄像头", + "Mapped ALPR Cameras": "地图标注 ALPR 摄像头", "Data Centers": "数据中心", "Active Fires": "活跃火点", "Military aircraft": "军用飞机", @@ -1840,7 +1840,7 @@ "Expand Cockpit Radio controls": "展开座舱电台控件", "CH {index} / {total}": "频道 {index} / {total}", "NO STATIONS": "无电台", - "NO STATION AVAILABLE": "没有可用电台", + "NO STATION AVAILABLE": "无可用电台", "{name}, station {index} of {total}": "{name},第 {index} 个电台,共 {total} 个", "No station available": "没有可用电台", "OFF AIR": "停播", @@ -1898,9 +1898,9 @@ "Radio is disabling…": "电台正在停用…", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", "Clear reading": "清除读数", - "Earlier observation": "早前观测", + "Earlier observation": "较早观测", "Observed history": "观测历史", - "Later observation": "稍后观测", + "Later observation": "较晚观测", "Latest": "最新", "Newest frame per product": "每个产品的最新一帧", "Replay observed history": "回放观测历史", @@ -1939,10 +1939,10 @@ "Searching the last 30 days": "正在搜索最近 30 天", "No days to show for this box": "此框选没有可显示的日期", "Drag the divider · SWAP trades sides": "拖动分割线 · “交换”互换两侧", - "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 固定当前日期", + "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 固定当前所选日期", "← → preview the other side · A or B pins it": "← → 预览另一侧 · A 或 B 将其固定", "S on another day replaces it · × unpins": "在其他日期按 S 替换 · × 取消固定", - "← → preview · S shows the focused day": "← → 预览 · S 显示当前日期", + "← → preview · S shows the focused day": "← → 预览 · S 显示当前所选日期", "ZOOM IN": "放大", "Fly in until the view fits the 1,000 km limit": "拉近直到视野满足 1,000 km 限制", "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 固定", @@ -2010,7 +2010,7 @@ "No shots yet. Use CAPTURE SHOT to save current look.": "暂无镜头。使用“保存镜头”保存当前视角及设置。", "Double-click to rename": "双击重命名", "Shot name": "镜头名称", - "LOAD": "载入", + "LOAD": "加载", "DEL": "删除", "{duration}s + {hold}s": "{duration} 秒 + {hold} 秒", "Choose files": "选择文件", @@ -2035,7 +2035,7 @@ "Shared {subject} could not be restored — feed unavailable": "无法恢复分享的 {subject}——数据源不可用", "Shared {subject} is unavailable": "分享的 {subject} 不可用", "ACQUIRING": "获取中", - "SHARED {subject}": "分享 {subject}", + "SHARED {subject}": "分享的 {subject}", "subject": "目标", "LOADING LIVE DATA": "正在加载实时数据", "LOAD COMPLETE": "加载完成", @@ -2119,7 +2119,7 @@ "LAST {count} DAYS": { "other": "最近 {count} 天" }, - "{window} · SINCE {date}": "{window} · 自 {date}", + "{window} · SINCE {date}": "{window} · 自 {date} 起", "Turn Street Level off": "关闭街景", "Turn Street Level on": "开启街景", "{name} imagery on": "{name} 影像已开启", @@ -2134,10 +2134,10 @@ "other": "此序列共 {n} 张图像 · Esc 清除" }, "{n} streets in view · click a line for its nearest photo": { - "other": "视野内共 {n} 条街道 · 点击线段查看最近的照片" + "other": "视野内共 {n} 条街道 · 点击线段查看距其最近的照片" }, "{n} sequences in view · click a line for its nearest photo": { - "other": "视野内共 {n} 条序列 · 点击线段查看最近的照片" + "other": "视野内共 {n} 条序列 · 点击线段查看距其最近的照片" }, "{n} sequences in view · click a line for its photos": { "other": "视野内共 {n} 条序列 · 点击线段查看其照片" @@ -2163,7 +2163,7 @@ "{time} · {minutes} min ago": "{time} · {minutes} 分钟前", "Active weather": "已启用的天气图层", "LATEST · newest per product": "最新 · 每个产品的最新一帧", - "Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布 {issued}", + "Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}", "{detail} · Does not follow history": "{detail} · 不随观测历史变化", "Forecast · valid {valid}": "预报 · 有效时间 {valid}", "voice|OFF": "关", @@ -2297,7 +2297,7 @@ }, "degraded": "降级", "stale": "过期", - "partial": "部分", + "partial": "部分可用", "fallback": "备用", "unavailable": "不可用", "Not answered: {layers}": "未响应:{layers}", @@ -2331,7 +2331,7 @@ "{layer} is enabled, but {source} is unavailable": "{layer} 已开启,但 {source} 不可用", "Could not arrive at {place}": "无法抵达 {place}", "{name} is no longer loaded": "{name} 已不在加载数据中", - "Unknown target layer: {target}": "未知目标图层:{target}", + "Unknown target layer: {target}": "未知的目标图层:{target}", "nominal": "正常", "{label} on": "{label} 已开启", "Frame aircraft": "将飞机纳入视野", @@ -2362,7 +2362,7 @@ "Layer": "图层", "there": "那里", "Camera range {range}": "相机距离 {range}", - "Within {radius} of the view": "距当前视野 {radius} 内", + "Within {radius} of the view": "当前视野 {radius} 范围内", "Labels on": "标签已开启", "Framing uses the nearest 80 loaded contacts": "调整视野时仅包含已加载的最近 80 个目标", "{distance} from {location}": "距 {location} {distance}", @@ -2477,8 +2477,8 @@ "FROM": "出发", "TO": "到达", "Cockpit briefing controls": "座舱简报控件", - "Previous briefing page": "上一页简报", - "Next briefing page": "下一页简报", + "Previous briefing page": "简报上一页", + "Next briefing page": "简报下一页", "Live signals": "实时信号", "Latest regional news": "最新地区新闻", "Location-based information": "基于位置的信息", @@ -2522,7 +2522,7 @@ "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。", "Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。", "Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白电影风格调色。", - "Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的雪天白茫茫效果。", + "Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的暴雪白化效果。", "MAP SOURCE": "地图源", "Map source": "地图源", "Style": "样式", @@ -2536,7 +2536,7 @@ "WEATHER": "天气", "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "框选内的影像日期", + "Imagery days in the box": "视野内的影像天数", "CONTEXT": "态势", "Compact Radio controls": "精简电台控件", "Open detailed Radio controls": "打开详细电台控件", @@ -2582,7 +2582,7 @@ "Local RTL-SDR receiver": "本地 RTL-SDR 接收机", "LOCAL RTL-SDR": "本地 RTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · 一个本地调谐器由 FM 与本地 ADS-B 图层共享。", - "Use your location for faster ADS-B position decoding": "使用你的位置以更快解码 ADS-B 位置", + "Use your location for faster ADS-B position decoding": "使用你的位置,加快 ADS-B 位置解码", "CHANGE DEVICE": "更换设备", "Choose a different RTL-SDR or receiver channel": "选择其他 RTL-SDR 或接收通道", "RTL-SDR receiver mode": "RTL-SDR 接收模式", @@ -2672,7 +2672,7 @@ "Sharpen intensity": "锐化强度", "EXIT CLEAN VIEW": "退出纯净界面", "Return UI controls": "恢复界面控件", - "Initializing photorealistic world...": "正在初始化照片级真实世界…", + "Initializing photorealistic world...": "正在初始化照片级逼真世界…", "DATA LAYERS": "数据图层", "ACQUIRING FRAME": "正在获取画面", "NEAREST": "最近", @@ -2697,7 +2697,7 @@ "Fit: show the whole photo, with dark bars where its shape differs from the frame": "适应:显示整张照片,比例与画框不同处留黑边", "FILL": "填充", "Fill: fill the frame edge to edge, cropping the photo": "填充:图像铺满画框并裁剪照片", - "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中", + "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中状态", "Close the street-level image": "关闭街景图像", "Street-level viewer": "街景查看器", "Open this image on the provider's site": "在服务商网站上打开此图像", @@ -2708,7 +2708,7 @@ "Panorama filter": "全景筛选", "ALL": "全部", "FLAT": "平面", - "SINCE": "时间", + "SINCE": "起始时间", "SCENES": "场景", "Scene recipe": "场景方案", "NEW": "新建", @@ -2741,7 +2741,7 @@ "Aircraft, vessels and nearby intelligence": "飞机、船舶及周边情报", "Launches, spacecraft and orbital context": "发射、航天器与轨道态势", "EXPLORE MANUALLY": "手动探索", - "Begin with a clean globe": "从未叠加数据图层的地球开始", + "Begin with a clean globe": "从空白地球开始", "Don't show this again": "不再显示", "ESC to dismiss": "按 ESC 关闭", "Tip: the GEV MIC button in the dock lets you talk to the map.": "提示:可通过停靠栏中的 GEV 麦克风按钮用语音操控地图。", From 4bf852298575c0f5d4731687a282c357111c2005 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 13:50:19 +0400 Subject: [PATCH 066/178] i18n(zh-CN): apply the second-pass review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh full review of the catalog (52 changes in 48 entries): - Age readouts that render as bare durations got their missing 前 (LIVE · updated {age}, POSITION {age}). - Unified the rate-limit family on 已被限流 (12 entries had 已限流, 正在限制请求频率 or 受到频率限制). - Unified photorealistic on 照片级逼真 (planet strings, 3D Tiles). - Round-1 stragglers: 括标 → 括号标记, FLIGHT/VESSEL WINDOW → 监视窗口, mapped-ALPR loading strings, plain Talk → 脱口秀, the hardcoded Current style twin. - Collocations: 与…同源, 不超过…上限, 单击放置顶点, 随语音分享. - Mainland usage and consistency polish: 气象高度, 地面风, 标签页, 已获取航迹 (acquire ≠ lock), 缩放级别 6, 发布时间, 独立视角, 视野中心, 播报当前视野, and similar one-word fixes. Kept deliberately: the 图像 (image) vs 影像 (imagery) distinction, which mirrors the English and is consistent across the catalog. --- src/i18n/locales/zh-CN.json | 98 ++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ad75de5d4..aee065901 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -219,7 +219,7 @@ "That file has an unreadable image or exceeds the 16-megapixel image limit.": "该文件包含无法读取的图像,或图像超过 1600 万像素上限。", "That KML could not be read.": "无法读取该 KML。", "That file has invalid point coordinates.": "该文件的点坐标无效。", - "Ground heights are not available for that file. Try again when the map has loaded.": "该文件无法获取地面高度。请在地图加载完成后重试。", + "Ground heights are not available for that file. Try again when the map has loaded.": "无法获取该文件的地面高度。请在地图加载完成后重试。", "That file took too long to read.": "读取该文件耗时过长。", "Nothing to import.": "没有可导入的内容。", "That file is larger than {size} MiB.": "该文件大于 {size} MiB。", @@ -230,7 +230,7 @@ "That file has {count} map objects; the limit is {limit}.": "该文件有 {count} 个地图对象,上限为 {limit} 个。", "Ground heights could not be read. Try that file again.": "无法读取地面高度。请重新导入该文件。", "That file has invalid point graphics.": "该文件的点样式无效。", - "Overpass rate-limited": "Overpass 已限流", + "Overpass rate-limited": "Overpass 已被限流", "Overpass timed out": "Overpass 超时", "Overpass could not complete the query": "Overpass 无法完成查询", "Map tiles temporarily unavailable": "地图瓦片暂时不可用", @@ -269,7 +269,7 @@ "VISUAL": "目视可见", "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可见天体:CelesTrak visual 组", "COMMS": "通信", - "Broadband constellation shell — shown only in DENSE mode": "宽带星座:仅在“密集”模式下显示", + "Broadband constellation shell — shown only in DENSE mode": "宽带星座壳层——仅在“密集”模式下显示", "FISHING": "渔船", "TOWING": "拖带", "DREDGER": "挖泥船", @@ -350,7 +350,7 @@ "POWERED UP": "已增强", "Provider Settings does not answer proxied requests": "服务商设置不响应经代理的请求", "Provider Settings is disabled while sharing is enabled": "启用分享时,服务商设置不可用", - "Provider Settings answers only the machine running the server": "服务商设置仅响应服务器所在的本机", + "Provider Settings answers only the machine running the server": "服务商设置仅响应运行服务器的那台机器", "Provider Settings answers only local hostnames": "服务商设置只响应本地主机名", "Provider Settings requires an exact local Origin": "服务商设置要求完全匹配的本地 Origin", "Unrecognized Origin refused": "已拒绝无法识别的 Origin", @@ -414,7 +414,7 @@ "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT 登录完成。下次云语音会话将使用 OAuth。", "Remove this key from your saved configuration?": "要从已保存的配置中移除此密钥吗?", "Removed from {store}. Restarting — this page reloads itself.": "已从 {store} 中移除。正在重启——本页面将自动重新加载。", - "The photorealistic 3D planet, place search + Street View": "照片级 3D 地球、地点搜索 + 街景", + "The photorealistic 3D planet, place search + Street View": "照片级逼真 3D 地球、地点搜索 + 街景", "Places context + Street View fallback; optional separate key": "地点信息 + 街景备用;可选的独立密钥", "Voice control — API key or ChatGPT OAuth": "语音控制——API 密钥或 ChatGPT OAuth", "Live ships, worldwide": "全球实时船舶", @@ -458,8 +458,8 @@ "{count} nearby": { "other": "附近 {count} 个" }, - "retrying mapped ALPR cameras": "正在重试加载 ALPR 摄像头", - "loading mapped ALPR cameras": "正在加载 ALPR 摄像头", + "retrying mapped ALPR cameras": "正在重试加载地图标注的 ALPR 摄像头", + "loading mapped ALPR cameras": "正在加载地图标注的 ALPR 摄像头", "No ALPR data for this area — US and Canada only": "此区域没有 ALPR 数据——仅覆盖美国和加拿大", "Zoom in to load mapped cameras": "放大以加载地图标注的摄像头", "Showing cached locations": "当前显示的是缓存位置", @@ -484,7 +484,7 @@ "Next — nearest unvisited contact in the 250 km window": "下一个:250 km 范围内最近的未访问目标", "NEXT": "下一个", "Named areas ({n})": "已命名区域({n})", - "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶范围", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} 航班 / 船舶监视窗口", "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "开源的地图标注/观测态势信息。缺失的广播、未加载的地图区域或未标注的设施不能作为不存在的证据。", "mapped matches within {km} km of the subject": "所选对象 {km} km 内的地图标注匹配", "none mapped within {km} km; not a complete 250 km survey": "{km} km 内无地图标注;并非完整的 250 km 普查", @@ -518,7 +518,7 @@ "SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)", "COVERAGE {value}km²": "覆盖 {value}km²", "OVERLAP {count} cams": "重叠 {count} 个摄像头", - "ISOLATED VIEW": "孤立视角", + "ISOLATED VIEW": "独立视角", "PROJ MONITOR": "投影:监视器", "PROJ OFF": "投影:关", "VIEWSHED": "可视域", @@ -556,15 +556,15 @@ "Wind unavailable": "风场数据不可用", "Advisory center / forecast track": "公告中的气旋中心 / 预报路径", "Center-track uncertainty cone": "中心路径预报不确定范围", - "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中选择以选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公告。该区域表示气旋中心路径预报的不确定范围,不代表气旋大小或全部灾害影响范围。预报点标注的是数据源的预报时效(小时),不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非天气高度。请以官方公告为准。", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "在地图上点选气旋,或在列表中点选气旋,即可选中并移动相机。点击空白地图区域可清除选择。内容来自 NOAA NHC/CPHC 公告。该区域表示气旋中心路径预报的不确定范围,不代表气旋大小或全部灾害影响范围。预报点标注的是数据源的预报时效(小时),不是按公告发布时刻推算的时间。几何图形贴地显示;显示高度并非气象高度。请以官方公告为准。", "DRIVE": "驾车", "Drive": "驾车", "WALK": "步行", "Walk": "步行", "BIKE": "骑行", "Bike": "骑行", - "rate limited": "已限流", - "routing service is rate limited": "路线服务已限流", + "rate limited": "已被限流", + "routing service is rate limited": "路线服务已被限流", "route leg too long": "路段过长", "route too long": "路线过长", "{error} — try again in a moment": "{error}——请稍后重试", @@ -599,8 +599,8 @@ "ordinal|{number}th": "第 {number} 个", "Head out on {road}": "沿 {road} 出发", "Head out": "出发", - "Arrive at your destination, on the left": "到达目的地,目的地在左侧", - "Arrive at your destination, on the right": "到达目的地,目的地在右侧", + "Arrive at your destination, on the left": "到达目的地,位于左侧", + "Arrive at your destination, on the right": "到达目的地,位于右侧", "Arrive at your destination": "到达目的地", "Make a U-turn onto {road}": "掉头进入 {road}", "Make a U-turn": "掉头", @@ -694,7 +694,7 @@ "FIRE": "火点", "FIRES": "火点", "max {value} MW": "最大 {value} MW", - "new {value}": "最新 {value}前", + "new {value}": "最新({value}前)", "{value}°N": "{value}°N", "{value}°S": "{value}°S", "{value}°E": "{value}°E", @@ -714,7 +714,7 @@ "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", - "LIVE · updated {age}": "实时 · 更新于 {age}", + "LIVE · updated {age}": "实时 · 更新于 {age}前", "Source snapshot time unavailable": "来源快照时间不可用", "Live data unavailable": "实时数据不可用", "Low-accuracy score < 2%": "低精度评分 < 2%", @@ -724,10 +724,10 @@ "Regional sample · 250 nm radius · cells from visited views over 30 min": "区域采样 · 半径 250 海里 · 30 分钟内已访问视图中的网格", "Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试", "Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", - "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视图中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "低精度评分 = 100 × max(0, bad − 1) / total:扣除一架飞机,因此并非原始占比。每个网格需在 {minutes} 分钟内有 {aircraft} 架飞机。导航完好性异常不等于检测到干扰或欺骗。", "GNSS snapshot needs a view anchor": "GNSS 快照需要视图锚点", - "GNSS feed rate limited (HTTP 429)": "GNSS 数据源已限流(HTTP 429)", + "GNSS feed rate limited (HTTP 429)": "GNSS 数据源已被限流(HTTP 429)", "Malformed GNSS integrity snapshot": "GNSS 完好性快照格式错误", "GNSS integrity snapshot has no observation time": "GNSS 完好性快照缺少观测时间", "installation class|AIRFIELD": "机场", @@ -787,7 +787,7 @@ "LAUNCH TIME · ": "发射时间 · ", "ORBIT · ": "轨道 · ", "ASCENT PATH · ": "上升轨迹 · ", - "CURRENT DISTANCE FROM EARTH · ": "当前距地球距离 · ", + "CURRENT DISTANCE FROM EARTH · ": "当前与地球的距离 · ", "SATELLITE SPEED · ": "卫星速度 · ", "PAYLOAD": "有效载荷", "NAME": "名称", @@ -831,7 +831,7 @@ "ALT {altitude} FT · GS {speed} KT · TRK {track}": "高度 {altitude} ft · 地速 {speed} kn · 航迹角 {track}", "V/S {rate}": "升降率 {rate}", "V/S {rate} FPM": "升降率 {rate} ft/min", - "POSITION {age}": "位置 {age}", + "POSITION {age}": "位置 {age}前", "POSITION {seconds} S AGO": "位置 {seconds} 秒前", "{count} MSGS": { "other": "{count} 条消息" @@ -889,7 +889,7 @@ "Malformed perimeter snapshot": "周界快照格式错误", "radio tag|All": "全部", "News": "新闻", - "Talk": "访谈", + "Talk": "脱口秀", "Weather / Emergency": "天气 / 应急", "Public Safety": "公共安全", "Aviation / Marine": "航空 / 海事", @@ -998,19 +998,19 @@ "The KartaView photo could not be loaded": "KartaView 照片无法加载", "zoom in to see KartaView coverage": "放大以查看 KartaView 覆盖范围", "no KartaView photos in view": "视野内没有 KartaView 照片", - "KartaView is rate-limiting requests — they resume on their own": "KartaView 正在限制请求频率——稍后会自动恢复", + "KartaView is rate-limiting requests — they resume on their own": "KartaView 已被限流——稍后会自动恢复", "KartaView’s photos here are archived and cannot be shown": "KartaView 在此处的照片已归档,无法显示", "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求", "None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选", "KartaView did not answer in time — try again": "KartaView 未及时响应——请重试", "KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络", "KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取", - "KartaView is rate-limiting requests": "KartaView 正在限制请求频率", + "KartaView is rate-limiting requests": "KartaView 已被限流", "KartaView sent too much data": "KartaView 返回的数据过多", - "KartaView is rate-limiting requests — try again in a moment": "KartaView 正在限制请求频率——请稍后重试", + "KartaView is rate-limiting requests — try again in a moment": "KartaView 已被限流——请稍后重试", "KartaView has no such photo": "KartaView 没有这张照片", "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary 拒绝了 MAPILLARY_CLIENT_TOKEN——请在服务商设置中更换", - "Coverage requests are being rate-limited — they resume on their own": "覆盖范围请求受到频率限制——稍后会自动恢复", + "Coverage requests are being rate-limited — they resume on their own": "覆盖范围请求已被限流——稍后会自动恢复", "The Panoramax photo could not be loaded": "Panoramax 照片无法加载", "Panoramax is busy — coverage resumes on its own": "Panoramax 繁忙——覆盖范围稍后会自动恢复", "no Panoramax photos in view": "视野内没有 Panoramax 照片", @@ -1184,7 +1184,7 @@ "STALE · cached source metadata": "已过期 · 数据源元数据来自缓存", "Map center is outside source coverage": "地图中心在数据源覆盖范围之外", "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "NOAA/NWS 15 分钟闪电密度,来源为 Vaisala NLDN/GLD360。覆盖自 110°E 经太平洋/美洲至 0°,25°S–80°N。不是实时闪击计数、不是全球覆盖,也不构成安全警示。", - "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据分辨率约 1 km;显示限制在第 6 级。各帧使用数据源公布的精确观测时间。", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "NOAA MRMS 雷达回波反映降水形态,不代表降雨强度、风暴警报或未来预报。原始数据分辨率约 1 km;显示仅限缩放级别 6。各帧使用数据源公布的精确观测时间。", "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "GOES-19/18 第 14 波段长波红外区域图;NESDIS 全球长波镶嵌图。“仅云区”会调暗除明亮冷云顶之外的一切;这是亮度滤镜,不是云掩膜。覆盖范围与新鲜度因区域而异。", "REGION": "区域", "IMAGE": "图像", @@ -1229,15 +1229,15 @@ "Sea-level air pressure: broad highs and lows": "海平面气压:大范围的高压与低压", "Air temperature two meters above the surface": "地面以上两米的气温", "Wind speed units": "风速单位", - "Read wind at map center": "读取地图中心气象数据", + "Read wind at map center": "读取地图中心风况", "Read the forecast at the center of the map without changing selection": "读取地图中心的预报值,不改变当前选择", "{model} forecast · {field} ({unit})": "{model} 预报 · {field}({unit})", "Valid: {time}": "有效时间:{time}", "preparing": "准备中", - "Issued: {time}": "发布:{time}", + "Issued: {time}": "发布时间:{time}", "Selected field unavailable · wind remains visible": "所选气象场不可用 · 风场仍可见", "{error} · wind remains visible": "{error} · 风场仍可见", - "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地表风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非天气高度。视图光照仅为提高可读性。动画展示的是单一固定预报内的流场,不推进时间。气象场填色叠加于地球底图或当前的照片级 3D Tiles。", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "10 m 高度的地面风。约 1° 全球网格。流线沿 10 m 风场绘制,为便于观看抬升至 12 km;显示高度并非气象高度。视图光照仅为提高可读性。动画展示的是单一固定预报内的流场,不推进时间。气象场填色叠加于地球底图或当前的照片级逼真 3D Tiles。", "MODEL": "模式", "FIELD": "要素", "UNITS": "单位", @@ -1509,7 +1509,7 @@ "Fallback Street View frame": "备用街景画面", "Upstream unavailable": "上游不可用", "No source configured": "未配置来源", - "Configured source": "已配置数据源", + "Configured source": "已配置来源", "Configured Source": "已配置数据源", "Synthetic fallback": "合成备用", "Upstream HTTP {status}": "上游 HTTP {status}", @@ -1560,7 +1560,7 @@ "MIXED CONDITIONS": "混合天气", "CYCLE ON": "轮换开", "CYCLE OFF": "轮换关", - "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及“信号”“新闻”“本地”标签仍可使用。", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "停止自动翻页。上一页、下一页以及“信号”“新闻”“本地”标签页仍可使用。", "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自动轮换简报页面(信号 → 新闻 → 本地)。悬停或聚焦面板时暂停。无论是否轮换,实时信号数据都会持续刷新。", "ACQUIRING REGIONAL NEWS": "正在获取地区新闻", "REGIONAL NEWS UNAVAILABLE": "地区新闻不可用", @@ -1576,7 +1576,7 @@ "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "CONTEXT STANDBY": "态势待机", - "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以获取邻近目标", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", "CONTACT LOST · {subject}": "目标丢失 · {subject}", "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目标已脱离其数据源 · 读数保持最后已知值", @@ -1647,7 +1647,7 @@ "SOURCE STATUS UNAVAILABLE": "数据源状态不可用", "Restore real aircraft": "恢复真实飞机", "Reclassify as TR-3B": "重新归类为 TR-3B", - "TRACK ACQUIRED": "已锁定航迹", + "TRACK ACQUIRED": "已获取航迹", "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", @@ -1938,13 +1938,13 @@ "Select a box or use the view": "框选区域,或使用当前视野", "Searching the last 30 days": "正在搜索最近 30 天", "No days to show for this box": "此框选没有可显示的日期", - "Drag the divider · SWAP trades sides": "拖动分割线 · “交换”互换两侧", + "Drag the divider · SWAP trades sides": "拖动分割线 · “交换”可互换两侧", "← → preview · A or B pins the focused day": "← → 预览 · A 或 B 固定当前所选日期", "← → preview the other side · A or B pins it": "← → 预览另一侧 · A 或 B 将其固定", "S on another day replaces it · × unpins": "在其他日期按 S 替换 · × 取消固定", "← → preview · S shows the focused day": "← → 预览 · S 显示当前所选日期", "ZOOM IN": "放大", - "Fly in until the view fits the 1,000 km limit": "拉近直到视野满足 1,000 km 限制", + "Fly in until the view fits the 1,000 km limit": "拉近直到视野不超过 1,000 km 上限", "Imagery days, newest first · arrows move, S or A and B pin": "影像日期(最新在前)· 方向键移动,S、A、B 固定", "On the map": "地图上", "Mode": "模式", @@ -1985,7 +1985,7 @@ "Acquired {time}": "获取于 {time}", "Coverage {coverage}": "覆盖 {coverage}", "START HERE · {reason}": "从此开始 · {reason}", - "Daily overview shows little detail in a box this small": "框选这么小时,每日概览几乎看不出细节", + "Daily overview shows little detail in a box this small": "在这么小的框选内,每日概览几乎看不出细节", "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "影像:NASA GIBS 与 Worldview · HLS(Sentinel-2、Landsat 8/9)与 VIIRS", "Details": "详情", "Times, coverage, sources": "时间、覆盖与数据源", @@ -2048,7 +2048,7 @@ "RETRYING ALPR CAMERAS": "正在重试 ALPR 摄像头", "FETCHING ALPR CAMERAS": "正在获取 ALPR 摄像头", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暂时不可用", - "OVERPASS RATE-LIMITED": "Overpass 已限流", + "OVERPASS RATE-LIMITED": "Overpass 已被限流", "OVERPASS TIMED OUT": "Overpass 超时", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 无法完成查询", "MAP TILES TEMPORARILY UNAVAILABLE": "地图瓦片暂时不可用", @@ -2076,8 +2076,8 @@ }, "{feed} feed unavailable": "{feed} 数据源不可用", "Source snapshot {minutes} min old": "数据源快照:{minutes} 分钟前", - "{source} rate limited (anonymous)": "{source} 已限流(匿名)", - "{source} rate limited": "{source} 已限流", + "{source} rate limited (anonymous)": "{source} 已被限流(匿名)", + "{source} rate limited": "{source} 已被限流", "{source} network error": "{source} 网络错误", "Malformed {source} aircraft rows": "{source} 飞机数据行格式错误", "Malformed {source} response": "{source} 响应格式错误", @@ -2287,7 +2287,7 @@ "{label} loading": "{label} 加载中", "{label} error": "{label} 出错", "counted the first {returned} loaded records": "已统计最先加载的 {returned} 条记录", - "counted {returned} of {total} loaded records": "已统计已加载 {total} 条记录中的 {returned} 条", + "counted {returned} of {total} loaded records": "已统计 {total} 条已加载记录中的 {returned} 条", "{layers} just turned on — still loading, counts will rise": "{layers} 刚开启——仍在加载,计数还会增加", "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "目标面板快照({time})——卡片、语音播报和后续追问记忆均来自同一组保留目标", "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "目标面板快照——卡片、语音播报和后续追问记忆均来自同一组保留目标", @@ -2415,7 +2415,7 @@ "Layer off": "图层已关闭", "Zoom out": "缩小", "Zoom in": "放大", - "Read the view": "读取视野", + "Read the view": "播报当前视野", "Check view state": "检查视野状态", "Action": "操作", "Finding places": "正在查找地点", @@ -2450,7 +2450,7 @@ "Previous cockpit vision style": "上一个座舱视觉样式", "Previous vision style": "上一个视觉样式", "Current cockpit vision style: NORMAL. Activate for next style.": "当前座舱视觉样式:常规。点击可切换到下一个样式。", - "Current style: NORMAL — click for next": "当前样式:常规——点击切换下一个", + "Current style: NORMAL — click for next": "当前样式:常规——点击切换到下一个", "CURRENT": "当前", "Next cockpit vision style": "下一个座舱视觉样式", "Next vision style": "下一个视觉样式", @@ -2616,7 +2616,7 @@ "Sonar intensity": "声呐强度", "Opacity": "不透明度", "Cyber contact and label opacity": "赛博目标与标签不透明度", - "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "两次声呐扫描之间地图点、图标、模型、括标与标签的最低不透明度", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "两次声呐扫描之间地图点、图标、模型、括号标记与标签的最低不透明度", "Sector": "扇区", "Sonar sector width": "声呐扇区宽度", "Detection Overlay (D)": "探测叠加层(D)", @@ -2642,7 +2642,7 @@ "Feather": "羽化", "Scope edge feather": "视域边缘羽化", "Scope edge feather as a percentage of the keyhole radius": "视域边缘羽化,以视域半径的百分比表示", - "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击顶点,双击或按 Enter 完成,按 Esc 取消", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在地球上绘制——单击放置顶点,双击或按 Enter 完成,按 Esc 取消", "Shape": "形状", "Shape to draw": "要绘制的形状", "Area": "面", @@ -2658,7 +2658,7 @@ "Red": "红色", "Clear": "清除", "Remove every mark from the board": "清除画板上的所有标注", - "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上显示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件内容仅保留在本设备上;叠加层可能出现在共享给语音的地图截图中。", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "在地球上显示自己的 GeoJSON、KML、KMZ 或 GPX 文件(也可直接拖放到地球上)。文件内容仅保留在本设备上;叠加层可能出现在随语音分享的地图截图中。", "Import": "导入", "Choose GeoJSON, KML, KMZ or GPX files": "选择 GeoJSON、KML、KMZ 或 GPX 文件", "Imported files": "已导入的文件", @@ -2694,7 +2694,7 @@ "Turn Street Level on or off": "开启或关闭街景", "Image fit": "图像适配", "FIT": "适应", - "Fit: show the whole photo, with dark bars where its shape differs from the frame": "适应:显示整张照片,比例与画框不同处留黑边", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "适应:显示整张照片,在比例与画框不符处留黑边", "FILL": "填充", "Fill: fill the frame edge to edge, cropping the photo": "填充:图像铺满画框并裁剪照片", "Close the image and deselect it on the map": "关闭图像并取消其在地图上的选中状态", @@ -2725,7 +2725,7 @@ "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "地球无需密钥即可运行。下面的每个密钥都会启用一个新的真实数据源:粘贴后会保存到本应用的本地配置,随后服务器自动重启。服务端密钥只保存在本机;Google Maps 和 Cesium ion 在浏览器中运行,必须设置服务商限制。你在其他位置配置的密钥会显示,但不会被改动。", "SAVE KEYS": "保存密钥", "ESC to close": "按 ESC 关闭", - "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供照片级 3D 地球,其余数据源都叠加在其上。", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps 密钥提供照片级逼真 3D 地球,其余数据源都叠加在其上。", "NO PLACE LEFT BEHIND": "不遗漏任何地方", "ACTIVE STYLE": "当前样式", "Globe actions": "地球操作", @@ -2756,7 +2756,7 @@ "Switch Street Level on to draw its coverage.": "开启“街景”以绘制其覆盖范围。", "Every provider is switched off: light a chip to draw its coverage.": "所有服务商均已关闭:点亮一个标签即可绘制其覆盖范围。", "No sequences in view match the filter": "视野内没有符合筛选条件的序列", - "No coverage in view": "视野内没有覆盖", + "No coverage in view": "视野内无覆盖范围", "Voice provider": "语音服务商", "Changing provider reconnects voice if it is on.": "如语音已开启,切换服务商会重新连接语音。", "Gemini returned an oversized audio chunk": "Gemini 返回的音频块过大", @@ -2769,7 +2769,7 @@ "Gemini token response did not include an ephemeral credential": "Gemini 令牌响应中没有临时凭据", "Invalid Gemini connection endpoint": "Gemini 连接端点无效", "Gemini voice could not connect. Try again.": "Gemini 语音无法连接。请重试。", - "Gemini voice requires a same-origin app request": "Gemini 语音需要来自本应用同源的请求", + "Gemini voice requires a same-origin app request": "Gemini 语音需要与本应用同源的请求", "Add a Gemini API key in Provider Settings": "请在服务商设置中添加 Gemini API 密钥", "The Gemini Live model configuration is invalid": "Gemini Live 模型配置无效", "Gemini voice is starting too often. Try again shortly.": "Gemini 语音启动过于频繁。请稍后重试。", From 1416266bcb9575219d8159ff8e0ad4fb74be9a59 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 22:07:09 +0400 Subject: [PATCH 067/178] fix(i18n): avoid duplicate Chinese FIRMS age suffix --- src/i18n/firms-live-zh.test.mjs | 43 +++++++++++++++++++++++++++++++++ src/i18n/locales/zh-CN.json | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/i18n/firms-live-zh.test.mjs diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs new file mode 100644 index 000000000..649842e7f --- /dev/null +++ b/src/i18n/firms-live-zh.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getLocale, setLocale } from './index.js'; +import { createModel } from '../layers/firms/model.js'; +import { createQueries } from '../layers/firms/queries.js'; + +const cases = [ + [0, '不到 1 分钟前', '<1m ago'], + [120000, '2 分钟前', '2m ago'], + [7200000, '2 小时前', '2h ago'], +]; + +for (const [age, zh, en] of cases) { + test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { + const now = 1800000000000; + context.mock.method(Date, 'now', () => now); + const previousLocale = getLocale(); + const layerState = { + _loading: false, + _stale: false, + _keyRequired: false, + _error: null, + _lastUpdate: now - age, + _count: 1, + _cellCount: 0, + _fires: [], + }; + const model = createModel({ services: { anchors: {} } }); + const { methods } = createQueries({ + layerState, + components: { model }, + config: {}, + }); + try { + setLocale('en'); + assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); + setLocale('zh-CN'); + assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + } finally { + setLocale(previousLocale); + } + }); +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index aee065901..0816935f6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -714,7 +714,7 @@ "refreshing...": "刷新中…", "loading...": "加载中…", "KEY REQUIRED": "需要密钥", - "LIVE · updated {age}": "实时 · 更新于 {age}前", + "LIVE · updated {age}": "实时 · 更新于 {age}", "Source snapshot time unavailable": "来源快照时间不可用", "Live data unavailable": "实时数据不可用", "Low-accuracy score < 2%": "低精度评分 < 2%", From 2c3b364069d59281f6c5ddeac61f2399d894fad2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 22 Oct 2026 22:29:24 +0400 Subject: [PATCH 068/178] docs: simplify Chinese localization notes --- changelog.d/1298-simplified-chinese.md | 8 +++----- docs/CURRENT-STATE.md | 5 ++--- docs/translations/zh-CN.md | 4 +--- src/ui/styles/cjk.css | 9 +++------ 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md index e6850e964..d35d08cf1 100644 --- a/changelog.d/1298-simplified-chinese.md +++ b/changelog.d/1298-simplified-chinese.md @@ -1,6 +1,4 @@ -- Add Simplified Chinese (zh-CN) as an interface language, selectable from - the globe picker. The catalog builds on the Chinese translations by - @abiyeenzo (#914) and @EDIT-Aeterna (#979); the rest was translated and the - whole catalog reviewed against the screens. Chinese text gets tight tracking - and readable minimum sizes, and year-first dates read 2021年10月16日 +- Add Simplified Chinese (zh-CN) to the language picker, with localized dates + and readable Chinese labels. Based on translations by @abiyeenzo (#914) + and @EDIT-Aeterna (#979). (German, @gekh, #1298). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index de60d3f34..3e61080ab 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3429,9 +3429,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The value). English has no catalog; every other locale is `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - `navigator.languages` → English. Any `zh` tag maps to `zh-CN` except - Traditional ones (`Hant`, `TW`, `HK`, `MO`), which fall through until a - Traditional catalog ships. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing + `Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop it, so a recipient keeps their own language. The globe picker in the bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops `?lang=` (keeps it only when storage is blocked) and reloads. It asks first diff --git a/docs/translations/zh-CN.md b/docs/translations/zh-CN.md index 33f7c7251..e3b7bcad1 100644 --- a/docs/translations/zh-CN.md +++ b/docs/translations/zh-CN.md @@ -2,9 +2,7 @@ Catalog: `src/i18n/locales/zh-CN.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). -The catalog started from the Chinese locale by abiyeenzo (#914) and the -Simplified Chinese localization by EDIT-Aeterna (#979); the rest was translated -for the current English and reviewed against both. +Translation sources: abiyeenzo (#914) and EDIT-Aeterna (#979). ## Style diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css index 9ecfa0ce3..3c01d3987 100644 --- a/src/ui/styles/cjk.css +++ b/src/ui/styles/cjk.css @@ -1,7 +1,5 @@ -/* Chinese text. The UI sets Latin capitals with wide tracking; Chinese set - that way falls apart into single glyphs, so its tracking stays tight. The - intel HUD readouts (codes, coordinates) and text kept in English on purpose - (translate="no", and whatever holds it) keep the Latin look. */ +/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for + intel HUD readouts and content marked translate="no", including its containers. */ html:lang(zh) body :not( @@ -14,8 +12,7 @@ html:lang(zh) letter-spacing: 0.02em !important; } -/* Below about 9px a Chinese glyph blurs: lift the dock's voice labels and the - panel layer buttons to that floor. */ +/* Keep small Chinese labels legible at a minimum of 9px. */ html:lang(zh) #command-dock .gev-mic-label, html:lang(zh) #command-dock #gev-voice-detail, html:lang(zh) .gev-voice-tier-btn, From e479b6e91dc06846e232002a41fd9110c789b21e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 17:46:54 -0700 Subject: [PATCH 069/178] chore(camera): remove unused camera presets CAMERA_PRESETS and flyToPreset have no callers. Startup uses flyToAustin, which stays, and city navigation uses flyToPresetLocation from src/locations.js. Neither removed symbol is a package export. --- src/camera.js | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/camera.js b/src/camera.js index 3941c9d48..3fd49b58c 100644 --- a/src/camera.js +++ b/src/camera.js @@ -1,51 +1,5 @@ import * as Cesium from 'cesium'; -/** - * Camera presets for notable locations. - * Phase 1 default: fly to Austin, TX on load. - */ -export const CAMERA_PRESETS = { - austin: { - destination: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 800), - orientation: { - heading: Cesium.Math.toRadians(0), - pitch: Cesium.Math.toRadians(-35), - roll: 0.0, - }, - }, - sf: { - destination: Cesium.Cartesian3.fromDegrees(-122.4194, 37.7749, 1000), - orientation: { - heading: Cesium.Math.toRadians(30), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, - nyc: { - destination: Cesium.Cartesian3.fromDegrees(-73.9857, 40.7484, 1200), - orientation: { - heading: Cesium.Math.toRadians(-20), - pitch: Cesium.Math.toRadians(-30), - roll: 0.0, - }, - }, -}; - -/** - * Fly the camera to a preset location with a smooth animation. - */ -export function flyToPreset(viewer, presetName, duration = 3.0) { - const preset = CAMERA_PRESETS[presetName]; - if (!preset) return; - - viewer.camera.flyTo({ - destination: preset.destination, - orientation: preset.orientation, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - /** * Set camera to Austin on load with a cinematic fly-in. * @returns {Function} Cancels the pending or active startup flight. From f60b9ef7c87d9e4256faf4f4a01fe7f6cb65c07b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:09:37 -0700 Subject: [PATCH 070/178] chore: remove unused imports and local helpers Drop module-local helpers that nothing calls (linkAbort, summarize, stringOption, summarizeEntity and the polygonCenter, propertyObject and unwrapProperties helpers only it used, CALLOUT_DOT_SIZE_PX) and unused import specifiers in runtime modules, tests, QA scripts and tools. None of the removed bindings is exported. Re-exports such as the ALPR policy constants and every package export are unchanged. --- scripts/qa-failstate-b10.mjs | 4 -- scripts/qa-labels.mjs | 8 --- scripts/qa-street-level.mjs | 1 - scripts/qa-transit.mjs | 2 - scripts/qa-weather-swap.mjs | 8 +-- scripts/run-unit-tests.mjs | 2 +- server/providers/cctv.js | 6 +- server/providers/cctv/sources.js | 1 - src/annotations/resolver.js | 12 ---- src/cctvFocusPolicy.test.mjs | 1 - src/cctvFocusRequest.test.mjs | 1 - src/contextSessionOrdering.test.mjs | 2 - src/data/analystEngine.js | 22 -------- src/data/bhoteKoshiLocator.js | 1 - src/data/cockpitDetectionSubjects.test.mjs | 1 - src/data/cockpitModels.test.mjs | 1 - src/data/detection.js | 1 - src/data/layerState.js | 20 ------- src/data/militaryAwareness.test.mjs | 1 - src/data/militaryInstallations.test.mjs | 1 - src/data/trackingClickGesture.test.mjs | 1 - src/keyboardFocusStyles.test.mjs | 1 - src/layers/alpr/index.js | 4 -- src/layers/cctv/catalog.js | 2 +- src/layers/cctv/frames.js | 3 - src/layers/cctv/geometry.js | 1 - src/layers/cctv/health.js | 2 +- src/layers/traffic/state.js | 2 +- src/layers/transit/ingestion.js | 1 - src/layers/transit/lifecycle.test.mjs | 1 - src/layers/transit/movement.test.mjs | 2 +- src/layers/transit/rendering.js | 1 - src/maps/availability.js | 5 +- src/panelEscape.test.mjs | 1 - src/scenes/director.test.mjs | 1 - src/sharelink.celestial.test.mjs | 1 - src/ui/visualSettings.js | 1 - src/voice/gevActions.js | 65 ---------------------- tools/pano-pinhole.mjs | 4 +- tools/sat-ortho.mjs | 2 - tools/streetview-headings.mjs | 1 - tools/streetview-panorama.mjs | 2 - 42 files changed, 9 insertions(+), 191 deletions(-) diff --git a/scripts/qa-failstate-b10.mjs b/scripts/qa-failstate-b10.mjs index 95dc2ba4f..2033a2731 100644 --- a/scripts/qa-failstate-b10.mjs +++ b/scripts/qa-failstate-b10.mjs @@ -34,10 +34,6 @@ 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 REPO_ROOT = path.resolve(__dirname, '..'); const argv = process.argv.slice(2); const getOpt = (name, dflt) => { diff --git a/scripts/qa-labels.mjs b/scripts/qa-labels.mjs index 8d26dcb30..fb04d0025 100644 --- a/scripts/qa-labels.mjs +++ b/scripts/qa-labels.mjs @@ -86,14 +86,6 @@ function percentile(values, pct) { return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * pct) - 1)]; } -function parseJson(value, fallback) { - try { - return JSON.parse(value); - } catch { - return fallback; - } -} - function analyzeMembership(samples) { let replacements = 0; let exposure = 0; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1be1a80fa..0dd841acf 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -30,7 +30,6 @@ import { import { answerKartaViewRequest, describeKartaViewCall, - KV_LINE, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0fd0bd9 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -47,8 +47,6 @@ import { reduceScenario, reduceSensorContrast, reduceScriptedMotion, - reduceMotion, - reduceFleet, } from '../src/layers/transit/qaMetrics.js'; const BASE = process.env.QA_BASE_URL || 'http://localhost:4173'; diff --git a/scripts/qa-weather-swap.mjs b/scripts/qa-weather-swap.mjs index e5b26d723..da7f77bb2 100644 --- a/scripts/qa-weather-swap.mjs +++ b/scripts/qa-weather-swap.mjs @@ -8,13 +8,7 @@ * node scripts/qa-weather-swap.mjs --url http://localhost:4173 --label google * [--layer weather-satellite] [--product clouds] [--headless] */ -import { - existsSync, - mkdirSync, - readdirSync, - readFileSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs index 6cc6aaa94..3712df28e 100644 --- a/scripts/run-unit-tests.mjs +++ b/scripts/run-unit-tests.mjs @@ -1,7 +1,7 @@ import { readdirSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; export const ALLOCATION_TEST_FILES = Object.freeze([ 'src/data/focusAllocations.test.mjs', diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 672b9be42..a977f4e75 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -1,9 +1,5 @@ import { createCctvCatalog } from './cctv/catalog.js'; -import { - normalizeFeedType, - isVideoFeedType, - toFiniteNumber, -} from './cctv/normalize.js'; +import { normalizeFeedType, isVideoFeedType } from './cctv/normalize.js'; import { buildSyntheticCctvSvg, cctvLiveMediaType, diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 114a91979..c7def2284 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -133,7 +133,6 @@ import { fallbackHeadingFromId, isLikelyFinlandCoordinate, fintrafficCameraName, - hashSeed, isPlausibleLatLon, isLikelyBcCoordinate, isLikelyTexasCoordinate, diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index 10c251b60..fb5795d53 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -103,18 +103,6 @@ export function createAnnotationResolver({ cacheWrite(cache, key, null); } - /** Wire an external AbortSignal to a local controller; returns a detach fn. */ - function linkAbort(controller, externalSignal) { - if (!externalSignal) return () => {}; - if (externalSignal.aborted) { - controller.abort(); - return () => {}; - } - const onAbort = () => controller.abort(); - externalSignal.addEventListener('abort', onAbort, { once: true }); - return () => externalSignal.removeEventListener('abort', onAbort); - } - /** * Resolve a single annotation target to a normalized world anchor. * diff --git a/src/cctvFocusPolicy.test.mjs b/src/cctvFocusPolicy.test.mjs index 13fbff552..333926a0f 100644 --- a/src/cctvFocusPolicy.test.mjs +++ b/src/cctvFocusPolicy.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { runCctvLayerEnableFocus, runCctvLayerEnableTransition, diff --git a/src/cctvFocusRequest.test.mjs b/src/cctvFocusRequest.test.mjs index be05ef7cb..700a70a74 100644 --- a/src/cctvFocusRequest.test.mjs +++ b/src/cctvFocusRequest.test.mjs @@ -1,7 +1,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { CCTV_WORLD_CLICK_FOCUS_DURATION_SEC, CCTV_FOCUS_REQUEST_EVENT, diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 6b1c90904..32ec10b28 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -21,8 +21,6 @@ const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', imp // - the right-rail entry ignored the activation result entirely. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; const src = readShellSource(); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 59a631421..6a31c5a20 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -167,28 +167,6 @@ export function applyScope(records, scope, resolved) { return records; } -/** Numeric summary for the narration layer. */ -function summarize(items, sortField) { - const summary = { count: items.length }; - if (sortField && items.length) { - // A loop, not Math.min(...vals): a full FIRMS day is ~160k values, past - // the engine's argument limit. - let min = Infinity; - let max = -Infinity; - for (const item of items) { - const value = Number(item[sortField]); - if (!Number.isFinite(value)) continue; - if (value < min) min = value; - if (value > max) max = value; - } - if (Number.isFinite(min)) { - summary[`${sortField}Min`] = min; - summary[`${sortField}Max`] = max; - } - } - return summary; -} - const OPS_BY_TYPE = Object.freeze({ number: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], time: ['gt', 'gte', 'lt', 'lte', 'eq', 'neq'], diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index f3917c463..79aab080f 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -35,7 +35,6 @@ const BORDER_START_DELAY_MS = 700; const INCIDENT_CALLOUT_GAP_MS = 200; const CALLOUT_REVEAL_DURATION_MS = 1200; const CALLOUT_PULSE_DURATION_MS = 1800; -const CALLOUT_DOT_SIZE_PX = 7; const CALLOUT_RING_MIN_SIZE_PX = 16; const CALLOUT_RING_SIZE_RANGE_PX = 14; const CITY_CALLOUT_START_DELAY_MS = 350; diff --git a/src/data/cockpitDetectionSubjects.test.mjs b/src/data/cockpitDetectionSubjects.test.mjs index dfe6baee9..8421267fc 100644 --- a/src/data/cockpitDetectionSubjects.test.mjs +++ b/src/data/cockpitDetectionSubjects.test.mjs @@ -2,7 +2,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { dispatchCockpitModeChanged, enter, exit, _adoptTrackedEntity } from '../ui/cockpitTrackingController.js'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; import flightsLayer, { _setCockpitDetectionSubjectForTest as setFlightsCockpitSubject, diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 36f8b24d3..8177b7414 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; /** * Cockpit 3D aircraft policy. diff --git a/src/data/detection.js b/src/data/detection.js index 5c5e90396..c2e63c3d9 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -43,7 +43,6 @@ import { detectionHorizontalSector, labelBudgetFor, normalizeAllocationStrategy, - normalizeProfile, profileForDensity, viewScaleForAltitude, } from './detectionPolicy.js'; diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..d88235188 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -157,26 +157,6 @@ function trackingIdOption(key, token, defaultValue = null) { }); } -function stringOption(key, token, defaultValue) { - return Object.freeze({ - key, - token, - defaultValue, - normalize: (value) => { - if (typeof value === 'number' && Number.isFinite(value)) - return String(value).trim().toLowerCase(); - if (typeof value !== 'string') return null; - const normalized = value.trim().toLowerCase(); - return normalized ? normalized : null; - }, - encode: (value) => String(value), - decode: (value) => - typeof value === 'string' && value.trim() - ? value.trim().toLowerCase() - : null, - }); -} - function enumOption( key, token, diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 33d20f98d..64104953e 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import * as Cesium from 'cesium'; import militaryAwarenessLayer, { _getAwarenessNavigationStateForTest, diff --git a/src/data/militaryInstallations.test.mjs b/src/data/militaryInstallations.test.mjs index 65d1c7ea8..c3f9cdb89 100644 --- a/src/data/militaryInstallations.test.mjs +++ b/src/data/militaryInstallations.test.mjs @@ -798,7 +798,6 @@ test('zoom-out aborts an active installation request and returns non-loading gui // by source probes against the shipped file, the same technique the HUD datum // tests use where a full boot is impractical. import { installationRetryDelayMs } from './militaryInstallations.js'; -import fs from 'node:fs'; const installationsSource = readLayerSource( new URL('./militaryInstallations.js', import.meta.url), 'utf8'); diff --git a/src/data/trackingClickGesture.test.mjs b/src/data/trackingClickGesture.test.mjs index a9a918fef..764c60104 100644 --- a/src/data/trackingClickGesture.test.mjs +++ b/src/data/trackingClickGesture.test.mjs @@ -1,7 +1,6 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; import { bindTrackingClickGesture, isTrackingClickGesture, diff --git a/src/keyboardFocusStyles.test.mjs b/src/keyboardFocusStyles.test.mjs index fc59481aa..e07cd5e11 100644 --- a/src/keyboardFocusStyles.test.mjs +++ b/src/keyboardFocusStyles.test.mjs @@ -1,7 +1,6 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; const css = readStylesheet(new URL('../style.css', import.meta.url)); diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 1a0ca4c73..6cbf58f51 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -3,16 +3,12 @@ import * as Cesium from 'cesium'; import { LAYER_ID, REQUEST_DEBOUNCE_MS, - QUERY_LIMIT, - MAX_RENDERED, - QUERY_SNAP_DEGREES, QUERY_REUSE_MS, ALPR_COLOR, } from './policy.js'; import { snapAlprBox, boxContains, - destinationPointDeg, alprRetryDelayMs, validateAlprSnapshot, alprCreditMarkup, diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 900692939..110c03bd3 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,4 @@ -import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { CAMERA_SEEDS } from './policy.js'; import { lookupModelSpec, horizontalFovRangeDeg, diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 807770c10..0c1812127 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -5,9 +5,6 @@ import { PROJECTION_CANVAS_HEIGHT, PROJECTION_MATTE_COLOR, PROJECTION_TEXTURE_SWAP_MS, - ACTIVE_FRAME_REFRESH_MS, - FRAME_ENDPOINT, - MEDIA_ENDPOINT, PROJECTION_ACTIVE_REFRESH_MS, PROJECTION_IDLE_REFRESH_MS, PLACEHOLDER_REPAINT_MS, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 797d1706b..d9db40fe9 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -1,7 +1,6 @@ import * as Cesium from 'cesium'; import { createFrustumVolumePrimitive } from '../../data/cctvViewshed.js'; import { - PROJECTION_VERT_ASPECT, FRUSTUM_GROUND_CLEARANCE_M, PROBE_MIN_RANGE_M, PROBE_CLEARANCE_M, diff --git a/src/layers/cctv/health.js b/src/layers/cctv/health.js index 979e997cf..925acc34e 100644 --- a/src/layers/cctv/health.js +++ b/src/layers/cctv/health.js @@ -1,4 +1,4 @@ -import { HEALTH_SYNC_INTERVAL_MS, HEALTH_ENDPOINT } from './policy.js'; +import { HEALTH_SYNC_INTERVAL_MS } from './policy.js'; export function createHealth({ state: layerState, services, parts, source }) { /** diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 4edf4c234..595bc2a0d 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -1,6 +1,6 @@ import { normalizeRoadMode } from './roadModes.js'; import * as Cesium from 'cesium'; -import { FLOW_BUCKET_COLORS, TRAFFIC_TIMING_ENABLED } from './policy.js'; +import { FLOW_BUCKET_COLORS } from './policy.js'; export function createState({ services }) { const layerState = {}; diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 6fd46211f..22b179120 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -9,7 +9,6 @@ import { FEED_STALE_AFTER_MS, MAX_VEHICLES_TOTAL, MISSED_POLLS_TO_DROP, - TRANSIT_POLL_MS, isStaleVehicleFix, transitVehicleKey, vehicleReportTimeMs, diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 8a2d39edf..0719acd95 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -6,7 +6,6 @@ import { FEED_EVICT_AFTER_MS, FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, - SELECTED_CARD_REFRESH_MS, TRANSIT_POLL_MS, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; diff --git a/src/layers/transit/movement.test.mjs b/src/layers/transit/movement.test.mjs index e758a52c8..692d2d3ac 100644 --- a/src/layers/transit/movement.test.mjs +++ b/src/layers/transit/movement.test.mjs @@ -9,7 +9,7 @@ import { displacementPlausible, } from './movement.js'; import { buildTransitSelectionCopy } from './policy.js'; -import { seek, sampleAt } from '../../data/contactPlayback.js'; +import { sampleAt } from '../../data/contactPlayback.js'; const entry = () => { const e = { mode: 'bus', modeInferred: false, courseDeg: null }; initializePlayback(e); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 96d77f2a2..2309978a5 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -22,7 +22,6 @@ import { import { MARKER_PIXEL_SIZE, ROTATION_REFRESH_MS, - SELECTED_CARD_REFRESH_MS, VISIBILITY_REFRESH_MS, SELECTED_PIXEL_SIZE, TRANSIT_MODE_COLORS, diff --git a/src/maps/availability.js b/src/maps/availability.js index 30ef3e6c6..ceace9968 100644 --- a/src/maps/availability.js +++ b/src/maps/availability.js @@ -1,7 +1,4 @@ -import { - keySetupRequirement, - keySetupRequirementEnvVars, -} from '../keySetupCore.mjs'; +import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { t } from '../i18n/index.js'; /** * Why Google 3D is unavailable, phrased so the tooltip and toast recommend the diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index a9549c85d..e0588e9b2 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -6,7 +6,6 @@ const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', i const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; const source = readShellSource(); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index f72b43dc7..59f3a3a0e 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -12,7 +12,6 @@ import { readShellSource } from '../testSupport/readShellSource.mjs'; // cancellable — an AbortSignal for the data manager, a liveness predicate for // the visual commit. Several of these assert exactly that plumbing. import assert from 'node:assert/strict'; -import fs from 'node:fs'; import test from 'node:test'; import { SceneDirector } from './director.js'; diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 13dedd709..ad421dde7 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -7,7 +7,6 @@ import { _claimContextVisualAuthority, setContextMode } from './ui/contextAction import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index a15c2060a..c804ea342 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -10,7 +10,6 @@ import { MILITARY_DETECTION_PRESET, } from './effects.js'; import { createStyleParameters } from './visualInput.js'; -import * as Cesium from 'cesium'; import { BLOOM_SCALE_VERSION, clampBloomIntensity, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d8b63565f..49e2ba20a 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4369,46 +4369,6 @@ function dominantValue(values) { }; } -function summarizeEntity(viewer, entity, { includeProperties = false } = {}) { - const now = Cesium.JulianDate.now(); - if (entity.__gevContextId) { - const store = window.__gevContextStore; - const record = store?.entities?.get(entity.__gevContextId); - if (record) return summarizeContextRecord(record, { includeProperties }); - } - const props = propertyObject(entity); - const layerId = entity.__localLayerId || props.layerId || null; - const tags = props.tags || {}; - const label = cleanText( - props.name || - tags.name || - tags['name:en'] || - tags.official_name || - tags.operator || - props.operator || - entity.name || - layerTitle(layerId), - ); - const position = - entity.__localBaseCartesian || - entity.position?.getValue?.(now) || - polygonCenter(entity, now); - const carto = position ? Cesium.Cartographic.fromCartesian(position) : null; - return { - id: String(entity.id || ''), - name: label || layerTitle(layerId), - layerId, - layerName: layerTitle(layerId), - latitude: carto - ? Number(Cesium.Math.toDegrees(carto.latitude).toFixed(6)) - : null, - longitude: carto - ? Number(Cesium.Math.toDegrees(carto.longitude).toFixed(6)) - : null, - properties: includeProperties ? compactProperties(props) : undefined, - }; -} - function summarizeContextRecord(record, { includeProperties = false } = {}) { return { id: String(record.id || ''), @@ -4427,31 +4387,6 @@ function summarizeContextRecord(record, { includeProperties = false } = {}) { }; } -function polygonCenter(entity, now) { - const hierarchy = entity.polygon?.hierarchy?.getValue?.(now); - const positions = hierarchy?.positions; - if (!positions?.length) return null; - return Cesium.BoundingSphere.fromPoints(positions).center; -} - -function propertyObject(entity) { - const raw = entity?.properties?.getValue?.(Cesium.JulianDate.now()) || {}; - return unwrapProperties(raw); -} - -function unwrapProperties(value) { - if (!value || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(unwrapProperties); - const out = {}; - for (const [key, entry] of Object.entries(value)) { - out[key] = - entry && typeof entry.getValue === 'function' - ? unwrapProperties(entry.getValue(Cesium.JulianDate.now())) - : unwrapProperties(entry); - } - return out; -} - function compactProperties(props) { const preferredKeys = [ 'name', diff --git a/tools/pano-pinhole.mjs b/tools/pano-pinhole.mjs index 36ceaa546..42fee8fbd 100644 --- a/tools/pano-pinhole.mjs +++ b/tools/pano-pinhole.mjs @@ -34,13 +34,11 @@ * Dependencies: sharp (devDependency) */ -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; import { resolve, join, basename, extname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- diff --git a/tools/sat-ortho.mjs b/tools/sat-ortho.mjs index d42039e46..514bb22d4 100644 --- a/tools/sat-ortho.mjs +++ b/tools/sat-ortho.mjs @@ -32,11 +32,9 @@ import { readFileSync, mkdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); const TILE_SIZE = 256; const MAX_CONCURRENT = 8; diff --git a/tools/streetview-headings.mjs b/tools/streetview-headings.mjs index 708ae7b93..e9a124d22 100644 --- a/tools/streetview-headings.mjs +++ b/tools/streetview-headings.mjs @@ -28,7 +28,6 @@ import { projectRoot } from '../scripts/project-root.mjs'; import { parseEnv } from 'node:util'; import { resolveGoogleServerKey } from '../scripts/google-server-key.mjs'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); function parseArgs() { diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index c93f487bd..a40d8d287 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -24,11 +24,9 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { projectRoot } from '../scripts/project-root.mjs'; import sharp from 'sharp'; -const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = projectRoot(import.meta.url); // --------------------------------------------------------------------------- From dbb20c72fe4aeb359f5a6c7d388efef8ed268956 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:09:40 -0700 Subject: [PATCH 071/178] chore: drop unused local bindings Remove locals that are assigned but never read: two unused JulianDate.now() calls in the flights and military nearby queries, an unread record lookup in the military snapshot renderer, unused axes from the CCTV gizmo, an unused vessel state alias, and two unused ALPR presentation handles. Each removed expression is side-effect free. --- src/data/cctvGizmo.js | 2 +- src/layers/alpr/index.js | 2 -- src/layers/flights/queries.js | 1 - src/layers/military/queries.js | 1 - src/layers/military/snapshotRenderer.js | 1 - src/layers/vessels/cards.js | 2 -- 6 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/data/cctvGizmo.js b/src/data/cctvGizmo.js index 6e7e18bcb..fa15c5532 100644 --- a/src/data/cctvGizmo.js +++ b/src/data/cctvGizmo.js @@ -316,7 +316,7 @@ export function createCalibrationGizmo({ } const mount = positions.mount; const axes = enuAxes(mount); - const { forwardHoriz, right, view } = viewAxesFor( + const { forwardHoriz } = viewAxesFor( camera.headingDeg, camera.pitchDeg, axes, diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 6cbf58f51..474ec1de0 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -80,9 +80,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { viewportBox, clearRendered, hideOnMapCredit, - presentOnMapCredit, renderRecords, - selectRecord, focusNearest, clearSelection, updateSelectedAnchor, diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 552adbe01..371b3a854 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -580,7 +580,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 9c106e927..d23ad621a 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -366,7 +366,6 @@ export function createQueries({ const maxRange = Number.isFinite(range) && range > 0 ? range : Number.POSITIVE_INFINITY; - const now = Cesium.JulianDate.now(); const nearby = []; for (const [icao24, bb] of flightState._billboards) { diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 6d619bf72..9de25db1d 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -187,7 +187,6 @@ export function createMilitarySnapshotRenderer({ // If this is the tracked aircraft, update label text // (position updates automatically via dead-reckoning CallbackProperty) if (isTracked && flightState._trackedEntity) { - const info = flightState.records.data.get(icao24); tracking._updateTrackedLabelModel(icao24); } } diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index da0a2ce82..64e115637 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -12,8 +12,6 @@ export function createCards({ layer, options, }) { - const { state } = vesselState; - function updateSelectedVesselHud(record) { const el = document.getElementById('hud-ais-vessel'); if (!el) return; From 504f9342aab147c0a3846f9f7bc5b2ad29a5461e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 18:53:56 -0700 Subject: [PATCH 072/178] chore: remove unreachable legacy entry modules Delete three modules that no entry point, package export, test or script imports: - src/ui.js, a StyleManager subclass that pre-selected the standalone catalog. Application controls are built through src/app/controls.js and src/standalone/controls.js. - src/standalone/ui.js, a bare re-export of StyleManager. - src/data/localLayers.js, a list of default infrastructure, cable and FIRMS layer instances. The catalog builds these layers itself. With src/ui.js gone, the import-direction check keeps a single compatibility exception (server/providers/local.js to server/standalone/key-setup.js), and its test now asserts that a src/ui.js importing standalone code is rejected. The boundary and format registries and the docs that named these files are updated. --- CONTRIBUTING.md | 2 +- docs/CODE-BOUNDARIES.md | 12 +++++------- docs/CURRENT-STATE.md | 8 ++++---- scripts/check-import-directions.mjs | 7 +++---- scripts/format-scope.json | 2 -- scripts/package-boundaries.json | 1 - src/data/localLayers.js | 18 ------------------ src/standalone/ui.js | 1 - src/tooling/importDirections.test.mjs | 9 +++++++-- src/ui.js | 16 ---------------- 10 files changed, 20 insertions(+), 56 deletions(-) delete mode 100644 src/data/localLayers.js delete mode 100644 src/standalone/ui.js delete mode 100644 src/ui.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 442b9c1bf..bb2c2de9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ The highest-leverage places to jump in: ## Architecture in one minute - **No framework.** Vanilla JS + [CesiumJS](https://cesium.com/platform/cesiumjs/) + [Vite](https://vitejs.dev/). -- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/ui.js` and `src/data/.js` entries retain compatibility; new code belongs with its focused owner. +- **Assembly lives in `src/app/`; standalone defaults live in `src/standalone/`.** UI controllers live in `src/ui/`, layer factories in `src/layers/`, portable sources in `src/sources/`, and application operations in `src/services/`. Existing `src/data/.js` entries retain compatibility; new code belongs with its focused owner. - Sources acquire records; renderers own Cesium resources. Import `gods-eye-view/layers//source` when only a source factory is needed. Common voice controls consume the session interface; protocol adapters own connection details. - **Secrets stay server-side.** Anything needing a private key goes through a local proxy under `server/providers/`. The browser only ever sees the Google Maps key (which you restrict) and ephemeral tokens. - `docs/CURRENT-STATE.md` is the authoritative runtime reference — read it first. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a23ba3e0f..3362b98f0 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -79,7 +79,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | Retained entry | Owner and current reason | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `src/ui.js` | UI facade; direct callers retain standalone StyleManager defaults. Normal assembly uses `src/ui/composition.js` | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | | `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | @@ -87,9 +86,8 @@ moves it. Do not label all ingestion modules platform-independent by filename. | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | -The two exact standalone-import exceptions are `src/ui.js` to -`src/standalone/catalog.js` and `server/providers/local.js` to -`server/standalone/key-setup.js`. They do not authorize new compatibility +The one exact standalone-import exception is `server/providers/local.js` to +`server/standalone/key-setup.js`. It does not authorize new compatibility back-edges. Voice actions use plain `src/data/feedState.js`, not the manager facade. Settings filesystem hardening belongs in `server/standalone/key-setup-hardening.mjs`; its settings policy is unchanged. @@ -237,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui.js` retains panel layout, persistence, share restoration and application +`src/ui/composition.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. @@ -470,8 +468,8 @@ release listeners; pending action feedback is limited to its current owner. ## UI assembly and styles `ui/shell` assembles controls from supplied existing layer, navigation, terrain, -rendering, HUD and share operations. `src/standalone/ui.js` provides the running -application's instances; `src/ui.js` remains the compatibility entry. The shell +rendering, HUD and share operations. `src/standalone/controls.js` provides the running +application's instances. The shell imports no standalone bootstrap or concrete live layer implementation. Panel layout, position/drag, notices, recording and deferred UI work have separate owners with synchronous cleanup. Existing scene, share and HUD engines retain diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5ec660548..7184c5d96 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3595,7 +3595,7 @@ Historical planning documents may not match runtime behavior. ## Runtime Stack - Vite + CesiumJS app with Google Photorealistic 3D Tiles -- Scene/HUD/style systems in `src/ui.js` and `src/hud.js` +- Scene/HUD/style systems in `src/ui/` and `src/hud.js` - Layer management in `src/data/manager.js` - Map stack switching in `src/mapStackController.js` - Voice control in `src/voice/` (OpenAI Realtime over WebRTC) @@ -3627,8 +3627,8 @@ its criteria cannot be silently ignored. | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | +| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | 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) | @@ -5164,7 +5164,7 @@ pins and share links already supply complete boxes. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. -- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. +- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. - Civilian and military 2D aircraft use the established distance scale: `3×` near the camera and a `0.5×` floor from 8,000 km outward. A standard 20 px ambient diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index ac1a3f732..3f4fec617 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -24,11 +24,10 @@ const renderer = (file) => /^src\/layers\/[^/]+\/(?:index|rendering|snapshotRenderer|overlay|presentation|cards|controls)\.js$/.test( file, ); -// Two compatibility composition entries intentionally select standalone defaults. +// One compatibility composition entry intentionally selects standalone defaults. const compatibilityEdge = (from, to) => - (from === 'server/providers/local.js' && - to === 'server/standalone/key-setup.js') || - (from === 'src/ui.js' && to === 'src/standalone/catalog.js'); + from === 'server/providers/local.js' && + to === 'server/standalone/key-setup.js'; const portableExport = (key) => key.startsWith('./sources/') || /\/source$/.test(key) || diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2c78c0774..e313ade42 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -285,8 +285,6 @@ "src/ui/sceneControls.test.mjs", "scripts/qa-scene-controls.mjs", "scripts/qa-action-buttons.mjs", - "src/ui.js", - "src/standalone/ui.js", "src/ui/applicationShell.js", "src/ui/panelLayoutController.js", "src/ui/panelPositionControls.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a7c35c286..f2efea33c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1852,7 +1852,6 @@ "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", - "src/data/localLayers.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", diff --git a/src/data/localLayers.js b/src/data/localLayers.js deleted file mode 100644 index 63c07b073..000000000 --- a/src/data/localLayers.js +++ /dev/null @@ -1,18 +0,0 @@ -import { localGeoJsonServices } from './localGeojson.js'; -import { createInfrastructureLayers } from './infrastructure.js'; -import { createFirmsHeatmapLayer } from './firmsHeatmap.js'; -import submarineCablesLayer from './telegeographySubmarineCables.js'; - -const [datacenters, dams] = createInfrastructureLayers(localGeoJsonServices); - -// Live NASA FIRMS fires (VIIRS ×3 NRT via the /api/firms proxy). The id keeps -// the historical `local-` prefix for persistence + voice-tool-enum compat, -// but the data is NOT bundled anymore — it needs FIRMS_MAP_KEY server-side. -const fires = createFirmsHeatmapLayer({ - id: 'local-firms', - name: 'FIRMS Active Fires', - icon: '▲', - source: 'NASA FIRMS · LIVE', -}); - -export default [datacenters, dams, submarineCablesLayer, fires]; diff --git a/src/standalone/ui.js b/src/standalone/ui.js deleted file mode 100644 index 780d515cf..000000000 --- a/src/standalone/ui.js +++ /dev/null @@ -1 +0,0 @@ -export { StyleManager } from '../ui/composition.js'; diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index b34fee3b9..4352bd582 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -156,13 +156,18 @@ test('self package imports cannot evade portable ownership', (t) => { assert.throws(() => checkImportDirections(root), /Source imports rendering/); }); -test('compatibility exceptions are limited to the two existing composition entries', (t) => { +test('the compatibility exception is limited to the existing composition entry', (t) => { const { root, write } = fixture(t); - write('src/ui.js', "import './standalone/catalog.js';"); write('src/standalone/catalog.js'); write('server/providers/local.js', "import '../standalone/key-setup.js';"); write('server/standalone/key-setup.js'); assert.doesNotThrow(() => checkImportDirections(root)); + write('src/ui.js', "import './standalone/catalog.js';"); + assert.throws( + () => checkImportDirections(root), + /Reusable module imports standalone/, + ); + write('src/ui.js'); write('src/ui/new.js', "import '../standalone/catalog.js';"); assert.throws( () => checkImportDirections(root), diff --git a/src/ui.js b/src/ui.js deleted file mode 100644 index 5142301a3..000000000 --- a/src/ui.js +++ /dev/null @@ -1,16 +0,0 @@ -import { StyleManager as ApplicationControls } from './ui/composition.js'; -import { catalogControlServices } from './app/catalog.js'; -import { getStandaloneCatalog } from './standalone/catalog.js'; -/** Compatibility entry for direct standalone control construction. */ -export class StyleManager extends ApplicationControls { - constructor(viewer, options = {}) { - super(viewer, { - ...options, - services: { - ...catalogControlServices(getStandaloneCatalog()), - ...getStandaloneCatalog().surface.controlServices, - ...options.services, - }, - }); - } -} From 73e789489bdb830fa0c6bfdbcc60de5fd81bf9ad Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:00:35 -0700 Subject: [PATCH 073/178] chore: drop exports nothing imports Knip, configured with the application, package exports, scripts, QA harnesses, workers, tools and tests as entry points, reports exports that no module imports. Each was checked by hand against string-based and namespace imports. - Symbols that are still used inside their own module lose only the `export` keyword (176 constants and helpers). - Unused re-exports are removed from barrel modules (flight, military, vessel, CCTV, directions, transit history, local ADS-B and street-level policy modules). - Declarations that nothing uses at all are deleted, among them AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText, DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band constants. - Default exports that nothing imports are removed. The ones in src/data/earthquakes.js, src/data/telegeographySubmarineCables.js, src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a layer instance at import time that nothing used; those instances are no longer created. Every export of a package.json entry stays, as does TOMTOM_CREDIT, which the traffic layer reads from the credits namespace it is given. --- server/providers/cctv/constants.js | 2 +- server/providers/cctv/groundHeights.js | 2 +- server/providers/cctv/media.js | 4 +- server/providers/cctv/normalize.js | 6 +-- server/providers/cctv/sources.js | 2 +- server/providers/cctv/stream.js | 2 +- server/providers/cctv/taiwan.js | 6 +-- server/providers/local-receivers.js | 18 ++++---- server/providers/mapillary/tiles.js | 2 +- server/providers/openai/codex-auth.js | 2 +- server/providers/openai/debug-log.js | 2 +- server/providers/radio/catalog.js | 2 +- server/providers/transitHistory.js | 1 - server/providers/wind/ifs.js | 2 +- src/annotations/drawMode.js | 8 ++-- src/annotations/drawTool.js | 2 +- src/annotations/resolver.js | 2 +- src/app/embed.js | 6 +-- src/app/sourceComposition.js | 2 +- src/cameraGroundGuard.js | 4 +- src/cctvFocusPolicy.js | 2 +- src/cockpitCloudEffects.js | 2 +- src/data/adminBoundaries.js | 2 +- src/data/aisStreamAdapter.js | 2 +- src/data/aisWatchdog.js | 22 +--------- src/data/analystEngine.js | 44 ++----------------- src/data/bhoteKoshiLocator.js | 5 +-- src/data/bikeshare.js | 1 - src/data/cctvFootprint.js | 4 +- src/data/cctvLod.js | 6 +-- src/data/dataCredits.js | 4 +- src/data/detectionCohort.js | 2 +- src/data/detectionPolicy.js | 3 +- src/data/directions.js | 6 +-- src/data/earthquakes.js | 1 - src/data/geoFileImport.js | 8 ++-- src/data/geoImportImages.js | 2 +- src/data/gtfsRealtime.js | 5 +-- src/data/localGeojson.js | 1 - src/data/militaryNames.js | 2 +- src/data/radioCountry.js | 5 --- src/data/telegeographySubmarineCables.js | 1 - src/data/wind.js | 8 ---- src/i18n/index.js | 2 +- src/layers/alpr/records.js | 6 +-- src/layers/alpr/tileRecords.js | 2 +- src/layers/awareness/policy.js | 5 +-- src/layers/bikeshare/policy.js | 2 +- src/layers/cctv/policy.js | 11 +---- src/layers/cctv/sourcePolicy.js | 4 -- src/layers/cyclones/labels.js | 4 +- src/layers/flights/policy.js | 8 ---- src/layers/installations/policy.js | 2 - src/layers/localAdsb/feeds.js | 4 +- src/layers/localAdsb/index.js | 14 +----- src/layers/localAdsb/motion.js | 2 +- src/layers/localAdsb/status.js | 4 +- src/layers/military/policy.js | 7 --- src/layers/radio/policy.js | 2 +- src/layers/recentImagery/model.js | 2 +- src/layers/satellites/policy.js | 4 +- src/layers/streetLevel/photoViewer.js | 10 ++--- .../streetLevel/providers/kartaview/policy.js | 4 +- .../streetLevel/providers/panoramax/index.js | 2 - .../streetLevel/providers/panoramax/policy.js | 2 +- src/layers/streetLevel/registry.js | 2 +- src/layers/streetLevel/sequenceCones.js | 4 +- src/layers/streetLevel/tileCache.js | 4 +- src/layers/streetLevel/uiState.js | 4 +- src/layers/submarineCables/geometry.js | 6 +-- src/layers/traffic/footprint.js | 6 +-- src/layers/traffic/policy.js | 10 ----- src/layers/transit/policy.js | 14 +++--- src/layers/vessels/policy.js | 5 --- src/layers/weather/source.js | 2 +- src/layers/wind/gpuRendering.js | 4 +- src/locationStatus.js | 2 - src/maps/imageryComparison.js | 2 +- src/maps/terrainRetry.js | 2 +- src/scenes/recipes.js | 4 +- src/sdr/controller.js | 12 ++--- src/sdr/gain.js | 2 +- src/sky/stars.js | 4 +- src/sources/adsbRecords.js | 8 ++-- src/sources/featureGeometry.js | 12 ++--- src/sources/openFreeMap.js | 2 +- src/sources/transitHistoryStore.js | 2 +- src/testSupport/kartaViewFakes.mjs | 2 +- src/tools/queries/panelRequest.js | 2 +- src/tools/queries/share.js | 2 +- src/ui/cameraOrientationControls.js | 4 +- src/ui/cctvFrames.js | 2 +- src/ui/statusText.js | 2 +- src/voice/deixis.js | 2 +- src/voice/narration.js | 2 +- src/voice/pointerContext.js | 8 ++-- src/voice/pointerPick.js | 2 +- src/voice/pointerReticle.js | 2 +- src/voice/realtimeDiagnostics.js | 29 +++++------- src/voice/realtimePreferences.js | 6 +-- src/voice/realtimeTurns.js | 2 +- src/voice/realtimeViewport.js | 16 +++---- src/voice/resultDisplay.js | 2 +- src/voice/speechLint.js | 2 +- src/voice/turnMetrics.js | 2 +- src/voice/voiceCard.js | 2 +- 106 files changed, 180 insertions(+), 342 deletions(-) diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 257c292b3..a26fa6c20 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -18,7 +18,7 @@ export const CCTV_MAX_SOURCES_CEILING = 5000; export const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 }; /** Caltrans CCTV: one JSON feed per district, identical schema statewide. */ /** TxDOT ITS: one keyless JSON catalog per district (25 districts statewide). */ -export const TXDOT_ORIGIN = 'https://its.txdot.gov'; +const TXDOT_ORIGIN = 'https://its.txdot.gov'; export const TXDOT_CCTV_STATUS_URL = (district) => `${TXDOT_ORIGIN}/its/DistrictIts/GetCctvStatusListByDistrict?districtCode=${encodeURIComponent(district)}`; /** Per-camera frame. Returns JSON `{snippet:}`, not an image body; diff --git a/server/providers/cctv/groundHeights.js b/server/providers/cctv/groundHeights.js index c73b6e840..f20b9758d 100644 --- a/server/providers/cctv/groundHeights.js +++ b/server/providers/cctv/groundHeights.js @@ -3,7 +3,7 @@ import path from 'node:path'; import { poseHash } from '../../../src/data/cctvFootprint.js'; /** Sidecar written by scripts/precompute-cctv-heights.mjs. */ -export const DEFAULT_GROUND_HEIGHTS_FILE = +const DEFAULT_GROUND_HEIGHTS_FILE = 'src/data/local_data/cctv_ground_heights/cctv_ground_heights.json'; /** @type {{path:string, mtimeMs:number, cameras:object}|null} */ diff --git a/server/providers/cctv/media.js b/server/providers/cctv/media.js index 1323bc841..206a24888 100644 --- a/server/providers/cctv/media.js +++ b/server/providers/cctv/media.js @@ -92,7 +92,7 @@ export function buildSyntheticCctvSvg({ cameraId, label, city, status }) { * @param {ReadableStream|NodeJS.ReadableStream|null} body * @returns {import('stream').Readable|null} */ -export function toReadable(body) { +function toReadable(body) { if (!body) return null; if (typeof body.pipe === 'function') return body; if (typeof body.getReader === 'function') { @@ -108,7 +108,7 @@ export function toReadable(body) { * @returns {{essence:string,params:Map}|null} Null when the * value is missing or is not a `type/subtype` media type. */ -export function parseMediaType(value) { +function parseMediaType(value) { const [head, ...rest] = String(value ?? '').split(';'); const essence = head.trim().toLowerCase(); if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) return null; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 74f693dd8..f190f257e 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -64,7 +64,7 @@ export function toFiniteNumber(value, fallback = NaN) { * @param {string} text * @returns {string} */ -export function normalizeKey(text) { +function normalizeKey(text) { return String(text || '') .trim() .toLowerCase() @@ -80,7 +80,7 @@ export function normalizeKey(text) { * @param {string} value * @returns {{lat:number, lon:number}} */ -export function parsePointString(value) { +function parsePointString(value) { const match = String(value || '').match( /POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i, ); @@ -100,7 +100,7 @@ export function parsePointString(value) { * @param {string|object|null} value * @returns {{lat:number, lon:number}} */ -export function coerceLatLon(value) { +function coerceLatLon(value) { if (!value) return { lat: NaN, lon: NaN }; if (typeof value === 'string') { diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c7def2284..a479a945c 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1868,7 +1868,7 @@ export function normalizeCataloniaImageUrl(rawUrl) { * @param {string} imageUrl - A URL normalizeCataloniaImageUrl accepted. * @returns {string} */ -export function cataloniaCameraId(imageUrl) { +function cataloniaCameraId(imageUrl) { const parsed = new URL(imageUrl); const slug = (text) => text diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index 14d1743bb..da811f5f9 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -70,7 +70,7 @@ export async function fetchHlsBytes( } } -export function sameOriginHlsUrl(value, base) { +function sameOriginHlsUrl(value, base) { const url = new URL(value, base); if ( !['https:', 'http:'].includes(url.protocol) || diff --git a/server/providers/cctv/taiwan.js b/server/providers/cctv/taiwan.js index 830ed47fd..a14aabf9a 100644 --- a/server/providers/cctv/taiwan.js +++ b/server/providers/cctv/taiwan.js @@ -12,14 +12,14 @@ export const TAIWAN_FREEWAY_CCTV_URL = 'https://tisvcloud.freeway.gov.tw/history/motc20/CCTV.xml'; /** The whole network is ~1.2 MB. */ export const TAIWAN_MAX_CATALOG_BYTES = 6 * 1024 * 1024; -export const DEFAULT_TAIWAN_MAX_SOURCES = 600; +const DEFAULT_TAIWAN_MAX_SOURCES = 600; /** After a failed download, wait this long before asking again. The catalog * host does not answer from some networks outside Taiwan, and every attempt * there holds the shared catalog refresh until the connect timeout. */ export const TAIWAN_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000; /** The table is republished daily; a good copy stands in for a failed * download for up to this long. */ -export const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const TAIWAN_LAST_GOOD_MAX_AGE_MS = 24 * 60 * 60 * 1000; // The table publishes each camera's MJPEG stream. The same camera servers // answer a single JPEG for the same camera ID on a sibling path, which is what @@ -50,7 +50,7 @@ const ANCHORS = [ /** Taiwan main island with slack for the coast; the freeway network has no * offshore-island cameras. */ -export function isLikelyTaiwanCoordinate(lat, lon) { +function isLikelyTaiwanCoordinate(lat, lon) { return ( Number.isFinite(lat) && Number.isFinite(lon) && diff --git a/server/providers/local-receivers.js b/server/providers/local-receivers.js index 994da7bd8..f3f340b54 100644 --- a/server/providers/local-receivers.js +++ b/server/providers/local-receivers.js @@ -27,13 +27,13 @@ import { normalizeDump1090Aircraft } from '../../src/sources/adsbRecords.js'; */ export const LOCAL_RECEIVERS_ROUTE = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; -export const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); -export const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; -export const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; -export const LOCAL_RECEIVER_CACHE_MS = 1_000; +const LOCAL_RECEIVER_FEEDS_ENV = 'LOCAL_RECEIVER_FEEDS'; +const LOCAL_RECEIVER_BANDS = Object.freeze(['1090', '978']); +const LOCAL_RECEIVER_TIMEOUT_MS = 2_000; +const LOCAL_RECEIVER_MAX_BODY_BYTES = 2 * 1024 * 1024; +const LOCAL_RECEIVER_CACHE_MS = 1_000; /** A feed whose own `now` is older than this is reported stale. */ -export const LOCAL_RECEIVER_STALE_MS = 10_000; +const LOCAL_RECEIVER_STALE_MS = 10_000; const MAX_FEEDS = 8; const LOG_PREFIX = '[local-receivers]'; @@ -139,7 +139,7 @@ class FeedError extends Error { * @param {string} address Resolved IP address. * @returns {boolean} */ -export function isLocalReceiverAddress(address) { +function isLocalReceiverAddress(address) { const value = String(address || '').toLowerCase(); if (value === '::1') return true; const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); @@ -152,7 +152,7 @@ export function isLocalReceiverAddress(address) { * @param {typeof dnsLookup} lookupImpl `dns.promises.lookup`-compatible. * @returns {Promise>} */ -export async function resolveLocalReceiverAddresses(hostname, lookupImpl) { +async function resolveLocalReceiverAddresses(hostname, lookupImpl) { let resolved; try { resolved = await lookupImpl(hostname, { all: true, verbatim: true }); @@ -274,7 +274,7 @@ function fetchLocalReceiverFeed(url, options) { * @param {object} options * @returns {Promise} Parsed JSON document. */ -export async function fetchLocalReceiverDocument( +async function fetchLocalReceiverDocument( url, { fetchImpl = fetchLocalReceiverFeed, diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 569e7e45f..6e2207848 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -42,7 +42,7 @@ export class TileUpstreamError extends Error { } /** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */ -export function retryAfterSeconds(header, now = Date.now()) { +function retryAfterSeconds(header, now = Date.now()) { const text = String(header ?? '').trim(); if (!text) return null; const seconds = /^\d+$/.test(text) diff --git a/server/providers/openai/codex-auth.js b/server/providers/openai/codex-auth.js index 5f16ee824..4d3f992da 100644 --- a/server/providers/openai/codex-auth.js +++ b/server/providers/openai/codex-auth.js @@ -145,7 +145,7 @@ const CODEX_LOGIN_ENV = /^(?:PATH|PATHEXT|HOME|USER|USERNAME|LOGNAME|SHELL|LANG|LANGUAGE|LC_[A-Z_]+|TERM|TZ|TMPDIR|TEMP|TMP|XDG_[A-Z_]+|DISPLAY|WAYLAND_DISPLAY|DBUS_SESSION_BUS_ADDRESS|BROWSER|WSL_[A-Z_]+|WSLENV|(?:HTTPS?|ALL|NO)_PROXY|SSL_CERT_(?:FILE|DIR)|NODE_EXTRA_CA_CERTS|SYSTEMROOT|WINDIR|COMSPEC|USERPROFILE|APPDATA|LOCALAPPDATA|PROGRAMDATA|HOMEDRIVE|HOMEPATH|CODEX_[A-Z_]+)$/i; /** The environment `codex login` runs with: the allowlist, then `CODEX_HOME`. */ -export function codexLoginEnvironment(environment, codexHome) { +function codexLoginEnvironment(environment, codexHome) { const env = {}; for (const [name, value] of Object.entries(environment || {})) if (CODEX_LOGIN_ENV.test(name) && value !== undefined) env[name] = value; diff --git a/server/providers/openai/debug-log.js b/server/providers/openai/debug-log.js index 956569642..c8151d2f6 100644 --- a/server/providers/openai/debug-log.js +++ b/server/providers/openai/debug-log.js @@ -157,4 +157,4 @@ function createDebugLogHandler({ }; } -export { createDebugLogHandler, omitVoiceContent }; +export { createDebugLogHandler }; diff --git a/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index 36a3eb730..2864c8986 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -26,7 +26,7 @@ import { RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, } from './constants.js'; -export async function mapRadioConcurrent(values, concurrency, mapper) { +async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); let cursor = 0; const workers = Array.from( diff --git a/server/providers/transitHistory.js b/server/providers/transitHistory.js index f396f3d8f..4da5a8891 100644 --- a/server/providers/transitHistory.js +++ b/server/providers/transitHistory.js @@ -1,5 +1,4 @@ export { createTransitHistory, - TRANSIT_HISTORY_LIMITS, validTransitIdentifier, } from '../../src/sources/transitHistoryStore.js'; diff --git a/server/providers/wind/ifs.js b/server/providers/wind/ifs.js index 7767d3905..076baf064 100644 --- a/server/providers/wind/ifs.js +++ b/server/providers/wind/ifs.js @@ -3,7 +3,7 @@ import { decodeWindGribMessage } from './decode.js'; import { resampleWeatherSnapshot, weatherScalarMetadata } from './grid.js'; /** Keyless ECMWF Open Data root for real-time IFS forecasts. */ -export const IFS_BASE = 'https://data.ecmwf.int/forecasts'; +const IFS_BASE = 'https://data.ecmwf.int/forecasts'; /** * Select the latest IFS cycle that should be available. diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 02a501010..fcfe08be6 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -31,10 +31,10 @@ export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); */ export const MAX_VERTICES = 512; /** Two clicks closer than this are one vertex: a double-click to finish must not add a stray point. */ -export const MIN_VERTEX_SEPARATION_M = 0.5; +const MIN_VERTEX_SEPARATION_M = 0.5; /** A line shorter than this, or an area thinner than this, is a mis-click rather than a shape. */ -export const MIN_PATH_LENGTH_M = 1; -export const MIN_AREA_M2 = 1; +const MIN_PATH_LENGTH_M = 1; +const MIN_AREA_M2 = 1; /** @param {string} shape @returns {'area'|'line'|'pin'} */ export function normalizeShape(shape) { @@ -50,7 +50,7 @@ export function createDrawSession(shape = 'area') { } /** Great-circle distance in metres between two {lon, lat} points. */ -export function greatCircleM(a, b) { +function greatCircleM(a, b) { return greatCircleMeters(a, b); } diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 0f4e0fc76..9467406dd 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -52,7 +52,7 @@ import { import { msg, registerUnsavedWork, t, translateKnown } from '../i18n/index.js'; /** The id this tool claims the pointer under. */ -export const DRAW_POINTER_OWNER = 'draw'; +const DRAW_POINTER_OWNER = 'draw'; const PREVIEW_DATA_SOURCE_NAME = 'gev-draw-preview'; const COLORS = ['primary', 'amber', 'cyan', 'green', 'red']; diff --git a/src/annotations/resolver.js b/src/annotations/resolver.js index fb5795d53..226caf140 100644 --- a/src/annotations/resolver.js +++ b/src/annotations/resolver.js @@ -29,7 +29,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; /** Ms the analyst region lookup waits on geocode + admin boundary. */ -export const REGION_FALLBACK_BUDGET_MS = 3_000; +const REGION_FALLBACK_BUDGET_MS = 3_000; /** Entity facts that rule out a state/county reading of the ask. */ const NON_ADMIN_ENTITY_KINDS = new Set([ diff --git a/src/app/embed.js b/src/app/embed.js index a1485fe7e..f0d6cad0a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -15,9 +15,9 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; import { annotationsFromParams, createView } from '../view/index.js'; -export const EMBED_VIEW_MESSAGE = 'gev:view'; -export const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; -export const EMBED_READY_MESSAGE = 'gev:ready'; +const EMBED_VIEW_MESSAGE = 'gev:view'; +const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; +const EMBED_READY_MESSAGE = 'gev:ready'; const FOLLOW_LAYERS = { aircraft: 'flights', diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..04794ab13 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -53,7 +53,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ -export class SourceUnavailableError extends Error { +class SourceUnavailableError extends Error { constructor(name) { super(`Data source not configured: ${name}`); this.name = 'SourceUnavailableError'; diff --git a/src/cameraGroundGuard.js b/src/cameraGroundGuard.js index 5a5fa3775..3f9de0cab 100644 --- a/src/cameraGroundGuard.js +++ b/src/cameraGroundGuard.js @@ -20,8 +20,8 @@ import { ownCameraArrival } from './data/cameraArrival.js'; /** Minimum eye height above the rendered surface for a usable view. */ export const MIN_EYE_CLEARANCE_M = 120; /** Retries while tiles stream in; sampling fails until the mesh exists. */ -export const GUARD_ATTEMPTS = 8; -export const GUARD_INTERVAL_MS = 600; +const GUARD_ATTEMPTS = 8; +const GUARD_INTERVAL_MS = 600; /** Ignore sub-metre noise rather than nudging the camera forever. */ const GUARD_EPSILON_M = 2; diff --git a/src/cctvFocusPolicy.js b/src/cctvFocusPolicy.js index 7fb2ebe1b..e11c1e1ca 100644 --- a/src/cctvFocusPolicy.js +++ b/src/cctvFocusPolicy.js @@ -27,7 +27,7 @@ export function runCctvLayerEnableFocus({ * @param {Object} [after={}] Ownership immediately after the await. * @returns {{ trackedEntity: *, cockpitActive: boolean }} Conservative ownership. */ -export function mergeCctvEnableOwnership(before = {}, after = {}) { +function mergeCctvEnableOwnership(before = {}, after = {}) { return { trackedEntity: before.trackedEntity || after.trackedEntity || null, cockpitActive: !!(before.cockpitActive || after.cockpitActive), diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index ccbf31831..c9117b7d3 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -174,7 +174,7 @@ export function cockpitWeatherEnabledFromStoredValue(value) { * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. */ -export class CockpitCloudEffectsController { +class CockpitCloudEffectsController { constructor(viewer, { weatherService = applicationServices.weather } = {}) { this.weatherService = weatherService; this.viewer = viewer; diff --git a/src/data/adminBoundaries.js b/src/data/adminBoundaries.js index e9f97cf8f..9efa967d4 100644 --- a/src/data/adminBoundaries.js +++ b/src/data/adminBoundaries.js @@ -123,7 +123,7 @@ export function decodeRing(encoded, decimals) { } /** Even-odd point-in-ring test; ring is [[lon, lat], …], open or closed. */ -export function pointInRing(ring, lat, lon) { +function pointInRing(ring, lat, lon) { if (!Array.isArray(ring) || ring.length < 3) return false; let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { diff --git a/src/data/aisStreamAdapter.js b/src/data/aisStreamAdapter.js index 1f7b0ef9b..05e747308 100644 --- a/src/data/aisStreamAdapter.js +++ b/src/data/aisStreamAdapter.js @@ -215,7 +215,7 @@ export const AIS_RECOGNIZED_MESSAGE_TYPES = Object.freeze( * @param {Object} body * @returns {string|null} */ -export function aisEnvelopeMmsi(envelope, body = {}) { +function aisEnvelopeMmsi(envelope, body = {}) { const metadata = envelope?.MetaData || envelope?.Metadata || {}; const raw = metadata.MMSI ?? body.UserID ?? body.UserId ?? body.Mmsi; if (raw === null || raw === undefined) return null; diff --git a/src/data/aisWatchdog.js b/src/data/aisWatchdog.js index a83c281d9..f024a6f67 100644 --- a/src/data/aisWatchdog.js +++ b/src/data/aisWatchdog.js @@ -46,28 +46,8 @@ export const AIS_WATCHDOG_DEFAULTS = Object.freeze({ authProbeMs: 3_600_000, }); -/** - * Statuses reported to the browser. 'missing-key' and 'unsupported' keep their - * pre-watchdog meaning so a keyless install still reads as "feature off". - */ -export const AIS_WATCHDOG_STATUSES = Object.freeze([ - 'idle', - 'missing-key', - 'unsupported', - 'connecting', - 'live', - 'stale', - 'reconnecting', - 'down', - 'auth-failed', -]); - /** Failure classes. Only 'transport' is allowed to walk the fast ladder. */ -export const AIS_FAILURE_KINDS = Object.freeze([ - 'transport', - 'auth', - 'rate-limit', -]); +const AIS_FAILURE_KINDS = Object.freeze(['transport', 'auth', 'rate-limit']); /** Statuses that must not flip back to a hopeful 'connecting' on a retry. */ const QUIET_TERMINAL = new Set(['down', 'auth-failed']); diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index 6a31c5a20..5175fec48 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -62,7 +62,7 @@ const COMMON_FIELDS = Object.freeze(['id', 'lat', 'lon']); /** Derived per-record field for layers with a time field (hours before now). */ const AGE_FIELD = 'ageHours'; /** Spatial scope kinds `query()` accepts. */ -export const ANALYST_SCOPE_KINDS = Object.freeze([ +const ANALYST_SCOPE_KINDS = Object.freeze([ 'view', 'region', 'radius', @@ -74,7 +74,7 @@ export const ANALYST_SCOPE_KINDS = Object.freeze([ * @param {string[]} layerKeys Queried layer ids. * @returns {string[]} Field names, common fields first. */ -export function analystFieldsFor(layerKeys) { +function analystFieldsFor(layerKeys) { const fields = new Set(COMMON_FIELDS); for (const key of layerKeys) { const layer = ANALYST_LAYERS[key]; @@ -104,44 +104,6 @@ export function haversineKm(lat1, lon1, lat2, lon2) { return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } -/** - * One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. - * With `time: true`, both sides compare as epoch ms (ISO strings accepted). - */ -export function applyFilter(records, filter, { time = false } = {}) { - const { field, op } = filter || {}; - if (!field || !op) return records; - const value = time ? toEpochMs(filter.value) : filter.value; - return records.filter((r) => { - const got = time ? toEpochMs(r[field]) : r[field]; - if (got === null || got === undefined || (time && !Number.isFinite(got))) - return false; - if (time && (op === 'eq' || op === 'neq')) - return (got === value) === (op === 'eq'); - switch (op) { - case 'gt': - return Number(got) > Number(value); - case 'gte': - return Number(got) >= Number(value); - case 'lt': - return Number(got) < Number(value); - case 'lte': - return Number(got) <= Number(value); - case 'eq': { - if (typeof got === 'boolean' || typeof value === 'boolean') - return Boolean(got) === Boolean(value); - return String(got).toLowerCase() === String(value).toLowerCase(); - } - case 'neq': - return String(got).toLowerCase() !== String(value).toLowerCase(); - case 'contains': - return String(got).toLowerCase().includes(String(value).toLowerCase()); - default: - return false; - } - }); -} - /** Scope records spatially. scope: {kind:'view'|'region'|'radius'|'anywhere', …}. */ export function applyScope(records, scope, resolved) { if (!scope || scope.kind === 'anywhere') return records; @@ -191,7 +153,7 @@ const COMMON_UNITS = Object.freeze({ * @param {string} field Field name. * @returns {'number'|'text'|'flag'|'time'|null} Field type. */ -export function analystFieldType(layerKeys, field) { +function analystFieldType(layerKeys, field) { if (!analystFieldsFor(layerKeys).includes(field)) return null; if (COMMON_TYPES[field]) return COMMON_TYPES[field]; for (const key of layerKeys) { diff --git a/src/data/bhoteKoshiLocator.js b/src/data/bhoteKoshiLocator.js index 79aab080f..369465680 100644 --- a/src/data/bhoteKoshiLocator.js +++ b/src/data/bhoteKoshiLocator.js @@ -14,7 +14,7 @@ import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js'; import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js'; import { t, msg, getLocale } from '../i18n/index.js'; -export const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; +const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator'; export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID = 'bhote-koshi-locator-callout'; export const NEPAL_CONTEXT_PRESENTATION = 'nepal-context'; @@ -1247,6 +1247,3 @@ export function createBhoteKoshiLocatorLayer({ }, }; } - -const bhoteKoshiLocatorLayer = createBhoteKoshiLocatorLayer(); -export default bhoteKoshiLocatorLayer; diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 15c9dc802..6370c790e 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -23,4 +23,3 @@ export { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS, } from '../layers/bikeshare/index.js'; -export default layer; diff --git a/src/data/cctvFootprint.js b/src/data/cctvFootprint.js index 42a3ad424..93f2b48ed 100644 --- a/src/data/cctvFootprint.js +++ b/src/data/cctvFootprint.js @@ -17,7 +17,7 @@ */ const EARTH_RADIUS_M = 6371000; -export const PLANE_VERT_ASPECT = 16 / 9; +const PLANE_VERT_ASPECT = 16 / 9; export const SUPPORT_KEYS = Object.freeze([ 'bl', 'bm', @@ -128,7 +128,7 @@ export function planeSupportPoints(pose) { * @param {number} capAltM @param {-1|0|1} row @param {{upVert:number}} dims * @returns {number} */ -export function supportAltitude(capAltM, row, dims) { +function supportAltitude(capAltM, row, dims) { return capAltM + row * dims.upVert; } diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 44b2e8ba3..834dd964f 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -94,7 +94,7 @@ export function incumbentRankKm( export const CCTV_CARD_CENTER_WEIGHT = 0.5; /** Robust pool-distance percentile used to scale the screen-space term. */ -export const CCTV_CARD_SPREAD_PERCENTILE = 0.9; +const CCTV_CARD_SPREAD_PERCENTILE = 0.9; /** True only when both viewport dimensions are finite positive pixels. */ export function hasFiniteCctvViewport(viewW, viewH) { @@ -172,8 +172,8 @@ export function blendCenterRankKm( // viewport is bucketed into this grid and every occupied cell gets its best // candidate before global rank fills the rest. Owner-tunable together with // the budgets above. -export const CCTV_CARD_GRID_COLS = 5; -export const CCTV_CARD_GRID_ROWS = 4; +const CCTV_CARD_GRID_COLS = 5; +const CCTV_CARD_GRID_ROWS = 4; /** * Screen-space distribution pass (owner round 2, item C). Buckets the diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 12dd7682a..aff87aeb7 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -25,11 +25,11 @@ import * as Cesium from 'cesium'; * follows DATA_SOURCES.md (live sources, then bundled snapshots). * @type {{ key: string, html: string }[]} */ -export const OSM_CREDIT = { +const OSM_CREDIT = { key: 'openstreetmap', html: 'Map and place data © OpenStreetMap contributors (ODbL)', }; -export const OPENMAPTILES_CREDIT = { +const OPENMAPTILES_CREDIT = { key: 'openfreemap', html: 'Vector tiles: OpenFreeMap © OpenMapTiles', }; diff --git a/src/data/detectionCohort.js b/src/data/detectionCohort.js index ed9f46c52..95cda50f0 100644 --- a/src/data/detectionCohort.js +++ b/src/data/detectionCohort.js @@ -18,7 +18,7 @@ export function stableIdentityHash(layerId, sourceId) { } /** Negative means a is a better deterministic contender than b. */ -export function compareCohortContenders(a, b) { +function compareCohortContenders(a, b) { const priorityDelta = (Number(b?._cohortPriority) || 0) - (Number(a?._cohortPriority) || 0); if (priorityDelta) return priorityDelta; diff --git a/src/data/detectionPolicy.js b/src/data/detectionPolicy.js index bf47785fd..0105d3beb 100644 --- a/src/data/detectionPolicy.js +++ b/src/data/detectionPolicy.js @@ -5,7 +5,6 @@ * module so density cannot drift into contradictory mode semantics again. */ -export const DENSITY_STOPS = Object.freeze([0, 25, 50, 75, 100]); export const ALLOCATION_ELASTIC = 'ELASTIC'; export const ALLOCATION_WEIGHTED = 'WEIGHTED'; export const ALLOCATION_STRATEGIES = Object.freeze([ @@ -118,7 +117,7 @@ export function detectionHorizontalSector(screenX, viewportWidth) { return 'front'; } -export const VIEW_SCALE_BUDGETS = Object.freeze({ +const VIEW_SCALE_BUDGETS = Object.freeze({ street: Object.freeze({ 0: 9, 25: 23, 50: 45, 75: 68, 100: 90 }), city: Object.freeze({ 0: 7, 25: 18, 50: 35, 75: 53, 100: 70 }), metro: Object.freeze({ 0: 6, 25: 14, 50: 28, 75: 41, 100: 55 }), diff --git a/src/data/directions.js b/src/data/directions.js index ef5565488..8a871bfbf 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -24,7 +24,7 @@ import * as input from './inputOwnership.js'; import { createDirectionsLayer } from '../layers/directions/index.js'; /** The shared scene owners every Directions instance runs on. */ -export const directionsServices = Object.freeze({ +const directionsServices = Object.freeze({ credits, render, sprites, @@ -49,15 +49,11 @@ export { DIRECTIONS_MODES, DIRECTIONS_POINTER_OWNER, DIRECTIONS_ROUTE_COLOR, - DIRECTIONS_STEP_OVERLAY_SOURCE_ID, - DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS, - FLIGHT_PROGRESS_MS, POINTER_TOOL_EXITS, STEP_ANCHOR_DEADLINE_MS, STEP_ANCHOR_FAST_ATTEMPTS, STEP_ANCHOR_RETRY_MS, STEP_ANCHOR_SLOW_RETRY_MS, - createDirectionsLayer, createDirectionsStepOverlayEntry, directionsRequestUrl, directionsRowControls, diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index f003bf712..aac775d92 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -8,4 +8,3 @@ export function createEarthquakesLayer({ } = {}) { return createApplicationEarthquakes({ source, ...options }); } -export default createEarthquakesLayer(); diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..cf7037388 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -5,9 +5,9 @@ import { createImportedPins } from './geoImportPins.js'; import { msg } from '../i18n/marker.js'; /** Largest file accepted, in bytes. */ -export const MAX_IMPORT_BYTES = 50 * 1024 * 1024; +const MAX_IMPORT_BYTES = 50 * 1024 * 1024; /** Largest total a KMZ may inflate to — a zip bomb stops here. */ -export const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; +const MAX_KMZ_INFLATED_BYTES = 120 * 1024 * 1024; /** Most files on the globe at once. */ export const MAX_IMPORTED_FILES = 12; /** Most entities one file may create before it is refused. */ @@ -15,9 +15,9 @@ export const MAX_IMPORT_ENTITIES = 20_000; /** Most vertices (points of every line, ring and marker) in one file. */ export const MAX_IMPORT_POSITIONS = 250_000; /** Most normalized style definitions and local image links in one KML/KMZ. */ -export const MAX_KML_LINKS = 2_000; +const MAX_KML_LINKS = 2_000; /** Longest plain-text name kept from a file's features. */ -export const MAX_IMPORT_NAME_LENGTH = 120; +const MAX_IMPORT_NAME_LENGTH = 120; /** What the file picker offers. */ export const GEO_FILE_ACCEPT = '.geojson,.json,.kml,.kmz,.gpx'; diff --git a/src/data/geoImportImages.js b/src/data/geoImportImages.js index 86f8e98e2..71133df26 100644 --- a/src/data/geoImportImages.js +++ b/src/data/geoImportImages.js @@ -1,5 +1,5 @@ // Inspect encoded headers before allowing the browser to decode local images. -export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; +const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024; const MAX_SIDE = 8192; /** Width and height from a supported raster header; no image is decoded. */ diff --git a/src/data/gtfsRealtime.js b/src/data/gtfsRealtime.js index 96d6b28a5..1fe888d4a 100644 --- a/src/data/gtfsRealtime.js +++ b/src/data/gtfsRealtime.js @@ -35,14 +35,14 @@ import { PbfReader } from 'pbf'; /** VehiclePosition.VehicleStopStatus enum → label. */ -export const VEHICLE_STOP_STATUS = Object.freeze({ +const VEHICLE_STOP_STATUS = Object.freeze({ 0: 'INCOMING_AT', 1: 'STOPPED_AT', 2: 'IN_TRANSIT_TO', }); /** VehiclePosition.OccupancyStatus enum → label. */ -export const OCCUPANCY_STATUS = Object.freeze({ +const OCCUPANCY_STATUS = Object.freeze({ 0: 'EMPTY', 1: 'MANY_SEATS_AVAILABLE', 2: 'FEW_SEATS_AVAILABLE', @@ -64,7 +64,6 @@ export const GTFS_MAX_ENTITIES = 50_000; export const GTFS_MAX_STRING_CHARS = 256; /** FeedHeader.Incrementality: only FULL_DATASET carries a complete snapshot. */ export const GTFS_INCREMENTALITY_FULL_DATASET = 0; -export const GTFS_INCREMENTALITY_DIFFERENTIAL = 1; /** * Read a length-delimited string, refusing one that is implausibly long. diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js index 8871cfe39..61f6c3abf 100644 --- a/src/data/localGeojson.js +++ b/src/data/localGeojson.js @@ -1,5 +1,4 @@ import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -export { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayPublisher as createPublisher, diff --git a/src/data/militaryNames.js b/src/data/militaryNames.js index b26ab04e6..d4b4b8846 100644 --- a/src/data/militaryNames.js +++ b/src/data/militaryNames.js @@ -7,7 +7,7 @@ import { createRetryableLoader } from './retryableLoad.js'; export const MILITARY_POINT_CAP = 512; export const MILITARY_LABEL_CAP = 24; -export const MILITARY_NAMES_TIMEOUT_MS = 10_000; +const MILITARY_NAMES_TIMEOUT_MS = 10_000; /** Share a bounded acquisition; failed assets become retryable after cooldown. */ export function createMilitaryNamesLoader({ diff --git a/src/data/radioCountry.js b/src/data/radioCountry.js index 55d9b36a5..1f77d8e98 100644 --- a/src/data/radioCountry.js +++ b/src/data/radioCountry.js @@ -117,8 +117,3 @@ export function normalizeRadioCountryInput(value) { name: canonicalCountryName(code), }); } - -/** Return whether a value is a recognized ISO 3166-1 alpha-2 code. */ -export function isRadioCountryCode(value) { - return typeof value === 'string' && ISO_ALPHA_2_SET.has(value.toUpperCase()); -} diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 6aaba700f..6bee7c56d 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -28,4 +28,3 @@ export { createCableReferenceSweepGate, updateCableReferenceStem, } from '../layers/submarineCables/index.js'; -export default createTeleGeographySubmarineCableLayer(); diff --git a/src/data/wind.js b/src/data/wind.js index 38bc10688..59b9aaf5d 100644 --- a/src/data/wind.js +++ b/src/data/wind.js @@ -1,5 +1,4 @@ import { createSourceSlot } from '../sources/sourceSlot.js'; -import { createWindLayer as createLayer } from '../layers/wind/index.js'; import { createWindSource } from '../layers/wind/source.js'; const sourceSlot = createSourceSlot( @@ -8,10 +7,3 @@ const sourceSlot = createSourceSlot( 'Wind source', ); export const configureWindSource = sourceSlot.configure; - -/** Create a wind layer using the configured application source by default. */ -export function createWindLayer(options = {}) { - return createLayer({ ...options, feed: options.feed ?? sourceSlot.source }); -} - -export default createWindLayer(); diff --git a/src/i18n/index.js b/src/i18n/index.js index d5698aa5b..48b63b2f6 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -210,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) { } /** Collapse markup whitespace so a message reads the same in HTML and JS. */ -export function normalizeMarkupText(text) { +function normalizeMarkupText(text) { return String(text ?? '') .replace(/\s+/g, ' ') .trim(); diff --git a/src/layers/alpr/records.js b/src/layers/alpr/records.js index 0d751f12d..10cd88062 100644 --- a/src/layers/alpr/records.js +++ b/src/layers/alpr/records.js @@ -1,11 +1,11 @@ import { QUERY_LIMIT, QUERY_SNAP_DEGREES } from './policy.js'; -export function textTag(value) { +function textTag(value) { const t = String(value ?? '').trim(); return t || null; } -export function numTag(value) { +function numTag(value) { // `Number('')` and `Number(' ')` are 0, not NaN — a blank or whitespace-only // tag value must read as missing, never as a due-north bearing. if (value == null) return null; @@ -102,7 +102,7 @@ export function normalizeAlprNode(el) { }; } -export function normalizeDirection(value) { +function normalizeDirection(value) { const degrees = numTag(value); return degrees != null && degrees >= 0 && degrees <= 360 ? degrees % 360 diff --git a/src/layers/alpr/tileRecords.js b/src/layers/alpr/tileRecords.js index 5db9192b3..0368f53fb 100644 --- a/src/layers/alpr/tileRecords.js +++ b/src/layers/alpr/tileRecords.js @@ -3,7 +3,7 @@ import { VectorTile } from '@mapbox/vector-tile'; import { normalizeAlprNode } from './records.js'; /** Map extract attributes without treating an OSM edit date as a field verification. */ -export function normalizeAlprTileFeature(feature) { +function normalizeAlprTileFeature(feature) { const p = feature?.properties || {}; if (feature?.geometry?.type !== 'Point' || p.osmType !== 'node') return null; const [lon, lat] = feature.geometry.coordinates; diff --git a/src/layers/awareness/policy.js b/src/layers/awareness/policy.js index eda5a46e6..22cc8eb98 100644 --- a/src/layers/awareness/policy.js +++ b/src/layers/awareness/policy.js @@ -1,9 +1,6 @@ export const AIRCRAFT_DEPENDENCIES = ['flights', 'military']; -export const DEFERRED_DEPENDENCIES = [ - 'ais-live-vessels', - 'military-installations', -]; +const DEFERRED_DEPENDENCIES = ['ais-live-vessels', 'military-installations']; export const DEPENDENCIES = [ ...AIRCRAFT_DEPENDENCIES, diff --git a/src/layers/bikeshare/policy.js b/src/layers/bikeshare/policy.js index 14f1edf6e..1b9c1579f 100644 --- a/src/layers/bikeshare/policy.js +++ b/src/layers/bikeshare/policy.js @@ -11,7 +11,7 @@ export const BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ // --- Activation / display thresholds --- /** Altitude (m) at which bikeshare layer becomes eligible for display. */ -export const ACTIVATION_ALTITUDE_M = 50000; +const ACTIVATION_ALTITUDE_M = 50000; /** Hysteresis enter threshold — layer activates when camera drops below this. */ diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index 96d7985f1..bc369c02a 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -1,10 +1,4 @@ -export { - FRAME_ENDPOINT, - SOURCE_ENDPOINT, - HEALTH_ENDPOINT, - MEDIA_ENDPOINT, - ACTIVE_FRAME_REFRESH_MS, -} from './sourcePolicy.js'; +export { ACTIVE_FRAME_REFRESH_MS } from './sourcePolicy.js'; import * as Cesium from 'cesium'; import { CCTV_CARD_FETCH_BURST_SPACING_MS } from '../../data/cctvCards.js'; import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; @@ -90,9 +84,6 @@ export const CCTV_CALIBRATION_STORAGE_KEY_V2 = export const PROJECTION_TEXTURE_SWAP_MS = 1000; -export const PROJECTION_VERT_ASPECT = - PROJECTION_CANVAS_WIDTH / PROJECTION_CANVAS_HEIGHT; - // V2 frustum geometry (design §2a/§6): the far-cap center + corners never sink // below groundAlt + this clearance, so a fabricated pitch (-24°) cannot bury // the monitor plane in the 3D tiles. Exported for the unit suite. diff --git a/src/layers/cctv/sourcePolicy.js b/src/layers/cctv/sourcePolicy.js index 5ce0cd024..3341a4ce9 100644 --- a/src/layers/cctv/sourcePolicy.js +++ b/src/layers/cctv/sourcePolicy.js @@ -1,9 +1,5 @@ export const FRAME_ENDPOINT = '/api/cctv/frame'; -export const SOURCE_ENDPOINT = '/api/cctv/sources'; - -export const HEALTH_ENDPOINT = '/api/cctv/health'; - export const MEDIA_ENDPOINT = '/api/cctv/media'; export const ACTIVE_FRAME_REFRESH_MS = 10000; diff --git a/src/layers/cyclones/labels.js b/src/layers/cyclones/labels.js index e95b5a28c..cb418865f 100644 --- a/src/layers/cyclones/labels.js +++ b/src/layers/cyclones/labels.js @@ -64,7 +64,7 @@ const SHARED_CARD = Object.freeze({ * @param {boolean} selected * @returns {object} */ -export function cycloneStormEntry(storm, selected) { +function cycloneStormEntry(storm, selected) { return { ...SHARED_CARD, ...markerClearance( @@ -92,7 +92,7 @@ export function cycloneStormEntry(storm, selected) { * @param {{tauHours:number,position:object}} point * @returns {object} */ -export function cycloneLeadEntry(stormId, point) { +function cycloneLeadEntry(stormId, point) { return { ...SHARED_CARD, ...markerClearance(CYCLONE_MARKER_PX.forecast), diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2c015e6c9..2ecb0a8a4 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -1,11 +1,3 @@ -export { - GROUND_FLOOR_CLAMP_RADIUS_KM, - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/installations/policy.js b/src/layers/installations/policy.js index fae021cf3..92da9dd7b 100644 --- a/src/layers/installations/policy.js +++ b/src/layers/installations/policy.js @@ -2,8 +2,6 @@ export const LAYER_ID = 'military-installations'; export const REQUEST_DEBOUNCE_MS = 180; -export const MAX_VIEWPORT_DEGREES = 10; - export const MAX_RENDERED = 700; export const GOOGLE_MILITARY_PLACE_TYPES = new Set(['military_base']); diff --git a/src/layers/localAdsb/feeds.js b/src/layers/localAdsb/feeds.js index 4bb4bced7..5899729af 100644 --- a/src/layers/localAdsb/feeds.js +++ b/src/layers/localAdsb/feeds.js @@ -5,12 +5,12 @@ import { /** Same-origin route served by `server/providers/local-receivers.js`. */ export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft'; -export const LOCAL_RECEIVER_POLL_MS = 1_000; +const LOCAL_RECEIVER_POLL_MS = 1_000; /** * Browser-side deadline for one route request, body included. The server * bounds each upstream read at 2 s; this bounds the browser-to-server leg. */ -export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; +const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000; function abortError() { const error = new Error('Local receiver request aborted'); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index fcf54b5d5..4db1e2852 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -36,18 +36,8 @@ import { } from './policy.js'; import { localAdsbStatus } from './status.js'; -export { - LAYER_ID as LOCAL_ADSB_LAYER_ID, - HEARD_BY_RECEIVER, -} from './policy.js'; -export { - localAdsbCardModel, - localAdsbClassLine, - localAdsbIsUat, - localAdsbReceiverLine, - localAdsbTitle, -} from './card.js'; -export { localAdsbClass } from './enrichment.js'; +export { HEARD_BY_RECEIVER } from './policy.js'; +export { localAdsbCardModel, localAdsbReceiverLine } from './card.js'; export { localAdsbStatus } from './status.js'; const FEET_TO_METERS = 0.3048; diff --git a/src/layers/localAdsb/motion.js b/src/layers/localAdsb/motion.js index 279bef6d3..084f3aa7e 100644 --- a/src/layers/localAdsb/motion.js +++ b/src/layers/localAdsb/motion.js @@ -26,7 +26,7 @@ import { export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000; export const LOCAL_ADSB_HISTORY_POINTS = 600; /** A display correction larger than this snaps instead of sliding. */ -export const LOCAL_ADSB_SNAP_M = 3_000; +const LOCAL_ADSB_SNAP_M = 3_000; /** Chord course uses the newest fix at least this much older than the anchor. */ const CHORD_BASELINE_MS = 2_000; /** Turn rate is estimated over this window of heard fixes. */ diff --git a/src/layers/localAdsb/status.js b/src/layers/localAdsb/status.js index d264c5e15..2e084cffb 100644 --- a/src/layers/localAdsb/status.js +++ b/src/layers/localAdsb/status.js @@ -24,7 +24,7 @@ function plural(count, one, many) { * @param {object[]} feeds Feed statuses from the route. * @returns {string} */ -export function describeFeedProblems(feeds) { +function describeFeedProblems(feeds) { const byStatus = new Map(); for (const feed of feeds) { if (feed?.status === 'live') continue; @@ -41,7 +41,7 @@ export function describeFeedProblems(feeds) { } /** Whether the server reported at least one configured feed. */ -export function feedsConfigured(feedState) { +function feedsConfigured(feedState) { return Boolean( feedState?.configured === true && Array.isArray(feedState.feeds) && diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index f375e5419..bb1d89127 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -1,10 +1,3 @@ -export { - GROUND_FLOOR_WARM_MAX_ALT_M, - POSITION_HISTORY_LIMIT, - LANDED_MISSING_POLL_LIMIT, - MISSING_POLL_LIMIT, - ERROR_BACKOFF_INTERVAL, -} from './recordPolicy.js'; import * as Cesium from 'cesium'; import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js'; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 85777ed74..0d4ad174a 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -10,7 +10,7 @@ export const MARKER_LIFT_M = 2.5; export const SELECTED_LIFT_M = 5; -export const RADIO_PICK_TOLERANCE_PX = 8; +const RADIO_PICK_TOLERANCE_PX = 8; export const RADIO_TUNER_DIRECTORY_LIMIT = 750; diff --git a/src/layers/recentImagery/model.js b/src/layers/recentImagery/model.js index 406dc3e19..3fbcf1123 100644 --- a/src/layers/recentImagery/model.js +++ b/src/layers/recentImagery/model.js @@ -300,7 +300,7 @@ export function hhmm(iso) { } /** Candidate key: `PRODUCT:YYYY-MM-DD`. */ -export function candidateKey(product, day) { +function candidateKey(product, day) { return `${product}:${day}`; } diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index 656374fc6..e7ea5456d 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -94,9 +94,9 @@ export const TRACK_VIEW_FROM_HIGH_SCALE = 4; * attribute, so classification costs nothing per frame. */ -export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); +const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -export const _classColor = (group) => +const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); export const POINT_STYLES = { diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index c083879db..0ca4e3eb2 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -17,18 +17,18 @@ const EXPANDED_SELECTORS = [ ]; /** A photo download (tens of kilobytes to a few megabytes). */ -export const IMAGE_TIMEOUT_MS = 15_000; +const IMAGE_TIMEOUT_MS = 15_000; /** A picture file or decoded side past these is refused (a photo is a few MB). */ export const MAX_IMAGE_BYTES = 48 * 1024 * 1024; export const MAX_IMAGE_SIDE = 16_384; /** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */ -export const MAX_IMAGE_PIXELS = 8_192 * 8_192; +const MAX_IMAGE_PIXELS = 8_192 * 8_192; /** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */ const HEADER_BYTES = 256 * 1024; /** Whether a picture this size is past what the viewer decodes. */ -export function tooManyPixels(width, height) { +function tooManyPixels(width, height) { return ( width > MAX_IMAGE_SIDE || height > MAX_IMAGE_SIDE || @@ -37,7 +37,7 @@ export function tooManyPixels(width, height) { } /** Panorama texture widths: enough for a docked viewer, and for an expanded one. */ -export const PANO_MID_SIZE = Object.freeze([2048, 1024]); +const PANO_MID_SIZE = Object.freeze([2048, 1024]); export const PANO_FULL_SIZE = Object.freeze([4096, 2048]); /** @@ -155,7 +155,7 @@ async function loadBitmap(url, timeoutMs, resize, failure, signal) { * A picture refused for its size (`tooLarge`): the viewer will not load * that file another way, an having no size limit at all. */ -export function oversized(failure) { +function oversized(failure) { return Object.assign(new Error(failure), { tooLarge: true }); } diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 939e0ec1c..b4533c1b5 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -14,7 +14,7 @@ export const KARTAVIEW_LABEL = 'KARTAVIEW'; */ export const KARTAVIEW_API_HOST = 'https://api.kartaview.org'; /** KartaView's image proxy: resizes a photo given its base64url address. */ -export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; +const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org'; /** Stable id prefixes for picked primitives; every one starts with `kv:`. */ export const PICK_PREFIX = Object.freeze({ @@ -140,8 +140,6 @@ export const MASK_SLOW_MESSAGE = msg( export const FILTERED_OUT_MESSAGE = msg( 'None of KartaView’s photos here pass the imagery filter', ); -/** Under the SINCE slider while KartaView is lit. */ -export const SINCE_NOTE = '(by upload date)'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 4dfa023ef..e855c9c72 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -41,8 +41,6 @@ import { normalizeItem, } from './policy.js'; -export { PANORAMAX_PROVIDER_ID } from './policy.js'; - /** * A tile's bytes decoded by `decode`; a body that is not a vector tile * (an HTML error page, a cut-off answer) reads as such, not as a parser's diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index e9a51c10e..670772c6a 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -15,7 +15,7 @@ export const PANORAMAX_LABEL = 'PANORAMAX'; */ export const PANORAMAX_API = 'https://api.panoramax.xyz/api'; /** The meta-catalogue's web viewer, where the caption link goes. */ -export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; +const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/'; /** Stable id prefixes for picked primitives; every one starts with `pnx:`. */ export const PICK_PREFIX = Object.freeze({ diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 6770cd133..5e51929a6 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -1,6 +1,6 @@ import { encodeIdSwitches } from '../../data/idSwitches.js'; -export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); +const IMAGERY_FILTERS = Object.freeze(['pano', 'since']); export function filtersOf(provider) { return provider?.filters ?? IMAGERY_FILTERS; diff --git a/src/layers/streetLevel/sequenceCones.js b/src/layers/streetLevel/sequenceCones.js index b18f2cc57..b6c068d49 100644 --- a/src/layers/streetLevel/sequenceCones.js +++ b/src/layers/streetLevel/sequenceCones.js @@ -5,8 +5,8 @@ import { createHorizonCull, metresBetween } from './view.js'; import { msg } from '../../i18n/index.js'; /** Cones closer than this to the previous kept one are dropped. */ -export const IMAGE_CONE_MIN_SPACING_M = 3; -export const IMAGE_CONE_SIZE_PX = 26; +const IMAGE_CONE_MIN_SPACING_M = 3; +const IMAGE_CONE_SIZE_PX = 26; /** * @typedef {object} ConeImage One image of a sequence, as the cones draw it. diff --git a/src/layers/streetLevel/tileCache.js b/src/layers/streetLevel/tileCache.js index d6c0a91e5..6ee0897ef 100644 --- a/src/layers/streetLevel/tileCache.js +++ b/src/layers/streetLevel/tileCache.js @@ -1,6 +1,6 @@ /** Decoded coverage tiles kept, and for how long. */ -export const TILE_CACHE_SIZE = 24; -export const TILE_CACHE_TTL_MS = 10 * 60_000; +const TILE_CACHE_SIZE = 24; +const TILE_CACHE_TTL_MS = 10 * 60_000; /** Least-recently-used map: reading an entry renews it. */ export function createLru(limit) { diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 577bb8ae1..04c6ed6c4 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -64,7 +64,7 @@ export function keyAlternatives(providers) { * The filter controls a switched-on provider honours (both when none is on). * @param {Array} providers */ -export function filterControls(providers) { +function filterControls(providers) { const active = providers.filter((p) => p.on); const honoured = (key) => active.length === 0 || @@ -78,7 +78,7 @@ export function filterControls(providers) { * note needs no provider name. * @param {Array} providers */ -export function sinceNote(providers) { +function sinceNote(providers) { return providers.some((p) => p.on && p.sinceBasis === 'upload') ? t('(by upload date)') : null; diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 08366dba4..b4b434710 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -33,7 +33,7 @@ export function featureReference(feature) { }; } -export function coordsFromProperty(value) { +function coordsFromProperty(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -41,7 +41,7 @@ export function coordsFromProperty(value) { return [lon, lat]; } -export function coordsFromPoint(value) { +function coordsFromPoint(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); const lat = Number(value[1]); @@ -49,7 +49,7 @@ export function coordsFromPoint(value) { return [lon, lat]; } -export function collectLonLat(value, out) { +function collectLonLat(value, out) { if (!Array.isArray(value)) return; if (typeof value[0] === 'number' && typeof value[1] === 'number') { const lon = Number(value[0]); diff --git a/src/layers/traffic/footprint.js b/src/layers/traffic/footprint.js index d75947941..9a241d997 100644 --- a/src/layers/traffic/footprint.js +++ b/src/layers/traffic/footprint.js @@ -1,9 +1,9 @@ import * as Cesium from 'cesium'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; -export const RETICLE_RANGE_KM = 10; -export const RETICLE_TILE_CAP = 16; -export const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; +const RETICLE_RANGE_KM = 10; +const RETICLE_TILE_CAP = 16; +const tileKey = (tile) => `${tile.z}/${tile.x}/${tile.y}`; const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); diff --git a/src/layers/traffic/policy.js b/src/layers/traffic/policy.js index 81b01f299..c20b15e41 100644 --- a/src/layers/traffic/policy.js +++ b/src/layers/traffic/policy.js @@ -175,13 +175,3 @@ export const STYLED_MIN_BASE_PX = 5; /** @const {number} Maximum tile cache entries before LRU eviction */ export const TILE_CACHE_MAX_ENTRIES = 64; - -/** - * Milliseconds the first dot paint will wait for flow data. Cached flow - * settles within this window (decode cache, 120 s TTL) and renders fully - * colored; a cold tile fetch loses the race, dots paint immediately in - * white, and recolorDotsInPlace applies the colors when flow arrives — - * field-test round 1's "takes forever to load" was the sequential wait. - */ - -export const FLOW_RENDER_RACE_MS = 250; diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 2fb5101ce..010a2efc2 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -99,7 +99,7 @@ export const VISIBILITY_REFRESH_MS = 250; * on screen. Generous on purpose: a vehicle that crosses the edge mid-glide * should already have been moving, not pop into motion at the border. */ -export const VIEW_MARGIN_RATIO = 0.6; +const VIEW_MARGIN_RATIO = 0.6; /** * Whether a vehicle is close enough to the camera's look-at area to be worth @@ -516,10 +516,10 @@ export function createTransitSelectedOverlayEntry(key, position, copy, mode) { } /** Modes that ordinarily run below the street, drawn on the surface above themselves. */ -export const UNDERGROUND_MODES = new Set(['subway']); +const UNDERGROUND_MODES = new Set(['subway']); /** The mode in plain words, for a card a person reads rather than scans. */ -export const TRANSIT_MODE_WORD = Object.freeze({ +const TRANSIT_MODE_WORD = Object.freeze({ bus: msg('Bus'), tram: msg('Tram'), subway: msg('Subway'), @@ -529,7 +529,7 @@ export const TRANSIT_MODE_WORD = Object.freeze({ }); /** Short, fixed-width mode word for the detection card's class field. */ -export const TRANSIT_MODE_ABBR = Object.freeze({ +const TRANSIT_MODE_ABBR = Object.freeze({ bus: 'BUS', tram: 'TRAM', subway: 'METRO', @@ -588,7 +588,7 @@ export function transitDetectionId(record, feed = null) { } /** Longest route text the single-line detection label can carry. */ -export const DETECTION_ID_MAX_CHARS = 14; +const DETECTION_ID_MAX_CHARS = 14; /** * Cut long route text with an ellipsis, so a reader can see it was cut. @@ -600,7 +600,7 @@ export const DETECTION_ID_MAX_CHARS = 14; * @param {string} text * @returns {string} Uppercase, at most DETECTION_ID_MAX_CHARS characters. */ -export function elideRouteText(text) { +function elideRouteText(text) { const value = String(text || '').toUpperCase(); if (value.length <= DETECTION_ID_MAX_CHARS) return value; return `${value.slice(0, DETECTION_ID_MAX_CHARS - 1)}\u2026`; @@ -659,6 +659,6 @@ export function transitDetectionMetric( * @param {number} [nowMs] * @returns {string} */ -export function transitVehicleState(entry, nowMs = Date.now()) { +function transitVehicleState(entry, nowMs = Date.now()) { return displayMotion(entry, nowMs).word; } diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index 75e5cdd67..9ab06fbb8 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -1,8 +1,3 @@ -export { - PARTIAL_RETENTION_MS, - SELECTED_PIN_REFRESHES, - AIS_FIRST_CONNECT_LABEL, -} from './recordPolicy.js'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; import { msg } from '../../i18n/index.js'; diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index cf004476f..5386bfad7 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -4,7 +4,7 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; // the weather layer translates them where it shows them (t / translateKnown). const msg = (text) => text; -export const WEATHER_PRODUCTS = Object.freeze([ +const WEATHER_PRODUCTS = Object.freeze([ 'radar', 'clouds', 'clouds-regional', diff --git a/src/layers/wind/gpuRendering.js b/src/layers/wind/gpuRendering.js index 27d71dcd5..0873fa63b 100644 --- a/src/layers/wind/gpuRendering.js +++ b/src/layers/wind/gpuRendering.js @@ -6,8 +6,8 @@ import { WIND_NARROW_PATH_LIMIT, } from './streamlines.js'; -export const WIND_FADE_LOW_METERS = 15000; -export const WIND_FADE_HIGH_METERS = 60000; +const WIND_FADE_LOW_METERS = 15000; +const WIND_FADE_HIGH_METERS = 60000; // Original material: a persistent fine curve plus a moving, tapered highlight. // The integer part of s identifies a path; its fractional part follows the wind. diff --git a/src/locationStatus.js b/src/locationStatus.js index 1d90582c9..2c9e406f3 100644 --- a/src/locationStatus.js +++ b/src/locationStatus.js @@ -64,5 +64,3 @@ export function locationMiniStatus({ return { ...EMPTY }; } - -export default locationMiniStatus; diff --git a/src/maps/imageryComparison.js b/src/maps/imageryComparison.js index 5586bd86e..cf7d266ae 100644 --- a/src/maps/imageryComparison.js +++ b/src/maps/imageryComparison.js @@ -1,5 +1,5 @@ /** Basemap that before/after imagery comparisons are read against. */ -export const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; +const IMAGERY_COMPARISON_STACK_ID = 'esri-imagery'; const SWITCH_POLICIES = new Set(['preserve', 'esri', 'esri-if-photoreal']); const leases = new WeakMap(); diff --git a/src/maps/terrainRetry.js b/src/maps/terrainRetry.js index 26fc95576..6cf0fe2af 100644 --- a/src/maps/terrainRetry.js +++ b/src/maps/terrainRetry.js @@ -24,7 +24,7 @@ */ export const TERRAIN_RETRY_ATTEMPTS = 3; -export const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); +const TERRAIN_RETRY_STATUSES = Object.freeze([429, 502, 503, 504]); export const TERRAIN_RETRY_BASE_MS = 750; export const TERRAIN_RETRY_MAX_MS = 15_000; export const TERRAIN_RETRY_SPREAD_MS = 1_500; diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index 191bb1ac1..ba3940a57 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -994,7 +994,7 @@ const PUBLIC_SCENE_RECIPES = [ ]; /** Build the recipe list without mutating stored user-authored projects. */ -export function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { +function createSceneRecipes({ localDemoRecipes = EVENT_RECIPES } = {}) { return [...localDemoRecipes, ...PUBLIC_SCENE_RECIPES]; } @@ -1004,7 +1004,7 @@ export const SCENE_APPEND_RECIPES = BHOTE_KOSHI_NEPAL_APPEND_RECIPE ? [BHOTE_KOSHI_NEPAL_APPEND_RECIPE] : []; -export function getSceneRecipeById(id) { +function getSceneRecipeById(id) { return SCENE_RECIPES.find((recipe) => recipe.id === id) || null; } diff --git a/src/sdr/controller.js b/src/sdr/controller.js index 50fec4a38..019b768a1 100644 --- a/src/sdr/controller.js +++ b/src/sdr/controller.js @@ -38,12 +38,12 @@ const READ_PIPELINE_DEPTH = 4; const RATE_WINDOW_MS = 1_000; // A bulk read that has not completed in this long is treated as a stalled // USB transfer: the session is torn down instead of waiting forever. -export const SDR_READ_STALL_MS = 5_000; +const SDR_READ_STALL_MS = 5_000; // Decoder admission. Every block sent to the worker is numbered and must be // acknowledged; past these bounds the receiver stops with an overload error // rather than queueing raw IQ the decoder cannot keep up with. Eight 16 ms // blocks is 128 ms of input, about forty times the measured decode cost. -export const SDR_WORKER_ADMISSION = Object.freeze({ +const SDR_WORKER_ADMISSION = Object.freeze({ maxBlocks: 8, maxBytes: 8 * 65_536, maxAgeMs: 1_000, @@ -66,7 +66,7 @@ const DECODER_WINDOW_SAMPLES = 256; // raw USB device is closed directly, which aborts any pending transfer. The // raw close is bounded by the same deadline, so a teardown settles within // twice this even when `USBDevice.close()` itself never settles. -export const SDR_CLOSE_TIMEOUT_MS = 2_000; +const SDR_CLOSE_TIMEOUT_MS = 2_000; /** Tuning presets per receiver mode; gain is a separate per-mode setting. */ export const SDR_RECEIVER_PRESETS = Object.freeze({ @@ -1912,9 +1912,3 @@ function formatMhz(hz) { function positiveModulo(value, divisor) { return ((value % divisor) + divisor) % divisor; } - -export const SDR_FM_BAND = Object.freeze({ - minHz: FM_MIN_HZ, - maxHz: FM_MAX_HZ, -}); -export const SDR_ADSB_FREQUENCY_HZ = ADSB_FREQUENCY_HZ; diff --git a/src/sdr/gain.js b/src/sdr/gain.js index 761edf013..94d525fe2 100644 --- a/src/sdr/gain.js +++ b/src/sdr/gain.js @@ -10,7 +10,7 @@ * about 1 msg/s and 28.0 dB about 9 msg/s on the same antenna. */ -export const SDR_GAIN_AUTO = 'auto'; +const SDR_GAIN_AUTO = 'auto'; /** R820T tuner gain steps in dB, as listed by librtlsdr. */ export const R820T_GAIN_STEPS_DB = Object.freeze([ diff --git a/src/sky/stars.js b/src/sky/stars.js index 9fd5aa698..f4139c3c5 100644 --- a/src/sky/stars.js +++ b/src/sky/stars.js @@ -18,7 +18,7 @@ export const STAR_MAX_SIGMA_PX = 1.35; /** Peak opacity of the faintest star. */ export const STAR_MIN_ALPHA = 0.22; /** B−V for stars the catalogue gives no colour (a solar-type white). */ -export const STAR_DEFAULT_BV = 0.6; +const STAR_DEFAULT_BV = 0.6; /** Floats per star in the vertex buffer: xyz, σ, rgb, alpha. */ export const STAR_VERTEX_FLOATS = 8; @@ -76,7 +76,7 @@ export function bvToTemperature(bv) { } /** Black-body colour of a temperature, sRGB 0..1 (Helland's fit). */ -export function temperatureToRgb(kelvin) { +function temperatureToRgb(kelvin) { const t = clamp(kelvin, 1000, 40000) / 100; const red = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592; const green = diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 9c0082700..814da60b2 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -41,12 +41,12 @@ const CATEGORY_PATTERN = /^[A-D][0-7]$/; /** A previous accepted fix older than this is no reference for a new one. */ export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000; /** Speed limit (kt) for an aircraft that has not reported a ground speed. */ -export const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; +const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000; /** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */ -export const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; +const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350; /** Reported ground speed is multiplied by this, plus the margin below. */ -export const LOCAL_ADSB_SPEED_FACTOR = 1.5; -export const LOCAL_ADSB_SPEED_MARGIN_KT = 50; +const LOCAL_ADSB_SPEED_FACTOR = 1.5; +const LOCAL_ADSB_SPEED_MARGIN_KT = 50; /** Distance always allowed between two fixes (CPR and reception error). */ export const LOCAL_ADSB_POSITION_MARGIN_M = 500; /** diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index 429dbe826..a18d5f22f 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -77,7 +77,7 @@ export function simplifyRing(ring, tolM) { return out.length >= 4 ? out : ring; } -export function douglasPeucker(points, tol) { +function douglasPeucker(points, tol) { const n = points.length; if (n < 3) return points.slice(); // Iterative, index-based (no per-call array slicing / recursion), so a large @@ -113,7 +113,7 @@ export function douglasPeucker(points, tol) { return out; } -export function perpDistance(p, a, b) { +function perpDistance(p, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len2 = dx * dx + dy * dy; @@ -149,7 +149,7 @@ export function stitchRing(ways) { return endpointGapM(largest) <= allow ? largest : []; } -export function largestByArea(components) { +function largestByArea(components) { let best = components[0]; let bestArea = approximateAreaM2(best); for (let i = 1; i < components.length; i += 1) { @@ -162,7 +162,7 @@ export function largestByArea(components) { return best; } -export function ringSpanM(chain) { +function ringSpanM(chain) { let minLat = Infinity; let maxLat = -Infinity; let minLon = Infinity; @@ -176,7 +176,7 @@ export function ringSpanM(chain) { return approximateDistanceM(minLat, minLon, maxLat, maxLon); } -export function buildRingComponents(ways) { +function buildRingComponents(ways) { const same = (a, b) => Math.abs(a.lon - b.lon) < 1e-7 && Math.abs(a.lat - b.lat) < 1e-7; const remaining = ways.map((w) => w.slice()); @@ -207,7 +207,7 @@ export function buildRingComponents(ways) { return components; } -export function endpointGapM(chain) { +function endpointGapM(chain) { if (!chain || chain.length < 2) return Infinity; const a = chain[0]; const b = chain[chain.length - 1]; diff --git a/src/sources/openFreeMap.js b/src/sources/openFreeMap.js index 274c2ace7..00c288a8b 100644 --- a/src/sources/openFreeMap.js +++ b/src/sources/openFreeMap.js @@ -24,7 +24,7 @@ const ROAD_TYPES = Object.freeze({ * OpenMapTiles folds private access into `no`. Tunnels cannot be placed on * the surface above them; ramps/bridges remain valid only on a motor road. */ -export function isDrivableOpenMapRoad(properties = {}) { +function isDrivableOpenMapRoad(properties = {}) { return ( Object.hasOwn(ROAD_TYPES, properties.class) && !properties.subclass && diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 9fe29f59d..cf82835c1 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -1,7 +1,7 @@ import { validTransitIdentifier } from './transitHistory.js'; export { validTransitIdentifier } from './transitHistory.js'; /** Bounded, process-local operator position history. No upstream or scene access. */ -export const TRANSIT_HISTORY_LIMITS = Object.freeze({ +const TRANSIT_HISTORY_LIMITS = Object.freeze({ retentionMs: 900000, fixes: 128, feedBytes: 16 * 1024 * 1024, diff --git a/src/testSupport/kartaViewFakes.mjs b/src/testSupport/kartaViewFakes.mjs index a2e6452f6..326ba9d77 100644 --- a/src/testSupport/kartaViewFakes.mjs +++ b/src/testSupport/kartaViewFakes.mjs @@ -1,7 +1,7 @@ // DOM stand-ins for the KartaView viewer's tests (no browser in Node). /** A DOM element with what the viewer touches: children, events, attributes. */ -export function fakeElement(tag) { +function fakeElement(tag) { const listeners = new Map(); const element = { tagName: tag.toUpperCase(), diff --git a/src/tools/queries/panelRequest.js b/src/tools/queries/panelRequest.js index 3864c9889..51ecd6406 100644 --- a/src/tools/queries/panelRequest.js +++ b/src/tools/queries/panelRequest.js @@ -55,7 +55,7 @@ const COMPRESS_MIN_BYTES = 1024; * rather than fail; past `PANEL_QUEUED_REQUESTS` waiting, they are refused. */ export const PANEL_CONCURRENT_REQUESTS = 6; -export const PANEL_QUEUED_REQUESTS = 256; +const PANEL_QUEUED_REQUESTS = 256; /** * Each server's panel state: bodies too large for one call, kept for the diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 0c59ca7f0..5284e8a06 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -7,7 +7,7 @@ import { AREA_SCHEMA, resolveArea } from '../area.js'; import { cameraForArea, cameraLookingAt } from '../views.js'; /** Tool arguments that describe a view: an area to frame, or a camera. */ -export const VIEW_ARGUMENTS = Object.freeze({ +const VIEW_ARGUMENTS = Object.freeze({ view: Object.freeze({ type: 'object', description: diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index 0fb67f1d4..d10bacf2f 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) { } /** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */ -export function setCameraTargetFrame(viewer, frame) { +function setCameraTargetFrame(viewer, frame) { const camera = viewer?.camera; if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; try { @@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) { * * @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}. */ -export function frameIsTilted(frame) { +function frameIsTilted(frame) { const pitch = Number(frame?.pitch); return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false; } diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index ac6687f54..33818984d 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -46,7 +46,7 @@ const SOURCE_LABELS = Object.freeze({ }); /** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */ -export function cctvSourceKindLabel(kind) { +function cctvSourceKindLabel(kind) { const key = String(kind || 'unknown').toLowerCase(); return SOURCE_KIND_LABELS[key] ? t(SOURCE_KIND_LABELS[key]) diff --git a/src/ui/statusText.js b/src/ui/statusText.js index b0899d55e..677c9b886 100644 --- a/src/ui/statusText.js +++ b/src/ui/statusText.js @@ -218,7 +218,7 @@ function statusPatternText(piece) { } /** One piece of a status line in the UI language, or as it came. */ -export function translateStatusPiece(piece) { +function translateStatusPiece(piece) { const known = translateKnown(piece); if (known !== piece) return known; return statusPatternText(piece) ?? piece; diff --git a/src/voice/deixis.js b/src/voice/deixis.js index d68fcd61a..25d7939eb 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -18,7 +18,7 @@ import { finiteOrNull } from './pointerContext.js'; -export const POINTER_SENTINEL = 'pointer'; +const POINTER_SENTINEL = 'pointer'; const TRACKABLE_LAYERS = new Set([ 'flights', diff --git a/src/voice/narration.js b/src/voice/narration.js index a91dbbe2b..ae97931f7 100644 --- a/src/voice/narration.js +++ b/src/voice/narration.js @@ -4,7 +4,7 @@ import { progressLine } from './speech.js'; * Timing policy for filling silence while tools run. Tuned against the turn * spans' `max_silence_ms`. */ -export const NARRATION_TIMING = Object.freeze({ +const NARRATION_TIMING = Object.freeze({ /** Start the earcon when nothing is audible this long after end of speech. */ earconAfterMs: 700, /** First spoken progress line when a tool is still running after this. */ diff --git a/src/voice/pointerContext.js b/src/voice/pointerContext.js index 3f9d09b6d..590e33a52 100644 --- a/src/voice/pointerContext.js +++ b/src/voice/pointerContext.js @@ -20,15 +20,15 @@ */ /** Movement below this many CSS pixels is still the same dwell. */ -export const POINTER_DWELL_PX = 12; +const POINTER_DWELL_PX = 12; /** Stillness that makes a dwell point. */ export const POINTER_DWELL_MS = 250; /** A resting pointer stays a referent this long after it last moved. */ export const POINTER_FRESH_MS = 30000; /** Pixels from the canvas edge that count as "parked", never a referent. */ -export const POINTER_EDGE_PX = 8; +const POINTER_EDGE_PX = 8; /** A dwell pick is reused by a snapshot only while this recent (contacts move). */ -export const POINTER_DWELL_REUSE_MS = 1000; +const POINTER_DWELL_REUSE_MS = 1000; /** * Open mic has no gesture, so the pointer only counts when it moved or * settled on the map this recently: a cursor resting there for minutes is @@ -76,7 +76,7 @@ export function cameraPoseKey(viewer) { } /** Bound and de-control a label before it goes to the model or the card. */ -export function pointerLabel(value, max = LABEL_MAX) { +function pointerLabel(value, max = LABEL_MAX) { const clean = String(value ?? '') .replace(/[\p{Cc}\p{Cf}]+/gu, ' ') .replace(/\s+/g, ' ') diff --git a/src/voice/pointerPick.js b/src/voice/pointerPick.js index 33ccab9cb..e74671eda 100644 --- a/src/voice/pointerPick.js +++ b/src/voice/pointerPick.js @@ -4,7 +4,7 @@ import { cameraPoseKey } from './pointerContext.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; /** "Around here" radius as a share of camera height, clamped to sane bounds. */ -export function pointerRadiusKm(cameraHeightM) { +function pointerRadiusKm(cameraHeightM) { const heightKm = Number(cameraHeightM) / 1000; if (!Number.isFinite(heightKm) || heightKm <= 0) return 50; return Math.round(Math.min(400, Math.max(5, heightKm * 0.75))); diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 96729a933..0702d7345 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -14,7 +14,7 @@ export const RETICLE_MAX_MS = 10000; export const RETICLE_LINGER_MS = 1500; /** Tools that move the camera away from the pointed spot. */ -export const CAMERA_TOOLS = new Set([ +const CAMERA_TOOLS = new Set([ 'fly_to_location', 'track_entity', 'zoom_to_globe', diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 630e29433..55e078280 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -1,10 +1,10 @@ -export const ERROR_LOG_LIMIT = 30; +const ERROR_LOG_LIMIT = 30; -export const ERROR_STORAGE_KEY = 'gev-realtime-errors'; +const ERROR_STORAGE_KEY = 'gev-realtime-errors'; -export const DEBUG_LOG_URL = '/api/realtime/debug-log'; +const DEBUG_LOG_URL = '/api/realtime/debug-log'; -export function createDebugSessionId() { +function createDebugSessionId() { const randomPart = Math.random().toString(36).slice(2, 10); return `gev-${Date.now().toString(36)}-${randomPart}`; } @@ -47,7 +47,7 @@ export function sanitizeDebugValue(value, depth = 0) { return output; } -export function sanitizeDebugString(value) { +function sanitizeDebugString(value) { if (value.startsWith('data:image/')) { return `[Redacted image data URL, ${value.length} chars]`; } @@ -65,13 +65,13 @@ export function sanitizeDebugString(value) { : redacted; } -export function isSecretLikeKey(key) { +function isSecretLikeKey(key) { return /(?:api[_-]?key|authorization|bearer|client[_-]?secret|token|secret|password)/i.test( key, ); } -export function createErrorRecord(source, error, extra = {}) { +function createErrorRecord(source, error, extra = {}) { const rtcError = error?.error || error; return { timestamp: new Date().toISOString(), @@ -90,7 +90,7 @@ export function createErrorRecord(source, error, extra = {}) { }; } -export function formatErrorForDisplay(record) { +function formatErrorForDisplay(record) { const primary = [record.source, record.message].filter(Boolean).join(': '); const state = [ record.errorDetail && `detail=${record.errorDetail}`, @@ -105,7 +105,7 @@ export function formatErrorForDisplay(record) { return state ? `${primary}\n${state}` : primary; } -export function removeEmptyValues(value) { +function removeEmptyValues(value) { return Object.fromEntries( Object.entries(value || {}).filter( ([, item]) => item !== null && item !== undefined && item !== '', @@ -113,14 +113,7 @@ export function removeEmptyValues(value) { ); } -export function compactText(value, maxLength) { - const text = String(value || '') - .replace(/\s+/g, ' ') - .trim(); - return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; -} - -export function loadStoredErrors() { +function loadStoredErrors() { try { const value = JSON.parse(localStorage.getItem(ERROR_STORAGE_KEY) || '[]'); return Array.isArray(value) ? value.slice(0, ERROR_LOG_LIMIT) : []; @@ -129,7 +122,7 @@ export function loadStoredErrors() { } } -export function storeErrors(errors) { +function storeErrors(errors) { try { localStorage.setItem( ERROR_STORAGE_KEY, diff --git a/src/voice/realtimePreferences.js b/src/voice/realtimePreferences.js index 1d4d1dd23..d3b78a922 100644 --- a/src/voice/realtimePreferences.js +++ b/src/voice/realtimePreferences.js @@ -8,12 +8,12 @@ import { // Voice cost control (repo-wide `godsEyeView..` convention; // the neighbouring ERROR_STORAGE_KEY predates it). -export const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; +const VOICE_TIER_STORAGE_KEY = 'godsEyeView.voiceCost.tier'; -export const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; +const VOICE_LIMITS_STORAGE_KEY = 'godsEyeView.voiceCost.limits'; /** Best-effort localStorage handle; absent in tests and locked-down browsers. */ -export function voiceStorage(storage) { +function voiceStorage(storage) { if (storage) return storage; try { return typeof localStorage === 'undefined' ? null : localStorage; diff --git a/src/voice/realtimeTurns.js b/src/voice/realtimeTurns.js index cbc8d5dbc..2073be06d 100644 --- a/src/voice/realtimeTurns.js +++ b/src/voice/realtimeTurns.js @@ -13,7 +13,7 @@ import { import { narrationLabel } from './speech.js'; /** Out-of-band progress responses carry this metadata tag. */ -export const PROGRESS_RESPONSE_TAG = 'progress'; +const PROGRESS_RESPONSE_TAG = 'progress'; /** * Speaks one code-authored progress line out of band. The line travels as diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index 755913906..e229b6cc2 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -5,17 +5,17 @@ import { import { cameraPoseKey } from './pointerContext.js'; /** The live viewer's camera pose identity (Cesium-free read). */ -export function readViewerCameraKey() { +function readViewerCameraKey() { return cameraPoseKey(globalThis.window?.__godsEyeView?.viewer); } // Viewport-screenshot size guards (M13). The old code clamped WIDTH only, so a // tall portrait window produced an oversized capture whose dc.send could throw. // Cap total pixels (clamps both dimensions) and drop the image entirely if the // encoded data URL is still too big for the data channel. -export const VIEWPORT_MAX_PIXELS = 1200 * 900; +const VIEWPORT_MAX_PIXELS = 1200 * 900; // ~1.08 MP, matches the old 1200px-wide landscape budget -export const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; +const VIEWPORT_MAX_ENCODED_BYTES = 200 * 1024; export async function captureViewportImage() { const viewer = window.__godsEyeView?.viewer; @@ -66,11 +66,11 @@ export async function captureViewportImage() { } /** Side of the point-and-ask crop, in CSS pixels, and its output size. */ -export const POINTER_CROP_SIZE = 512; +const POINTER_CROP_SIZE = 512; /** A pointer snapshot older than this no longer matches the frame. */ -export const POINTER_CROP_MAX_AGE_MS = 20000; +const POINTER_CROP_MAX_AGE_MS = 20000; /** View scales where a crop shows buildings and terrain, not a smear. */ -export const POINTER_CROP_SCALES = new Set(['local', 'city']); +const POINTER_CROP_SCALES = new Set(['local', 'city']); /** * Square source rectangle around a canvas point, clamped inside the canvas, @@ -128,7 +128,7 @@ export function pointerCropRect({ * @returns {Promise<{dataUrl: string, rect: object}|null>} JPEG data URL and * the crop's normalized bounds in the full canvas, or null. */ -export async function capturePointerCrop(screenPx) { +async function capturePointerCrop(screenPx) { const viewer = window.__godsEyeView?.viewer; const source = viewer?.scene?.canvas; if (!source?.width || !source?.height || !screenPx) return null; @@ -259,7 +259,7 @@ export async function renderFreshCesiumFrame(viewer) { } } -export function isNearlyBlackFrame(ctx, width, height) { +function isNearlyBlackFrame(ctx, width, height) { const sampleWidth = Math.min(48, width); const sampleHeight = Math.min(32, height); if (!sampleWidth || !sampleHeight) return true; diff --git a/src/voice/resultDisplay.js b/src/voice/resultDisplay.js index 642db2bb4..ac99f86d7 100644 --- a/src/voice/resultDisplay.js +++ b/src/voice/resultDisplay.js @@ -59,7 +59,7 @@ function notesOf(...values) { ]; } -export function analystNounFor(layerKeys) { +function analystNounFor(layerKeys) { const keys = [...new Set(layerKeys.filter(Boolean))]; if (keys.length !== 1) return 'results'; const key = keys[0]; diff --git a/src/voice/speechLint.js b/src/voice/speechLint.js index 9b6c02ab4..f1b1c1878 100644 --- a/src/voice/speechLint.js +++ b/src/voice/speechLint.js @@ -7,7 +7,7 @@ * Phrases that add length without adding an answer. Tool numbers are spoken * verbatim, so approximations are hedges too. */ -export const HEDGE_PHRASES = Object.freeze([ +const HEDGE_PHRASES = Object.freeze([ 'keep in mind', "it's worth noting", 'it is worth noting', diff --git a/src/voice/turnMetrics.js b/src/voice/turnMetrics.js index 13e9c6940..b2c447cee 100644 --- a/src/voice/turnMetrics.js +++ b/src/voice/turnMetrics.js @@ -1,7 +1,7 @@ import { countWords, findHedges } from './speechLint.js'; /** Quiet period after the last audio or response before a turn is closed. */ -export const TURN_SETTLE_MS = 2500; +const TURN_SETTLE_MS = 2500; /** * Per-turn latency and speech spans for one voice session. diff --git a/src/voice/voiceCard.js b/src/voice/voiceCard.js index 72b754287..09619bb54 100644 --- a/src/voice/voiceCard.js +++ b/src/voice/voiceCard.js @@ -5,7 +5,7 @@ import { } from './voiceCardPresentation.js'; /** Hide an idle card this long after its last update. */ -export const VOICE_CARD_IDLE_MS = 14000; +const VOICE_CARD_IDLE_MS = 14000; const ELEMENT_IDS = Object.freeze({ card: 'gev-voice-card', From 86f271a78fb0935c9dcabb80912001251028f8a7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:01:09 -0700 Subject: [PATCH 074/178] chore: remove unused data and model assets - src/data/local_data/dams/dams.geojson held the same 704 features as dams.geojsonl, feature for feature. Only the JSONL file is loaded (src/sources/infrastructureData.js), so the GeoJSON copy is dropped and the dataset README no longer mentions it. - public/models/ship.glb is not loaded anywhere: no module, template, stylesheet or script names it. Its row in public/models/README.md goes with it. Together these remove 960,345 bytes of tracked data. --- public/models/README.md | 1 - public/models/ship.glb | Bin 230024 -> 0 bytes src/data/local_data/dams/README.md | 3 +-- src/data/local_data/dams/dams.geojson | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 public/models/ship.glb delete mode 100644 src/data/local_data/dams/dams.geojson diff --git a/public/models/README.md b/public/models/README.md index 7e9c089be..aa7f40fbd 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -8,7 +8,6 @@ under the license listed below. |---|---|---|---|---| | `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | | `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `ship.glb` | “Low Poly Cargo Ship” by [Javier_Fernandez](https://sketchfab.com/Javier.Fernandez) | [Sketchfab model](https://sketchfab.com/3d-models/low-poly-cargo-ship-4c22cbaf01c1427f8ab60b3a07b1b32c) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized and repackaged as a glTF binary for the project's vessel visualization. | | `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | | `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | diff --git a/public/models/ship.glb b/public/models/ship.glb deleted file mode 100644 index e52d13b2c08a4cac1ef05829cae264ba8d489c8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 230024 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 Date: Thu, 22 Oct 2026 19:07:46 -0700 Subject: [PATCH 075/178] chore: drop unused exports from modules QA harnesses load QA harnesses import some modules in the page by path, for example import('/src/data/radio.js'). Those calls name exactly which bindings they use, and every other export of these modules has no importer: - src/data/radio.js: 20 aliases of radio layer methods and two re-exported altitude constants. The radio layer keeps the methods. - src/layers/transit/movement.js: tuning constants and course helpers used only inside the module lose `export`; FIX_HISTORY_MAX, DISPLAY_LAG_MIN_MS, DISPLAY_LAG_RESET_MS, FIX_NOISE_M, STOPPED_FIXES and courseBetween, which nothing reads, are deleted. - src/overlays/worldOverlay.js, src/splitFlap.js, src/data/contactPlayback.js and src/voice/cloudVoiceAuth.js: helpers and constants used only locally lose `export`. --- src/data/contactPlayback.js | 2 +- src/data/radio.js | 24 ------------------ src/layers/transit/movement.js | 46 ++++++---------------------------- src/overlays/worldOverlay.js | 8 ++---- src/splitFlap.js | 2 +- src/voice/cloudVoiceAuth.js | 6 ++--- 6 files changed, 15 insertions(+), 73 deletions(-) diff --git a/src/data/contactPlayback.js b/src/data/contactPlayback.js index 2aa1c9372..14b6b0593 100644 --- a/src/data/contactPlayback.js +++ b/src/data/contactPlayback.js @@ -181,7 +181,7 @@ export function trimTrack(track, nowMs, keep = track.capacity) { for (const id of track.epochs.keys()) if (!used.has(id)) track.epochs.delete(id); } -export function validFix(fix) { +function validFix(fix) { return ( Number.isFinite(fix?.t) && fix.t >= 0 && diff --git a/src/data/radio.js b/src/data/radio.js index e0a8009bc..954c8dd44 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -49,34 +49,12 @@ export const buildRadioTunerBand = layer.buildRadioTunerBand; export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay; export const getRadioAcceptedCatalogSnapshot = layer.getRadioAcceptedCatalogSnapshot; -export const radioStationResolutionMatches = - layer.radioStationResolutionMatches; export const getRadioUIState = layer.getRadioUIState; -export const subscribeToRadio = layer.subscribeToRadio; -export const subscribeToRadioPlaybackControls = - layer.subscribeToRadioPlaybackControls; -export const getRadioTunerStations = layer.getRadioTunerStations; -export const beginRadioTuning = layer.beginRadioTuning; -export const setRadioTuningStatic = layer.setRadioTuningStatic; export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed; -export const previewRadioTuningStation = layer.previewRadioTuningStation; -export const endRadioTuning = layer.endRadioTuning; -export const cancelRadioTuning = layer.cancelRadioTuning; -export const commitRadioTuningStation = layer.commitRadioTuningStation; -export const playSelectedRadio = layer.playSelectedRadio; export const confirmRadioPlayback = layer.confirmRadioPlayback; -export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice; -export const stopRadioPlayback = layer.stopRadioPlayback; -export const toggleRadioPlayback = layer.toggleRadioPlayback; -export const pauseRadioPlayback = layer.pauseRadioPlayback; export const setRadioVolume = layer.setRadioVolume; export const setRadioParams = layer.setRadioParams; -export const getRadioParams = layer.getRadioParams; export const setRadioVoiceDucking = layer.setRadioVoiceDucking; -export const selectRadioStation = layer.selectRadioStation; -export const cycleRadioStation = layer.cycleRadioStation; -export const selectRequestedRadioStation = layer.selectRequestedRadioStation; -export const setRadioFilter = layer.setRadioFilter; export const retainRadioClusterIdentitiesForStations = layer.retainRadioClusterIdentitiesForStations; export { @@ -86,10 +64,8 @@ export { RADIO_SINGLETON_MID_LIMIT, RADIO_SINGLETON_NEAR_LIMIT, RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, RADIO_OVERLAY_SOURCE_OPTIONS, DEFAULT_RADIO_FILTER, - GLOBAL_RADIO_ALTITUDE_M, } from '../layers/radio/index.js'; export default layer; diff --git a/src/layers/transit/movement.js b/src/layers/transit/movement.js index 84128cc39..2c9a61dcd 100644 --- a/src/layers/transit/movement.js +++ b/src/layers/transit/movement.js @@ -9,10 +9,7 @@ import { correctHeight, setTimeOffsetMs, } from '../../data/contactPlayback.js'; -export const FIX_HISTORY_MAX = 128; -export const DISPLAY_LAG_MIN_MS = 25_000; -export const DISPLAY_LAG_RESET_MS = 90_000; -export const MODE_MAX_SPEED_MPS = Object.freeze({ +const MODE_MAX_SPEED_MPS = Object.freeze({ bus: 36, // ~130 km/h — a coach on a motorway tram: 28, // ~100 km/h — interurban light rail subway: 42, // ~150 km/h @@ -32,22 +29,19 @@ export const MODE_MAX_SPEED_MPS = Object.freeze({ * @param {boolean} [inferred] Whether the mode came from a feed default. * @returns {number} m/s */ -export function speedCeilingMps(mode, inferred = false) { +function speedCeilingMps(mode, inferred = false) { if (inferred) return MODE_MAX_SPEED_MPS.unknown; return MODE_MAX_SPEED_MPS[mode] ?? MODE_MAX_SPEED_MPS.unknown; } -/** Position deadband is disabled: even a short reported crawl is played. */ -export const FIX_NOISE_M = 0; - /** * Displacement (m) a glide must cover before its direction is worth believing. * Below this the course derived from it is noise; the last good one is held. */ -export const COURSE_MIN_TRAVEL_M = 12; +const COURSE_MIN_TRAVEL_M = 12; /** Maximum display-course change per second (deg). A bus is not a gyroscope. */ -export const COURSE_MAX_RATE_DEG_PER_S = 120; +const COURSE_MAX_RATE_DEG_PER_S = 120; /** * Longest stretch of elapsed time (s) one evaluation may spend. The limiter * used to bank the time since the course last CHANGED, so a vehicle that held @@ -56,15 +50,7 @@ export const COURSE_MAX_RATE_DEG_PER_S = 120; * EVALUATED, and capped here so that even a first look after a long idle * sweeps visibly instead of snapping. */ -export const COURSE_STEP_MAX_S = 0.25; - -/** - * Consecutive fixes at the same place before a vehicle is called STOPPED. - * One is not enough: a fifteen-second gap between identical fixes is also what - * a vehicle waiting at a light looks like, and so is a feed that repeated - * itself. - */ -export const STOPPED_FIXES = 2; +const COURSE_STEP_MAX_S = 0.25; /** * How long after its newest fix a vehicle stops being described as travelling. @@ -75,7 +61,7 @@ export const STOPPED_FIXES = 2; * otherwise is the same overreach as reading STOPPED off a stale status. Ninety * seconds matches the threshold at which a whole feed reads as gone quiet. */ -export const MOTION_UNKNOWN_AFTER_MS = 90_000; +const MOTION_UNKNOWN_AFTER_MS = 90_000; const EARTH_M_PER_DEG = 111_320; @@ -94,29 +80,13 @@ export function fixDistanceM(a, b) { return Math.hypot(dLat, dLon); } -/** - * Course in degrees clockwise from north, from one point to another. - * @param {{lat:number, lon:number}} from - * @param {{lat:number, lon:number}} to - * @returns {number} 0-360 - */ -export function courseBetween(from, to) { - const dLat = (to.lat - from.lat) * EARTH_M_PER_DEG; - const dLon = - (to.lon - from.lon) * - EARTH_M_PER_DEG * - Math.cos((from.lat * Math.PI) / 180); - const deg = (Math.atan2(dLon, dLat) * 180) / Math.PI; - return (deg + 360) % 360; -} - /** * Shortest signed difference between two courses, in degrees (-180, 180]. * @param {number} from * @param {number} to * @returns {number} */ -export function courseDelta(from, to) { +function courseDelta(from, to) { let delta = ((to - from + 540) % 360) - 180; if (delta === -180) delta = 180; return delta; @@ -259,7 +229,7 @@ export function attachFloorToPlace(entry, fix, heightM) { correctHeight(entry.track, scratch.seq, heightM); } } -export function displayCourse(entry, nowMs) { +function displayCourse(entry, nowMs) { if (!entry) return null; const target = Number.isFinite(entry.sample?.segmentCourseDeg) ? entry.sample.segmentCourseDeg diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 717c826a2..ccf3cbd59 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -1155,11 +1155,7 @@ export function isOverlayPointVisible( * Return true when a rectangle intersects any cached UI exclusion box. * `count` lets pooled buffers expose a live prefix without truncating. */ -export function overlayRectIntersectsAny( - rect, - exclusions, - count = exclusions.length, -) { +function overlayRectIntersectsAny(rect, exclusions, count = exclusions.length) { for (let i = 0; i < count; i++) { const other = exclusions[i]; if ( @@ -1177,7 +1173,7 @@ export function overlayRectIntersectsAny( * Like `overlayRectIntersectsAny`, but only against exclusions that composite * BELOW the host — the ones a placement may never overlap at any cost. */ -export function overlayRectIntersectsAnyHard( +function overlayRectIntersectsAnyHard( rect, exclusions, count = exclusions.length, diff --git a/src/splitFlap.js b/src/splitFlap.js index 17d677999..9b78feeba 100644 --- a/src/splitFlap.js +++ b/src/splitFlap.js @@ -72,7 +72,7 @@ export const FLAP_STAGGER_MS = 26; */ export const FLAP_MAX_TOTAL_MS = 620; /** Slack after the last character lands before the cells are stripped. */ -export const FLAP_SETTLE_SLACK_MS = 60; +const FLAP_SETTLE_SLACK_MS = 60; /** * Fraction of a character's flip at which the outgoing glyph has rotated * away and the incoming one takes over as what the eye reads. MUST track the diff --git a/src/voice/cloudVoiceAuth.js b/src/voice/cloudVoiceAuth.js index 77feae8aa..5cf9ca882 100644 --- a/src/voice/cloudVoiceAuth.js +++ b/src/voice/cloudVoiceAuth.js @@ -1,5 +1,5 @@ -export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); -export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; +const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']); +const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key'; export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth'; function storageRef(storage) { @@ -11,7 +11,7 @@ function storageRef(storage) { } } -export function normalizeCloudVoiceAuthMode(mode) { +function normalizeCloudVoiceAuthMode(mode) { const normalized = String(mode || '') .trim() .toLowerCase(); From f3a87777ee2926ce81f61ed41a4f9a34f7dcd834 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:08:10 -0700 Subject: [PATCH 076/178] chore: remove the remaining unused local bindings An ESLint no-unused-vars scan over src, server, scripts, tools, build and pinokio, repeated until it is stable, reported these. Runtime modules keep every call and drop only the unread binding: - src/ui/cockpitInput.js: the unread result of enter()/exit(). - src/layers/launches/rendering.js: the unread entity returned by entities.add(). - src/layers/radio/rendering.js: the unread map key in the horizon scan, which now iterates the map's values. QA scripts and tools drop an unused helper, two unused in-page module imports, unused counters and destructured fields, and an unread wait result. Tests drop source files read into variables that no assertion uses, redundant StyleManager imports (the shell-source helper already imports it), and unused destructured fields. The one remaining report is the `_` loop variable in src/tools/schema.js, which counts iterations and is intentional. --- scripts/qa-final-polish-credits.mjs | 1 - scripts/qa-radio.mjs | 2 -- scripts/qa-traffic-oblique.mjs | 4 +--- scripts/qa-vessel-cards.mjs | 2 +- src/cameraHandoff.test.mjs | 3 +-- src/contextSessionOrdering.test.mjs | 1 - src/contextTabKeyboard.test.mjs | 5 ----- src/creditAttribution.test.mjs | 2 -- src/data/radio.test.mjs | 4 +--- src/data/trackedModelRegime.test.mjs | 2 -- src/firstRunExperience.test.mjs | 4 ---- src/layers/bikeshare/failureReporting.test.mjs | 2 +- src/layers/launches/rendering.js | 2 +- src/layers/radio/rendering.js | 2 +- src/layers/streetLevel/lineCoverage.test.mjs | 4 ++-- src/layers/transit/lifecycle.test.mjs | 1 - src/layers/wind/index.test.mjs | 2 +- src/loadingFeedback.test.mjs | 1 - src/locationReadouts.test.mjs | 1 - src/mapSourceFocus.test.mjs | 4 +--- src/panelEscape.test.mjs | 2 -- src/sharelink.celestial.test.mjs | 1 - src/ui/cockpitInput.js | 3 ++- src/voice/realtimeNarration.test.mjs | 2 +- tools/streetview-panorama.mjs | 2 +- 25 files changed, 15 insertions(+), 44 deletions(-) diff --git a/scripts/qa-final-polish-credits.mjs b/scripts/qa-final-polish-credits.mjs index 7e7182985..dde1431b9 100644 --- a/scripts/qa-final-polish-credits.mjs +++ b/scripts/qa-final-polish-credits.mjs @@ -52,7 +52,6 @@ try { id, value, ); - const text = () => page.$eval('#cesium-credits', (e) => e.innerText); const shot = async (name, osm, tiles) => { await sleep(300); const state = await page.$eval('#cesium-credits', (e) => { diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 648075bd2..14a9680ac 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -1291,8 +1291,6 @@ async function main() { const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); - const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); - const { radioStationIdFromPick } = await import('/src/data/radio.js'); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; const source = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations'); diff --git a/scripts/qa-traffic-oblique.mjs b/scripts/qa-traffic-oblique.mjs index 596d2f009..6ea1c953d 100644 --- a/scripts/qa-traffic-oblique.mjs +++ b/scripts/qa-traffic-oblique.mjs @@ -80,9 +80,7 @@ try { page.evaluate(() => { const { viewer, dataManager } = window.__godsEyeView; let upper = 0, - lower = 0, - near = 0; - const camera = viewer.camera.positionWC; + lower = 0; dataManager.layers .get('traffic') .module.visitMotionDots((id, key, p) => { diff --git a/scripts/qa-vessel-cards.mjs b/scripts/qa-vessel-cards.mjs index b5ad8ac87..278c1c71f 100644 --- a/scripts/qa-vessel-cards.mjs +++ b/scripts/qa-vessel-cards.mjs @@ -295,7 +295,7 @@ async function main() { ); // Wait for the photoreal tileset and at least one vessel refresh. - const settled = await page + await page .waitForFunction(() => { const gev = window.__godsEyeView; const t = gev.tileset; diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 8a09810ea..d91b9d8a4 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,7 +1,6 @@ import { LayerBindings } from './ui/layerBindings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; @@ -384,7 +383,7 @@ test('world-focus listener lifecycle is symmetric and idempotent', () => { }); test('vessel and fire layers announce valid clicks and never fly cameras', () => { - for (const [label, source] of [['vessels', vessels], ['fires', firms]]) { + for (const source of [vessels, firms]) { assert.match(source, /requestWorldFocus\(\{/); assert.doesNotMatch(source, /camera\.flyTo/); } diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 32ec10b28..2f9bfeae0 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -8,7 +8,6 @@ import { setContextMode as contextModeAction } from './ui/contextActions.js'; import { ContextControls } from './ui/contextControls.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); // Source-contract pins read the actual Context owners and root disposal wiring. Each pin guards a bug that shipped or nearly shipped: // - session bookkeeping ran AFTER the exit early-return, so the compensating diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 55ff429a2..6994459a7 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; @@ -8,13 +7,11 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); -const ui = readShellSource(); const css = readStylesheet(new URL('../style.css', import.meta.url)); test('Contacts and Space Missions both participate in the ordinary Tab sequence', () => { @@ -32,8 +29,6 @@ test('Contacts and Space Missions both participate in the ordinary Tab sequence' }); test('Context transition state preserves focus and Tab availability until settle', () => { - const syncSource = _syncContextModeButtons.toString(); - const attributes = () => new Map(); const makeButton = () => { const attrs = attributes(); diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index d5306dbaa..894d8f7ed 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -1,4 +1,3 @@ -import { readShellSource } from './testSupport/readShellSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -8,7 +7,6 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const css = readStylesheet(path.join(ROOT, 'style.css')); -const ui = readShellSource(); /* * Required-attribution keep-out pin. diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 58a4101a1..8bc543b03 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -1779,7 +1779,6 @@ test('voice playback confirmation waits for playing and requires a hard duck', a }); test('voice playback confirmation accepts fallback buffering but times out safely', async () => { - let listener = () => {}; const state = { audioState: 'loading', playingStationId: 'fallback', @@ -1789,9 +1788,8 @@ test('voice playback confirmation accepts fallback buffering but times out safel const confirmed = await confirmRadioPlayback({ startPlayback: async () => false, subscribe: (next) => { - listener = next; next(state); - return () => { listener = () => {}; }; + return () => {}; }, getState: () => state, timeoutMs: 10, diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 1b02df1d5..17fec219d 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -930,7 +930,6 @@ for (const fixture of LAYERS) { } test('both layers gate the tracked-model load and record its failures', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); assert.match(source, /!(?:flightState\.)?_trackedModel\s*&&\s*!(?:flightState\.)?_trackedModelLoading\s*&&\s*(?:parts\.\w+\.)?_trackedModelLoadAllowed\(\s*,?\s*\)/, @@ -946,7 +945,6 @@ test('both layers gate the tracked-model load and record its failures', async () }); test('the tracked regime never consults the fleet models3d toggle', async () => { - const { readFile } = await import('node:fs/promises'); for (const name of ['flights.js', 'militaryFlights.js']) { const source = readLayerSource(new URL(`./${name}`, import.meta.url)); const regime = /^([ \t]*)function _trackedModelRegimeActive\b[\s\S]*?\n\1\}/m.exec(source)?.[0]; diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..e4a6d2483 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -373,10 +373,6 @@ test('one ESC does one thing — the radio disclosure stops the launcher outrigh new URL('./ui/radioBindings.js', import.meta.url), 'utf8', ); - const module = fs.readFileSync( - new URL('./firstRunExperience.js', import.meta.url), - 'utf8', - ); // stopPropagation() does NOT stop later listeners on the SAME document, so the // disclosure's earlier capture handler closed the disclosure and the launcher diff --git a/src/layers/bikeshare/failureReporting.test.mjs b/src/layers/bikeshare/failureReporting.test.mjs index 89a87bdad..e49ebd962 100644 --- a/src/layers/bikeshare/failureReporting.test.mjs +++ b/src/layers/bikeshare/failureReporting.test.mjs @@ -60,7 +60,7 @@ test('the layer reports failure when a failed city left it with nothing', () => }); test('a layer with nothing to draw and nothing failed is not an error', () => { - const { state, methods } = makeLayer(); + const { methods } = makeLayer(); // Out of range of every city is empty, not broken. assert.equal(methods.getStats().error, undefined); diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 60e6e0070..6379e78e1 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -109,7 +109,7 @@ export function createRendering({ ); }); if (satelliteTrack) layerState._orbitMatches++; - const entity = layerState._dataSource.entities.add({ + layerState._dataSource.entities.add({ id: `rocket-launch:${launch.id}`, position, point: { diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 36cbc5050..9b4e6b387 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -259,7 +259,7 @@ export function createRendering({ layerState._horizonScanCount += 1; const occluder = horizonOccluder(layerState._viewer.camera); let visibilityChanged = false; - for (const [id, record] of layerState._renderById) { + for (const record of layerState._renderById.values()) { const matches = parts.categories.stationMatchesRadioCategory( record.station, layerState._filter, diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 4d0cb0408..049d2f746 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -317,7 +317,7 @@ test('a rejected key stops coverage requests until the layer goes off', async () test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); - const { viewer, source, state, coverage } = setup(); + const { viewer, source, coverage } = setup(); coverage.refresh(); source.calls[0].reject( Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), @@ -593,7 +593,7 @@ test('a tile that fails before the terrain table loads is reported at once, neve }); test('the selected sequence is drawn and highlighted even past the per-tile cap', async () => { - const { state, source, coverage, centre, viewer } = setup(); + const { state, source, coverage, centre } = setup(); const line = (n) => [ [ [centre.lon - 0.001, centre.lat + n * 1e-6], diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 0719acd95..bcc152576 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -1406,7 +1406,6 @@ test('the selected card lands where the vehicle stopped, not short of it', async // segment ends left it a few metres behind, and then the render hold was // released with no frame scheduled to close the gap. const app = harness(t); - const start = Date.now(); app.serve('mbta', () => ({ status: 200, body: snapshot('mbta', 'MBTA', [vehicle('b', 42.36, -71.06, reported())], { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index 3b5e09697..175381031 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -97,7 +97,7 @@ const complete = (model, extra = {}) => ({ }); test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => { const requests = []; - const { layer, calls } = harness({ + const { layer } = harness({ getSnapshot: async (args) => { requests.push(args); return complete( diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 01d00b1d6..19c660702 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -93,7 +93,6 @@ 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 ui = readShellSource(); const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); assert.match(handler, /this\.showStatus\(message\)/); assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 79cc92489..9d38592e3 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -1,5 +1,4 @@ import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index b7beec325..0fa66e0da 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,6 +1,5 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { shellMethod } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; -import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,7 +7,6 @@ import { readFileSync } from 'node:fs'; import { msg, t } from './i18n/index.js'; // Exercise the installed event routes and central close method, without WebGL. -const source = readShellSource(); const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); diff --git a/src/panelEscape.test.mjs b/src/panelEscape.test.mjs index e0588e9b2..d3d5cfc6d 100644 --- a/src/panelEscape.test.mjs +++ b/src/panelEscape.test.mjs @@ -2,8 +2,6 @@ import { readShellSource } from './testSupport/readShellSource.mjs'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); -const radioControlsSource = readRadioSource(new URL('./ui/radioControls.js', import.meta.url), 'utf8'); import { bindPanelDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import assert from 'node:assert/strict'; import test from 'node:test'; diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index ad421dde7..230843584 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -2,7 +2,6 @@ import { DisplayBindings } from './ui/displayBindings.js'; import { LocationNavigation } from './ui/locationNavigation.js'; import { VisualSettings } from './ui/visualSettings.js'; import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; -import { StyleManager } from './ui/applicationShell.js'; import { _claimContextVisualAuthority, setContextMode } from './ui/contextActions.js'; import { _initGlobalContextPanel } from './ui/contextBindings.js'; import { test } from 'node:test'; diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index aed87f483..95fb2aee2 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -64,7 +64,8 @@ export function onKeyDown(event) { event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; - const changed = this.active ? this.exit() : this.enter(); + if (this.active) this.exit(); + else this.enter(); return; } } diff --git a/src/voice/realtimeNarration.test.mjs b/src/voice/realtimeNarration.test.mjs index c9cd3e55f..e47a888c8 100644 --- a/src/voice/realtimeNarration.test.mjs +++ b/src/voice/realtimeNarration.test.mjs @@ -135,7 +135,7 @@ test('an old progress completion leaves the active in-band response alone', asyn }); test('user speech stops an audible progress line: cancel and clear its audio', async () => { - const { controller, sent, event, progressLine } = controllerFixture(); + const { sent, event, progressLine } = controllerFixture(); await progressLine('resp_p'); await event({ type: 'output_audio_buffer.started', response_id: 'resp_p' }); sent.length = 0; diff --git a/tools/streetview-panorama.mjs b/tools/streetview-panorama.mjs index a40d8d287..73010aa6c 100755 --- a/tools/streetview-panorama.mjs +++ b/tools/streetview-panorama.mjs @@ -157,7 +157,7 @@ async function fetchTile(apiKey, session, panoId, z, x, y) { // --------------------------------------------------------------------------- async function stitchPanorama(apiKey, session, panoId, metadata, zoom) { - const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata; + const { tileWidth, tileHeight } = metadata; // Street View tile grids follow a fixed power-of-2 scheme: // zoom 0: 1×1 (360° FOV, entire panorama in one tile) From 9623bc4d585db1fdf72d47e7814110f1899bfc50 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:19:03 -0700 Subject: [PATCH 077/178] docs(changelog): add the dead-code cleanup entry --- changelog.d/1321-dead-code-cleanup.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/1321-dead-code-cleanup.md diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md new file mode 100644 index 000000000..6fec4a692 --- /dev/null +++ b/changelog.d/1321-dead-code-cleanup.md @@ -0,0 +1,6 @@ +- Remove dead code across the repository: three modules nothing imports + (`src/ui.js`, `src/standalone/ui.js`, `src/data/localLayers.js`), exports + and locals nothing reads, a duplicate copy of the bundled dams data and an + unused ship model. The app no longer builds an unused default wind layer + and Bhote Koshi locator layer when it loads. Package exports are unchanged + (Sameh Khamis, #1321). From f6a4b73459caef81ffe274d84e4947f89c944640 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:26:09 -0700 Subject: [PATCH 078/178] docs: point remaining src/ui.js references at their owners The README source tree now lists src/ui/ instead of the removed src/ui.js. docs/CODE-BOUNDARIES.md names src/ui/applicationShell.js as the owner of panel layout, persistence and share restoration, and docs/CURRENT-STATE.md places GLOBAL_POST_DEFAULTS in src/ui/visualPresets.js. QA script comments and test assertion messages that still named ui.js are updated the same way. --- README.md | 2 +- docs/CODE-BOUNDARIES.md | 2 +- docs/CURRENT-STATE.md | 4 ++-- scripts/qa-cockpit-plates.mjs | 2 +- scripts/qa-l9-matrix.mjs | 2 +- src/contactsDetectionPolicy.test.mjs | 4 ++-- src/loadingFeedback.test.mjs | 2 +- src/scenes/director.test.mjs | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e3833a654..1b1fc46a0 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ How the globe handles live data: ``` src/ ├── main.js # Bootstrap: Google 3D tiles, layer registration -├── ui.js # Runtime UI — panels, HUD, styles, control facade +├── ui/ # Runtime UI — panels, HUD, styles, controls ├── hud.js # Intelligence HUD + AI scene summary ├── keySetup.js # POWER UP panel — in-app provider keys (dev server only) ├── mapStackController.js # Basemap switching — Google 3D / Esri / OSM / ion stacks diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 3362b98f0..9085a7b72 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -235,7 +235,7 @@ DOM elements and callbacks; importing it creates no browser state. `destroy()` removes owned listeners and cancels pending work without changing saved state or moving focus. Call it before removing or replacing the controls. -`src/ui/composition.js` retains panel layout, persistence, share restoration and application +`src/ui/applicationShell.js` retains panel layout, persistence, share restoration and application reactions to state changes. Map Source selection and Location draft cleanup are provided through callbacks. The component imports no globe, data, application or server modules. Package checks and scoped formatting cover this entry. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7184c5d96..714cb30f1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5373,7 +5373,7 @@ demand with square-root demand scaling and semantic layer weights (`labelArbiter.js:181`). First-run default: ELASTIC. **A default has THREE surfaces, and a PARSE fallback that is not one of them.** -The value literals (engine constant, markup value, markup readout, `ui.js` +The value literals (engine constant, markup value, markup readout, `src/ui/visualPresets.js` `GLOBAL_POST_DEFAULTS`, the share generator's starting state) must all move together, because a fresh boot runs no restore and those literals ARE the startup state. The `scf` / `ko` PARSE fallbacks deliberately do NOT move: they answer @@ -5421,7 +5421,7 @@ easier to meet (detection is now on more often), but does not create it. - **Detection OUTSIDE opacity: `1%`** (moved `5% → 3% → 1%` on 2026-08-24). `KEYHOLE_OUTSIDE_OPACITY_DEFAULT` in `src/celestialRing.js`, mirrored by `#detection-opacity-slider`'s markup value AND readout, - `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `ui.js`, and + `GLOBAL_POST_DEFAULTS.detectionOutsideOpacityPct` in `src/ui/visualPresets.js`, and `_detectionOutsideOpacityPct` in `sharelink.js`. The slider's `step` is now `1`, so 1–4 % are reachable at all (at the previous step of 5 the entire sub-default range was one stop wide). `AIRCRAFT_BRACKET_FLOOR_ANCHOR` in diff --git a/scripts/qa-cockpit-plates.mjs b/scripts/qa-cockpit-plates.mjs index 4e725fd8f..3d3aece03 100644 --- a/scripts/qa-cockpit-plates.mjs +++ b/scripts/qa-cockpit-plates.mjs @@ -12,7 +12,7 @@ * passed with the painter stubbed out — the number was right and nothing was * drawn. Every assertion here now comes from the RENDERED path instead: * - * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui.js), the + * ids/counts — `styleManager.getDetectionDiagnostics()` (src/ui/shellFacade.js), the * overlay's own published `visibleCount` / `labeledKeys`, so a contact only * counts once the sweep, the arbiter and the placement solve have all run. * plate scale — tapped off `CanvasRenderingContext2D` while the frame paints. diff --git a/scripts/qa-l9-matrix.mjs b/scripts/qa-l9-matrix.mjs index fdac4fd40..8709366de 100644 --- a/scripts/qa-l9-matrix.mjs +++ b/scripts/qa-l9-matrix.mjs @@ -1884,7 +1884,7 @@ async function runBrowserGroup(record) { await step('C15', async () => { // CRT/NVG/FLIR are DISPLAY LABELS; the real style ids are retro / - // surveillance / thermal (src/ui.js STYLE_STATUS_LABELS). Passing the + // surveillance / thermal (src/ui/visualPresets.js STYLE_STATUS_LABELS). Passing the // labels made every call a silent no-op that still "passed" — so assert // the returned visual state actually changed to the requested style. const wanted = [['retro', 'CRT'], ['surveillance', 'NVG'], ['thermal', 'FLIR'], ['normal', 'Normal']]; diff --git a/src/contactsDetectionPolicy.test.mjs b/src/contactsDetectionPolicy.test.mjs index 862d055ce..8fe78e1de 100644 --- a/src/contactsDetectionPolicy.test.mjs +++ b/src/contactsDetectionPolicy.test.mjs @@ -281,7 +281,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_syncContactsDetection() {'), uiSource.indexOf('/** Apply a temporary cockpit-only'), ); - assert.ok(helper.length > 0, 'the Contacts detection helper is present in ui.js'); + assert.ok(helper.length > 0, 'the Contacts detection helper is present in the UI shell source'); assert.match(helper, /if \(this\._contextModeChanging\) return;/, 'fires at settle, not at click'); assert.match(helper, /active: this\._contextMode === 'flights'/); assert.match(helper, /styleOwnsDetection:\s*!this\._detectionUserOverridden/); @@ -302,7 +302,7 @@ test('detection is wired to the Contacts transaction, and cockpit no longer touc uiSource.indexOf('_setCockpitVision(mode, active, { revealParameters = false } = {}) {'), uiSource.indexOf('_syncIrBoost() {'), ); - assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in ui.js'); + assert.ok(visionHook.length > 0, 'the cockpit vision hook is present in the UI shell source'); assert.doesNotMatch(visionHook, /etection/, 'cockpit transitions must not touch detection'); }); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 19c660702..b9903f97e 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -628,7 +628,7 @@ test('the loading ticker never runs hidden and stops after loading and notices s // safety-net poll, so its `if (document.hidden) return;` is correct there // and must not be confused with this self-stopping ticker's leak. const armBody = ui.slice(ui.indexOf('_armLoadingFeedbackTicker() {')); - assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in ui.js'); + assert.ok(armBody.startsWith('_armLoadingFeedbackTicker() {'), 'ticker function not found in the UI shell source'); const arm = armBody.slice(0, armBody.indexOf('\n }\n') + 5); // 1. The ARM guard itself refuses while hidden. Previously the only hidden diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index 59f3a3a0e..1077b0658 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -1264,7 +1264,7 @@ test('applyVisualState gates the map-stack switch on both sides of its await', ( // switch would otherwise stand on the globe. const source = readShellSource(); const method = source.match(/\n {2}async applyVisualState\([\s\S]*?\n {2}\}\n/); - assert.ok(method, 'applyVisualState is missing from ui.js'); + assert.ok(method, 'applyVisualState is missing from the UI shell source'); assert.match(method[0], /async applyVisualState\(state = \{\}, \{ isCurrent = null \} = \{\}\)/); const mapStackBlock = method[0].match(/if \(state\.mapStack\) \{[\s\S]*?\n {4}\}/); From 04f8c7fad0b1d537b295ac7eb9303fec089cd33a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 20:47:34 -0700 Subject: [PATCH 079/178] chore: credit the changelog entry to the GitHub handle --- changelog.d/1321-dead-code-cleanup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md index 6fec4a692..e8cad2a23 100644 --- a/changelog.d/1321-dead-code-cleanup.md +++ b/changelog.d/1321-dead-code-cleanup.md @@ -3,4 +3,4 @@ and locals nothing reads, a duplicate copy of the bundled dams data and an unused ship model. The app no longer builds an unused default wind layer and Bhote Koshi locator layer when it loads. Package exports are unchanged - (Sameh Khamis, #1321). + (samehkhamis, #1321). From 626852401a98929db4f0362420a3407253ad3aa1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:29 +0700 Subject: [PATCH 080/178] 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 081/178] 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 fff16f3884730f2bd106ac7eb9551b05da1374fd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 082/178] feat(ui): phone layout with a bottom tab bar and one panel sheet at a time At 720px and narrower the globe stays full screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one bottom sheet at a time, presenting the existing rail and dock panels in place. Sheets borrow panels without persisting or sharing them and put back what they changed. Cockpit, recording and clean view close the sheet; scene playback keeps STOP reachable. Focus moves into the sheet and back to its tab, the required attribution stays visible above the voice control and any sheet, and the page uses safe-area insets. Wider viewports are unchanged. Adapted from the phone tab-shell proposed in #203 by @blearymoth. --- index.html | 2 +- scripts/package-boundaries.json | 5 + src/creditAttribution.test.mjs | 3 + src/exclusiveSurfaces.js | 11 + src/firstRunExperience.js | 8 +- src/firstRunExperience.test.mjs | 6 + src/i18n/locales/es.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/zh-CN.json | 6 +- src/ui/applicationShell.js | 18 ++ src/ui/mobileShell.js | 400 ++++++++++++++++++++++++++ src/ui/mobileShell.test.mjs | 483 ++++++++++++++++++++++++++++++++ src/ui/panelChrome.js | 20 +- src/ui/panelRails.test.mjs | 18 +- src/ui/rightPanelRail.js | 17 +- src/ui/styles/mobile-shell.css | 415 +++++++++++++++++++++++++++ style.css | 1 + 17 files changed, 1405 insertions(+), 20 deletions(-) create mode 100644 src/exclusiveSurfaces.js create mode 100644 src/ui/mobileShell.js create mode 100644 src/ui/mobileShell.test.mjs create mode 100644 src/ui/styles/mobile-shell.css diff --git a/index.html b/index.html index 8a01415b6..a9874d550 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 f2efea33c..43b765214 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -651,6 +651,8 @@ "exports": ["./ui/shell"], "modules": [ "src/i18n/marker.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/placeNames.js", "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -1905,6 +1907,7 @@ "src/data/vesselLabels.js", "src/data/vesselTypes.js", "src/firstRunExperience.js", + "src/exclusiveSurfaces.js", "src/hud.js", "src/hudLocality.js", "src/hudLocalityFormat.js", @@ -2192,6 +2195,8 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/mobileShell.js", + "src/exclusiveSurfaces.js", "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs index 894d8f7ed..3032a25fe 100644 --- a/src/creditAttribution.test.mjs +++ b/src/creditAttribution.test.mjs @@ -245,6 +245,9 @@ function ownBoxEntries() { const compound = lastCompound(part); if (!ELEMENT_KEYS.some((key) => compound.includes(key))) continue; if (compound.includes('::')) continue; // a pseudo-element is its own box + // The phone shell is its own layout; mobileShell.test.mjs pins its + // attribution clearance. + if (part.startsWith('body.mobile-shell')) continue; entries.push({ rule, part }); } } diff --git a/src/exclusiveSurfaces.js b/src/exclusiveSurfaces.js new file mode 100644 index 000000000..af941d69f --- /dev/null +++ b/src/exclusiveSurfaces.js @@ -0,0 +1,11 @@ +/** + * Body classes that mean "another surface owns the screen": cockpit, scene + * playback, recording and clean view. First run yields to them and the phone + * shell closes its sheets for them. + */ +export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ + 'cockpit-mode', + 'scene-playback-mode', + 'recording-mode', + 'ui-clean-view', +]); diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 59ddf3e6a..408152295 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -22,6 +22,7 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // is enthusiasm, not "never show me this again". import { t, msg } from './i18n/index.js'; +import { EXCLUSIVE_SURFACE_CLASSES } from './exclusiveSurfaces.js'; /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; @@ -305,12 +306,7 @@ export async function runFirstRunChoice( * hides the launcher in CSS, and the observer below turns that into a yield — * see the ESC ARBITRATION note on initFirstRunExperience. */ -export const EXCLUSIVE_SURFACE_CLASSES = Object.freeze([ - 'cockpit-mode', - 'scene-playback-mode', - 'recording-mode', - 'ui-clean-view', -]); +export { EXCLUSIVE_SURFACE_CLASSES }; /** * Is some other surface currently claiming the screen? diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index e4a6d2483..40d42befc 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -247,6 +247,10 @@ test('no storage is touched from a default parameter position', () => { test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { const module = fs.readFileSync( + new URL('./exclusiveSurfaces.js', import.meta.url), + 'utf8', + ); + const firstRun = fs.readFileSync( new URL('./firstRunExperience.js', import.meta.url), 'utf8', ); @@ -279,6 +283,8 @@ test('the JS and CSS lists of screen-claiming surfaces stay in step', () => { module, /export const EXCLUSIVE_SURFACE_CLASSES = Object\.freeze\(\[/, ); + // First run keeps exporting the one shared list (the phone shell reads it too). + assert.match(firstRun, /export \{ EXCLUSIVE_SURFACE_CLASSES \};/); }); test('exclusiveSurfaceActive reads the live body classes', () => { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3c5602ae7..fd55b2e25 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2943,5 +2943,9 @@ "hemisphere|E": "E", "hemisphere|W": "O", "OK": "OK", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Capas", + "Search": "Buscar", + "More": "Más", + "Panels": "Paneles" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7a790e04e..442312de2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3010,5 +3010,9 @@ "hemisphere|E": "В", "hemisphere|W": "З", "OK": "НОРМА", - "{category} ({count})": "{category} ({count})" + "{category} ({count})": "{category} ({count})", + "Layers": "Слои", + "Search": "Поиск", + "More": "Ещё", + "Panels": "Панели" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 46ccf96a7..fa08cca1f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2809,5 +2809,9 @@ "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。", "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)", "OK": "正常", - "{category} ({count})": "{category}({count})" + "{category} ({count})": "{category}({count})", + "Layers": "图层", + "Search": "搜索", + "More": "更多", + "Panels": "面板" } diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 82413f43e..de37ab036 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; +import { initMobileShell } from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -124,6 +125,8 @@ export class StyleManager extends ShellFacade { readInitialShare: () => this._initialShareState, readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner, readDisplayScrollTop: () => this._standardDisplayScrollTop, + readSharedCollapsed: (panelId) => + this._mobileShell?.sharedCollapsed(panelId), }); this._feedback = new ShellFeedback({ readLayers: () => this._dataManager?.getAll?.() || [], @@ -587,6 +590,7 @@ export class StyleManager extends ShellFacade { this._initStreetLevelPanel(); this._initGlobalContextPanel(); this._initLocationBar(); + this._initMobileShell(); this._initShareButton(); this._initCameraOrientationControls(); this._initClearSelectedLayersButton(); @@ -848,6 +852,19 @@ export class StyleManager extends ShellFacade { } } + /** + * Phone layout: a bottom tab bar presenting one panel sheet at a time + * (src/ui/mobileShell.js). Inactive above 720px. + * @returns {void} + */ + _initMobileShell() { + this._mobileShell = initMobileShell({ + setPanelCollapsed: (panelId, collapsed, options) => + this._panelChrome.setPanelCollapsed(panelId, collapsed, options), + onSheetsClosed: () => this._syncShareState(), + }); + } + /** * Renders the owner-approved map stack chip row from the matching controller * entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable, @@ -1707,6 +1724,7 @@ export class StyleManager extends ShellFacade { this._localePicker?.destroy(); this._localePicker = null; this._recording.destroy(); + this._mobileShell?.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/mobileShell.js b/src/ui/mobileShell.js new file mode 100644 index 000000000..a2b8638b2 --- /dev/null +++ b/src/ui/mobileShell.js @@ -0,0 +1,400 @@ +/** + * Phone shell for narrow viewports (≤720px). The globe stays full screen and a + * bottom tab bar opens one sheet at a time; each sheet shows existing panels, + * which stay where they are in the rails and dock while CSS presents them + * (styles/mobile-shell.css). Desktop never sees the class or the sheet state. + * + * Sheets borrow panels: the panels a sheet expands are put back exactly as + * they were when the sheets close, and nothing a sheet does is persisted or + * treated as the user's own panel choice. + * + * Adapted from the phone tab-shell proposed in public PR #203 by blearymoth. + */ + +import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; +import { msg, t } from '../i18n/index.js'; + +export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)'; +export const MOBILE_SHELL_CLASS = 'mobile-shell'; +export const MOBILE_SHEET_ATTR = 'data-mobile-sheet'; + +/** + * Panels each sheet expands. A sheet also shows its rail's other panels + * collapsed (Scenes under Layers), per styles/mobile-shell.css. + */ +export const MOBILE_SHEET_PANELS = Object.freeze({ + layers: Object.freeze(['data-panel']), + cameras: Object.freeze(['cctv-panel', 'street-level-panel']), + context: Object.freeze(['global-context-panel']), + search: Object.freeze(['location-bar']), + more: Object.freeze(['control-panel', 'pp-toggles']), +}); + +export const MOBILE_TABS = Object.freeze([ + Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }), + Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }), + Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }), + Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }), + Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }), +]); + +/** + * Surfaces that own the screen close the open sheet and keep sheets closed. + * Scene playback is the exception: its STOP control lives in the Scenes panel + * (Layers sheet), and a phone has no Escape key to stop it otherwise. + */ +const SHEET_BLOCKING_CLASSES = Object.freeze( + EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'), +); + +/** Every panel any sheet borrows; the dock trays close each other. */ +const BORROWED_PANELS = Object.freeze([ + ...new Set(Object.values(MOBILE_SHEET_PANELS).flat()), +]); + +const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false }); + +/** + * @param {string} panelId + * @returns {string|null} The sheet that presents `panelId`. + */ +export function sheetForPanelId(panelId) { + for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) { + if (ids.includes(panelId)) return sheet; + } + return null; +} + +/** + * The sheet after a tab press: the same tab closes its sheet, another opens. + * @param {string|null} current + * @param {string|null} pressed + * @returns {string|null} + */ +export function nextSheet(current, pressed) { + if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null; + return current === pressed ? null : pressed; +} + +function sheetBlocked(body) { + return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name)); +} + +function currentSheetOf(body) { + const value = body.getAttribute(MOBILE_SHEET_ATTR); + return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null; +} + +function buildChrome(documentRef) { + const backdrop = documentRef.createElement('div'); + backdrop.id = 'mobile-sheet-backdrop'; + backdrop.hidden = true; + + const tabBar = documentRef.createElement('nav'); + tabBar.id = 'mobile-tab-bar'; + tabBar.setAttribute('aria-label', t('Panels')); + for (const tab of MOBILE_TABS) { + const button = documentRef.createElement('button'); + button.type = 'button'; + button.className = 'mobile-tab'; + button.dataset.mobileSheet = tab.sheet; + button.setAttribute('aria-expanded', 'false'); + const icon = documentRef.createElement('span'); + icon.className = 'material-symbols-outlined mobile-tab-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = tab.icon; + const label = documentRef.createElement('span'); + label.className = 'mobile-tab-label'; + label.textContent = t(tab.label); + button.append(icon, label); + tabBar.append(button); + } + documentRef.body.append(backdrop, tabBar); + return { backdrop, tabBar }; +} + +/** + * Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus, + * and closing for full-screen modes. + * @param {object} input + * @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed + * @param {Document} [input.documentRef] + * @param {(query: string) => MediaQueryList} [input.matchMedia] + * @param {typeof MutationObserver} [input.MutationObserverImpl] + * @param {typeof ResizeObserver} [input.ResizeObserverImpl] + * @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). + * @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }} + */ +export function initMobileShell({ + setPanelCollapsed, + documentRef = globalThis.document, + matchMedia = globalThis.matchMedia?.bind(globalThis), + MutationObserverImpl = globalThis.MutationObserver, + ResizeObserverImpl = globalThis.ResizeObserver, + requestFrame = globalThis.requestAnimationFrame?.bind(globalThis), + onSheetsClosed = () => {}, +} = {}) { + const body = documentRef.body; + const { backdrop, tabBar } = buildChrome(documentRef); + const viewport = documentRef.querySelector('meta[name="viewport"]'); + const viewportContent = viewport?.getAttribute('content') ?? null; + const removers = []; + const listen = (target, type, handler, options) => { + target.addEventListener(type, handler, options); + removers.push(() => target.removeEventListener(type, handler, options)); + }; + const element = (id) => documentRef.getElementById(id); + const rails = ['left-panel-stack', 'right-context-rail'] + .map(element) + .filter(Boolean); + const dock = element('command-dock'); + const shown = (node) => + node && globalThis.getComputedStyle?.(node).display !== 'none'; + + /** + * Panel states from before the first sheet opened (`before`) and right after + * the open sheet was set up (`applied`); null when closed. + */ + let borrowed = null; + let returnFocus = null; + const written = new Map(); + + const writeVar = (name, value) => { + if (written.get(name) === value) return; + written.set(name, value); + body.style.setProperty(name, value); + }; + + // The dock (Presets, Search) sits on top of whichever rail is the sheet, + // and the required map attribution sits above everything the sheet covers. + const measureSheet = () => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const height = Math.max( + 0, + ...rails.map((rail) => + shown(rail) ? rail.getBoundingClientRect().height : 0, + ), + ); + writeVar('--mobile-sheet-height', `${Math.round(height)}px`); + const viewportHeight = globalThis.innerHeight || 0; + const cover = Math.max( + 0, + ...[...rails, currentSheetOf(body) ? dock : null] + .filter(shown) + .map((node) => viewportHeight - node.getBoundingClientRect().top), + ); + writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`); + }; + + const resize = + typeof ResizeObserverImpl === 'function' + ? new ResizeObserverImpl(measureSheet) + : null; + removers.push(() => resize?.disconnect()); + + const isCollapsed = (panelId) => + element(panelId)?.classList.contains('collapsed') ?? true; + // The user's preference, as share links record it: a collapse that layout + // applies only for space (Tactical, Cyber) still counts as expanded. + const prefersCollapsed = (panelId) => { + const classes = element(panelId)?.classList; + if (!classes) return true; + return ( + classes.contains('collapsed') && + !classes.contains('layout-auto-collapsed') && + !classes.contains('cyber-accordion-collapsed') + ); + }; + + const panelStates = () => + new Map( + BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]), + ); + + // Undo only what opening the sheet did (including side effects, such as dock + // trays closing each other). A panel the user changed since stays as they + // left it, and becomes the state later sheets put back. + const restoreBorrowed = () => { + if (!borrowed) return; + for (const [panelId, before] of borrowed.before) { + const now = prefersCollapsed(panelId); + if (now !== borrowed.applied.get(panelId)) + borrowed.before.set(panelId, now); + else if (now !== before) + setPanelCollapsed(panelId, before, BORROW_OPTIONS); + } + }; + + const present = (sheet) => { + if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet); + else body.removeAttribute(MOBILE_SHEET_ATTR); + backdrop.hidden = !sheet; + for (const button of tabBar.querySelectorAll('[data-mobile-sheet]')) + button.setAttribute( + 'aria-expanded', + String(button.dataset.mobileSheet === sheet), + ); + measureSheet(); + }; + + /** The first visible control of the sheet's first panel, for focus on open. */ + const sheetFocusTarget = (sheet) => { + if (sheet === 'search') return element('location-search'); + const panel = element(MOBILE_SHEET_PANELS[sheet][0]); + const controls = + panel?.querySelectorAll?.( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', + ) ?? []; + // Sheets hide some panel chrome (tray toggles, pins) that stays in the + // DOM, and a panel's own collapse control is a poor first stop. + return ( + [...controls].find( + (control) => + control.getClientRects().length > 0 && + !control.matches?.('.panel-collapse-btn'), + ) ?? null + ); + }; + + const setSheet = (requested) => { + if (!body.classList.contains(MOBILE_SHELL_CLASS)) return; + const sheet = sheetBlocked(body) ? null : requested; + const previous = currentSheetOf(body); + if (previous === sheet) return; + if (!previous) { + borrowed = { before: panelStates(), applied: panelStates() }; + returnFocus = documentRef.activeElement ?? null; + } else { + if (previous === 'search') element('location-search')?.blur?.(); + restoreBorrowed(); + } + present(sheet); + if (!sheet) { + borrowed = null; + onSheetsClosed(); + const focus = returnFocus; + returnFocus = null; + if (focus && focus !== body && body.contains?.(focus)) focus.focus?.(); + return; + } + for (const panelId of MOBILE_SHEET_PANELS[sheet]) + if (isCollapsed(panelId)) + setPanelCollapsed(panelId, false, BORROW_OPTIONS); + borrowed.applied = panelStates(); + // Rail layout settles on a later frame; what it does is part of opening. + const opening = (borrowed.opening ?? 0) + 1; + borrowed.opening = opening; + const opened = borrowed; + requestFrame?.(() => { + if (borrowed === opened && opened.opening === opening) + opened.applied = panelStates(); + }); + measureSheet(); + sheetFocusTarget(sheet)?.focus?.(); + }; + + const tabFor = (sheet) => + [...tabBar.querySelectorAll('[data-mobile-sheet]')].find( + (button) => button.dataset.mobileSheet === sheet, + ); + + const onMedia = (matches) => { + if (matches) { + body.classList.add(MOBILE_SHELL_CLASS); + // Lets the page draw under the notch and system bars; the shell's CSS + // keeps its chrome clear of them. + if (viewport && !/viewport-fit\s*=/.test(viewportContent || '')) + viewport.setAttribute( + 'content', + `${viewportContent || ''}, viewport-fit=cover`, + ); + for (const node of [...rails, dock].filter(Boolean)) + resize?.observe(node); + measureSheet(); + } else { + // Crossing to desktop closes the sheet, which puts its panels back. + setSheet(null); + body.classList.remove(MOBILE_SHELL_CLASS); + present(null); + resize?.disconnect(); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + } + tabBar.setAttribute('aria-hidden', String(!matches)); + }; + + const media = + typeof matchMedia === 'function' + ? matchMedia(MOBILE_SHELL_MEDIA_QUERY) + : null; + onMedia(Boolean(media?.matches)); + const onChange = (event) => onMedia(event.matches); + if (media?.addEventListener) { + media.addEventListener('change', onChange); + removers.push(() => media.removeEventListener('change', onChange)); + } else if (media?.addListener) { + // Safari before 14 only offers the legacy listener API. + media.addListener(onChange); + removers.push(() => media.removeListener(onChange)); + } + + listen(tabBar, 'click', (event) => { + const button = event.target?.closest?.('[data-mobile-sheet]'); + if (!button) return; + event.preventDefault(); + const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet); + // Closing from the tab bar returns focus to the tab that was pressed. + if (!sheet) returnFocus = button; + setSheet(sheet); + }); + listen(backdrop, 'click', () => setSheet(null)); + // 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. + listen(documentRef, 'keydown', (event) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + const sheet = currentSheetOf(body); + if (!sheet || documentRef.querySelector('dialog[open]')) return; + event.preventDefault(); + returnFocus = tabFor(sheet) ?? returnFocus; + setSheet(null); + }); + + if (typeof MutationObserverImpl === 'function') { + const observer = new MutationObserverImpl(() => { + if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null); + }); + observer.observe(body, { attributes: true, attributeFilter: ['class'] }); + removers.push(() => observer.disconnect()); + } + + return { + setSheet, + getSheet: () => currentSheetOf(body), + /** + * The collapsed state to share for a panel a sheet borrowed: its state + * before the sheets, or the user's own change since. Undefined otherwise. + */ + sharedCollapsed(panelId) { + if (!borrowed?.before.has(panelId)) return undefined; + const now = prefersCollapsed(panelId); + return now !== borrowed.applied.get(panelId) + ? now + : borrowed.before.get(panelId); + }, + destroy() { + setSheet(null); + for (const remove of removers.splice(0)) remove(); + body.classList.remove(MOBILE_SHELL_CLASS); + body.removeAttribute(MOBILE_SHEET_ATTR); + body.style.removeProperty('--mobile-sheet-height'); + body.style.removeProperty('--mobile-sheet-cover'); + if (viewport && viewportContent !== null) + viewport.setAttribute('content', viewportContent); + backdrop.remove(); + tabBar.remove(); + }, + }; +} diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs new file mode 100644 index 000000000..751333c9a --- /dev/null +++ b/src/ui/mobileShell.test.mjs @@ -0,0 +1,483 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { + MOBILE_SHEET_PANELS, + MOBILE_TABS, + initMobileShell, + nextSheet, + sheetForPanelId, +} from './mobileShell.js'; + +function element(tag, document) { + const node = new EventTarget(); + const attributes = new Map(); + const classes = new Set(); + Object.assign(node, { + tagName: tag.toUpperCase(), + children: [], + dataset: {}, + hidden: false, + textContent: '', + className: '', + parent: null, + classList: { + add: (...names) => names.forEach((name) => classes.add(name)), + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + style: { + values: new Map(), + setProperty(name, value) { + this.values.set(name, value); + }, + removeProperty(name) { + this.values.delete(name); + }, + }, + setAttribute: (key, value) => attributes.set(key, String(value)), + getAttribute: (key) => attributes.get(key) ?? null, + removeAttribute: (key) => attributes.delete(key), + append(...children) { + for (const child of children) { + child.parent = node; + node.children.push(child); + if (child.id) document.ids.set(child.id, child); + } + }, + remove() { + const siblings = node.parent?.children; + siblings?.splice(siblings.indexOf(node), 1); + document.ids.delete(node.id); + }, + contains(other) { + for (let current = other; current; current = current.parent) + if (current === node) return true; + return false; + }, + closest(selector) { + for (let current = node; current; current = current.parent) + if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet) + return current; + return null; + }, + querySelectorAll(selector) { + const found = []; + const matches = (child) => + selector === '[data-mobile-sheet]' + ? Boolean(child.dataset.mobileSheet) + : child.tagName === 'BUTTON'; + const walk = (parent) => { + for (const child of parent.children) { + if (matches(child)) found.push(child); + walk(child); + } + }; + walk(node); + return found; + }, + getBoundingClientRect: () => ({ top: 0, height: 0 }), + getClientRects: () => (node.hidden ? [] : [{}]), + matches: (selector) => + selector === '.panel-collapse-btn' && + node.className.split(' ').includes('panel-collapse-btn'), + focus() { + document.activeElement = node; + }, + blur() { + if (document.activeElement === node) + document.activeElement = document.body; + }, + }); + return node; +} + +const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())]; + +function fixture({ mobile = true, expanded = [] } = {}) { + const document = new EventTarget(); + document.ids = new Map(); + document.createElement = (tag) => element(tag, document); + document.getElementById = (id) => document.ids.get(id) ?? null; + const viewport = element('meta', document); + viewport.setAttribute('content', 'width=device-width, initial-scale=1.0'); + document.querySelector = (selector) => + selector === 'meta[name="viewport"]' + ? viewport + : selector === 'dialog[open]' && document.openDialog + ? {} + : null; + document.body = element('body', document); + document.activeElement = document.body; + const search = element('input', document); + search.id = 'location-search'; + document.body.append(search); + for (const id of PANEL_IDS) { + const panel = element('section', document); + panel.id = id; + if (!expanded.includes(id)) panel.classList.add('collapsed'); + // A hidden tray toggle and a collapse control first, like the panels' + // own chrome in a sheet. + const toggle = element('button', document); + toggle.hidden = true; + const collapse = element('button', document); + collapse.className = 'panel-collapse-btn'; + panel.append(toggle, collapse, element('button', document)); + document.body.append(panel); + } + + const media = new EventTarget(); + media.matches = mobile; + const calls = []; + const frames = []; + let closedCount = 0; + let observed = null; + class FakeMutationObserver { + constructor(callback) { + observed = callback; + } + observe() {} + disconnect() { + observed = null; + } + } + const shell = initMobileShell({ + documentRef: document, + matchMedia: () => media, + MutationObserverImpl: FakeMutationObserver, + ResizeObserverImpl: null, + requestFrame: (callback) => frames.push(callback), + onSheetsClosed: () => { + closedCount += 1; + }, + setPanelCollapsed: (panelId, collapsed, options) => { + calls.push({ panelId, collapsed, ...options }); + const panel = document.getElementById(panelId); + if (collapsed) panel.classList.add('collapsed'); + else panel.classList.remove('collapsed'); + }, + }); + const tab = (sheet) => + document + .getElementById('mobile-tab-bar') + .children.find((button) => button.dataset.mobileSheet === sheet); + const press = (sheet) => { + const event = new Event('click', { cancelable: true }); + Object.defineProperty(event, 'target', { value: tab(sheet) }); + document.getElementById('mobile-tab-bar').dispatchEvent(event); + }; + const resize = (matches) => { + media.matches = matches; + const event = new Event('change'); + event.matches = matches; + media.dispatchEvent(event); + }; + const setBodyClass = (name) => { + document.body.classList.add(name); + observed?.(); + }; + const collapsed = (id) => + document.getElementById(id).classList.contains('collapsed'); + const runFrames = () => frames.splice(0).forEach((callback) => callback()); + return { + document, + shell, + calls, + runFrames, + closedCount: () => closedCount, + press, + tab, + resize, + viewport, + search, + setBodyClass, + collapsed, + }; +} + +test('every tab names a sheet and every sheet panel maps back to its sheet', () => { + assert.deepEqual( + MOBILE_TABS.map(({ sheet }) => sheet), + Object.keys(MOBILE_SHEET_PANELS), + ); + for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS)) + for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet); + assert.equal(sheetForPanelId('unknown-panel'), null); +}); + +test('pressing a tab opens its sheet, pressing it again closes it', () => { + assert.equal(nextSheet(null, 'layers'), 'layers'); + assert.equal(nextSheet('layers', 'layers'), null); + assert.equal(nextSheet('layers', 'more'), 'more'); + assert.equal(nextSheet('layers', 'nonsense'), null); + assert.equal(nextSheet(null, 'constructor'), null); +}); + +test('one sheet at a time, presented with aria-expanded and a backdrop', () => { + const { document, shell, press, tab } = fixture(); + assert.equal(document.body.classList.contains('mobile-shell'), true); + press('layers'); + assert.equal(shell.getSheet(), 'layers'); + assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'true'); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false); + press('more'); + assert.equal(shell.getSheet(), 'more'); + assert.equal(tab('layers').getAttribute('aria-expanded'), 'false'); + assert.equal(tab('more').getAttribute('aria-expanded'), 'true'); + press('more'); + assert.equal(shell.getSheet(), null); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true); +}); + +test('sheets borrow panels and put them back exactly as they were', () => { + // The user left Data Layers and Presets open on desktop. + const kept = ['data-panel', 'control-panel']; + const { calls, press, collapsed } = fixture({ expanded: kept }); + press('cameras'); + assert.equal(collapsed('cctv-panel'), false); + assert.equal(collapsed('street-level-panel'), false); + press('search'); + assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch'); + assert.equal(collapsed('location-bar'), false); + press('search'); + for (const id of PANEL_IDS) + assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`); + // Nothing a sheet does is the user's choice, persisted or shared. + assert.ok(calls.length > 0); + for (const call of calls) { + assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`); + assert.equal(call.persist, false); + assert.equal(call.syncShare, false); + } + // Already-open panels are not touched. + assert.ok(!calls.some(({ panelId }) => kept.includes(panelId))); +}); + +test('a panel the user changes inside a sheet stays as they left it', () => { + const { press, collapsed, document } = fixture(); + press('cameras'); + // The user collapses CCTV inside the sheet (their own, persisted choice). + document.getElementById('cctv-panel').classList.add('collapsed'); + press('cameras'); + assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept'); + assert.equal( + collapsed('street-level-panel'), + true, + 'the borrowed one is put back', + ); + + // Expanding one in a sheet is kept too, and later sheets respect it. + press('layers'); + document.getElementById('cctv-panel').classList.remove('collapsed'); + press('more'); + press('more'); + assert.equal(collapsed('cctv-panel'), false); +}); + +test('focus moves into the sheet and returns to the tab on close', () => { + const { document, press, search, tab } = fixture(); + press('search'); + assert.equal(document.activeElement, search); + press('search'); + assert.equal(document.activeElement, tab('search')); + + press('layers'); + const dataPanel = document.getElementById('data-panel'); + // The first visible control that is not the panel's own collapse button. + assert.equal(document.activeElement, dataPanel.children[2]); + const escape = new Event('keydown', { cancelable: true }); + escape.key = 'Escape'; + document.dispatchEvent(escape); + assert.equal(escape.defaultPrevented, true); + assert.equal(document.activeElement, tab('layers')); +}); + +test('the share link keeps the state from before the sheets', () => { + const { shell, press, document, closedCount } = fixture({ + expanded: ['street-level-panel'], + }); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); + press('layers'); + // Data Layers is open only because the sheet borrowed it. + assert.equal(shell.sharedCollapsed('data-panel'), true); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined); + // A user change inside the sheet is theirs to share. + document.getElementById('cctv-panel').classList.remove('collapsed'); + assert.equal(shell.sharedCollapsed('cctv-panel'), false); + press('more'); + assert.equal(closedCount(), 0, 'switching sheets is not closing'); + press('more'); + assert.equal(closedCount(), 1, 'closing refreshes the share link once'); + assert.equal(shell.sharedCollapsed('data-panel'), undefined); +}); + +test('Escape closes the sheet only when nothing else took it', () => { + const { document, shell, press } = fixture(); + const escape = () => { + const event = new Event('keydown', { cancelable: true, bubbles: true }); + event.key = 'Escape'; + return event; + }; + press('layers'); + // A field cancelling its edit (or a lightbox closing) handles it first. + const handled = escape(); + handled.preventDefault(); + document.dispatchEvent(handled); + assert.equal(shell.getSheet(), 'layers'); + // An open dialog keeps Escape for its native close. + document.openDialog = true; + document.dispatchEvent(escape()); + assert.equal(shell.getSheet(), 'layers'); + document.openDialog = false; + const plain = escape(); + document.dispatchEvent(plain); + assert.equal(shell.getSheet(), null); + assert.equal(plain.defaultPrevented, true); +}); + +test('share state and restores follow the preference, not layout collapse', () => { + const { shell, press, collapsed, document } = fixture(); + // Layout collapsed Street Level only for space; the user left it open. + const street = document.getElementById('street-level-panel'); + street.classList.add('layout-auto-collapsed'); + press('cameras'); + assert.equal(shell.sharedCollapsed('street-level-panel'), false); + press('cameras'); + assert.equal( + collapsed('street-level-panel'), + false, + 'not turned into a real collapse', + ); +}); + +test('layout settling after a sheet opens is part of opening', () => { + const { press, runFrames, collapsed, document } = fixture({ + expanded: ['street-level-panel'], + }); + press('cameras'); + // A rail layout pass on the next frame collapses Street Level. + document.getElementById('street-level-panel').classList.add('collapsed'); + runFrames(); + press('cameras'); + assert.equal(collapsed('street-level-panel'), false, 'put back as it was'); +}); + +test('the backdrop closes the open sheet', () => { + const { document, shell, press } = fixture(); + press('context'); + document + .getElementById('mobile-sheet-backdrop') + .dispatchEvent(new Event('click')); + assert.equal(shell.getSheet(), null); +}); + +test('surfaces that own the screen close sheets; scene playback does not', () => { + for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) { + const { shell, press, setBodyClass, collapsed } = fixture(); + press('layers'); + setBodyClass(mode); + assert.equal(shell.getSheet(), null, `${mode} closes the sheet`); + assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`); + press('layers'); + assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`); + } + // Scene STOP lives in the Scenes panel of the Layers sheet. + const playback = fixture(); + playback.setBodyClass('scene-playback-mode'); + playback.press('layers'); + assert.equal(playback.shell.getSheet(), 'layers'); +}); + +test('desktop widths never see the shell; crossing to desktop restores it', () => { + const desktop = fixture({ mobile: false }); + assert.equal(desktop.document.body.classList.contains('mobile-shell'), false); + desktop.press('layers'); + assert.equal(desktop.shell.getSheet(), null); + assert.equal( + desktop.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); + + const phone = fixture({ expanded: ['street-level-panel'] }); + assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/); + phone.press('cameras'); + phone.resize(false); + assert.equal(phone.shell.getSheet(), null); + assert.equal(phone.document.body.classList.contains('mobile-shell'), false); + assert.equal(phone.collapsed('cctv-panel'), true); + assert.equal(phone.collapsed('street-level-panel'), false); + assert.equal( + phone.viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('destroy puts panels back and removes the shell', () => { + const { document, shell, press, viewport, collapsed } = fixture(); + press('layers'); + shell.destroy(); + assert.equal(collapsed('data-panel'), true); + assert.equal(document.getElementById('mobile-tab-bar'), null); + assert.equal(document.getElementById('mobile-sheet-backdrop'), null); + assert.equal(document.body.classList.contains('mobile-shell'), false); + assert.equal(document.body.getAttribute('data-mobile-sheet'), null); + assert.equal(document.body.style.values.size, 0); + assert.equal( + viewport.getAttribute('content'), + 'width=device-width, initial-scale=1.0', + ); +}); + +test('the phone shell keeps the required attribution visible', () => { + // Whitespace-normalised, so formatter line wrapping cannot hide a rule. + const css = readFileSync( + new URL('./styles/mobile-shell.css', import.meta.url), + 'utf8', + ) + .replace(/\s+/g, ' ') + .replace(/\( /g, '(') + .replace(/ \)/g, ')'); + /** The declaration block of the rule whose selector list ends with `selector`. */ + const rule = (selector) => { + const end = css.indexOf(`${selector} {`); + assert.notEqual(end, -1, `missing rule ending in ${selector}`); + const open = css.indexOf('{', end); + return css.slice(open, css.indexOf('}', open)); + }; + const lift = (block) => + Number( + /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1], + ); + const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]); + const idleCredit = rule( + 'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + const dock = rule('body.mobile-shell #command-dock'); + const voice = rule('body.mobile-shell #command-dock #gev-voice-control'); + const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16; + // Idle: the credit line clears the voice bar, which clears the tab bar. + assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx); + // It must beat the dock's :has() placements, so the offset is !important. + assert.match(idleCredit, /\) !important;/); + assert.doesNotMatch( + idleCredit, + /display:\s*none|visibility:\s*hidden|opacity:\s*0/, + ); + // Open sheet: above everything the sheet covers, drawn above the sheets. + const sheetCredit = rule( + 'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits', + ); + assert.match( + sheetCredit, + /bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/, + ); + const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock'); + const railSheet = rule( + "body.mobile-shell[data-mobile-sheet='more'] #right-context-rail", + ); + assert.ok(z(sheetCredit) > z(openDock)); + assert.ok(z(sheetCredit) > z(railSheet)); +}); diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index e0bd37355..d920e8545 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -46,6 +46,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed = () => undefined, }) { Object.assign(this, elements, operations, { readHud, @@ -54,6 +55,7 @@ export class PanelChrome { readInitialShare, readScrollRestoreOwner, readDisplayScrollTop, + readSharedCollapsed, }); this._disposed = false; this._lifetime = new UiLifetime(); @@ -149,6 +151,9 @@ export class PanelChrome { } _collapsePanelOnEscape(event, panelId) { + // In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel. + if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet')) + return false; return collapsePanelOnEscape(event, { panel: document.getElementById(panelId), onChange: (collapsed, options) => @@ -250,7 +255,10 @@ export class PanelChrome { disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`), openDelayMs, closeDelayMs, - isActive: () => !this._disposed, + // A phone sheet holds its trays open; the tab bar closes them. + isActive: () => + !this._disposed && + !globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'), onChange: (collapsed, options) => this.setPanelCollapsed(panelId, collapsed, options), onEscape: (event) => this._collapsePanelOnEscape(event, panelId), @@ -380,7 +388,11 @@ export class PanelChrome { panelEl.classList.contains('cyber-accordion-collapsed') ? false : panelEl.classList.contains('collapsed'); - const entry = { id: spec.id, collapsed }; + // A panel the phone shell only borrowed for a sheet shares its own state. + const entry = { + id: spec.id, + collapsed: this.readSharedCollapsed(spec.id) ?? collapsed, + }; if (spec.pinnable) entry.pinned = panelEl.classList.contains('dock-pinned'); specs.push(entry); @@ -439,7 +451,9 @@ export class PanelChrome { !restore && !nextCollapsed && document.documentElement?.dataset.uiTheme === 'cyber' && - panelEl.parentElement === this._rightPanelStack + panelEl.parentElement === this._rightPanelStack && + // Phone sheets show several rail panels at once. + !document.body?.classList?.contains('mobile-shell') ) { for (const peer of this._rightPanelStack.children) { if ( diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 9be9ffbaf..270ba61f5 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => { }); test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => { - for (const mobile of [false, true]) { + for (const mobile of [false]) { const f = fixture('right', { mobile, hud: { visible: true, variant: 'cyber' }, @@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the } }); +test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => { + // A sheet (for example Cameras: CCTV with Street Level) shows several of the + // rail's panels at once; the accordion would collapse all but one. + const f = fixture('right', { + mobile: true, + hud: { visible: true, variant: 'cyber' }, + }); + f.expand(f.first, 250); + f.expand(f.second, 250); + f.options.preferredPanelId = f.second.id; + f.run(); + assert.equal(f.first.classList.contains('collapsed'), false); + assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false); + assert.equal(f.second.classList.contains('collapsed'), false); +}); + test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => { const f = fixture('right', { hud: { visible: true, variant: 'cyber' } }); f.expand(f.first, 250); diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 836fd75d4..5d3e5e40c 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -91,7 +91,9 @@ export function layoutRightPanelRail({ } // Normalize restored state and theme entry without overwriting saved panel // preferences. This marker is distinct from Tactical's space-based collapse. - if (hud.variant === 'cyber') { + // Phones present the rail as tab sheets that show several panels at once. + const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; + if (hud.variant === 'cyber' && !isMobile) { const expanded = panels.filter( (panel) => !panel.classList.contains('collapsed'), ); @@ -111,16 +113,19 @@ export function layoutRightPanelRail({ onCollapse(panel); } } - const isMobile = windowRef.matchMedia('(max-width: 720px)').matches; const hasExpandedPanel = panels.some( (panel) => !panel.classList.contains('collapsed') && (!isMobile || panel.id !== 'pp-toggles'), ); - const exclusive = shouldHideCollapsedRightPanels({ - hudVariant: hud.variant, - hasExpandedPanel, - }); + // Phones present the rail as tab sheets (mobileShell.js), which choose what + // shows; hiding collapsed siblings would empty a sheet and its a11y tree. + const exclusive = + !isMobile && + shouldHideCollapsedRightPanels({ + hudVariant: hud.variant, + hasExpandedPanel, + }); stack.classList.toggle('layout-exclusive', exclusive); for (const panel of panels) { if (exclusive && panel.classList.contains('collapsed')) diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css new file mode 100644 index 000000000..4cf32513a --- /dev/null +++ b/src/ui/styles/mobile-shell.css @@ -0,0 +1,415 @@ +/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ───────── + * The globe is full screen. A bottom tab bar opens one sheet; the sheet shows + * the panels of that tab from the rails or dock, everything else stays hidden. + */ +:root { + --mobile-tab-height: 58px; + --mobile-bottom-inset: env(safe-area-inset-bottom, 0px); + --mobile-top-inset: env(safe-area-inset-top, 0px); + --mobile-tab-space: calc( + var(--mobile-tab-height) + var(--mobile-bottom-inset) + ); + --mobile-sheet-max-height: min(62vh, calc(100vh - 168px)); + --mobile-dock-sheet-max-height: 34vh; + --mobile-rail-under-dock-max-height: 40vh; +} + +/* Dynamic viewport units keep sheets inside the area a mobile browser's + toolbars leave visible. */ +@supports (height: 1dvh) { + :root { + --mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px)); + --mobile-dock-sheet-max-height: 34dvh; + --mobile-rail-under-dock-max-height: 40dvh; + } +} + +#mobile-tab-bar, +#mobile-sheet-backdrop { + display: none; +} + +body.mobile-shell #mobile-tab-bar { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 170; + display: grid; + grid-template-columns: repeat(5, 1fr); + height: var(--mobile-tab-space); + padding: 0 max(4px, env(safe-area-inset-right, 0px)) + var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px)); + background: rgba(8, 14, 22, 0.94); + border-top: 1px solid var(--glass-border); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +body.mobile-shell .mobile-tab { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 0; + padding: 6px 0 4px; + color: var(--text-secondary); + background: none; + border: 0; + font: 500 10px/1.2 var(--font-sans); + letter-spacing: 0.04em; + -webkit-tap-highlight-color: transparent; +} + +body.mobile-shell .mobile-tab-icon { + font-size: 24px; + line-height: 1; +} + +body.mobile-shell .mobile-tab[aria-expanded='true'] { + color: var(--accent); +} + +body.mobile-shell .mobile-tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -4px; +} + +body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) { + position: fixed; + inset: 0 0 var(--mobile-tab-space); + z-index: 155; + display: block; + background: rgba(0, 0, 0, 0.18); +} + +/* ── Top bar: logo left, globe actions right ───────────────────────────── */ +body.mobile-shell #title-bar { + top: calc(var(--mobile-top-inset) + 12px); + left: 14px; +} + +body.mobile-shell #title-bar h1 > :not(.title-logo), +body.mobile-shell #title-bar .subtitle { + display: none; +} + +body.mobile-shell #top-center-actions { + top: calc(var(--mobile-top-inset) + 10px); + right: 12px; +} + +/* ── HUD: one compact readout under the top bar ────────────────────────── */ +body.mobile-shell #intel-hud .hud-edge, +body.mobile-shell #intel-hud .hud-corner, +body.mobile-shell #intel-hud .hud-top-bar, +body.mobile-shell #intel-hud .hud-bottom-bar { + display: none; +} + +body.mobile-shell #intel-hud .hud-bottom-left { + top: calc(var(--mobile-top-inset) + 62px); + bottom: auto; + left: 16px; + font-size: 9px; + opacity: 0.8; +} + +/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */ +body.mobile-shell #left-panel-stack, +body.mobile-shell #right-context-rail { + display: none; +} + +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack, +body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='context'] #right-context-rail, +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + position: fixed; + top: auto; + right: 0; + bottom: var(--mobile-tab-space); + left: 0; + z-index: 160; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + width: auto; + max-width: none; + max-height: var(--mobile-sheet-max-height); + padding: 14px 10px 10px; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; + transform: none; +} + +body.mobile-shell #left-panel-stack > *, +body.mobile-shell #right-context-rail > *, +/* Rail rules show expanded Weather and Recent Imagery by id; on phones they + belong to the Context sheet only. */ +body.mobile-shell #right-context-rail > #weather-panel, +body.mobile-shell #right-context-rail > #recent-imagery-panel { + display: none; +} + +/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD + hiding collapsed siblings) never take a panel out of an open sheet. */ +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel, +body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #cctv-panel, +body.mobile-shell[data-mobile-sheet='cameras'] + #right-context-rail + > #street-level-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #global-context-panel, +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #weather-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='context'] + #right-context-rail + > #recent-imagery-panel:not([hidden]), +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles { + position: relative; + inset: auto; + display: block; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */ +body.mobile-shell #command-dock { + /* The dock's tray rules size voice by this basis; a column makes it a height. */ + --dock-voice-width: auto; + top: auto; + bottom: calc(var(--mobile-tab-space) + 10px); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + height: auto; + min-height: 0; + max-height: none; + padding: 0 10px; + pointer-events: none; + background: none; + border: 0; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +/* The desktop dock centres itself with left: 50% and a translate under + :has() selectors that outrank any phone selector; on phones it spans the + width, so these few geometry properties must win. */ +body.mobile-shell #command-dock { + right: 0 !important; + left: 0 !important; + width: auto !important; + max-width: none !important; + transform: none !important; + translate: none !important; +} + +body.mobile-shell #command-dock > * { + pointer-events: auto; +} + +body.mobile-shell #location-bar, +body.mobile-shell #control-panel { + display: none; +} + +body.mobile-shell[data-mobile-sheet] #command-dock { + z-index: 165; + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px); +} + +body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar, +body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel { + position: relative; + inset: auto; + display: flex; + flex: none; + width: 100%; + max-width: none; + margin: 0; + transform: none; +} + +/* Search and More: the dock becomes the top of the sheet, flush with the rail + sheet below it (More) or alone above the tab bar (Search). */ +body.mobile-shell[data-mobile-sheet='search'] #command-dock, +body.mobile-shell[data-mobile-sheet='more'] #command-dock { + bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px)); + align-items: stretch; + max-height: var(--mobile-dock-sheet-max-height); + padding: 14px 10px 8px; + overflow-x: hidden; + overflow-y: auto; + pointer-events: auto; + background: rgba(8, 14, 22, 0.96); + border: 1px solid var(--glass-border); + border-bottom: 0; + border-radius: 18px 18px 0 0; +} + +body.mobile-shell[data-mobile-sheet='more'] #right-context-rail { + max-height: var(--mobile-rail-under-dock-max-height); + padding-top: 4px; + border-top: 0; + border-radius: 0; +} + +/* Presets and Location show their popover inline instead of as a floating + tray; the dock scrolls, so a tray above it would be clipped. */ +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .panel-inner, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel-popover, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .location-inner, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-popover { + position: static; + inset: auto; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: none; + max-height: none; + margin: 0; + opacity: 1; + visibility: visible; + transform: none; + pointer-events: auto; +} + +/* The tab names the sheet; the tray toggle and pin have nothing to do here. */ +body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle, +body.mobile-shell[data-mobile-sheet='more'] + #command-dock + #control-panel + .dock-pin-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar-toggle, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .panel-collapse-btn, +body.mobile-shell[data-mobile-sheet='search'] + #command-dock + #location-bar + .dock-pin-btn { + display: none; +} + +body.mobile-shell[data-mobile-sheet='more'] #style-buttons { + flex-wrap: wrap; +} + +/* Search: the field takes the full width; the place pills scroll beneath it. */ +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row { + flex-wrap: wrap; + row-gap: 8px; +} + +body.mobile-shell[data-mobile-sheet='search'] + #location-bar + .location-search-wrap { + flex: 1 1 100%; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-search { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + +body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills { + flex: 1 1 100%; + max-width: 100%; + overflow-x: auto; +} + +/* With a sheet open, voice steps aside; Search and More own the dock. */ +body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control { + display: none; +} + +body.mobile-shell #command-dock #gev-voice-control { + position: relative; + inset: auto; + flex: none; + align-self: center; + width: min(280px, 80vw); + height: 5.35rem; + margin: 0; + transform: none; +} + +/* ── Credits and corner chips clear the tab bar ────────────────────────── */ +/* While the tab bar shows, the credit line sits above the voice bar. The + dock's placements (including the Minimal HUD's :has() variant) outrank any + phone selector, so the offset must win. Clean view keeps its own corner. */ +body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) + #cesium-credits { + right: auto; + bottom: calc(var(--mobile-tab-space) + 106px) !important; + left: 10px; + max-width: calc(100vw - 20px); + transform-origin: left bottom; + scale: 0.8; +} + +/* An open sheet lifts the required attribution above everything it covers. */ +body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not( + .cockpit-mode + ):not(.ui-clean-view) + #cesium-credits { + bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important; + z-index: 166; +} + +/* The view switcher clears the tab bar and the voice bar, opposite the credit. */ +body.mobile-shell:not(.cockpit-mode) #view-switcher { + right: 12px; + bottom: calc(var(--mobile-tab-space) + 106px); +} + +body.mobile-shell #corner-actions { + top: calc(var(--mobile-top-inset) + 60px); + right: 12px; + bottom: auto; +} + +/* Full-screen modes hide the phone chrome like the desktop chrome. Scene + playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */ +body.mobile-shell.ui-clean-view #mobile-tab-bar, +body.mobile-shell.recording-mode #mobile-tab-bar, +body.mobile-shell.cockpit-mode #mobile-tab-bar { + display: none; +} + +body.mobile-shell.ui-clean-view #command-dock, +body.mobile-shell.cockpit-mode #command-dock { + bottom: calc(var(--mobile-bottom-inset) + 10px); +} diff --git a/style.css b/style.css index 0117150ce..1b55c459f 100644 --- a/style.css +++ b/style.css @@ -33,4 +33,5 @@ @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/cjk.css'; +@import './src/ui/styles/mobile-shell.css'; @import './src/ui/styles/cyber.css'; From 10bb2c6a36e46a815848f7e3cc19afa73da07b3f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 083/178] test(qa): open the phone tab sheets in phone-width harness checks At 720px and narrower the panels live in the phone shell's tab sheets, so the camera, scene and map-source tray harnesses open the matching tab (from the keyboard where they check focus rings) before measuring, wait for the shell to follow a viewport change, measure the container that actually scrolls, and run the pin and compact-wing checks at 800px where the narrow desktop dock still exists. --- scripts/qa-camera-controls.mjs | 3 ++ scripts/qa-map-source-tray.mjs | 87 ++++++++++++++++++++++++++++++++-- scripts/qa-scene-controls.mjs | 3 ++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index 7eb2aeca7..c2d2b5646 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -370,6 +370,9 @@ try { await page.screenshot({ path: 'qa-shots/camera-controls/angle.png' }); await page.setViewport({ width: 390, height: 844 }); await new Promise((resolve) => setTimeout(resolve, 700)); + // Phones present CCTV in the Cameras tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="cameras"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: 'qa-shots/camera-controls/narrow.png' }); check( 'narrow camera panel remains within the viewport', diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs index 17b19f4c9..2876e0cac 100644 --- a/scripts/qa-map-source-tray.mjs +++ b/scripts/qa-map-source-tray.mjs @@ -156,14 +156,50 @@ const pressTabs = async (count, backwards = false) => { // The row scrolls smoothly when native Tab brings an edge button into view. await new Promise((resolve) => setTimeout(resolve, 250)); }; +// Phones (≤720px) present the dock's Presets inline in the More tab's sheet. +// It opens from the keyboard, as a keyboard user would, so the focus it moves +// into the sheet shows a ring. +const openPhoneMoreSheet = async () => { + const tab = '#mobile-tab-bar [data-mobile-sheet="more"]'; + if (await page.evaluate(() => document.body.dataset.mobileSheet === 'more')) { + await page.focus(tab); + await page.keyboard.press('Enter'); + } + await page.focus(tab); + await page.keyboard.press('Enter'); + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +}; + +// Media-query changes reach the page on a rendering step; wait until the +// phone shell agrees with the viewport before measuring. +const waitForShellSettled = () => page.waitForFunction(() => ( + document.body.classList.contains('mobile-shell') + === matchMedia('(max-width: 720px)').matches +), { timeout: 10_000, polling: 100 }); + +// The dock slides back into place when the layout leaves the phone shell. +const waitForDockSettled = () => page.waitForFunction(() => ( + !document.getElementById('command-dock').getAnimations({ subtree: true }) + .some((animation) => animation.playState === 'running') +), { timeout: 5_000 }); + const checkResponsiveStyleFocus = async (width) => { // The disclosure is only the starting boundary. Every style target below is // reached by actual Tab events, never by focusing the style under test. const selectedMapBefore = await page.evaluate(() => ( window.__godsEyeView.styleManager.mapStackController.getActiveId() )); - await page.focus('#control-panel-toggle'); - await pressTabs(2); // disclosure -> pin -> Normal + if (width <= 720) { + // Phones show Presets in the More tab's sheet, which focuses its first + // visible control (Normal) when it opens; the tray toggle and pin are + // desktop chrome there. + await openPhoneMoreSheet(); + } else { + await page.focus('#control-panel-toggle'); + await pressTabs(2); // disclosure -> pin -> Normal + } for (const [index, style] of ['normal', 'thermal', 'snow'].entries()) { if (index) await pressTabs(3); const state = await styleFocusMetrics(); @@ -215,7 +251,16 @@ const layerFocusState = async (id) => ({ ...await page.evaluate((layerId) => { const manager = window.__godsEyeView.dataManager; const button = document.querySelector(`[data-layer-id="${layerId}"] .data-toggle-btn`); - const list = document.getElementById('data-toggles'); + // The list scrolls on its own on desktop; in a phone sheet the sheet + // scrolls instead. Measure whichever container actually scrolls. + let list = document.getElementById('data-toggles'); + for (let node = list; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === 'auto' || overflow === 'scroll') && node.scrollHeight > node.clientHeight) { + list = node; + break; + } + } return { lifecycle: manager.getLayerLifecycleState(layerId), feedState: button?.dataset.feedState, label: button?.textContent.trim(), @@ -1146,6 +1191,8 @@ try { await page.setViewport({ width: 620, height: 900, deviceScaleFactor: 1 }); await new Promise((resolve) => setTimeout(resolve, 180)); + await waitForShellSettled(); + await openPhoneMoreSheet(); const at620 = await trayMetrics(); check( '620 px tray uses two rows without clipping', @@ -1179,7 +1226,13 @@ try { await page.screenshot({ path: path.join(shotsDir, '480-open.png') }); await checkResponsiveStyleFocus(480); + // Pinning and the compact wing are the narrow desktop dock (721–900 px); + // phones use the tab bar instead. + await page.setViewport({ width: 800, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + await waitForDockSettled(); await page.click('.dock-pin-btn[data-pin-target="control-panel"]'); + await waitForDockSettled(); await page.click('#control-panel-toggle'); const cdp = await page.createCDPSession(); await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); @@ -1374,6 +1427,27 @@ try { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720) { + // Phones present Location as the Search tab's sheet: the keyboard opens + // it onto the field, and Escape returns focus to the tab. + const searchTab = '#mobile-tab-bar [data-mobile-sheet="search"]'; + await page.focus(searchTab); + await page.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const field = await focusMetrics('#location-search'); + check(`Search: ${width} px keyboard opens the sheet on the location field`, + field.focusedId === 'location-search' && field.visible && field.insideViewport, + JSON.stringify(field)); + await page.screenshot({ path: path.join(shotsDir, `${width}-search-open.png`) }); + await page.keyboard.press('Escape'); + await new Promise((resolve) => setTimeout(resolve, 250)); + const tab = await focusMetrics(searchTab); + const closedSheet = await page.evaluate(() => !document.body.dataset.mobileSheet); + check(`Search: ${width} px Escape closes the sheet with a visible ring on its tab`, + closedSheet && hasVisibleControlFocus(tab), JSON.stringify({ closedSheet, tab })); + continue; + } await page.evaluate(() => { const manager = window.__godsEyeView.styleManager; manager.setPanelCollapsed('control-panel', true, { persist: false, syncShare: false }); @@ -1489,6 +1563,12 @@ try { if (dataSetup.ready) { for (const width of [1000, 620, 480]) { await page.setViewport({ width, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); + if (width <= 720 && !(await page.evaluate(() => document.body.dataset.mobileSheet === 'layers'))) { + // Phones present Data Layers in the Layers tab's sheet. + await page.focus('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await page.keyboard.press('Enter'); + } await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); // Resizing schedules rail placement on animation frames. Wait for that // pass and its CSS transitions before comparing a focus rectangle with @@ -1549,6 +1629,7 @@ try { ))); await page.setViewport({ width: 1000, height: 900, deviceScaleFactor: 1 }); + await waitForShellSettled(); await page.evaluate(() => window.__godsEyeView.styleManager.setPanelCollapsed('data-panel', false, { persist: false, syncShare: false })); await page.focus('#data-panel .panel-collapse-btn'); const transitionTab = await tabTo(`[data-layer-id="${dataSetup.transitionId}"] .data-toggle-btn`); diff --git a/scripts/qa-scene-controls.mjs b/scripts/qa-scene-controls.mjs index c7068f26c..ab92c128d 100644 --- a/scripts/qa-scene-controls.mjs +++ b/scripts/qa-scene-controls.mjs @@ -329,6 +329,9 @@ try { await page.keyboard.press('Escape'); await page.screenshot({ path: path.join(shots, 'desktop.png') }); await page.setViewport({ width: 390, height: 844 }); + // Phones present Scenes in the Layers tab's sheet. + await page.click('#mobile-tab-bar [data-mobile-sheet="layers"]'); + await new Promise((resolve) => setTimeout(resolve, 400)); await page.screenshot({ path: path.join(shots, 'narrow.png') }); check( 'Scene controls stay inside the narrow viewport', From 9e0c0fe9603ff1e157fe92ba4bb0de5a2ee5b38b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 19:54:43 -0700 Subject: [PATCH 084/178] docs: describe the phone layout --- docs/CURRENT-STATE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 714cb30f1..b8af5c646 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1379,6 +1379,32 @@ The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. +## Phone layout + +At 720px and narrower, `src/ui/mobileShell.js` keeps the globe full screen and +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 +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. + +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 +panel the user changed inside the sheet stays as they left it. Escape closes the +sheet before a panel's own Escape handling, and the dock's hover auto-close does +not apply inside a sheet. +Cockpit, recording and clean view close the sheet and hide the tab bar; scene +playback keeps both so its STOP control in the Scenes panel stays reachable. The +right rail neither hides collapsed siblings nor applies Cyber's one-panel +accordion at these widths, since the sheet chooses what shows. The Cesium and Google attribution stays visible above the +voice control, and above an open sheet. The page draws under the notch and +system bars (`viewport-fit=cover`) and the shell's chrome uses safe-area insets. +Wider viewports are unchanged, and crossing to them closes any open sheet. + ## Surface keyboard lifecycle Surface Tab cycling excludes controls with a negative tabindex, native disabled From e7cdd47d8df21c2215aa869a2b5334c48ca8b8bd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 22 Oct 2026 22:12:36 -0700 Subject: [PATCH 085/178] chore: changelog entry for the phone layout --- changelog.d/1325-phone-layout.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1325-phone-layout.md diff --git a/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md new file mode 100644 index 000000000..c1653df62 --- /dev/null +++ b/changelog.d/1325-phone-layout.md @@ -0,0 +1,4 @@ +- Add a phone layout for viewports 720px and narrower: the globe stays full + screen and a bottom tab bar (Layers, Cameras, Context, Search, More) opens one + panel sheet at a time, keeping attribution visible and scene STOP reachable. + Adapted from the phone tab-shell in #203 by blearymoth (samehkhamis, #1325). From 090eeb9b115ed05f498a47f372d70049e4fada4a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 086/178] Settle pending Traffic height work when the population empties A closure that removes the last animated points mid height correction released the render hold, and animate() then returned before easeHeights() could run, leaving _heightPending (and getStats().loading) true. Settle every retained road on its corrected heights before the hold is released. Adds a regression combining height correction, a late closure and an empty population. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1281-traffic-issue-fix.md | 1 + src/layers/traffic/animation.js | 3 +++ src/layers/traffic/motion.test.mjs | 31 +++++++++++++++++++++++++++ src/layers/traffic/retention.js | 25 ++++++++++++++++++++- 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md index f9477de48..3ce673038 100644 --- a/changelog.d/1281-traffic-issue-fix.md +++ b/changelog.d/1281-traffic-issue-fix.md @@ -5,3 +5,4 @@ - Isolate the Traffic keyless browser scenario from the keyed page’s workers and intercepted cache (asturksever, #1281). - Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1281). - Intercept only Traffic provider URLs, leaving Cesium worker and module loading untouched (asturksever, #1281). +- Settle an unfinished Traffic height correction when the last animated point leaves, so the layer stops reporting loading once its render hold is released (asturksever, #1281). diff --git a/src/layers/traffic/animation.js b/src/layers/traffic/animation.js index 4d9f6c4b3..6c58a57a4 100644 --- a/src/layers/traffic/animation.js +++ b/src/layers/traffic/animation.js @@ -25,6 +25,9 @@ export function createAnimation({ layerState._enabled && (layerState._dots.length || layerState._heatJamPrim), ); + // Height easing only runs in a held frame. Settle it before releasing, so + // a population emptied mid-correction cannot report loading forever. + if (!needed && layerState._heightPending) parts.retention.finishHeights(); if (needed === Boolean(layerState._renderHeld)) return; layerState._renderHeld = needed; if (needed) { diff --git a/src/layers/traffic/motion.test.mjs b/src/layers/traffic/motion.test.mjs index 6338eb10f..ef60e478f 100644 --- a/src/layers/traffic/motion.test.mjs +++ b/src/layers/traffic/motion.test.mjs @@ -565,6 +565,37 @@ test('provisional opposite-direction heights cannot overwrite a measured sibling assert.notEqual(forward.waypoints, reverse.waypoints); }); +test('a closure that empties the population mid-correction settles heights and clears loading before release', (t) => { + const h = setup(t); + h.parts.retention.reconcile(h.roads(), 450, true); + h.settle(); + assert.equal(h.state._renderHeld, true); + const road = h.state._roads[0]; + // Republishing the sole road starts a height correction. + const corrected = h.roads(0, 120); + h.parts.retention.reconcile(corrected, 450, true); + assert.equal(h.state._heightPending, true); + assert.equal(road.heightMoving, true); + // The closure arrives before the next population pass, which then removes + // the final points after their fade, long before the correction finishes. + corrected[0].flow = { level: 0, closure: true }; + for (let i = 0; i < 20 && h.state._dots.length; i++) h.frame(); + assert.equal(h.state._dots.length, 0); + assert.equal(h.state._heatJamPrim, null); + assert.equal(h.state._renderHeld, false); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._populationPending, false); + assert.equal(road.heightMoving, false); + assert.ok( + Math.abs(C.Cartographic.fromCartesian(road.waypoints[0]).height - 120) < + 0.01, + ); + // Frames driven by other layers stay idle and cannot revive the work. + h.frame(); + assert.equal(h.state._heightPending, false); + assert.equal(h.state._renderHeld, false); +}); + test('large height corrections obey a screen-pixel bound and reuse projection scratch objects', (t) => { const h = setup(t); h.state._viewer.scene = { diff --git a/src/layers/traffic/retention.js b/src/layers/traffic/retention.js index ec0762b5c..a987f64b0 100644 --- a/src/layers/traffic/retention.js +++ b/src/layers/traffic/retention.js @@ -242,6 +242,29 @@ export function createRetention({ state, parts }) { road.heightMoving = moving; } } + /** + * Settle every retained road on its corrected heights at once. Easing only + * runs inside the held animation frame, so once nothing is left to animate + * the remaining correction would otherwise stay pending indefinitely. + * Published roads whose record was just dropped are settled too, so a later + * held frame cannot revive their correction. + */ + function finishHeights() { + const roads = new Set(state._roads); + for (const record of records.values()) roads.add(record.road); + for (const road of roads) { + if (!road.heightMoving) continue; + for (let i = 0; i < road.waypoints.length; i++) + Cesium.Cartesian3.clone(road.targetWaypoints[i], road.waypoints[i]); + for (let i = 0; i < road.segmentDist.length; i++) + road.segmentDist[i] = Cesium.Cartesian3.distance( + road.waypoints[i], + road.waypoints[i + 1], + ); + road.heightMoving = false; + } + state._heightPending = false; + } function clear() { if (timer !== null) { if (typeof cancelAnimationFrame === 'function') @@ -253,5 +276,5 @@ export function createRetention({ state, parts }) { state._populationPending = false; state._heightPending = false; } - return { reconcile, easeHeights, clear, refreshCounts }; + return { reconcile, easeHeights, finishHeights, clear, refreshCounts }; } From 1b7498820ffca1ce1418f94f6da7124226016fb4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 087/178] Remove the unused trafficLogs collector from Traffic QA Co-Authored-By: Claude Opus 5.5 --- scripts/qa-traffic.mjs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/qa-traffic.mjs b/scripts/qa-traffic.mjs index a6c0bdc53..9770999df 100644 --- a/scripts/qa-traffic.mjs +++ b/scripts/qa-traffic.mjs @@ -161,20 +161,15 @@ async function main() { await focusSession.send('Emulation.setFocusEmulationEnabled', { enabled: true }); await page.setViewport({ width: 1440, height: 900 }); - // Track flow-tile requests + traffic console lines for (ii)/(iii)/(v). + // Track flow-tile requests for the (iii) budget check. const flowRequests = []; page.on('request', (req) => { if (req.url().includes('/api/tomtom/flow/')) flowRequests.push(req.url()); }); - const trafficLogs = []; const pageErrors = []; if (FIXTURES) await installTrafficRoutes(page, { origin: appUrl.origin, fixtures: true, onError: error => pageErrors.push(error.message) }); page.on('pageerror', error => { pageErrors.push(error.message); console.error('PAGE ERROR:', error.message); }); page.on('console', message => { if (message.type() === 'error') console.error('BROWSER ERROR:', message.text()); }); - page.on('console', (msg) => { - const t = msg.text(); - if (t.includes('[Data:Traffic]')) trafficLogs.push(t); - }); console.log('Loading app...'); await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 }); @@ -248,7 +243,6 @@ async function main() { // ── (ii) C4 oblique bounds ─────────────────────────────────────────────── console.log('\n(ii) C4 — oblique fetch bounds land at the look-at point...'); - trafficLogs.length = 0; const oblique = await settleTraffic(page, { lon: -97.72, lat: 30.245, height: 2500, heading: 315, pitch: -20 }, { minCount: 100 }); { const bounds = oblique?.roadBounds; From e974ce096a750462df54bc0890344b0b48571cec Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:12:27 +0100 Subject: [PATCH 088/178] Qualify the Traffic render-hold note for failed loads A failed load leaves no hold only when no animated content is retained. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1851702dd..109c72d73 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -14,7 +14,7 @@ existing batch size and one bounded upstream chunk. Larger requests return HTTP 413 before disk-cache loading or upstream work; malformed coordinates remain 400. The browser continues to split larger workloads into sequential 64-point requests. -Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty, failed, and high-altitude views release the hold; population admission reacquires it and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. +Traffic holds continuous rendering only while enabled with animated dots or a pulsing jam heat-line. Empty and high-altitude views release the hold. A failed load leaves no hold only when no animated content is retained: if an earlier load succeeded, ingestion keeps its animated points and the hold stays. Population admission reacquires the hold and resets animation time. Clear, disable, and destroy also release it. Retained fading dots keep animation alive until they are removed. When the last point leaves mid height correction, the correction is settled at once, before the hold is released, so it cannot keep the layer reporting loading. Shared distance checks use `src/geoDistance.js` with `{ lat, lon }` points and the IUGG mean spherical Earth radius, 6371.0088 km. `greatCircleKm` is From 07d6417446f4e1f724e392147bacce73713f8093 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:22:42 +0100 Subject: [PATCH 089/178] 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 090/178] 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 730ba570738658d111d336477973902751fdd895 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:26:02 +0100 Subject: [PATCH 091/178] Report unsupported military track history in get_aircraft_track The standalone military source no longer has getTrack, so the MCP get_aircraft_track tool threw a TypeError and MCP advised a retry. It now returns an `unsupported` ToolError when the selected feed has no getTrack, while custom sources that supply history still work. Tests cover the default composition through the catalog and MCP. Also drop the unused epoch import and update the CURRENT-STATE track sections to describe local trails and 503 history_unavailable. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 6 +-- src/sources/live/standalone.js | 1 - src/tools/queries/aviation.js | 19 +++++++--- src/tools/queries/aviation.test.mjs | 57 +++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 48e44c016..739d76cc5 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. -The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. This does not restore historical coverage or download bulk daily archives. +The standalone military source no longer requests the unavailable, undocumented ADSB.lol trace endpoint. Tracking retains its locally accumulated trail, and custom sources may still supply optional getTrack history. The old HTTP route validates the identifier then reports 503 history_unavailable without any upstream request, rather than forwarding a misleading 403. The `get_aircraft_track` tool reports `unsupported` for a military feed without `getTrack` instead of a retry-later failure. This does not restore historical coverage or download bulk daily archives. 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 @@ -4713,7 +4713,7 @@ silently demoting every later lookup for the session. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames. - **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted. -- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket) and `/api/military/track` (tar1090 readsb, ~24h history, ODbL — credit adsb.lol). +- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request. - Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera. ### Optional Overpass configuration @@ -5277,7 +5277,7 @@ pins and share links already supply complete boxes. - `/api/overpass` is bounded by body/response caps, per-client/global rate limits, concurrency limits, operator-configured upstreams, in-flight dedupe, cache bounds, and static validation that every selector is spatially bounded. - `/api/military-installations` uses an independent limiter with the same 90-per-client/300-global one-minute bounds, so viewport installation refreshes never consume `/api/overpass` annotation capacity. - `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors. -- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (60s cache, 5MB cap, ODbL attribution required in UI). +- Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - Realtime debug logs redact API keys, bearer tokens, client secrets, and image data URLs before writing to disk; request bodies are size-capped. - `/api/realtime/debug-log` enforces an always-on 120-per-client/400-global one-minute limiter (not the opt-in `GEV_RATELIMIT_OPENAI_PER_MIN` bucket the cost-bearing OpenAI routes share), appends asynchronously through a serialized queue, and rotates `realtime-conversations.jsonl` at 32 MB keeping one prior generation, so the sink is bounded at twice that regardless of session length. A malformed record answers 400 and a failed write 500, both with a fixed message. diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 7311c492d..3f2c73b3b 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -1,5 +1,4 @@ import { - epoch, finite, httpError, LiveSourceError, diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index a5f4babf0..153e36e03 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -333,13 +333,20 @@ export const getAircraftTrack = defineTool({ requires: ['aircraft'], async run(args, { services, signal }) { const icao24 = args.icao24.toLowerCase(); + const source = feedFor(services, args.military); + // History is optional: the standalone military source has none. + if (typeof source.getTrack !== 'function') + throw new ToolError( + 'unsupported', + args.military + ? 'Track history is not available for the military aircraft feed' + : 'Track history is not available for the aircraft feed', + ); // A track server answers 404 for an aircraft it has no positions for. - const track = await feedFor(services, args.military) - .getTrack(icao24, { signal }) - .catch((error) => { - if (error?.status === 404) return { records: [] }; - throw error; - }); + const track = await source.getTrack(icao24, { signal }).catch((error) => { + if (error?.status === 404) return { records: [] }; + throw error; + }); const points = [...track.records].sort( (a, b) => a.observedAtMs - b.observedAtMs, ); diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 1af1352c0..484719790 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -3,6 +3,8 @@ import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; import { airlineDesignator } from './aviation.js'; import { LiveSourceError } from '../../sources/live/contract.js'; +import { createMcpServer } from '../mcp/index.js'; +import { createToolServices } from '../services.js'; const record = (id, latitude, longitude, extra = {}) => ({ id, @@ -242,6 +244,61 @@ test('tracks are ordered and thinned to 200 points', async () => { ); }); +test('military track history is unsupported by the default composition and kept for custom sources', async () => { + const requests = []; + const services = createToolServices({ + fetchImpl: async (url) => { + requests.push(String(url)); + throw new Error('no request expected'); + }, + appUrl: 'http://localhost:5173', + }); + const catalog = composeCatalog({ tools: coreTools, services }); + await assert.rejects( + catalog.call('get_aircraft_track', { icao24: 'AE0001', military: true }), + (error) => + error.name === 'ToolError' && + error.code === 'unsupported' && + /military/.test(error.message), + ); + const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'get_aircraft_track', + arguments: { icao24: 'ae0001', military: true }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unsupported'); + assert.doesNotMatch(result.content[0].text, /try again|later/i); + assert.deepEqual(requests, []); + + const track = [ + { + latitude: 37.6, + longitude: -122.3, + observedAtMs: Date.UTC(2026, 0, 1), + baroAltitudeM: 3000, + onGround: false, + }, + ]; + const custom = feed(military, { track }); + const withHistory = composeCatalog({ + tools: coreTools, + services: { aircraft: feed(civil), military: custom }, + }); + const kept = await withHistory.call('get_aircraft_track', { + icao24: 'AE0001', + military: true, + }); + assert.equal(custom.calls[0], 'ae0001'); + assert.equal(kept.data.total, 1); + assert.equal(kept.data.view.follow.kind, 'military_aircraft'); +}); + test('aircraft info combines type and route lookups', async () => { const aircraft = feed(civil, { enrichment: { From 0b5b897c114e5b1ae9bbd1678f28fba6b7c6ebb2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:26:41 +0100 Subject: [PATCH 092/178] Allow active TLE group, admit proxied same-origin requests, keep longest cooldown - CelesTrak allowlist includes `active`, which Space Missions' getActiveTle() requests; unknown groups still answer 400. - The CelesTrak gate uses admitSameSiteKeyless so the documented reverse proxy keeps working while foreign/opaque Origins and cross-site Sec-Fetch-Site are still refused with 403. - A provider failure never shortens an existing cooldown (CelesTrak and Launch Library keep the later deadline). - Tests: real getActiveTle() through the middleware, proxied app request vs unrelated Origin, concurrent failures finishing in both orders. - docs/CURRENT-STATE.md: eight groups, keyless gate, non-shortening cooldown. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 15 +-- server/providers/space/celestrak.js | 15 ++- server/providers/space/launch-library.js | 8 +- src/proxyErrorResponses.test.mjs | 1 - src/tooling/spaceProviders.test.mjs | 126 ++++++++++++++++++++++- 5 files changed, 151 insertions(+), 14 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 870eb51aa..82fc9201b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -9,12 +9,15 @@ 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. -CelesTrak accepts only the seven satellite groups used by the app and gates -requests before provider work. CelesTrak and Launch Library failures start a -provider-wide cooldown: Retry-After seconds or HTTP dates are bounded to 5 seconds -through 30 minutes, with a 60-second fallback. During cooldown dated last-good -data is served with stale provenance, otherwise the proxy returns a bounded -error without another upstream attempt. Error bodies are cancelled before reading. +CelesTrak accepts only the eight satellite groups used by the app (the seven +satellite-layer groups plus `active` for Space Missions) and gates cross-site +requests before provider work with the keyless same-site policy, so the +documented reverse proxy still works. CelesTrak and Launch Library failures +start a provider-wide cooldown that a later failure never shortens: Retry-After +seconds or HTTP dates are bounded to 5 seconds through 30 minutes, with a +60-second fallback. During cooldown dated last-good data is served with stale +provenance, otherwise the proxy returns a bounded error without another upstream +attempt. Error bodies are cancelled before reading. 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/server/providers/space/celestrak.js b/server/providers/space/celestrak.js index 315e9b3f0..013fce1b1 100644 --- a/server/providers/space/celestrak.js +++ b/server/providers/space/celestrak.js @@ -2,7 +2,7 @@ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js'; import { providerCooldownMs } from './cooldown.js'; -import { admitSameSite } from '../common/same-site.js'; +import { admitSameSiteKeyless } from '../common/same-site.js'; import { readResponseTextCapped } from '../common/http.js'; /** @@ -34,6 +34,8 @@ export function celestrakProxy() { 'galileo', 'geo', 'starlink', + // Space Missions (`getActiveTle`) reads the full active catalog. + 'active', ]); let cooldownUntil = 0; const mem = new Map(); // group -> { at: epochMs, body: string } @@ -77,7 +79,12 @@ export function celestrakProxy() { }, }); if (!res.ok) { - cooldownUntil = Date.now() + providerCooldownMs(res); + // Group refreshes run in parallel: a later failure without Retry-After + // must never shorten a longer deadline an earlier one already set. + cooldownUntil = Math.max( + cooldownUntil, + Date.now() + providerCooldownMs(res), + ); void res.body?.cancel().catch(() => {}); throw new Error(`HTTP ${res.status}`); } @@ -96,7 +103,9 @@ export function celestrakProxy() { const group = String(req.url || '') .replace(/^\//, '') .split('?')[0]; - if (admitSameSite(req, res)) return; + // Keyless and needed to render the satellites layer: refuse cross-site + // browsers but keep the documented reverse-proxy deployment working. + if (admitSameSiteKeyless(req, res)) return; if (req.method !== 'GET') { res.writeHead(405); res.end(); diff --git a/server/providers/space/launch-library.js b/server/providers/space/launch-library.js index 4e8ac629d..f95be2056 100644 --- a/server/providers/space/launch-library.js +++ b/server/providers/space/launch-library.js @@ -90,8 +90,12 @@ export function rocketLaunchesProxy() { headers: launchLibraryRequestHeaders(), }); if (!upstream.ok) { - cooldownUntil = Date.now() + providerCooldownMs(upstream); - cooldownStatus = upstream.status; + const deadline = Date.now() + providerCooldownMs(upstream); + // Never shorten a longer cooldown that is already in force. + if (deadline >= cooldownUntil) { + cooldownUntil = deadline; + cooldownStatus = upstream.status; + } void upstream.body?.cancel().catch(() => {}); const error = new Error(`upstream HTTP ${upstream.status}`); error.upstreamStatus = upstream.status; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index d215e78a3..4f9ef7602 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -25,7 +25,6 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, providerCooldownMs, - admitSameSite: () => false, // The rate-limited proxies reach for these; give them the real ones so a // fixture request is throttled exactly as production would be. makeRateLimiter, clientKey, diff --git a/src/tooling/spaceProviders.test.mjs b/src/tooling/spaceProviders.test.mjs index 2abc9d5fc..7aef775b1 100644 --- a/src/tooling/spaceProviders.test.mjs +++ b/src/tooling/spaceProviders.test.mjs @@ -12,6 +12,7 @@ import { launchLibraryRecentUrl, } from 'gods-eye-view/sources/space'; import * as compatibility from '../../server/providers/local.js'; +import { createLaunchSource } from 'gods-eye-view/layers/launches/source'; function install(plugin, preview = false) { const routes = new Map(); @@ -22,17 +23,23 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { const res = { headersSent: false, + headers: {}, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, + // The same-site gates answer 403 through statusCode + setHeader. + setHeader(name, value) { + this.headers[name] = value; + }, end(body) { this.body = body; + this.status ??= this.statusCode; }, }; - await routes.get(route)({ url, method }, res); + await routes.get(route)({ url, method, headers, socket: {} }, res); return res; }; } @@ -270,3 +277,118 @@ test('space providers honor Retry-After, gate groups, preserve stale dates and r } } }); + +test('Space Missions reads the active catalog through the real proxy while unknown groups stay rejected', async (t) => { + isolateDisk(t); + const tle = 'ISS\n1 25544U fixture\n2 25544 fixture\n'; + const fetched = []; + t.mock.method(globalThis, 'fetch', async (url) => { + fetched.push(new URL(url).searchParams.get('GROUP')); + return new Response(tle); + }); + const request = install(celestrakProxy()); + // Route the browser source's real request into the middleware. + const source = createLaunchSource({ + fetchImpl: async (url) => { + const prefix = '/api/celestrak'; + assert.ok(url.startsWith(`${prefix}/`)); + const res = await request(prefix, url.slice(prefix.length)); + return new Response(res.body, { status: res.status }); + }, + }); + assert.equal(await source.getActiveTle(), tle); + assert.deepEqual(fetched, ['active']); + for (const group of ['/not-a-group', '/active-extra', '/../active']) + assert.equal((await request('/api/celestrak', group)).status, 400); + assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream'); +}); + +test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => { + isolateDisk(t); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls++; + return new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'); + }); + const request = install(celestrakProxy()); + const proxied = { + host: 'gev.example.test', + 'x-forwarded-for': '203.0.113.7', + 'x-forwarded-host': 'gev.example.test', + 'x-forwarded-proto': 'https', + via: '1.1 proxy', + }; + const app = await request('/api/celestrak', '/stations', 'GET', { + ...proxied, + origin: 'https://gev.example.test', + 'sec-fetch-site': 'same-origin', + }); + assert.equal(app.status, 200); + assert.equal(app.headers['x-tle-cache'], 'MISS'); + assert.equal(calls, 1); + for (const headers of [ + { ...proxied, origin: 'https://unrelated.example' }, + { ...proxied, origin: 'null' }, + { ...proxied, 'sec-fetch-site': 'cross-site' }, + ]) { + const refused = await request('/api/celestrak', '/visual', 'GET', headers); + assert.equal(refused.status, 403); + } + assert.equal(calls, 1, 'refused requests do no provider or cache work'); +}); + +for (const longFirst of [true, false]) + test(`CelesTrak keeps the longest cooldown when concurrent failures finish ${longFirst ? 'long then short' : 'short then long'}`, async (t) => { + isolateDisk(t); + t.mock.method(console, 'warn', () => {}); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + const pending = new Map(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + calls++; + const group = new URL(url).searchParams.get('GROUP'); + await new Promise((resolve) => pending.set(group, resolve)); + return group === 'stations' + ? new Response('slow down', { + status: 429, + headers: { 'Retry-After': '1800' }, + }) + : new Response('unavailable', { status: 503 }); + }); + const request = install(celestrakProxy()); + const long = request('/api/celestrak', '/stations'); + const short = request('/api/celestrak', '/visual'); + while (pending.size < 2) await new Promise(setImmediate); + const order = longFirst + ? [ + ['stations', long], + ['visual', short], + ] + : [ + ['visual', short], + ['stations', long], + ]; + for (const [group, response] of order) { + pending.get(group)(); + assert.equal((await response).status, 502); + } + assert.equal(calls, 2); + const cooling = await request('/api/celestrak', '/geo'); + assert.equal(cooling.status, 502); + assert.equal(cooling.headers['Retry-After'], '1800'); + now += 61_000; + assert.equal((await request('/api/celestrak', '/geo')).status, 502); + assert.equal( + calls, + 2, + 'the 60 s fallback did not replace the 30 min deadline', + ); + now += 1800_000; + t.mock.method( + globalThis, + 'fetch', + async () => new Response('ISS\n1 25544U fixture\n2 25544 fixture\n'), + ); + assert.equal((await request('/api/celestrak', '/geo')).status, 200); + }); From 6b2aa95cc628ae8bcb7d940886e59b5dba2c8af8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:30:32 +0100 Subject: [PATCH 093/178] Skip DOCTYPE comments/PIs and match root namespaces only as attributes - The DOCTYPE scan now jumps over whole comments and processing instructions found outside a declaration string literal, so a quote or bracket inside them no longer leaves the scanner stuck before the root. - Root namespace repair walks the root's attributes in order, so declaration-like text inside a quoted value can no longer fill `seen` and cause the real declaration to be dropped as a duplicate. - Regression tests for both, plus linear-time cases in the isolated deadline test. - CURRENT-STATE: describe the scanner as a boundary check and leave the rest of the XML grammar to DOMParser. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 13 +++++++---- src/data/geoFileImport.js | 39 +++++++++++++++++++++++--------- src/data/geoFileImport.test.mjs | 40 ++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e1775ae2d..652a00515 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -10,10 +10,15 @@ lookups preserve the previous analyst cohort and boundary source. Navigation search keeps a successful geocode, with no search or view-based region recovery. KML namespace repair locates the opening token once and scans its quoted tag -delimiter once. Malformed or unterminated tags fail before DOM parsing, so a file -of repeated incomplete `', end + 4); + if (skip < 0) return text; + end = skip + 2; + } else if (char === '<' && text.startsWith('', end + 2); + if (skip < 0) return text; + end = skip + 1; + } else if (char === '[') subsetDepth++; else if (char === ']') subsetDepth--; else if (char === '>' && subsetDepth === 0) break; } @@ -1098,17 +1108,24 @@ export function repairKmlRootNamespaces(value, what = 'KML') { msg('That {format} file is not valid XML.'), { format: what }, ); + // Walk the root's attributes in order so quoted values are never read as + // declarations. Anything the walk cannot tokenize is kept verbatim for the + // XML parser to judge. const seen = new Set(); - let root = text - .slice(start, end) - .replace( - /\sxmlns:([\w.-]+)\s*=\s*(["'])[^"']*\2/g, - (declaration, prefix) => { - if (seen.has(prefix)) return ''; - seen.add(prefix); - return declaration; - }, - ); + const attribute = /(\s+)([^\s=/>"']+)\s*=\s*("[^"]*"|'[^']*')/y; + let position = start + match[0].length; + let root = text.slice(start, position); + for (;;) { + attribute.lastIndex = position; + const found = attribute.exec(text); + if (!found || attribute.lastIndex > end) break; + position = attribute.lastIndex; + const prefix = /^xmlns:([\w.-]+)$/.exec(found[2])?.[1]; + if (prefix && seen.has(prefix)) continue; + if (prefix) seen.add(prefix); + root += found[0]; + } + root += text.slice(position, end); if (!seen.has('xsi') && /[<\s]xsi:/.test(text)) root = root.replace( /(\/?)>$/, diff --git a/src/data/geoFileImport.test.mjs b/src/data/geoFileImport.test.mjs index 29545b0b6..9b147e1d2 100644 --- a/src/data/geoFileImport.test.mjs +++ b/src/data/geoFileImport.test.mjs @@ -1197,7 +1197,11 @@ test('KML root namespace repair respects quoted delimiters and preserves the doc test('repeated incomplete KML roots finish within an isolated process deadline', () => { const source = `import { repairKmlRootNamespaces } from ${JSON.stringify(new URL('./geoFileImport.js', import.meta.url).href)}; try { repairKmlRootNamespaces('")}.repeat(100000) + ']>'; + if (repairKmlRootNamespaces(wide).split('xmlns:gx="a"').length !== 2) process.exit(1);`; const child = spawnSync( process.execPath, ['--input-type=module', '-e', source], @@ -1224,3 +1228,37 @@ test('KML namespace repair follows a doctype with a quoted root-like entity', () input.replace(' xmlns:gx="b"', ''), ); }); + +test('KML namespace repair skips doctype comments and processing instructions whole', () => { + for (const prolog of [ + "]>", + ' here -->]>', + " fine?>]>", + '">]>', + ]) { + const input = `${prolog}`; + assert.equal( + repairKmlRootNamespaces(input), + input.replace( + ' xmlns:gx="b">', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">', + ), + prolog, + ); + } + const unterminated = ''; + assert.equal(repairKmlRootNamespaces(unterminated), unterminated); +}); + +test('KML namespace repair ignores declaration text inside quoted attribute values', () => { + const input = + ' b xmlns:gx="fake"\' xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:gx="dup">'; + const result = repairKmlRootNamespaces(input); + assert.equal(result, input.replace(' xmlns:gx="dup"', '')); + assert.match(result, /\sxmlns:gx="http:\/\/www.google.com\/kml\/ext\/2.2"/); + const xsi = ''; + assert.match( + repairKmlRootNamespaces(xsi), + /note="xmlns:xsi='x'" xmlns:xsi="http:\/\/www.w3.org\/2001\/XMLSchema-instance">/, + ); +}); From 0c20ed9975c60b217ff2fbedb9324e4644da6d41 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:33:05 +0100 Subject: [PATCH 094/178] 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 82190d531a99c507685b2d10e0330bb7f7f7a5b4 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:39:56 +0100 Subject: [PATCH 095/178] Reset the Radio rejection counter on destroy Clear _rejectedStationCount alongside _degraded so a destroyed and re-initialized layer does not report the previous catalog's count. Co-Authored-By: Claude Opus 5.5 --- src/layers/radio/lifecycle.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 414cfa5c0..45d66892e 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -108,6 +108,7 @@ export function createLifecycle({ layerState._loading = false; layerState._stale = false; layerState._degraded = false; + layerState._rejectedStationCount = 0; layerState._error = null; layerState._updatedAt = null; layerState._managerLifecyclePresentation = null; From f93982f55e4b15885def69e2e69273adc627abd2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:39:56 +0100 Subject: [PATCH 096/178] Carry server structural rejections into Radio diagnostics The proxy now skips malformed upstream rows individually but counts them, capped at 10,000, in the catalog metadata and the /stations envelope (rejectedStationCount). The count never fails health policy, so an otherwise healthy generation is still accepted. The client validates the field, adds its own rejected rows after admission, and reports degraded coverage when either side skipped rows. Adds an end-to-end proxy-to-client test and aligns DATA_SOURCES.md and CURRENT-STATE.md. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 2 +- changelog.d/1268-radio-issue-fix.md | 2 +- docs/CURRENT-STATE.md | 14 +++++-- server/providers/radio/catalog.js | 39 +++++++++++++------ server/providers/radio/constants.js | 3 ++ src/data/radio.test.mjs | 38 +++++++++++++++++- src/data/radioProxy.test.mjs | 60 ++++++++++++++++++++++++++++- src/layers/radio/ingestion.js | 20 +++++++++- src/layers/radio/policy.js | 4 ++ 9 files changed, 159 insertions(+), 23 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..3557c4137 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -177,7 +177,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. -- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. +- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md index 7a5be28b7..ab68243d6 100644 --- a/changelog.d/1268-radio-issue-fix.md +++ b/changelog.d/1268-radio-issue-fix.md @@ -1,2 +1,2 @@ -- Normalize station tags and retain valid mixed rows (asturksever, #1268). +- Normalize station tags and retain valid mixed rows; skipped malformed rows still report degraded coverage (asturksever, #1268). - Correct Radio QA push-to-talk setup to target the mounted voice controller (asturksever, #1268). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 12edc4f97..0001b850f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -11,10 +11,16 @@ search keeps a successful geocode, with no search or view-based region recovery. Radio tags are lowercased and whitespace-normalized before the final 80-character cap, including requested-tag coverage matching. Malformed station rows are skipped -individually; valid rows retain their accepted catalog generation. The UI snapshot -reports a bounded `rejectedStationCount` and degraded coverage when rows were -skipped. Malformed envelope/freshness/generation metadata and all-invalid responses -still preserve the previous usable catalog. +individually on the server and the client; valid rows retain their accepted +catalog generation. The server's `/stations` envelope carries +`rejectedStationCount`, the structurally malformed upstream rows it skipped +(capped at 10,000; policy-filtered rows such as offline or non-HTTPS streams are +not counted). It is diagnostic only and never fails catalog health. The client +adds its own rejected rows after admission, so the UI snapshot reports the +combined, capped `rejectedStationCount` and degraded coverage when either side +skipped rows. Destroy resets the count. Malformed envelope, freshness, generation +or rejection-count metadata and all-invalid responses still preserve the previous +usable catalog. 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/server/providers/radio/catalog.js b/server/providers/radio/catalog.js index de66ccbfb..30e6ed4ec 100644 --- a/server/providers/radio/catalog.js +++ b/server/providers/radio/catalog.js @@ -25,6 +25,7 @@ import { RADIO_USER_AGENT, RADIO_UUID_RE, RADIO_FALLBACK_MIRRORS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './constants.js'; async function mapRadioConcurrent(values, concurrency, mapper) { const results = new Array(values.length); @@ -177,17 +178,22 @@ export function createRadioProxyMiddleware({ const rows = await fetchPath(`/json/stations/search?${params}`); if (!Array.isArray(rows)) throw new Error('Radio Browser catalog payload was not an array'); - const stations = rows - .filter( - (row) => - row && - typeof row === 'object' && - !Array.isArray(row) && - typeof row.stationuuid === 'string' && - typeof row.name === 'string' && - (typeof row.url_resolved === 'string' || - typeof row.url === 'string'), - ) + const structuralRows = rows.filter( + (row) => + row && + typeof row === 'object' && + !Array.isArray(row) && + typeof row.stationuuid === 'string' && + typeof row.name === 'string' && + (typeof row.url_resolved === 'string' || + typeof row.url === 'string'), + ); + // Malformed rows are skipped individually, but counted so the + // catalog keeps a diagnostic for upstream schema drift. Policy + // rejections in normalization (offline, non-HTTPS, codec) are normal + // filtering and are not counted. + const rejectedRows = rows.length - structuralRows.length; + const stations = structuralRows .map(normalizeRadioBrowserStation) .filter(Boolean); const requestedTag = normalizeRadioTag(tag); @@ -206,9 +212,10 @@ export function createRadioProxyMiddleware({ // also contain an accepted station tagged for the requested category. succeeded: stations.length > 0 && requestedTagCovered, stations, + rejectedRows, }; } catch { - return { succeeded: false, stations: [] }; + return { succeeded: false, stations: [], rejectedRows: 0 }; } }, ); @@ -248,6 +255,10 @@ export function createRadioProxyMiddleware({ if (selected.length < RADIO_CATALOG_HEALTHY_MIN_STATIONS) healthReasons.push('station-coverage-below-policy'); const degraded = healthReasons.length > 0; + const rejectedStationCount = Math.min( + RADIO_REJECTED_STATION_COUNT_MAX, + outcomes.reduce((total, outcome) => total + outcome.rejectedRows, 0), + ); const coverage = { successfulQueries, totalQueries: queries.length, @@ -262,6 +273,9 @@ export function createRadioProxyMiddleware({ degraded, degradedReason: degraded ? healthReasons.join(',') : null, coverage, + // Diagnostic only: it never fails health policy, so a usable generation + // with a few skipped rows is still admitted. The client reports it. + rejectedStationCount, }; if (degraded && catalogCache) { const error = new Error( @@ -351,6 +365,7 @@ export function createRadioProxyMiddleware({ coverage: catalog.coverage || null, acceptedGeneration: catalog.acceptedGeneration ?? null, catalogInstance, + rejectedStationCount: catalog.rejectedStationCount ?? 0, }); } catch (error) { sendJson(res, 503, { diff --git a/server/providers/radio/constants.js b/server/providers/radio/constants.js index 0fee93ac6..b0e24babc 100644 --- a/server/providers/radio/constants.js +++ b/server/providers/radio/constants.js @@ -11,6 +11,9 @@ export const RADIO_CATALOG_HEALTHY_MIN_STATIONS = Math.ceil( export const RADIO_USER_AGENT = 'GodsEyeView/1.0 (Radio Browser directory client)'; export { RADIO_UUID_RE } from '../../../src/sources/radioBrowser.js'; +// Upper bound for the structurally malformed upstream rows reported in the +// /stations envelope. The client layer validates against the same bound. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; export const RADIO_FALLBACK_MIRRORS = Object.freeze([ 'https://de1.api.radio-browser.info', 'https://de2.api.radio-browser.info', diff --git a/src/data/radio.test.mjs b/src/data/radio.test.mjs index 8d24adc19..99963df16 100644 --- a/src/data/radio.test.mjs +++ b/src/data/radio.test.mjs @@ -723,13 +723,47 @@ test('unusable directory responses preserve warm client state atomically', async assert.equal(getRadioUIState().stale, false); assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); assert.equal(getRadioAcceptedCatalogSnapshot().stations[0].id, replacement.id); + // Server-side structural rejections are combined with client rejections + // after admission; the healthy generation is still accepted. globalThis.fetch = async () => new Response(JSON.stringify({ - stations: [{ id: 'malformed' }], stale: false, degraded: false, + stations: [replacement, { id: 'malformed' }], stale: false, degraded: false, acceptedGeneration: 3, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 2, })); await radioLayer.update(); - assert.equal(getRadioAcceptedCatalogSnapshot().generation, 2); + assert.equal(getRadioUIState().rejectedStationCount, 3); + assert.equal(getRadioUIState().degraded, true); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 3); + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 4, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount: 10_000, + })); + await radioLayer.update(); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + for (const rejectedStationCount of [-1, 1.5, '2', null, 10_001]) { + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [replacement], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + rejectedStationCount, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); + assert.equal(getRadioUIState().stale, true); + } + globalThis.fetch = async () => new Response(JSON.stringify({ + stations: [{ id: 'malformed' }], stale: false, degraded: false, + acceptedGeneration: 5, catalogInstance: 'qa-instance-a', updatedAt: now, + })); + await radioLayer.update(); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 4); assert.equal(getRadioUIState().stale, true); + assert.equal(getRadioUIState().rejectedStationCount, 10_000); + radioLayer.destroy(); + radioLayer.init(viewer); + assert.equal(getRadioUIState().rejectedStationCount, 0); + assert.equal(getRadioUIState().degraded, false); } finally { globalThis.fetch = originalFetch; radioLayer.destroy(); diff --git a/src/data/radioProxy.test.mjs b/src/data/radioProxy.test.mjs index d1c4d2660..e9df10678 100644 --- a/src/data/radioProxy.test.mjs +++ b/src/data/radioProxy.test.mjs @@ -11,7 +11,14 @@ import { publicRadioStation, publicRadioHttpsUrl, } from '../../vite.config.js'; -import { rankRadioStationsForRequest } from './radio.js'; +import { + getRadioAcceptedCatalogSnapshot, + getRadioUIState, + radioLayer, + rankRadioStationsForRequest, +} from './radio.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as SERVER_REJECTED_MAX } from '../../server/providers/radio/constants.js'; +import { RADIO_REJECTED_STATION_COUNT_MAX as CLIENT_REJECTED_MAX } from '../layers/radio/policy.js'; const UUID = '12345678-1234-4234-8234-123456789abc'; const publicLookup = async () => [{ address: '93.184.216.34', family: 4 }]; @@ -541,3 +548,54 @@ test('case expansion is capped after tag normalization in the portable and serve assert.equal(row.tags[0], 'i\u0307'.repeat(40)); assert.deepEqual(row.tags.slice(1), ['news radio']); }); + +test('server structural rejections reach the client diagnostic without discarding a healthy generation', async () => { + const originalFetch = globalThis.fetch; + const middleware = createProxy({ + now: () => Date.now(), + fetchImpl: async (url) => { + if (String(url).includes('/json/servers')) return responseJson([{ name: 'de1.api.radio-browser.info' }]); + const rows = healthyRowsForQuery(url); + if (queryTag(url)) return responseJson(rows); + // 400 valid broad rows, one row missing its required name, and one + // schema-valid row that normalization filters by policy (not counted). + const { name: _name, ...nameless } = station({ stationuuid: '50000000-0000-4000-8000-000000000001' }); + return responseJson([...rows, nameless, station({ stationuuid: '50000000-0000-4000-8000-000000000002', lastcheckok: 0 })]); + }, + }); + const response = await invoke(middleware, '/stations'); + assert.equal(response.status, 200); + const body = JSON.parse(response.body); + assert.equal(body.degraded, false); + assert.equal(body.degradedReason, null); + assert.equal(body.acceptedGeneration, 1); + assert.equal(body.rejectedStationCount, 1); + assert.equal(SERVER_REJECTED_MAX, CLIENT_REJECTED_MAX); + + const viewer = { + camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } }, + scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } }, + dataSources: { add() {}, remove() {} }, + entities: { add(entity) { return entity; }, remove() {} }, + }; + radioLayer.destroy(); + try { + globalThis.fetch = async () => new Response(response.body, { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + radioLayer.init(viewer); + radioLayer.enable(); + await radioLayer.update(); + const state = getRadioUIState(); + assert.equal(state.stationCount, body.stations.length); + assert.equal(state.rejectedStationCount, 1); + assert.equal(state.degraded, true); + assert.equal(state.stale, false); + assert.equal(getRadioAcceptedCatalogSnapshot().generation, 1); + assert.equal(getRadioAcceptedCatalogSnapshot().instance, body.catalogInstance); + } finally { + globalThis.fetch = originalFetch; + radioLayer.destroy(); + } +}); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 5a0ccfa1d..79f348469 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -2,6 +2,7 @@ import { msg } from '../../i18n/index.js'; import { RADIO_DIRECTORY_STALE_MS, RADIO_DIRECTORY_FUTURE_SKEW_MS, + RADIO_REJECTED_STATION_COUNT_MAX, } from './policy.js'; // Directory errors stay English in layer state and stats; the layer row and @@ -64,6 +65,18 @@ export function createIngestion({ typeof body.degraded !== 'boolean' ) throw new Error('Radio directory freshness metadata was malformed'); + // Rows the server skipped as structurally malformed. Older envelopes + // omit the field; a present value must be a bounded count. + const serverRejectedStationCount = + body.rejectedStationCount === undefined + ? 0 + : body.rejectedStationCount; + if ( + !Number.isSafeInteger(serverRejectedStationCount) || + serverRejectedStationCount < 0 || + serverRejectedStationCount > RADIO_REJECTED_STATION_COUNT_MAX + ) + throw new Error('Radio directory diagnostic metadata was malformed'); const rows = body.stations; const acceptedRows = rows.filter( parts.catalogModel.isValidRadioDirectoryStation, @@ -142,9 +155,12 @@ export function createIngestion({ } if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt; } + // Combine server-side structural rejections with rows this client + // refused. The count is diagnostic: it marks coverage degraded but does + // not undo the generation admitted above. layerState._rejectedStationCount = Math.min( - 1000000, - rows.length - acceptedRows.length, + RADIO_REJECTED_STATION_COUNT_MAX, + serverRejectedStationCount + rows.length - acceptedRows.length, ); layerState._degraded = body.degraded || layerState._rejectedStationCount > 0; diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 0d4ad174a..e0438c780 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -24,6 +24,10 @@ export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000; export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000; +// Bound for the server's `rejectedStationCount` envelope field and the +// combined client counter; matches server/providers/radio/constants.js. +export const RADIO_REJECTED_STATION_COUNT_MAX = 10_000; + export const RADIO_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; From 8cdd184a90e451b5d482c46cc60e26ecb56feace Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 23 Oct 2026 07:47:57 -0700 Subject: [PATCH 097/178] feat(street-level): provider exports and a Mapillary Graph endpoint for hosts (#1324) * feat(street-level): export the KartaView and Panoramax providers A host that composes Street Level with its own provider list can already import the Mapillary and Google Street View providers; KartaView and Panoramax were reachable only through the default list, which needs a Mapillary source. Exporting them lets a host offer the keyless providers without Mapillary. * chore: changelog entry for the Street Level provider exports * feat(street-level): send Mapillary Graph lookups to a source endpoint A Mapillary source can take a graphEndpoint, a Graph API base that adds the token itself. Its sequence lookups go there through the source's transport, and the photo viewer uses MapillaryJS's stock GraphDataProvider at that endpoint, so a host that proxies Mapillary needs no client token in the page. Without it, behaviour is unchanged: direct Graph API calls with the client token. --- .../1324-street-level-provider-exports.md | 8 +++ package.json | 2 + scripts/package-boundaries.json | 4 +- src/app/layers/streetLevel.test.mjs | 14 +++++ .../streetLevel/providers/mapillary/source.js | 26 ++++++++- .../providers/mapillary/source.test.mjs | 32 ++++++++++ .../streetLevel/providers/mapillary/viewer.js | 24 +++++++- src/layers/streetLevel/viewerHost.test.mjs | 58 ++++++++++++++++++- 8 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1324-street-level-provider-exports.md diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md new file mode 100644 index 000000000..a3dc4cb6b --- /dev/null +++ b/changelog.d/1324-street-level-provider-exports.md @@ -0,0 +1,8 @@ +- Export the KartaView and Panoramax Street Level providers, so a host composing + its own provider list can offer the keyless providers without Mapillary + (samehkhamis, #1324). +- A Mapillary source can take a `graphEndpoint`, a Graph API base that adds the + token itself (such as a same-origin proxy). Its sequence lookups go there + through the source's transport, and the photo viewer uses MapillaryJS's + Graph provider at that endpoint, so no client token is needed in the page + (samehkhamis, #1324). diff --git a/package.json b/package.json index 241c59e59..d6f74bc0f 100644 --- a/package.json +++ b/package.json @@ -272,6 +272,8 @@ "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js", + "./layers/street-level/kartaview": "./src/layers/streetLevel/providers/kartaview/index.js", + "./layers/street-level/panoramax": "./src/layers/streetLevel/providers/panoramax/index.js", "./tools": "./src/tools/index.js", "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 80d1126e9..4a756f656 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1430,7 +1430,9 @@ "./layers/street-level", "./layers/street-level/mapillary", "./layers/street-level/mapillary/source", - "./layers/street-level/google" + "./layers/street-level/google", + "./layers/street-level/kartaview", + "./layers/street-level/panoramax" ], "modules": [ "src/layers/streetLevel/index.js", diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index e82e762e5..d7f2c6125 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -72,3 +72,17 @@ test('the defaults are Mapillary (given its source), Street View, KartaView and // Without the application's Street Level source there are none. assert.deepEqual(createApplicationStreetLevel({}).providerIds, []); }); + +test('every built-in provider is exported, so hosts can choose their own list', async () => { + const google = await import('gods-eye-view/layers/street-level/google'); + const kartaview = await import('gods-eye-view/layers/street-level/kartaview'); + const panoramax = await import('gods-eye-view/layers/street-level/panoramax'); + const layer = createApplicationStreetLevel({ + providers: [ + google.createGoogleProvider({ getApiKey: () => null }), + kartaview.createKartaViewProvider(), + panoramax.createPanoramaxProvider(), + ], + }); + assert.deepEqual(layer.providerIds, ['google', 'kartaview', 'panoramax']); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 008697b1b..38a8b9c39 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -36,16 +36,29 @@ async function readJsonOrThrow(response, label) { return payload; } +/** `endpoint` with exactly one trailing slash, or '' when unset. */ +function endpointBase(endpoint) { + const text = String(endpoint || '').trim(); + return text ? text.replace(/\/*$/, '/') : ''; +} + /** * Mapillary network access: tiles via the local proxy, which adds the token * and caches; Graph API lookups direct with the client token, as MapillaryJS. - * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + * With `graphEndpoint` (a Graph API base that adds the token itself, such as + * a same-origin proxy), the source's Graph lookups go there through + * `fetchImpl` instead, and the photo viewer sends its own lookups there too; + * no token is sent or needed. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object, + * graphEndpoint?: string}} options */ export function createMapillarySource({ token = '', fetchImpl = (...args) => globalThis.fetch(...args), endpoints = {}, + graphEndpoint = '', } = {}) { + const graphBase = endpointBase(graphEndpoint); const urls = { status: '/api/mapillary/status', tiles: '/api/mapillary/tiles', @@ -54,6 +67,13 @@ export function createMapillarySource({ }; async function graph(path, params = {}, { signal } = {}) { + if (graphBase) { + const search = new URLSearchParams(params); + const response = await fetchImpl(`${graphBase}${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } if (!token) throw new MapillarySourceError('Mapillary token not configured', { status: 503, @@ -70,6 +90,10 @@ export function createMapillarySource({ get token() { return token; }, + /** The Graph API base that adds the token itself, or '' (direct). */ + get graphEndpoint() { + return graphBase; + }, hasToken: () => Boolean(token), async getStatus({ signal } = {}) { diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index d744e499d..b502a7fd8 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -125,3 +125,35 @@ test('a rejected key and a rate limit are told apart from other tile errors', as 'the Retry-After header wins over the body', ); }); + +test('with a Graph endpoint, sequence lookups go there without a token', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph', + fetchImpl, + }); + assert.equal(source.graphEndpoint, '/api/mapillary/graph/'); + assert.equal(source.hasToken(), false); + const images = await source.getSequenceImages('seq-1'); + assert.equal(images.length, 1); + const url = new URL(calls[0].url, 'http://localhost'); + assert.equal(url.pathname, '/api/mapillary/graph/images'); + assert.equal(url.searchParams.get('sequence_ids'), 'seq-1'); + assert.equal(url.searchParams.has('access_token'), false); +}); + +test('Graph endpoint errors keep their status', async () => { + const { fetchImpl } = fakeFetch(() => + jsonResponse({ error: 'no_key', keyRequired: true }, 503), + ); + const source = createMapillarySource({ + graphEndpoint: '/api/mapillary/graph/', + fetchImpl, + }); + await assert.rejects( + () => source.getSequenceImages('1'), + (error) => error.status === 503 && error.keyRequired === true, + ); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index d36580321..4ec2654e4 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -13,6 +13,20 @@ import { mapillaryImageUrl } from './policy.js'; * @property {string} externalUrl Deep link to the image on mapillary.com. */ +/** + * The source's Graph endpoint as an absolute URL ending in `/`, as MapillaryJS + * resolves its lookup paths against it; '' when the source has none. + */ +export function graphEndpointUrl(source) { + if (!source?.graphEndpoint) return ''; + const url = new URL( + source.graphEndpoint, + globalThis.location?.href || 'http://localhost/', + ); + url.pathname = url.pathname.replace(/\/*$/, '/'); + return url.href; +} + /** * The panel's photo viewer: loads MapillaryJS on the first `mount` and emits * a pose on every image, view or position change. @@ -115,12 +129,18 @@ export function createMapillaryViewer({ source, render } = {}) { async function buildViewer(host) { const built = generation; destroyViewer(); - const { Viewer } = await ensureLibrary(); + const { Viewer, GraphDataProvider } = await ensureLibrary(); // Unmounted (layer off) while the library loaded: a viewer built now // would hold a WebGL context nobody releases. if (built !== generation) throw new Error('Mapillary viewer was unmounted'); + const endpoint = graphEndpointUrl(source); viewer = new Viewer({ - accessToken: source.token, + // A source with its own Graph endpoint (which adds the token itself) + // answers the viewer's lookups, sent without a token; otherwise + // MapillaryJS calls the Graph API with the client token. + ...(endpoint + ? { dataProvider: new GraphDataProvider({ endpoint }) } + : { accessToken: source.token }), container: host, component: { cover: false, diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 4700acf14..fdcb74e21 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -22,13 +22,16 @@ registerHooks({ const source = url.includes('/library?') ? 'const lib = await globalThis.__gevMapillaryLibrary();\n' + 'export const Viewer = lib.Viewer;\n' + - 'export const RenderMode = lib.RenderMode;\n' + 'export const RenderMode = lib.RenderMode;\n' + + 'export const GraphDataProvider = lib.GraphDataProvider;\n' : 'export default {};\n'; return { format: 'module', shortCircuit: true, source }; }, }); -const { createMapillaryViewer } = +const { createMapillaryViewer, graphEndpointUrl } = await import('./providers/mapillary/viewer.js'); +const { createMapillarySource } = + await import('./providers/mapillary/source.js'); /** * A viewer host over `adapter`. `marker` records where the position marker @@ -345,7 +348,7 @@ function fakeLibrary() { } globalThis.__gevMapillaryLibrary = async () => { await gate.promise; - return { Viewer, RenderMode: {} }; + return { Viewer, RenderMode: {}, GraphDataProvider: FakeGraphDataProvider }; }; return { gate, viewers }; } @@ -565,3 +568,52 @@ test('a pose’s licence reaches the caption state, and a closed viewer forgets host.close(); assert.equal(state.street.license, null); }); + +/** MapillaryJS 4's Graph provider, recording what it was built with. */ +class FakeGraphDataProvider { + constructor(options = {}) { + this.options = options; + } +} + +test('without a Graph endpoint the viewer gets the client token, as before', async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const adapter = createMapillaryViewer({ source: { token: 't' } }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, 't'); + assert.equal(viewer.options.dataProvider, undefined); +}); + +test("with a Graph endpoint the viewer uses MapillaryJS's Graph provider there, without a token", async () => { + const library = fakeLibrary(); + library.gate.resolve(); + const source = createMapillarySource({ + graphEndpoint: 'https://app.example/api/mapillary/graph', + fetchImpl: async () => { + throw new Error('the viewer does not use the source transport'); + }, + }); + const adapter = createMapillaryViewer({ source }); + await adapter.mount({}); + const [viewer] = library.viewers.instances; + assert.equal(viewer.options.accessToken, undefined); + const provider = viewer.options.dataProvider; + assert.ok(provider instanceof FakeGraphDataProvider); + assert.deepEqual(provider.options, { + endpoint: 'https://app.example/api/mapillary/graph/', + }); +}); + +test('a relative Graph endpoint resolves against the page, with one trailing slash', () => { + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal( + graphEndpointUrl({ graphEndpoint: '/api/mapillary/graph//' }), + 'http://localhost/api/mapillary/graph/', + ); + assert.equal(graphEndpointUrl({ token: 't' }), ''); +}); From b2fb4a261e38557715c956e72ad2a3f9203b5a7a Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Sat, 24 Oct 2026 03:21:42 +0530 Subject: [PATCH 098/178] 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 099/178] 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 100/178] 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 101/178] 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 102/178] 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 103/178] 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 104/178] 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 105/178] 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 106/178] 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 107/178] 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 108/178] 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 109/178] 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 110/178] 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 111/178] 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 112/178] 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 113/178] 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 114/178] 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 115/178] 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 116/178] 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 117/178] 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 118/178] 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 07318cc9a4bed2b1f5b67fb0d80dc2e964572743 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:19:21 -0700 Subject: [PATCH 119/178] fix(geo): wrap longitude differences across the antimeridian in point distances (#1304) Wrap longitude differences in annotation proximity checks and nearby-place distances so points across the antimeridian remain close. Original contribution by quanticsoul4772. Co-authored-by: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> --- changelog.d/1304-antimeridian-distances.md | 2 ++ docs/CURRENT-STATE.md | 5 +++++ src/data/placeProviderPayloads.js | 5 ++++- src/sources/featureGeometry.js | 5 ++++- src/sources/featureGeometry.test.mjs | 15 +++++++++++++++ src/tooling/placeProviders.test.mjs | 18 ++++++++++++++++++ 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1304-antimeridian-distances.md create mode 100644 src/sources/featureGeometry.test.mjs diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md new file mode 100644 index 000000000..fc7744f86 --- /dev/null +++ b/changelog.d/1304-antimeridian-distances.md @@ -0,0 +1,2 @@ +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..ab0d18b77 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5515,6 +5515,11 @@ easier to meet (detection is now on more often), but does not create it. short-way-round longitude span and wraps the padded edges, so a dateline- crossing box stays its true width. Raw subtraction inflated a 0.41° metro box to 86.7° and a 60° territory to 132°. +- **Point distances take the short way round 180°.** The annotation + resolver's `approximateDistanceM` (voice "point out" proximity checks) and + `places_nearby` distances wrap the longitude difference. Raw subtraction put + Savusavu 38,148 km from a camera over the Lau Islands, so the proximity check + refused it, and sorted a place 2 km across 180° last. - CCTV calibration persists at `godsEyeView.cctv.calibration.v2` (v2 rebuild; the store was wiped clean, no import from the old `v1` key). - CCTV v3 floor QA pins are: zero samples for heading-only edits; zero transient diff --git a/src/data/placeProviderPayloads.js b/src/data/placeProviderPayloads.js index 37fd99ef7..8adf935a9 100644 --- a/src/data/placeProviderPayloads.js +++ b/src/data/placeProviderPayloads.js @@ -13,8 +13,11 @@ function approximateDistanceM(latA, lonA, latB, lonB) { return Number.MAX_SAFE_INTEGER; const latitudeScale = 111320; const longitudeScale = latitudeScale * Math.cos((latA * Math.PI) / 180); + // The short way round, so places either side of 180° stay close. + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); return Math.round( - Math.hypot((latB - latA) * latitudeScale, (lonB - lonA) * longitudeScale), + Math.hypot((latB - latA) * latitudeScale, dLon * longitudeScale), ); } diff --git a/src/sources/featureGeometry.js b/src/sources/featureGeometry.js index a18d5f22f..b2ccc9630 100644 --- a/src/sources/featureGeometry.js +++ b/src/sources/featureGeometry.js @@ -250,10 +250,13 @@ export function approximateAreaM2(coords) { return Math.abs(area) / 2; } +/** Local equirectangular distance in metres, the short way round 180°. */ export function approximateDistanceM(latA, lonA, latB, lonB) { const latScale = 111_320; const lonScale = latScale * Math.cos(((latA + latB) / 2) * (Math.PI / 180)); - return Math.hypot((latB - latA) * latScale, (lonB - lonA) * lonScale); + let dLon = lonB - lonA; + dLon -= 360 * Math.round(dLon / 360); + return Math.hypot((latB - latA) * latScale, dLon * lonScale); } export function pointInPolygon(lon, lat, coords) { diff --git a/src/sources/featureGeometry.test.mjs b/src/sources/featureGeometry.test.mjs new file mode 100644 index 000000000..6da3ff92f --- /dev/null +++ b/src/sources/featureGeometry.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { approximateDistanceM } from './featureGeometry.js'; + +test('approximate distance takes the short way round 180°', () => { + // Lau Islands (-179.0) to Savusavu (179.33): about 178 km apart. + const across = approximateDistanceM(-17.5, -179.0, -16.78, 179.33); + assert.ok(across > 150_000 && across < 200_000, String(across)); + assert.equal( + Math.round(approximateDistanceM(0, 179.9, 0, -179.9)), + Math.round(approximateDistanceM(0, -0.1, 0, 0.1)), + ); + // Away from 180° nothing changes. + assert.equal(Math.round(approximateDistanceM(0, 0, 0, 1)), 111_320); +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 352cc8824..d533b7fb9 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -81,6 +81,24 @@ test('nearby labels rank landmarks, deduplicate names/addresses and bound the pr assert.deepEqual(projectNearbyPlaces({}, 0, 0), []); }); +test('nearby place distances take the short way round 180°', () => { + const at = (name, longitude) => ({ + id: name, + displayName: { text: name }, + location: { latitude: -16.8, longitude }, + types: ['point_of_interest'], + }); + // From Taveuni at 179.98: 2 km east across 180°, 4 km west on this side. + const result = projectNearbyPlaces( + { places: [at('West', 179.942), at('East', -179.999)] }, + -16.8, + 179.98, + ); + const byName = Object.fromEntries(result.map((p) => [p.name, p.distanceM])); + assert.ok(byName.East > 1500 && byName.East < 2500, String(byName.East)); + assert.ok(byName.West > 3500 && byName.West < 4500, String(byName.West)); +}); + test('text search preserves bounds, rejects malformed bounds and tolerates absent locations', () => { const viewport = { low: { latitude: 1, longitude: 2 }, From 1c234ce0271d72d1e2fe93b214da8ce373b41203 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:25:42 -0700 Subject: [PATCH 120/178] fix(opensky): load OAuth credential files and retire Basic auth (#1229) Read OpenSky OAuth credentials from OPENSKY_CREDENTIALS_FILE and stop sending retired Basic authentication. Update setup diagnostics and guidance. Keep credential-file warnings free of parser excerpts and filesystem error details, cover both failure paths with a regression test, and use a launcher-compatible absolute path example. Original contribution by quanticsoul4772, with credential-warning and documentation follow-up by Sameh Khamis. Co-authored-by: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Co-authored-by: Sameh Khamis --- .env.example | 7 +- changelog.d/1229-opensky-oauth-only.md | 8 ++ docs/CURRENT-STATE.md | 4 + docs/opensky-auth.md | 24 ++-- scripts/setup-doctor.mjs | 25 +++- server/providers/aircraft/opensky.js | 131 ++++++++++-------- .../aircraft/openskyCredentials.test.mjs | 81 +++++++++++ src/setupDoctor.test.mjs | 41 +++++- src/sources/live/standalone.js | 4 +- src/tooling/liveProviders.test.mjs | 63 +++++++++ 10 files changed, 309 insertions(+), 79 deletions(-) create mode 100644 changelog.d/1229-opensky-oauth-only.md create mode 100644 server/providers/aircraft/openskyCredentials.test.mjs diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab0d18b77..dbd591b17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1916,6 +1916,10 @@ Updated: September 23, 2026 ## Aircraft and vessel server modules +OpenSky credential-file failures emit a fixed warning once per process; neither +JSON parser excerpts nor filesystem error messages are logged. The environment +example uses an absolute path compatible with the macOS launchers. + Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky state vectors and regional fallback, adsb.lol military positions, ADSBDB enrichment and track backfill. Vessel routes and websocket/watchdog setup live diff --git a/docs/opensky-auth.md b/docs/opensky-auth.md index 67844f912..c5f0a387b 100644 --- a/docs/opensky-auth.md +++ b/docs/opensky-auth.md @@ -3,10 +3,11 @@ God's Eye View uses explicit auth modes for `/api/flights`: - `OPENSKY_AUTH_MODE=oauth` (default, recommended) -- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) -- `OPENSKY_AUTH_MODE=basic` +- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous) - `OPENSKY_AUTH_MODE=anon` +`OPENSKY_AUTH_MODE=basic` is retired (see below). + Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow. ## Quick Start (OAuth Client JSON) @@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order: 2. `OPENSKY_CREDENTIALS_FILE` 3. Keychain (`opensky-network` / `client_id`, `client_secret`) -## Basic Auth (Legacy) +The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and +Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any +platform. It reads the file on each token refresh. -Basic auth is still supported for now, but OpenSky is moving away from it. +## Basic Auth (Retired) -```bash -OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh -``` +OpenSky stopped accepting username/password (HTTP Basic) sign-in on +2026-03-18; its API now takes only OAuth2 client credentials. With +`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` / +`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and +logs once what to set instead. Create an API client on your OpenSky +account page and use its `credentials.json` as above. ## Troubleshooting @@ -61,6 +67,6 @@ Useful reasons: - `oauth_invalid_or_missing`: OAuth client not found/usable - `oauth_invalid_credentials`: OAuth client rejected by OpenSky -- `basic_invalid_credentials`: username/password rejected -- `missing_basic_creds`: basic mode missing user/pass +- `missing_oauth_creds`: auto mode found no OAuth client +- `basic_retired`: basic mode requested; answered anonymously - `rate_limited`: OpenSky throttling diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 01e048091..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -153,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -164,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index a69885e2f..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, @@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -174,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -524,21 +574,21 @@ export function openSkyProxy() { _openskySnapshotInFlight, 'global', async () => { - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); const headers = { Accept: 'application/json' }; let usedMode = 'anon'; let reason = 'forced_anonymous'; if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; } + reason = 'basic_retired'; } else if (requestedMode === 'oauth') { const token = await getOpenSkyToken(); if (token) { @@ -554,31 +604,12 @@ export function openSkyProxy() { headers.Authorization = `Bearer ${token}`; usedMode = 'oauth'; reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; } else { - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) @@ -586,12 +617,6 @@ export function openSkyProxy() { // Refine the reason string to reflect the actual outcome if (upstream.ok && reason === 'forced_anonymous') { reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; } else if ( upstream.ok && usedMode === 'oauth' && @@ -627,12 +652,11 @@ export function openSkyProxy() { body, usedMode, reason, - hasBasicCreds, sourceEpochMs, }; }, ); - let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + let { upstream, body, usedMode, reason, sourceEpochMs } = await refresh.promise; if ( upstream.ok && @@ -684,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 3f2c73b3b..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -31,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 6b880d186..1f1befc95 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'basic', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return Response.json({ time: Math.floor(Date.now() / 1000), states: [] }); + } + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?basic=${Math.random()}` + ); + const flights = install(fresh.openSkyProxy()); + const first = await flights('/api/flights'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-opensky-auth'], 'anon'); + assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired'); + assert.deepEqual(authorizations, [undefined]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /retired username\/password sign-in/); + assert.ok(!warnings[0].includes('fixture-password')); +}); + +test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'auto', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/token')) + return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return new Response('', { status: 401 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?auto=${Math.random()}` + ); + await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97'); + assert.deepEqual(authorizations, ['Bearer fixture-token']); +}); + test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { t.mock.method(globalThis, 'fetch', async () => { assert.fail('retired history must not contact the upstream'); From cf4e8e1d87fe1c57315f1107aae996122086e669 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 24 Oct 2026 21:19:07 -0700 Subject: [PATCH 121/178] docs(cyclones): record upstream movement speed units --- docs/CURRENT-STATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..3db6d3fb9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -423,6 +423,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty, not storm size or the full hazard area. Successful empty and unavailable states are distinct. One owned native Cesium data source preserves polygon holes and geographic seams and releases on disable; forecast animation is not implied. +NHC movement speed is converted from miles per hour to rounded knots by the +proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Consult the linked official advisory for safety decisions. From 77b485d619e79fa59994a07909c78de4ff023590 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 24 Oct 2026 21:10:12 -0700 Subject: [PATCH 122/178] fix(ais): refresh changed static metadata without advancing position --- changelog.d/1351-ais-static-keeps-motion.md | 3 + docs/CURRENT-STATE.md | 8 + server/providers/vessels/ais-store.js | 60 ++++++-- src/data/aisStreamSentinels.test.mjs | 159 ++++++++++++++++++++ 4 files changed, 220 insertions(+), 10 deletions(-) diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md index 78b0804f7..52dd9bede 100644 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ b/changelog.d/1351-ais-static-keeps-motion.md @@ -1,3 +1,6 @@ - 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). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..282181df3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -459,6 +459,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the highest speed and course the fields can encode. +For an already tracked vessel, AISStream static messages update name, type, +destination and IMO without changing its position, motion, fix timestamps, +position freshness or trail. Non-empty static values replace earlier values; +omitted, empty or unavailable fields retain known metadata across Class B's +separate reports and in the cache used by the next position update. Position +reports still prefer their own metadata name. Invalid Class B parts are ignored; +absent fields prefer the latest live value over the static cache. + Native ` Don't show this again - ESC to dismiss + +
From 6bbf9b94c4f2045865a75d7fd4147491c970a63b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:26:26 -0700 Subject: [PATCH 140/178] fix(traffic): flag stale cached TomTom tiles in get_traffic_flow (#1239) Report stale traffic tiles and their fetch time, and release pending freshness metadata when tile requests fail or are cancelled. Contribution by @quanticsoul4772. --- changelog.d/1239-traffic-stale-flow.md | 3 + docs/CURRENT-STATE.md | 8 ++ server/providers/traffic.js | 15 ++- src/layers/traffic/flowSource.js | 77 +++++++++-- src/layers/traffic/source.test.mjs | 153 ++++++++++++++++++++++ src/tooling/environmentProviders.test.mjs | 9 +- src/tools/queries/mobility.js | 11 ++ src/tools/queries/mobility.test.mjs | 41 ++++++ 8 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 changelog.d/1239-traffic-stale-flow.md diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md new file mode 100644 index 000000000..5338e8e60 --- /dev/null +++ b/changelog.d/1239-traffic-stale-flow.md @@ -0,0 +1,3 @@ +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 332b99283..be09e5664 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1701,6 +1701,14 @@ The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before status, cache or upstream work, refusing cross-site browser requests while preserving same-origin app requests and non-browser local callers. The browser layers and their rendering remain in their existing modules. +Past the daily tile budget, or while TomTom fails, the proxy answers with its +last copy of a tile (`x-tomtom-cache: STALE-BUDGET` / `STALE-ERROR`) however old +it is, and every tile carries `x-tomtom-fetched-at`, the proxy fetch time +(or the persisted cache file time after restart). The flow source keeps that mark with each decoded tile, so +`get_traffic_flow` returns `stale` and `oldest_tile_at` and says when traffic +may be out of date instead of presenting cached tiles as live. Pending freshness +metadata is released when body reads fail, are cancelled, or exceed their size +limit, as well as when a tile decodes or the source resets. GBFS null or empty availability counts retain an unknown value through station parsing and rendering; they use the neutral station color rather than reporting an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. diff --git a/server/providers/traffic.js b/server/providers/traffic.js index da8ae73de..2069a0892 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -182,14 +182,17 @@ export function tomtomProxy() { }); res.end(JSON.stringify(obj)); }; - const sendTile = (buf, cacheStatus) => { + // x-tomtom-fetched-at is when the proxy fetched the tile (ms since the + // epoch), so a client handed a STALE-* copy can say how old it is. + const sendTile = (entry, cacheStatus) => { if (res.headersSent) return; res.writeHead(200, { 'Content-Type': 'application/x-protobuf', 'Cache-Control': 'no-store', 'x-tomtom-cache': cacheStatus, + 'x-tomtom-fetched-at': String(Math.round(entry.at)), }); - res.end(buf); + res.end(entry.buf); }; try { @@ -235,14 +238,14 @@ export function tomtomProxy() { } // Fresh cache hit — never counts against the budget. if (entry && now - entry.at < TILE_TTL_MS) { - sendTile(entry.buf, 'HIT'); + sendTile(entry, 'HIT'); return; } // Budget governor: over the soft cap, last-good data beats a dead layer. if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { if (entry) { - sendTile(entry.buf, 'STALE-BUDGET'); + sendTile(entry, 'STALE-BUDGET'); } else { sendJson(429, { error: 'budget' }); } @@ -271,9 +274,9 @@ export function tomtomProxy() { } const fresh = await inflight.get(key); if (fresh) { - sendTile(fresh.buf, 'MISS'); + sendTile(fresh, 'MISS'); } else if (entry) { - sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty + sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty } else { sendJson(502, { error: 'upstream' }); } diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 49567a9ee..f517c99bc 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js'; import { tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; +const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; + +/** + * Whether the proxy answered a tile from its cache past the refresh window + * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom + * fails), and when the proxy fetched it. + */ +function tileFreshness(response) { + const status = response.headers?.get?.('x-tomtom-cache') || ''; + const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); + return { + stale: status.startsWith('STALE'), + fetchedAt: fetchedAt > 0 ? fetchedAt : null, + }; +} + /** Own one bounded decoded flow cache; every request still passes the server tile budget. */ export function createFlowTileSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + // Freshness travels in response headers, but the tile cache keeps only + // decoded segments: hold each response's freshness until its tile decodes, + // then key it by the decoded tile, which the cache hands back on a hit. + const pending = new Map(); + const freshness = new WeakMap(); const tiles = createVectorTileSource({ - template: '/api/tomtom/flow/{z}/{x}/{y}.pbf', + template: FLOW_TILE_PATH('{z}', '{x}', '{y}'), allowedOrigin: 'http://localhost', - decode: (bytes, z, x, y) => - decodeFlowTile(bytes, z, x, y, { strict: true }) + decode: (bytes, z, x, y) => { + // Take the mark before decoding, so a tile that fails to decode + // leaves nothing behind. + const path = FLOW_TILE_PATH(z, x, y); + const entry = pending.get(path); + const mark = entry?.mark; + entry?.release(); + const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) .filter(isDrivableFlowSegment) .flatMap((segment) => clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({ ...segment, coords, })), - ), - fetchImpl, + ); + if (mark) freshness.set(segments, mark); + return segments; + }, + fetchImpl: async (url, init) => { + const response = await fetchImpl(url, init); + if (response.ok && !init.signal.aborted) { + const path = String(url); + const entry = { + mark: tileFreshness(response), + release() { + if (pending.get(path) === entry) pending.delete(path); + init.signal.removeEventListener('abort', entry.release); + }, + }; + pending.get(path)?.release(); + pending.set(path, entry); + // The vector source aborts this signal for failed/cancelled body reads, + // including size-limit failures that never reach decode. + init.signal.addEventListener('abort', entry.release, { once: true }); + } + return response; + }, ttlMs: 120_000, maxTiles: 16, }); let partial = false; /** * Flow segments for bounds, with whether any covering tile failed to load - * for this request. + * for this request, whether any came from the proxy's stale cache, and when + * the oldest tile was fetched. */ async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) { const result = await tiles.fetchBounds(bounds, { @@ -44,7 +93,16 @@ export function createFlowTileSource({ coords, })), ); - return { segments, partial: result.partial === true }; + const marks = result.tiles.map((tile) => freshness.get(tile) || {}); + const times = marks + .map((mark) => mark.fetchedAt) + .filter((at) => Number.isFinite(at)); + return { + segments, + partial: result.partial === true, + stale: marks.some((mark) => mark.stale === true), + oldestTileAt: times.length ? Math.min(...times) : null, + }; } return { fetchFlowDetail, @@ -52,6 +110,9 @@ export function createFlowTileSource({ return (await fetchFlowDetail(bounds, options)).segments; }, getFlowSessionStats: () => ({ ...tiles.getStats(), partial }), - resetFlowTileCache: () => tiles.clear(), + resetFlowTileCache: () => { + for (const entry of pending.values()) entry.release(); + tiles.clear(); + }, }; } diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index affaff56d..927dc416e 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import { createFlowTileSource } from './flowSource.js'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js'; @@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti assert.ok(publicUrls.length >= 4); assert.ok(apiUrls.length >= 4); }); + +test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => { + const fetchedAt = Date.UTC(2026, 9, 16, 8); + const reply = (status, at) => async () => + new Response(fixture, { + headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) }, + }); + const stale = createTrafficSource({ + fetchImpl: reply('STALE-BUDGET', fetchedAt), + }); + const first = await stale.fetchFlowDetail(bounds); + assert.ok(first.segments.length > 0); + assert.equal(first.stale, true); + assert.equal(first.oldestTileAt, fetchedAt); + // The decoded tile is served from cache now, and keeps its mark. + const again = await stale.fetchFlowDetail(bounds); + assert.equal(stale.getFlowSessionStats().tilesFetched, 1); + assert.equal(again.stale, true); + assert.equal(again.oldestTileAt, fetchedAt); + + const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) }); + const live = await fresh.fetchFlowDetail(bounds); + assert.equal(live.stale, false); + assert.equal(live.oldestTileAt, fetchedAt); + + const unmarked = createTrafficSource({ + fetchImpl: async () => new Response(fixture), + }); + const plain = await unmarked.fetchFlowDetail(bounds); + assert.equal(plain.stale, false); + assert.equal(plain.oldestTileAt, null); +}); + +// Capture constructor-owned Maps only while constructing the source, so tests +// can assert retained metadata without adding a diagnostic API to production. +function flowSourceWithMaps(fetchImpl) { + const maps = []; + const NativeMap = globalThis.Map; + let source; + try { + globalThis.Map = class extends NativeMap { + constructor(...args) { + super(...args); + maps.push(this); + } + }; + source = createFlowTileSource({ fetchImpl }); + } finally { + globalThis.Map = NativeMap; + } + return { source, pending: maps[0] }; +} + +test('flow freshness is released after body failures, size rejection, decode and reset', async () => { + for (const failure of ['body', 'size', 'decode']) { + let requestSignal; + let fail = true; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ + 'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS', + 'x-tomtom-fetched-at': fail ? '1000' : '2000', + ...(fail && failure === 'size' + ? { 'content-length': '99999999' } + : {}), + }), + async arrayBuffer() { + if (fail && failure === 'body') throw new Error('body disconnected'); + return fail && failure === 'decode' ? new Uint8Array([255]) : fixture; + }, + }; + }); + await assert.rejects(source.fetchFlowDetail(bounds)); + assert.equal(pending.size, 0, `${failure} leaves no pending metadata`); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); + fail = false; + const fresh = await source.fetchFlowDetail(bounds); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal(pending.size, 0, 'successful decode releases metadata'); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + source.resetFlowTileCache(); + assert.equal(pending.size, 0); + } +}); + +test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => { + let resolveOld; + let oldSignal; + let calls = 0; + const { source, pending } = flowSourceWithMaps((url, { signal }) => { + if (++calls === 1) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + } + return Promise.resolve( + new Response(fixture, { + headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' }, + }), + ); + }); + const old = source.fetchFlowDetail(bounds); + const rejected = assert.rejects(old, { name: 'AbortError' }); + while (!resolveOld) await new Promise((resolve) => setImmediate(resolve)); + source.resetFlowTileCache(); + const fresh = await source.fetchFlowDetail(bounds); + resolveOld( + new Response(fixture, { + headers: { + 'x-tomtom-cache': 'STALE-ERROR', + 'x-tomtom-fetched-at': '1000', + }, + }), + ); + await rejected; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(oldSignal, 'abort').length, 0); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000); +}); + +test('cancelling a flow body releases its freshness before a new request', async () => { + const controller = new AbortController(); + let requestSignal; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }), + async arrayBuffer() { + assert.equal(pending.size, 1); + controller.abort(); + return fixture; + }, + }; + }); + await assert.rejects( + source.fetchFlowDetail(bounds, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index bbb0e9d6e..5e786e408 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => { isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' }); let now = Date.UTC(2026, 8, 12, 12); + const fetchedAt = String(now); t.mock.method(Date, 'now', () => now); let calls = 0; t.mock.method(globalThis, 'fetch', async (raw) => { @@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback assert.equal(json(await request('/status')).dailyCount, 1); assert.equal(calls, 1); now += 120001; - assert.equal( - (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], - 'STALE-BUDGET', - ); + const staleTile = await request('/flow/8/1/1.pbf'); + assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET'); + // The stale copy still says when TomTom produced it. + assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt); assert.equal((await request('/flow/8/2/1.pbf')).status, 429); assert.equal(calls, 1); now += 86400000; diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 743fa22a2..0a57ae3ae 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -473,11 +473,22 @@ export const getTrafficFlow = defineTool({ : `No live traffic is reported in ${area.label}.`) + (flow.partial ? ' Some map tiles did not load, so these figures are partial.' + : '') + + // Past the daily TomTom budget, or while TomTom fails, the proxy + // serves its last copy of a tile however old it is. + (flow.stale + ? ' Some tiles are cached copies' + + (isoTime(flow.oldestTileAt) + ? `, the oldest from ${isoTime(flow.oldestTileAt)}` + : '') + + ', so traffic may be out of date.' : ''), data: { view: suggestView(services, { area, layers: ['traffic'] }), area: area.label, partial: flow.partial, + stale: flow.stale === true, + oldest_tile_at: isoTime(flow.oldestTileAt), speed_pct_of_free_flow: speedPct, measured_km: round(measuredKm, 1), congested_km: round(congestedKm, 1), diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 011e431fd..676351b6c 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -555,6 +555,47 @@ test('traffic counts only roads inside a radius area and reports missing tiles', assert.match(partial.summary, /Some map tiles did not load/); }); +test('traffic says when the proxy served cached tiles, and how old the oldest is', async () => { + const road = [ + { + coords: [ + [-0.2, 0], + [0.2, 0], + ], + trafficLevel: 0.9, + }, + ]; + const call = (detail) => + composeCatalog({ + tools: coreTools, + services: { + traffic: { + getStatus: async () => ({ hasKey: true }), + fetchFlowDetail: async () => ({ + segments: road, + partial: false, + ...detail, + }), + }, + }, + }).call('get_traffic_flow', { area: { lat: 0, lon: 0, radius_km: 10 } }); + const oldest = Date.UTC(2026, 9, 16, 8); + const stale = await call({ stale: true, oldestTileAt: oldest }); + assert.equal(stale.data.stale, true); + assert.equal(stale.data.oldest_tile_at, '2026-10-16T08:00:00.000Z'); + assert.match( + stale.summary, + /cached copies, the oldest from 2026-10-16T08:00:00\.000Z, so traffic may be out of date\./, + ); + const live = await call({ stale: false, oldestTileAt: oldest }); + assert.equal(live.data.stale, false); + assert.doesNotMatch(live.summary, /out of date/); + // A source that can't tell (no freshness fields) isn't reported as stale. + const unknown = await call({}); + assert.equal(unknown.data.stale, false); + assert.equal(unknown.data.oldest_tile_at, null); +}); + test('a whole-world bike-share search names every system it skipped', async () => { const empty = { data: { stations: [] } }; const result = await composeCatalog({ From eb8b63fc62b04ac505d72cd2c2d8e8cdb2427240 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:12 -0400 Subject: [PATCH 141/178] fix(directions): show a 59.6-second leg as 1 min, not 60 s routeDurationText checked the raw seconds against 60 but printed them rounded, so a leg of 59.5 s or more read "60 s". Compare the rounded seconds so it moves to minutes. --- changelog.d/1385-route-duration-minute.md | 3 +++ src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1385-route-duration-minute.md diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md new file mode 100644 index 000000000..fa354766b --- /dev/null +++ b/changelog.d/1385-route-duration-minute.md @@ -0,0 +1,3 @@ +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 4c9b54bd8..9426450a5 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', ( assert.equal(routeDistanceText(1500), '1.5\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); + assert.equal(routeDurationText(59.4), '59\u00a0s'); + assert.equal(routeDurationText(59.6), '1\u00a0min'); assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min'); assert.equal(routeDurationText(7200), '2\u00a0h'); assert.equal(routeDistanceText(-1), ''); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..8fec18f65 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -130,7 +130,8 @@ export function routeDistanceText(meters) { */ export function routeDurationText(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return ''; - if (seconds < 60) return formatQuantity(Math.round(seconds), 's'); + const wholeSeconds = Math.round(seconds); + if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's'); const minutes = Math.round(seconds / 60); if (minutes < 60) return formatQuantity(minutes, 'min'); const hours = formatQuantity(Math.floor(minutes / 60), 'h'); From b7e00f18a4b1ef2f92fdd17fd4418b72f250aae0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:15 -0400 Subject: [PATCH 142/178] fix(directions): show a 996 m leg as 1.0 km, not 1000 m routeDistanceText checked the raw meters against 1000 but printed them rounded to tens, so 995 m or more read "1000 m". Compare the rounded value so it moves to kilometres. --- changelog.d/1386-route-distance-kilometre.md | 3 +++ src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1386-route-distance-kilometre.md diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md new file mode 100644 index 000000000..0a60fcc62 --- /dev/null +++ b/changelog.d/1386-route-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 4c9b54bd8..187f1a8d0 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -846,6 +846,8 @@ test('route numbers follow the page language and never break from their unit', ( t.after(() => setLocale('en')); assert.equal(routeDistanceText(8.4), '8\u00a0m'); assert.equal(routeDistanceText(236), '240\u00a0m'); + assert.equal(routeDistanceText(994), '990\u00a0m'); + assert.equal(routeDistanceText(996), '1.0\u00a0km'); assert.equal(routeDistanceText(1500), '1.5\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..95e4cf258 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -112,7 +112,8 @@ export const FLIGHT_PROGRESS_MS = 250; export function routeDistanceText(meters) { if (!Number.isFinite(meters) || meters < 0) return ''; if (meters < 100) return formatQuantity(Math.round(meters), 'm'); - if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm'); + const tens = Math.round(meters / 10) * 10; + if (tens < 1000) return formatQuantity(tens, 'm'); const km = meters / 1000; return km < 10 ? formatQuantity(km, 'km', { From 6c225605c40aeb37964a6cfd489d3c0b83dc3c2b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:19 -0400 Subject: [PATCH 143/178] fix(awareness): show a 999.6 m contact as 1.0 km, not 1000 m The Contacts panel checked the raw meters against 1000 but printed them rounded, so 999.5 m or more read "1000 m". Compare the rounded meters so the distance moves to kilometres. --- changelog.d/1387-awareness-distance-kilometre.md | 3 +++ src/data/militaryAwareness.test.mjs | 11 ++++++++++- src/layers/awareness/panel.js | 5 +++-- 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1387-awareness-distance-kilometre.md diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md new file mode 100644 index 000000000..4414b7c5f --- /dev/null +++ b/changelog.d/1387-awareness-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 64104953e..ccb5739f2 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -42,7 +42,10 @@ import { formatAwarenessLabel, getAwarenessNavigationTargets, } from './militaryAwarenessEngine.js'; -import { formatAwarenessCount } from '../layers/awareness/panel.js'; +import { + formatAwarenessCount, + presentAwarenessDistance, +} from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; const militaryAwarenessSource = readLayerSource( @@ -69,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => { assert.equal(formatAwarenessCount({ count: null }), '?'); }); +test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => { + assert.equal(presentAwarenessDistance(999.4), '999\u00a0m'); + assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km'); + assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km'); +}); + function installAwarenessRuntime({ isEnabled = () => true, isEffectivelyEnabled = isEnabled, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 5fe2a0ac8..120e91b2c 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) { } /** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */ -function presentAwarenessDistance(meters) { +export function presentAwarenessDistance(meters) { if (!Number.isFinite(meters) || meters < 0) return '—'; - if (meters < 1000) return formatQuantity(Math.round(meters), 'm'); + const wholeMeters = Math.round(meters); + if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm'); const digits = meters < 10000 ? 1 : 0; return formatQuantity(meters / 1000, 'km', { minimumFractionDigits: digits, From be869f916061017eadb251328829579e055c3a9a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:22 -0700 Subject: [PATCH 144/178] docs: document PR #1385 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..848c6714f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4133,6 +4133,9 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. +Route duration labels select their unit after rounding seconds: a leg of +59.5 seconds or more displays as `1 min`, rather than `60 s`. + A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. What a card needs is a resolved ground anchor: a step whose ground cell has not From d6ae581652effa0c207992ceaf57c68bf673b6e2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:30 -0700 Subject: [PATCH 145/178] docs: document PR #1386 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..7543cb4f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4081,6 +4081,9 @@ routing it shows `Routing…`, and a router miss reads The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. +Route distance labels round to the nearest ten metres before selecting the +unit, so 995 metres and above display as `1.0 km` rather than `1000 m`. + **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From c30d2d34734b5256c7ec21a2ac65d40a22bc10c1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:37 -0700 Subject: [PATCH 146/178] docs: document PR #1387 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..dd9b15cda 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4363,6 +4363,9 @@ silent for five minutes are removed. Credits appear when the feed is used. coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. +Contacts distance labels select metres or kilometres after rounding to the +nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`. + At global scale, ambient Radio cluster badges are hard-opacity shared-host entries: count/category updates and identity replacement do not run keyhole or enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon From 4d64ac48bc20509075e31519019349386e275f8f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:58 -0700 Subject: [PATCH 147/178] docs: clarify unit transition examples --- docs/CURRENT-STATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 848c6714f..af916fcaf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4133,8 +4133,8 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. -Route duration labels select their unit after rounding seconds: a leg of -59.5 seconds or more displays as `1 min`, rather than `60 s`. +Route duration labels select their unit after rounding seconds, switching to +minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`. A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. From 455dbf2b5f56932782bdb2b07ec435fabec9b559 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:49:00 -0700 Subject: [PATCH 148/178] docs: clarify unit transition examples --- docs/CURRENT-STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7543cb4f0..f171806e0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4082,7 +4082,8 @@ The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. Route distance labels round to the nearest ten metres before selecting the -unit, so 995 metres and above display as `1.0 km` rather than `1000 m`. +unit, switching to kilometres at 995 metres; a 996-metre leg displays as +`1.0 km` rather than `1000 m`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From 48e649a95e57a9ae6d4f02dd89b16c0237b2e0e6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:50:11 -0700 Subject: [PATCH 149/178] docs(directions): scope the distance rounding threshold precisely --- docs/CURRENT-STATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f171806e0..87fadd8a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4081,9 +4081,9 @@ routing it shows `Routing…`, and a router miss reads The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. -Route distance labels round to the nearest ten metres before selecting the -unit, switching to kilometres at 995 metres; a 996-metre leg displays as -`1.0 km` rather than `1000 m`. +At the metre/kilometre boundary, route distance labels use the rounded +ten-metre value to select the unit, switching to kilometres at 995 metres; +a 996-metre leg displays as `1.0 km` rather than `1000 m`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From 496d024431b95bb5bbddd5dac17b00fd01ad1409 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:18:22 -0700 Subject: [PATCH 150/178] fix(hud): drop a summary reply once the camera starts moving (#1384) Discard stale HUD summaries when camera movement starts and allow cancelled requests to retry for unchanged place context. Contribution by @quanticsoul4772. --- changelog.d/1384-hud-summary-moving.md | 4 + docs/CURRENT-STATE.md | 1 + src/hud.js | 47 +++- src/hudAltitudeDatum.test.mjs | 343 ++++++++++++++++++++++++- 4 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1384-hud-summary-moving.md diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md new file mode 100644 index 000000000..21965af96 --- /dev/null +++ b/changelog.d/1384-hud-summary-moving.md @@ -0,0 +1,4 @@ +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eceb4c239..9afb99c45 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5107,6 +5107,7 @@ and unreachable upstream (502/504) separately from road geometry. - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. ### Place-search providers diff --git a/src/hud.js b/src/hud.js index 8190f4b1b..cf7983da7 100644 --- a/src/hud.js +++ b/src/hud.js @@ -224,6 +224,20 @@ export class IntelHUD { // session, so this flips once — and both altitude readouts have to move // together when it does (see the repaint in _updateCameraData). this._geoidCorrectionApplied = false; + // A summary built for one view must not land on the next: a request in + // flight when the camera starts moving is aborted and nothing it ends + // with paints (the revision moves), a reply still typing stops, and the + // summary is marked dirty so the next periodic tick asks about the view + // the camera has moved to. + this._onCameraMoveStart = () => { + this._markSummaryDirty(); + if (this._summaryRequest || this._summaryTypingInterval) { + // Cancelled requests and partially typed replies are not reusable answers. + this._lastSummarySignature = null; + } + this._summaryRequest?.abort(); + this._stopSummaryTyping(); + }; this._onCameraMoveEnd = () => { this._markSummaryDirty(); // The 250 ms telemetry timer and 15 s semantic-summary timer must not @@ -249,6 +263,7 @@ export class IntelHUD { this._passNum = 100 + Math.floor(Math.random() * 200); this._buildDOM(); + this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this._startTimers(); } @@ -766,6 +781,12 @@ export class IntelHUD { return provenance ? `${line} | ${provenance}` : line; } + /** Stop a summary still typing out, leaving what it has typed so far. */ + _stopSummaryTyping() { + clearInterval(this._summaryTypingInterval); + this._summaryTypingInterval = null; + } + /** * Animate the summary text into the DOM using a typewriter effect * (2 characters every 24ms). @@ -816,8 +837,10 @@ export class IntelHUD { } catch (error) { console.warn('[HUD] summary context unavailable:', error); // Left dirty on purpose: the next periodic tick retries instead of - // sticking on the fallback line for the rest of the session. - this._setSummaryText(fallbackText, animate); + // sticking on the fallback line for the rest of the session. The + // fallback was composed for this view; a moved camera keeps its own. + if (revision === this._summaryRevision) + this._setSummaryText(fallbackText, animate); return; } if (revision !== this._summaryRevision) return; @@ -832,7 +855,11 @@ export class IntelHUD { this._summaryDirty = false; this._lastSummarySignature = signature; const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 5000); + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, 5000); this._summaryRequest = controller; try { this.summaryPolicy.onRequest?.(); @@ -855,17 +882,24 @@ export class IntelHUD { animate, ); } catch (error) { - if (error?.name !== 'AbortError') { + // The 5 s timeout is a failure like any other; a move's abort (or + // destroy's) is not. + const moved = revision !== this._summaryRevision; + if (error?.name !== 'AbortError' || timedOut) { console.warn('[HUD] AI summary unavailable:', error); // Invalidate the committed signature so the next periodic tick // retries instead of sticking on the fallback line forever. this._lastSummarySignature = null; this._summaryDirty = true; } - this._setSummaryText(fallbackText, animate); + if (!moved) this._setSummaryText(fallbackText, animate); } finally { window.clearTimeout(timeout); if (this._summaryRequest === controller) this._summaryRequest = null; + // A request whose answer was dropped (aborted by a move, or a reply + // that came back after one) committed a signature it never painted; + // forget it, or the next tick over the same place would skip asking. + if (revision !== this._summaryRevision) this._lastSummarySignature = null; } } @@ -874,6 +908,8 @@ export class IntelHUD { this._typeSummary(text); return; } + // A plain paint replaces any reply still typing. + this._stopSummaryTyping(); const el = document.getElementById('hud-summary'); if (el) el.textContent = text; } @@ -1044,6 +1080,7 @@ export class IntelHUD { clearInterval(this._timestampInterval); clearInterval(this._summaryInterval); clearInterval(this._summaryTypingInterval); + this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this._dataManagerUnsubscribe?.(); this._summaryRequest?.abort(); diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index eeb407da4..ca14569e5 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -16,6 +16,9 @@ // globals exist: Cesium's widget bundle probes for a real `document` at module // scope and a partial stub sends it down the browser path. Hence hook → // import → install DOM, in that order. +// +// The same hook is why the live AI-summary test lives here too: this is the +// one file that can construct a real IntelHUD. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -33,7 +36,8 @@ registerHooks({ return { format: 'module', shortCircuit: true, - source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', }; } return next(url, context); @@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15; */ function installHudEnvironment() { const elements = new Map( - ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] - .map((id) => [id, { textContent: '' }]), + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), ); const previousDocument = globalThis.document; + const cameraListeners = {}; globalThis.document = { getElementById: (id) => elements.get(id) ?? null, querySelectorAll: () => [], @@ -78,12 +92,24 @@ function installHudEnvironment() { height: SFO_ELLIPSOIDAL_M, }, computeViewRectangle: () => undefined, - moveEnd: { addEventListener() {}, removeEventListener() {} }, + moveStart: { + addEventListener(listener) { + cameraListeners.moveStart = listener; + }, + removeEventListener() {}, + }, + moveEnd: { + addEventListener(listener) { + cameraListeners.moveEnd = listener; + }, + removeEventListener() {}, + }, }, }; return { elements, viewer, + cameraListeners, restore() { if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; @@ -93,7 +119,9 @@ function installHudEnvironment() { test('hud.js corrects the camera height to MSL through the geoid module', () => { assert.equal( - has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s), + has( + /import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s, + ), true, 'hud.js must take the datum correction from ./data/geoid.js, not re-derive one', ); @@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () => 'the ~2.7 MB grid must be requested once, on demand — not at HUD construction', ); assert.equal( - has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/), + has( + /\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/, + ), true, 'a failed geoid load must leave the readout uncorrected, not unhandled', ); @@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => { // Both are on screen together; a viewer reading "ALT -15M" in one corner and // "ALT: 17m" in the other has found a bug, not a distinction. assert.equal( - has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/), + has( + /const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/, + ), true, 'the summary altitude tag must prefer the MSL datum and fall back to the raw height', ); @@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in // Tick 1 — cold. This is also the tick that requests the grid. hud._updateCameraData(); assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`); - assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`); + assert.match( + summary(), + /\| ALT -15M \|/, + `cold summary tag, got ${summary()}`, + ); // The HUD registered its own continuation on this same shared promise // during tick 1, and it registered first, so awaiting here means its @@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig env.restore(); } }); + +test('a summary asked for one view is dropped once the camera moves', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context) => + new Promise((resolve) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary, error: null } }), + }); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + let place = 'Yellowstone'; + hud._summaryContext = async () => ({ place }); + const summary = () => env.elements.get('hud-summary').textContent; + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The periodic tick's call (force=false): it asks only for a dirty + // summary. Asked over Yellowstone; the camera leaves before the reply + // lands. + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + pending[0].reply('Canyon Junction Wyoming'); + await first; + assert.doesNotMatch( + summary(), + /Canyon Junction/, + 'a reply for the old view is dropped', + ); + + // The next tick, with the camera still moving (a follow camera or an + // orbit emits no moveEnd), asks about where it is now and paints it. + place = 'Atlantic'; + const moving = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'the move marked the summary dirty'); + assert.deepEqual(pending[1].context, { place: 'Atlantic' }); + pending[1].reply('North Atlantic Ocean'); + await moving; + assert.equal(summary(), 'North Atlantic Ocean'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + // Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`. + let ignoreAbort = false; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ signal, reject }); + if (!ignoreAbort) + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'Yellowstone' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The move aborts the request in flight, and the abort paints nothing. + let update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + assert.equal(pending[0].signal.aborted, true, 'the request is aborted'); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // A request that fails after the move (an error, the 5 s timeout) does + // not paint the old view's fallback line either. + ignoreAbort = true; + update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + pending[1].reject(new Error('network')); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // Nor does a context build that throws once the camera has moved. + hud._summaryContext = async () => { + env.cameraListeners.moveStart(); + throw new Error('view target unavailable'); + }; + element.textContent = 'shown before the move'; + await hud._updateSummary(false, true); + assert.equal(element.textContent, 'shown before the move'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a cancelled summary retries the same context but completed answers stay reusable', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary } }), + }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + await first; + + // An orbit can leave all semantic place/layer labels unchanged. The next + // periodic tick must retry even though its context equals the aborted one. + const retry = hud._updateSummary(false); + await settle(); + assert.equal( + pending.length, + 2, + 'the cancelled signature cannot suppress a retry', + ); + assert.deepEqual(pending[1].context, pending[0].context); + pending[1].reply('San Francisco Bay Urban Landscape'); + await retry; + assert.equal( + env.elements.get('hud-summary').textContent, + 'San Francisco Bay Urban Landscape', + ); + + // Movement without an active request must not discard a completed answer. + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(pending.length, 2, 'completed context still deduplicates'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ resolve }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + // The same place before and after: a slow follow camera, a small pan. + hud._summaryContext = async () => ({ place: 'Lisboa' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // A move aborts the request; the next tick over the same place asks again. + let update = hud._updateSummary(false); + await settle(); + env.cameraListeners.moveStart(); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'asked again after the abort'); + + // That one times out at 5 s: retried like any failure. + t.mock.timers.tick(5_000); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 3, 'asked again after the timeout'); + pending[2].resolve({ + ok: true, + status: 200, + data: { summary: 'Lisboa Rua Augusta', error: null }, + }); + await update; + + // A reply still typing stops when the camera starts moving. + hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park'); + t.mock.timers.tick(48); + const typed = element.textContent; + env.cameraListeners.moveStart(); + t.mock.timers.tick(2_000); + assert.equal( + element.textContent, + typed, + 'no more characters after the move', + ); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a summary interrupted while typing can retry unchanged context', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + let calls = 0; + const text = 'San Francisco Bay Urban Landscape'; + const summaryService = { + summarize: async () => { + calls++; + return { ok: true, status: 200, data: { summary: text } }; + }, + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + await hud._updateSummary(true); + t.mock.timers.tick(48); + const element = env.elements.get('hud-summary'); + assert.notEqual(element.textContent, text, 'the answer is still typing'); + assert.equal( + hud._summaryRequest, + null, + 'the network request already finished', + ); + env.cameraListeners.moveStart(); + assert.equal(hud._summaryTypingInterval, null); + await hud._updateSummary(false); + assert.equal( + calls, + 2, + 'a partially displayed answer must not suppress retry', + ); + assert.equal(element.textContent, text); + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(calls, 2, 'the fully displayed answer remains reusable'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); From 99429904d4c80709d4e4955aa908aedb9931972d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:10 -0400 Subject: [PATCH 151/178] fix(draw): show a 999.6 m line as 1.0 km, not 1000 m formatMeasure checked the raw length against 1000 but printed it rounded, so a drawn line of 999.5 m or more read "1000 m" in the hint. Compare the rounded metres so it moves to kilometres. --- changelog.d/1388-draw-line-kilometre.md | 3 +++ src/annotations/drawMode.js | 7 ++++--- src/annotations/drawMode.test.mjs | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1388-draw-line-kilometre.md diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md new file mode 100644 index 000000000..137075707 --- /dev/null +++ b/changelog.d/1388-draw-line-kilometre.md @@ -0,0 +1,3 @@ +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index fcfe08be6..538c2e5da 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -208,9 +208,10 @@ export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { const m = pathLengthM(session.vertices); - return m >= 1000 - ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` - : `${Math.round(m)} m`; + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const whole = Math.round(m); + if (whole < 1000) return `${whole} m`; + return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 50475cc9e..be9894dda 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', ( assert.equal(wrapLongitude(180), -180); assert.equal(unwrapLongitudes([]).length, 0); }); + +test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + const lat = 999.6 / metresPerDegree; + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0, lat }); + const metres = pathLengthM(line.vertices); + assert.ok( + metres >= 999.5 && metres < 1000, + `length should round to 1000 m, got ${metres}`, + ); + assert.equal(formatMeasure(line), '1.0 km'); + assert.match(drawHint(line), /^1\.0 km · /); +}); From 5ef12391e143099e445912222f8105633dad0cb9 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:18 -0400 Subject: [PATCH 152/178] =?UTF-8?q?fix(cockpit):=20show=20wind=20from=2035?= =?UTF-8?q?9.6=C2=B0=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatCockpitWindDirection rounded the bearing and printed that number, so wind from 359.5° or more read "N · 360°". Take the rounded degrees modulo 360 so north reads 0°. --- changelog.d/1389-cockpit-wind-north.md | 3 +++ src/ui/cockpitPresentation.js | 4 +++- src/ui/cockpitPresentation.test.mjs | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1389-cockpit-wind-north.md create mode 100644 src/ui/cockpitPresentation.test.mjs diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md new file mode 100644 index 000000000..cc81c362e --- /dev/null +++ b/changelog.d/1389-cockpit-wind-north.md @@ -0,0 +1,3 @@ +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 8285b74b3..b9c000503 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -156,7 +156,9 @@ export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); const normalized = ((value % 360) + 360) % 360; - return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`; + // 359.5° and above is north. Modulo keeps that from printing 360°. + const degrees = Math.round(normalized) % 360; + return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } // Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a diff --git a/src/ui/cockpitPresentation.test.mjs b/src/ui/cockpitPresentation.test.mjs new file mode 100644 index 000000000..da5c8e53b --- /dev/null +++ b/src/ui/cockpitPresentation.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatCockpitWindDirection } from './cockpitPresentation.js'; + +test('wind just west of north reads 0°, not 360°', () => { + assert.equal(formatCockpitWindDirection(90), 'E · 90°'); + assert.equal(formatCockpitWindDirection(0), 'N · 0°'); + assert.equal(formatCockpitWindDirection(359.4), 'N · 359°'); + assert.equal(formatCockpitWindDirection(359.6), 'N · 0°'); +}); From 63866c201af35acabcef580662cae152d62faf20 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 13:28:23 -0400 Subject: [PATCH 153/178] fix(awareness): wrap north-bound bearings --- changelog.d/1390-awareness-bearing-north.md | 2 ++ src/layers/awareness/presentation.js | 10 ++++++++++ src/layers/awareness/presentation.test.mjs | 11 +++++++++++ src/layers/awareness/rendering.js | 5 +++-- 4 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1390-awareness-bearing-north.md create mode 100644 src/layers/awareness/presentation.js create mode 100644 src/layers/awareness/presentation.test.mjs diff --git a/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md new file mode 100644 index 000000000..af7fb4da3 --- /dev/null +++ b/changelog.d/1390-awareness-bearing-north.md @@ -0,0 +1,2 @@ +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js new file mode 100644 index 000000000..7439907d9 --- /dev/null +++ b/src/layers/awareness/presentation.js @@ -0,0 +1,10 @@ +/** + * Format a compass direction for the awareness overlay. + * @param {number} degrees + * @returns {string} + */ +export function formatAwarenessDirection(degrees) { + if (!Number.isFinite(degrees)) return '—'; + const normalized = ((degrees % 360) + 360) % 360; + return String(Math.round(normalized) % 360).padStart(3, '0'); +} diff --git a/src/layers/awareness/presentation.test.mjs b/src/layers/awareness/presentation.test.mjs new file mode 100644 index 000000000..54d40765f --- /dev/null +++ b/src/layers/awareness/presentation.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatAwarenessDirection } from './presentation.js'; + +test('awareness bearings that round through north read 000, not 360', () => { + assert.equal(formatAwarenessDirection(359.4), '359'); + assert.equal(formatAwarenessDirection(359.6), '000'); + assert.equal(formatAwarenessDirection(0), '000'); + assert.equal(formatAwarenessDirection(-0.4), '000'); + assert.equal(formatAwarenessDirection(Number.NaN), '—'); +}); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8fe147e46..957e0bbdd 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -11,6 +11,7 @@ import { } from '../../data/militaryAwarenessEngine.js'; import { bearingBetweenCoordinates } from '../../cockpitMath.js'; import { AWARENESS_PAGE_SIZE, CONTEXT_RIM_HEIGHT_M } from './policy.js'; +import { formatAwarenessDirection } from './presentation.js'; export function createRendering({ state: layerState, @@ -225,12 +226,12 @@ export function createRendering({ : null; const bearingText = Number.isFinite(bearing) ? t('BRG {value}°', { - value: String(Math.round(bearing)).padStart(3, '0'), + value: formatAwarenessDirection(bearing), }) : t('BRG —'); const courseText = Number.isFinite(item.track) ? ` · ${t('CRS {value}°', { - value: String(Math.round(item.track)).padStart(3, '0'), + value: formatAwarenessDirection(item.track), })}` : ''; marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`; From f81ef406f7cafdb1a03ecb2a28aa19ca760cd23b Mon Sep 17 00:00:00 2001 From: John Rogers Date: Sun, 25 Oct 2026 14:08:14 -0500 Subject: [PATCH 154/178] =?UTF-8?q?(=E3=81=A3=E2=96=80=C2=AF=E2=96=80)?= =?UTF-8?q?=E3=81=A4=20--=20Issue=20#1240:=20added=20docker=20health=20che?= =?UTF-8?q?cks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Dockerfile b/Dockerfile index d34f4887a..4628279af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ COPY --chown=node:node . . ENV HOST=0.0.0.0 ENV PORT=4173 EXPOSE 4173 +# node:slim has no curl, so probe with Node's built-in fetch. 127.0.0.1 is +# always on the Host allowlist, and the generous start period covers Vite's +# first-run dependency optimisation. +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. From 6ebdee6710bbfae5e678feb414b300a72a8a2efa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:19 -0700 Subject: [PATCH 155/178] docs: document PR #1388 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..438d41776 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5240,6 +5240,7 @@ are omitted rather than framing the wrong part of the globe. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. From 8f025416573a88557ea65797750c31d80975960f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:49 -0700 Subject: [PATCH 156/178] docs: document PR #1389 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..dedc6489b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5460,6 +5460,7 @@ pins and share links already supply complete boxes. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. From 69fba2ebbe44d04d237852cc6b0c71a00d9db1e6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:36:50 -0700 Subject: [PATCH 157/178] fix(perf): free a turned-off layer's entities that Cesium keeps (#1367) Release stale Cesium command references after disabling a layer while preserving current-frame commands. Contribution by @quanticsoul4772. --- changelog.d/1367-turned-off-layer-memory.md | 4 + docs/CURRENT-STATE.md | 10 ++ scripts/package-boundaries.json | 3 +- src/app/tools.js | 10 ++ src/sceneCommandRelease.js | 74 +++++++++++ src/sceneCommandRelease.test.mjs | 137 ++++++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1367-turned-off-layer-memory.md create mode 100644 src/sceneCommandRelease.js create mode 100644 src/sceneCommandRelease.test.mjs diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md new file mode 100644 index 000000000..08bbbad81 --- /dev/null +++ b/changelog.d/1367-turned-off-layer-memory.md @@ -0,0 +1,4 @@ +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9bc87ad2..b15babbb2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2159,6 +2159,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > always exits non-zero, `1` meaning the control is healthy and `2` meaning it > is not. Plus the unit pins above. +> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138 +> keeps the draw commands past each frame's count in the opaque and other +> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view, +> so a turned-off layer's entities stayed reachable (Data Centers kept 116 +> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, +> requests one frame through `governorRequestRender` after every layer +> disable and then trims those arrays. It reads private Cesium fields; +> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames +> them or changes which passes it truncates. + > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > governor (`src/renderGovernor.js` — hold/release from every per-frame > animator; discrete mutators call `governorRequestRender`). Any NEW diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c84a11cad..68a57066a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2574,7 +2574,8 @@ "src/voice/geminiSession.js", "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/sceneCommandRelease.js" ], "external": [ "hls.js", diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..7aea39c22 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -110,6 +111,15 @@ export function createApplicationTools({ // nothing animates per frame. Installed AFTER every module above has had // its chance to register pre-install holds. (perf wave 2) installRenderGovernor(viewer); + // Cesium keeps a turned-off layer's draw commands (and so all its entities) + // alive in reused per-pass arrays; trim them after each disable. + defer( + installLayerReleaseTrim({ + scene: viewer.scene, + dataManager, + requestRender: governorRequestRender, + }), + ); // Install the explicit scope mask used by the DISPLAY controls. installScopeMask(viewer); diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js new file mode 100644 index 000000000..4d8594401 --- /dev/null +++ b/src/sceneCommandRelease.js @@ -0,0 +1,74 @@ +/** + * Let a turned-off layer's memory go. + * + * Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command + * arrays: every frame resets `frustumCommands.indices[pass]` to 0 and + * overwrites `commands[pass][i]` from the start. `Scene.js` truncates the + * voxel, Gaussian-splat and translucent passes to their counts; the opaque + * and other passes keep the slots past the new count. When a layer is turned + * off the next frame has fewer commands, so that tail keeps the destroyed + * primitives' DrawCommands, and through `_owner` → primitive → entity → + * EntityCollection the whole data source stays reachable. The offscreen view + * `scene.sampleHeight` / `pickFromRay` render into keeps its last list the + * same way, until the next height sample. + * + * Measured (production build, heap after GC, Data Centers on → off): 116 MiB + * kept on main, 13 MiB with this module; heap snapshots traced the entities + * through `View.frustumCommandsList` and `Picking._pickOffscreenView`. + * + * After a layer finishes turning off, this renders one frame, trims the main + * view's arrays to that frame's counts, and empties the offscreen pick view's + * lists (it rebuilds them in full on every pick). It reads private Cesium + * fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed + * or if Cesium changes which passes it truncates, and once Cesium clears the + * tail itself this module can go. + */ + +/** Drop command references a view no longer draws. */ +function trimView(view, { keepCurrent }) { + for (const frustum of view?.frustumCommandsList || []) { + const { commands, indices } = frustum; + for (let pass = 0; pass < commands.length; pass += 1) { + const keep = keepCurrent ? indices[pass] : 0; + if (commands[pass].length > keep) commands[pass].length = keep; + if (!keepCurrent) indices[pass] = 0; + } + } + if (!keepCurrent) + for (const extent of view?._commandExtents || []) + extent.command = undefined; +} + +/** + * Release stale command references in the scene's main view (keeping what + * the last frame drew) and in its offscreen pick view (all of it). + * @param {object} scene Cesium Scene, right after a render. + */ +export function trimStaleSceneCommands(scene) { + trimView(scene._defaultView, { keepCurrent: true }); + trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); +} + +/** + * After every layer disable, render one frame and trim once it is drawn. + * @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps + * @returns {() => void} uninstall + */ +export function installLayerReleaseTrim({ scene, dataManager, requestRender }) { + let removeOneShot = null; + const unsubscribe = dataManager.subscribe((change) => { + if (change?.type !== 'visibility' || change.enabled !== false) return; + if (removeOneShot) return; // a trim is already waiting for its frame + removeOneShot = scene.postRender.addEventListener(() => { + removeOneShot(); + removeOneShot = null; + trimStaleSceneCommands(scene); + }); + requestRender('layer-release-trim'); + }); + return () => { + unsubscribe(); + removeOneShot?.(); + removeOneShot = null; + }; +} diff --git a/src/sceneCommandRelease.test.mjs b/src/sceneCommandRelease.test.mjs new file mode 100644 index 000000000..d88037872 --- /dev/null +++ b/src/sceneCommandRelease.test.mjs @@ -0,0 +1,137 @@ +// A turned-off layer's draw commands must not stay reachable through Cesium's +// reused per-pass command arrays (src/sceneCommandRelease.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + installLayerReleaseTrim, + trimStaleSceneCommands, +} from './sceneCommandRelease.js'; + +const frustum = (lists, counts) => ({ + commands: lists.map((list) => [...list]), + indices: [...counts], +}); + +test('the main view keeps what the last frame drew and drops the stale tail', () => { + const view = { + frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])], + }; + trimStaleSceneCommands({ _defaultView: view }); + assert.deepEqual(view.frustumCommandsList[0].commands, [ + ['a', 'b'], + ['c'], + [], + ]); + assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]); +}); + +test('the offscreen pick view is emptied, since every pick rebuilds it', () => { + const extents = [{ command: 'x' }, { command: 'y' }]; + const pick = { + frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])], + _commandExtents: extents, + }; + trimStaleSceneCommands({ + _defaultView: { frustumCommandsList: [] }, + _picking: { _pickOffscreenView: pick }, + }); + assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]); + assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]); + assert.deepEqual( + extents.map((e) => e.command), + [undefined, undefined], + ); +}); + +test('a layer disable renders one frame and trims after it', () => { + const listeners = new Set(); + const postRender = new Set(); + const requests = []; + const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] }; + const scene = { + _defaultView: view, + postRender: { + addEventListener(fn) { + postRender.add(fn); + return () => postRender.delete(fn); + }, + }, + }; + const uninstall = installLayerReleaseTrim({ + scene, + dataManager: { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }, + requestRender: (reason) => requests.push(reason), + }); + const emit = (change) => listeners.forEach((fn) => fn(change)); + + emit({ type: 'visibility', layerId: 'x', enabled: true }); + assert.equal(requests.length, 0, 'enabling trims nothing'); + emit({ type: 'visibility', layerId: 'x', enabled: false }); + emit({ type: 'visibility', layerId: 'y', enabled: false }); + assert.deepEqual( + requests, + ['layer-release-trim'], + 'one frame for back-to-back disables', + ); + assert.equal( + view.frustumCommandsList[0].commands[0].length, + 2, + 'not before the frame', + ); + postRender.forEach((fn) => fn()); + assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]); + assert.equal(postRender.size, 0, 'the frame listener is one-shot'); + uninstall(); + assert.equal(listeners.size, 0); +}); + +// The workaround reads private Cesium fields. Pin the shape it relies on, and +// fail when Cesium starts clearing the tail itself so the module can go. +test('Cesium still reuses per-pass command arrays without clearing their tail', () => { + const require = createRequire(import.meta.url); + const enginePkg = require.resolve('@cesium/engine/package.json'); + const read = (rel) => + readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8'); + const scene = read('./Source/Scene/Scene.js'); + const view = read('./Source/Scene/View.js'); + const frustumCommands = read('./Source/Scene/FrustumCommands.js'); + const picking = read('./Source/Scene/Picking.js'); + // Every private field the trim reads. A rename would otherwise turn the + // trim into a silent no-op (it reaches them through `?.` and `|| []`). + assert.match(scene, /this\._picking = new Picking\(this\);/); + assert.match(scene, /this\._defaultView = new View\(/); + assert.match(picking, /this\._pickOffscreenView = new View\(/); + assert.match(view, /this\.frustumCommandsList = \[\];/); + assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/); + assert.match(view, /this\._commandExtents = \[\];/); + assert.match( + frustumCommands, + /this\.commands = commands;\s*this\.indices = indices;/, + ); + // Cesium truncates only the voxel, Gaussian-splat and translucent passes, + // each in its own draw step; the opaque and other passes keep their tail. + // A change here means Cesium's handling moved: re-check whether this + // module is still needed. + assert.doesNotMatch( + view, + /\.length\s*=\s*[^;]*indices\[/, + 'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js', + ); + assert.deepEqual( + [ + ...scene.matchAll( + /commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g, + ), + ].map((match) => match[1]), + ['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'], + 'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js', + ); +}); From ac8dc84f3cca503a55486de5211e69ab6f93b459 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sun, 25 Oct 2026 19:43:01 +0000 Subject: [PATCH 158/178] docs: require screenshots, recording, category and data licence on new-layer PRs (#957) Document visual evidence, category and data licensing requirements for new layers, using the changelog fragment workflow. Contribution by @asturksever. --- .github/pull_request_template.md | 59 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 29 +++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..e8c9f8bdf --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,59 @@ +## What this changes + + + +## How I verified it + +- [ ] `npm run build` +- [ ] `npm test` +- [ ] `npm run test:track` (dev server running) +- [ ] Feature gate for the area I touched: + + + +## New layer checklist + + + +**Screenshots** + + + +**Recording** + + + +**Proposed category** + + + +Category: + +**Data source and licence** + +- Source (provider and endpoint): +- Licence or terms (link): +- Attribution shown in the app: +- Needs a key? (none / optional / required): +- Fetched at runtime, or bundled in the repo: + + + +- [ ] Added a row to `DATA_SOURCES.md` +- [ ] Credit is registered in `src/data/dataCredits.js` +- [ ] Share-link token assigned with `npm run layer-token:next`, if the layer is shareable + +## Docs + +- [ ] `docs/CURRENT-STATE.md` updated and a `changelog.d/-.md` fragment added, or this PR doesn't change runtime behaviour diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb2c2de9e..a7fe8ab76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -108,7 +108,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Adding another city means a clean public camera catalog with coordinates, attribution, and server-registered frame URLs (the proxy only fetches registered URLs — never client-supplied ones, see [SECURITY.md](SECURITY.md)). City packs are the best first lane. -- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template. +- **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. - **🐛 Fix bugs / improve the first-run experience.** See [docs/KNOWN-ISSUES.md](docs/KNOWN-ISSUES.md). @@ -153,6 +153,33 @@ ownership and adoption process. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). +6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). + +## Adding a new layer + +A new layer changes what everyone sees on the globe, so maintainers need to see +it working and know where its data comes from before reviewing the code. PRs +that add a layer must include all four of these in the description. The PR +template has a section for them. + +1. **Screenshots.** At least one of the layer on the globe, plus its info card + or panel if it has one. +2. **A screen recording**, preferably 15 to 30 seconds: turn the layer on, move + around, and open a record. Drag an `.mp4` or `.mov` into the PR description. + GitHub accepts videos up to 10 MB on free accounts, so link to a larger file + instead of attaching it. +3. **The proposed category.** Say which layer-panel group the layer belongs in. + The groups are defined in `PANEL_GROUPS` in `src/ui/layerPanel.js` (today: + Movement, Cameras, Infrastructure, Events, Weather and Utilities). A layer + that isn't listed there falls under "Other layers". If none of the groups + fits, propose a new one and explain why. +4. **The data source and licence.** Name the provider and endpoint, link its + licence or terms, give the attribution shown in the app, and say whether it + needs a key and whether the data is fetched at runtime or bundled. Add the + same details to [DATA_SOURCES.md](DATA_SOURCES.md) and register the credit + in `src/data/dataCredits.js`. + +Maintainers may hold a layer PR until all four are there. ## Maintainers From 57df5f61ba84ed23628180388241a1765a523a9b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:45 -0400 Subject: [PATCH 159/178] fix(fires): report a 00:45 UTC detection as 00:45, not "45" get_active_fires joined FIRMS acq_date and the unpadded acq_time, so a detection at 00:45 UTC came back as "2026-01-01 45". The row now uses acquisitionMsUtc, which pads the time, and reports an ISO UTC timestamp like the other tool times. --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++++ src/tools/queries/hazards.js | 4 ++-- src/tools/queries/hazards.test.mjs | 17 +++++++++++++++++ src/tools/queries/queries.test.mjs | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1395-fires-tool-acquired-time.md create mode 100644 src/tools/queries/hazards.test.mjs diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md new file mode 100644 index 000000000..ea3bddd1d --- /dev/null +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -0,0 +1,4 @@ +- Report a fire detection's acquisition time as a UTC timestamp in + `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a + 00:45 UTC detection read "2026-01-01 45"; it now reads + `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 475df97ba..f644be562 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,5 +1,6 @@ /** Natural hazard queries: earthquakes and active fires. */ +import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; @@ -114,8 +115,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: - [fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null, + acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs new file mode 100644 index 000000000..fe2e6ac1f --- /dev/null +++ b/src/tools/queries/hazards.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { composeCatalog } from '../catalog.js'; +import { getActiveFires } from './hazards.js'; + +const catalogWith = (fires) => + composeCatalog({ + tools: [getActiveFires], + services: { fires: { getSnapshot: async () => ({ fires }) } }, + }); + +test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { + const result = await catalogWith([ + { lat: 24, lon: 121, frp: 5, acqDate: '2026-01-01', acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); +}); diff --git a/src/tools/queries/queries.test.mjs b/src/tools/queries/queries.test.mjs index b64f5e009..5f53cf9e8 100644 --- a/src/tools/queries/queries.test.mjs +++ b/src/tools/queries/queries.test.mjs @@ -125,7 +125,7 @@ test('fires need an area and report a missing key as unavailable', async () => { result.data.rows.map((row) => row.frp_mw), [40, 12], ); - assert.equal(result.data.rows[1].acquired, '2026-01-01 0130'); + assert.equal(result.data.rows[1].acquired, '2026-01-01T01:30:00.000Z'); const keyless = composeCatalog({ tools: coreTools, From 315c0f5dc61304cdf0adbd71e5efb1633e47e474 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 15:58:46 -0400 Subject: [PATCH 160/178] =?UTF-8?q?fix(local-adsb):=20show=20a=20359.7?= =?UTF-8?q?=C2=B0=20track=20as=20TRK=200=C2=B0,=20not=20TRK=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Local ADS-B card rounded the track without wrapping, so an aircraft tracking 359.5° or more read "TRK 360°". The rounded track now wraps to 0°. --- changelog.d/1396-local-adsb-track-north.md | 3 +++ src/layers/localAdsb/card.js | 2 +- src/layers/localAdsb/card.test.mjs | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1396-local-adsb-track-north.md create mode 100644 src/layers/localAdsb/card.test.mjs diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md new file mode 100644 index 000000000..9235a19a3 --- /dev/null +++ b/changelog.d/1396-local-adsb-track-north.md @@ -0,0 +1,3 @@ +- Show a locally heard aircraft tracking 359.5° or more as TRK 0°. The Local + ADS-B card rounded the track without wrapping, so 359.7° read "TRK 360°" + (Rudy Celekli, @rudycelekli, #1396). diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index 1b0122925..c1c923a8c 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -152,7 +152,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track}°`, + track: track === null ? DASH : `${track % 360}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/localAdsb/card.test.mjs b/src/layers/localAdsb/card.test.mjs new file mode 100644 index 000000000..ec53024da --- /dev/null +++ b/src/layers/localAdsb/card.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale } from '../../i18n/index.js'; +import { localAdsbCardModel } from './card.js'; + +test('a track of 359.7° reads TRK 0°, not TRK 360°', () => { + setLocale('en'); + const { details } = localAdsbCardModel( + { + icao: 'abc123', + altitudeFt: 12500, + groundSpeedKt: 240, + trackDeg: 359.7, + lastPositionAt: 1000, + }, + 5000, + ); + assert.equal(details[1], 'ALT 12,500 FT · GS 240 KT · TRK 0°'); +}); From 224d71753b02eb2fde881e283e1053e9ca2b639b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 16:01:26 -0400 Subject: [PATCH 161/178] fix(fires): parse the detection time inside the tools package The tools package cannot import firmsCsv. Pad the FIRMS clock here so 00:45 UTC still reads as 00:45. --- src/tools/queries/hazards.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index f644be562..194f9b8c1 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -1,11 +1,24 @@ /** Natural hazard queries: earthquakes and active fires. */ -import { acquisitionMsUtc } from '../../data/firmsCsv.js'; import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +function acquiredMsUtc(acqDate, acqTime) { + if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) + return NaN; + const timeText = String(acqTime ?? '').trim(); + if (!/^\d{1,4}$/.test(timeText)) return NaN; + const hhmm = timeText.padStart(4, '0'); + const hours = Number(hhmm.slice(0, 2)); + const minutes = Number(hhmm.slice(2, 4)); + if (hours > 23 || minutes > 59) return NaN; + return Date.parse( + `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, + ); +} + export const getEarthquakes = defineTool({ name: 'get_earthquakes', title: 'Recent earthquakes', @@ -115,7 +128,7 @@ export const getActiveFires = defineTool({ lon: fire.lon, frp_mw: fire.frp ?? null, confidence: fire.confidence ?? null, - acquired: isoTime(acquisitionMsUtc(fire.acqDate, fire.acqTime)), + acquired: isoTime(acquiredMsUtc(fire.acqDate, fire.acqTime)), satellite: fire.satellite ?? null, day_night: fire.daynight ?? null, })), From 3de745bd33123b9203e2b160d8bbbb6772d399a1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:25 -0700 Subject: [PATCH 162/178] fix(fires): reject rolled-over acquisition dates and document UTC times --- changelog.d/1395-fires-tool-acquired-time.md | 4 ++- docs/CURRENT-STATE.md | 3 ++ src/tools/queries/hazards.js | 6 +++- src/tools/queries/hazards.test.mjs | 29 ++++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md index ea3bddd1d..17d74e481 100644 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ b/changelog.d/1395-fires-tool-acquired-time.md @@ -1,4 +1,6 @@ - Report a fire detection's acquisition time as a UTC timestamp in `get_active_fires`. The tool joined FIRMS's unpadded date and time, so a 00:45 UTC detection read "2026-01-01 45"; it now reads - `2026-01-01T00:45:00.000Z` (Rudy Celekli, @rudycelekli, #1395). + `2026-01-01T00:45:00.000Z`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..5332216e5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1984,6 +1984,9 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, +including zero-padded hours and minutes; missing or invalid dates and times +remain `null`, including impossible calendar dates from a custom source. The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 194f9b8c1..2b11c639c 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -14,9 +14,13 @@ function acquiredMsUtc(acqDate, acqTime) { const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); if (hours > 23 || minutes > 59) return NaN; - return Date.parse( + const timestamp = Date.parse( `${acqDate}T${hhmm.slice(0, 2)}:${hhmm.slice(2, 4)}:00.000Z`, ); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString().slice(0, 10) === acqDate + ? timestamp + : NaN; } export const getEarthquakes = defineTool({ diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fe2e6ac1f..fa17158dd 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -15,3 +15,32 @@ test('a fire acquired at 00:45 UTC reads as 00:45, not "45"', async () => { ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); assert.equal(result.data.rows[0].acquired, '2026-01-01T00:45:00.000Z'); }); + +test('fire acquisition timestamps reject impossible calendar dates', async () => { + for (const acqDate of [ + '2026-02-29', + '2026-02-30', + '2026-04-31', + '1900-02-29', + '2026-13-01', + '2026-01-00', + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime: '45' }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, null, acqDate); + } +}); + +test('fire acquisition timestamps preserve valid leap days and UTC times', async () => { + for (const [acqDate, acqTime, expected] of [ + ['2024-02-29', '45', '2024-02-29T00:45:00.000Z'], + ['2000-02-29', 0, '2000-02-29T00:00:00.000Z'], + ['2026-04-30', '2359', '2026-04-30T23:59:00.000Z'], + ]) { + const result = await catalogWith([ + { lat: 24, lon: 121, acqDate, acqTime }, + ]).call('get_active_fires', { area: { bbox: [120, 23, 122, 25] } }); + assert.equal(result.data.rows[0].acquired, expected); + } +}); From 6642176de540776a04a7b3d9ed6413c190504530 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:32 -0700 Subject: [PATCH 163/178] docs: document Local ADS-B track rounding --- docs/CURRENT-STATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..921604789 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5994,6 +5994,8 @@ callsign, class and category, adsbdb operator/type and a plausible route when known, altitude, ground speed, track, vertical rate, position age, message count, "Heard by your receiver"); markers are not camera-followed. Positioned aircraft join detection boxes, labelled only with a decoded callsign. +The card's `TRK` label wraps after rounding to whole degrees, so a track of +359.5° displays as `TRK 0°` rather than `TRK 360°`. Receivers publish one record shape from `src/sources/adsbRecords.js` (`icao`, `callsign`, `category`, `onGround`, `lat`, `lon`, `altitudeFt`, From 1ad472fecf9d35f6f64b4ba537e0f09692373a2e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 14:08:32 -0700 Subject: [PATCH 164/178] fix(awareness): register and document bearing presentation --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b15babbb2..722fcd831 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4681,6 +4681,7 @@ silently demoting every later lookup for the session. - Selected AIS vessels use their layer-owned full-detail presentation model in the shared world-overlay host; mapped installations promote their own entry to a selected card. Both remain crisp above post-processing without duplicate selected labels; non-selected AIS cards retain their source-owned grid/visibility selection and are host-batched with other world cards. - Space Mission ascent replay uses the compact rocket/thrust overlay only through orbit insertion. Once the replay enters its orbit phase, that vehicle glyph is replaced by a fixed-size cyan dot following the same orbit path and callout. - While a subject is selected, an inner keyhole compass rotates against camera heading and up to three cyan shafted bearing arrows lock to its single faint tick-marked rim, with their labels inset just inside the circle. Labels explicitly separate the geographic bearing (`BRG`) from the contact's reported course (`CRS`) so the pointer direction is not confused with aircraft heading. They point toward the nearest observed/mapped examples; each cohort displays up to ten examples while retaining the complete locally loaded in-range cohort for navigation, with three visible at a time and a ten-second page rotation shared by the panel and arrows. This distinct neutral-context color avoids implying that all context indicators are military-flight symbols. `PREVIOUS`, `FOCUS`, and `NEXT` controls navigate selection history or the next nearby cohort example through the existing tracker. NEXT uses a cycle-scoped visited set and starts a deterministic new walk after exhausting the current candidates instead of re-admitting the nearest visited contact. +- Awareness `BRG` and `CRS` labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `000°` rather than `360°`. - Installations are viewport-bounded OSM map features (`military=airfield|naval_base|range|barracks|base` and `landuse=military`), capped to a 10° non-dateline request and 700 upstream features. The proxy caches five minutes and serves a one-hour stale fallback. Empty, stale, unavailable, and zoom-too-wide states remain visibly distinct. - With a selected subject, Contacts hands the installation layer the subject position every refresh. The layer loads a window around the subject (see Installations and map-source guidance), because a follow or Cockpit camera never fires `moveEnd` and often looks at the horizon: a first load from a globe view used to leave the row at "?" for the whole track. The row and the Cockpit scope read `WITHIN 100 KM` instead of `CURRENT VIEWPORT ONLY`, and an empty window reads "none mapped within 100 km; not a complete 250 km survey". `scripts/qa-context-arrivals.mjs` flies synthetic aircraft over Camp Mabry and Fort Cavazos from a wide start, presses CONTACTS, Cockpit and SEARCH NEARBY SITES, and records a clip. - The selected-only visual is one static, unfilled blue circle. It marks the 250 km proximity context window only; it is not coverage or a radar/weapon envelope. Missing broadcasts and unmapped sites are explicitly not evidence of absence. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 68a57066a..cddb29c40 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1287,6 +1287,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -1944,6 +1945,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", @@ -2736,6 +2738,7 @@ "src/layers/awareness/navigation.js", "src/layers/awareness/panel.js", "src/layers/awareness/policy.js", + "src/layers/awareness/presentation.js", "src/layers/awareness/queries.js", "src/layers/awareness/rendering.js", "src/layers/awareness/state.js", From 7bc86224e942df1304d0532b57256e87ad5d55f3 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:28:09 -0400 Subject: [PATCH 165/178] fix(route): phrase ramp and lane modifiers as directions (#1343) Correct ramp and lane instructions consistently in server output and localized browser text. Contribution by @rudycelekli. --- changelog.d/1343-route-ramp-lane-wording.md | 4 ++ docs/CURRENT-STATE.md | 4 ++ src/data/directions.test.mjs | 43 +++++++++++++++++++++ src/data/routeSteps.js | 33 ++++++++++++---- src/data/routeSteps.test.mjs | 20 ++++++++++ src/i18n/locales/es.json | 4 ++ src/i18n/locales/ru.json | 4 ++ src/i18n/locales/zh-CN.json | 4 ++ src/layers/directions/index.js | 24 +++++++++--- 9 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1343-route-ramp-lane-wording.md diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md new file mode 100644 index 000000000..4f6817e9c --- /dev/null +++ b/changelog.d/1343-route-ramp-lane-wording.md @@ -0,0 +1,4 @@ +- Phrase slight and sharp ramps as directions, so a ramp reads "slightly left" + instead of "on the slightly left", and a lane names its side in both + the proxy response and localized browser instructions + (Rudy Celekli, @rudycelekli, #1343). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b0865b7b0..cc4e7b861 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4150,6 +4150,10 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. +Directions ramp and exit text uses “on the left/right” only for a bare side; +slight and sharp modifiers read as “slightly/sharply left/right”. Lane instructions +name the left or right lane. The localized page and proxy English stay in sync. + Route duration labels select their unit after rounding seconds, switching to minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`. diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index b0c7503ac..bf6e2878e 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -876,3 +876,46 @@ test('route numbers follow the page language and never break from their unit', ( const list = directionsStepList({ route }); assert.equal(list.items[1].lead, '240\u00a0м'); }); + +test('ramp and lane wording stays localized after choosing the maneuver form', (t) => { + t.after(() => setLocale('en')); + const cases = [ + [ + 'ru', + 'Въезжайте по съезду плавно налево на I-95', + 'Сверните на съезд резко направо', + 'Займите левую полосу', + ], + [ + 'es', + 'Toma la rampa ligeramente a la izquierda hacia I-95', + 'Toma la salida bruscamente a la derecha', + 'Toma el carril izquierdo', + ], + [ + 'zh-CN', + '向左前方驶入匝道,进入 I-95', + '从出口向右后方驶出', + '使用左转车道', + ], + ]; + for (const [locale, ramp, exit, lane] of cases) { + setLocale(locale); + assert.equal( + directionsInstruction({ + type: 'on ramp', + modifier: 'slight left', + name: 'I-95', + }), + ramp, + ); + assert.equal( + directionsInstruction({ type: 'off ramp', modifier: 'sharp right' }), + exit, + ); + assert.equal( + directionsInstruction({ type: 'use lane', modifier: 'sharp left' }), + lane, + ); + } +}); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index e3144bdf7..736d812ee 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -54,6 +54,21 @@ function direction(modifier) { return DIRECTION_WORDS[key] || key; } +/** + * "on the left" only fits a bare side. "slight left" is the adverb + * "slightly left", and "on the slightly left" is not an instruction. + * @param {string} prefix "Take the ramp" or "Take the exit". + * @param {string} modifier OSRM maneuver modifier. + * @param {string} onto " onto …", or "". + */ +function directedWay(prefix, modifier, onto) { + const dir = direction(modifier); + if (dir === 'left' || dir === 'right') + return `${prefix} on the ${dir}${onto}`; + if (dir && dir !== 'straight') return `${prefix} ${dir}${onto}`; + return `${prefix}${onto}`; +} + function ordinal(n) { if (!Number.isInteger(n) || n < 1) return null; const suffix = @@ -103,13 +118,9 @@ export function instructionFor(step) { case 'merge': return dir ? `Merge ${dir}${onto}` : `Merge${onto}`; case 'on ramp': - return dir - ? `Take the ramp on the ${dir}${onto}` - : `Take the ramp${onto}`; + return directedWay('Take the ramp', modifier, onto); case 'off ramp': - return dir - ? `Take the exit on the ${dir}${onto}` - : `Take the exit${onto}`; + return directedWay('Take the exit', modifier, onto); case 'roundabout': case 'rotary': { const exit = ordinal(step?.exit); @@ -124,8 +135,14 @@ export function instructionFor(step) { case 'exit roundabout': case 'exit rotary': return `Exit the roundabout${onto}`; - case 'use lane': - return dir ? `Use the ${dir} lane${onto}` : `Continue${onto}`; + case 'use lane': { + const lane = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + return lane ? `Use the ${lane} lane${onto}` : `Continue${onto}`; + } case 'notification': default: return `Continue${onto}`; diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index 6bf74dd08..c0eb445dc 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -77,6 +77,26 @@ test('instructions read like a navigator, one sentence per OSRM maneuver', () => instructionFor({ type: 'on ramp', modifier: 'left' }), 'Take the ramp on the left', ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'slight left', name: 'I-95' }), + 'Take the ramp slightly left onto I-95', + ); + assert.equal( + instructionFor({ type: 'off ramp', modifier: 'sharp right', ref: 'I-280' }), + 'Take the exit sharply right onto I-280', + ); + assert.equal( + instructionFor({ type: 'on ramp', modifier: 'straight', name: 'US 101' }), + 'Take the ramp onto US 101', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'slight left' }), + 'Use the left lane', + ); + assert.equal( + instructionFor({ type: 'use lane', modifier: 'right', name: 'Main St' }), + 'Use the right lane onto Main St', + ); assert.equal( instructionFor({ type: 'merge', diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4fe130f23..098c82f55 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1867,11 +1867,15 @@ "maneuver|Merge": "Incorpórate", "Merge onto {road}": "Incorpórate a {road}", "Take the ramp on the {direction}": "Toma la rampa {direction}", + "Take the ramp {direction}": "Toma la rampa {direction}", "Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}", + "Take the ramp {direction} onto {road}": "Toma la rampa {direction} hacia {road}", "Take the ramp": "Toma la rampa", "Take the ramp onto {road}": "Toma la rampa hacia {road}", "Take the exit on the {direction}": "Toma la salida {direction}", + "Take the exit {direction}": "Toma la salida {direction}", "Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}", + "Take the exit {direction} onto {road}": "Toma la salida {direction} hacia {road}", "Take the exit": "Toma la salida", "Take the exit onto {road}": "Toma la salida hacia {road}", "At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 978640951..8d12ba43e 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1899,11 +1899,15 @@ "maneuver|Merge": "Перестройтесь", "Merge onto {road}": "Въезжайте на {road}", "Take the ramp on the {direction}": "Въезжайте на съезд {direction}", + "Take the ramp {direction}": "Въезжайте на съезд {direction}", "Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", + "Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}", "Take the ramp": "Въезжайте на съезд", "Take the ramp onto {road}": "Въезжайте по съезду на {road}", "Take the exit on the {direction}": "Сверните на съезд {direction}", + "Take the exit {direction}": "Сверните на съезд {direction}", "Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}", + "Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}", "Take the exit": "Сверните на съезд", "Take the exit onto {road}": "Сверните на съезд на {road}", "At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 01c495a27..e727b8516 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -626,11 +626,15 @@ "Merge onto {road}": "并入 {road}", "maneuver|Merge": "并道", "Take the ramp on the {direction} onto {road}": "{direction}驶入匝道,进入 {road}", + "Take the ramp {direction} onto {road}": "{direction}驶入匝道,进入 {road}", "Take the ramp on the {direction}": "{direction}驶入匝道", + "Take the ramp {direction}": "{direction}驶入匝道", "Take the ramp onto {road}": "驶入匝道,进入 {road}", "Take the ramp": "驶入匝道", "Take the exit on the {direction} onto {road}": "从出口{direction}驶出,进入 {road}", + "Take the exit {direction} onto {road}": "从出口{direction}驶出,进入 {road}", "Take the exit on the {direction}": "从出口{direction}驶出", + "Take the exit {direction}": "从出口{direction}驶出", "Take the exit onto {road}": "从出口驶出,进入 {road}", "Take the exit": "从出口驶出", "At the roundabout, take the {exit} exit onto {road}": "在环岛从{exit}出口驶出,进入 {road}", diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index e90e1be18..b8cceff02 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -308,16 +308,24 @@ export function directionsInstruction(step) { ? t('Merge onto {road}', values) : t('Merge', { context: 'maneuver' }); case 'on ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the ramp on the {direction} onto {road}', values) : t('Take the ramp on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the ramp {direction} onto {road}', values) + : t('Take the ramp {direction}', values); return road ? t('Take the ramp onto {road}', values) : t('Take the ramp'); case 'off ramp': - if (direction) + if (modifier === 'left' || modifier === 'right') return road ? t('Take the exit on the {direction} onto {road}', values) : t('Take the exit on the {direction}', values); + if (direction && modifier !== 'straight') + return road + ? t('Take the exit {direction} onto {road}', values) + : t('Take the exit {direction}', values); return road ? t('Take the exit onto {road}', values) : t('Take the exit'); case 'roundabout': case 'rotary': @@ -346,9 +354,14 @@ export function directionsInstruction(step) { return road ? t('Exit the roundabout onto {road}', values) : t('Exit the roundabout'); - case 'use lane': - if (direction) { - const lane = { road, direction: laneWord(modifier) }; + case 'use lane': { + const side = modifier.includes('left') + ? 'left' + : modifier.includes('right') + ? 'right' + : ''; + if (side) { + const lane = { road, direction: laneWord(side) }; return road ? t('Use the {direction} lane onto {road}', lane) : t('Use the {direction} lane', lane); @@ -356,6 +369,7 @@ export function directionsInstruction(step) { return road ? t('Continue onto {road}', values) : t('Continue', { context: 'maneuver' }); + } default: return road ? t('Continue onto {road}', values) From a7da90fdfcbc17673cccb9f761930ccb812fcfe9 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:51:56 -0700 Subject: [PATCH 166/178] fix(effects): run no sharpen pass when its slider is at 0 (#1398) Disable the sharpen pass when its intensity is zero. Contribution by @quanticsoul4772. --- changelog.d/1398-sharpen-zero-off.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/ui/visualEffects.js | 14 +++++++++++++- src/ui/visualEffects.test.mjs | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1398-sharpen-zero-off.md diff --git a/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md new file mode 100644 index 000000000..ab05aa6f2 --- /dev/null +++ b/changelog.d/1398-sharpen-zero-off.md @@ -0,0 +1,2 @@ +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 07daef46c..e2e24fc7d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1301,6 +1301,8 @@ Stopping the controller revokes animation before asynchronous UI teardown; final destruction removes its stages and restores the borrowed bloom configuration. Snow's sideways sway stays within a fixed range at any session age; Wind scales its amplitude. +Sharpen runs only when enabled with intensity above 0.001. At slider zero its +stage is disabled; raising the slider resumes it when sharpen is enabled. ## Display control ownership diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 8b5234ef2..9d10f0cef 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -159,18 +159,30 @@ export class VisualEffects { this.requestRender('bloom'); } + /** + * Sharpen runs only when it is on and its slider is above 0, as the style + * stages do: the pass costs the same at any amount (~1.5 ms GPU per frame + * on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0. + */ + syncSharpenEnabled() { + if (this.stopped || !this.sharpenStage) return; + this.sharpenStage.enabled = + this.sharpenEnabled && this.sharpenIntensity > 0.001; + } + applySharpenIntensity(value) { if (this.stopped) return; this.sharpenIntensity = value; if (this.sharpenStage) this.sharpenStage.uniforms.amount = 0.1 + value * 2.0; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } setSharpenEnabled(enabled) { if (this.stopped) return; this.sharpenEnabled = !!enabled; - if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled; + this.syncSharpenEnabled(); this.requestRender('sharpen'); } diff --git a/src/ui/visualEffects.test.mjs b/src/ui/visualEffects.test.mjs index 68fa45962..9e9542c52 100644 --- a/src/ui/visualEffects.test.mjs +++ b/src/ui/visualEffects.test.mjs @@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled', f.effects.destroy(); }); +test('sharpen at 0 runs no pass, and comes back with the slider', () => { + const f = fixture(); + f.effects.initPostProcess(0.49); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.applySharpenIntensity(0); + assert.equal( + f.effects.sharpenStage.enabled, + false, + 'no residual sharpen at 0', + ); + f.effects.setSharpenEnabled(true); + assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off'); + f.effects.applySharpenIntensity(0.2); + assert.equal(f.effects.sharpenStage.enabled, true); + f.effects.destroy(); +}); + test('stop revokes pending work immediately but retains stages until final destruction', () => { const f = fixture(); f.effects.initStyles(); From ba292ce47554a38b07dec513db5779d67aeae17f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:58:14 -0700 Subject: [PATCH 167/178] fix(flights): keep ground-snap sampling after the clock steps back (#1399) Recover aircraft ground-height sampling after backward wall-clock changes while preserving sampling budgets. Contribution by @quanticsoul4772. --- changelog.d/1399-ground-snap-clock-step.md | 3 ++ docs/CURRENT-STATE.md | 5 ++ src/services/groundSnap.js | 14 ++++- src/services/groundSnap.test.mjs | 62 ++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1399-ground-snap-clock-step.md create mode 100644 src/services/groundSnap.test.mjs diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md new file mode 100644 index 000000000..73196986a --- /dev/null +++ b/changelog.d/1399-ground-snap-clock-step.md @@ -0,0 +1,3 @@ +- Aircraft on the ground keep getting ground-height samples after the + computer's clock steps back, instead of waiting for it to catch up + (quanticsoul4772, #1399). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e2e24fc7d..c5cb8f905 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5309,6 +5309,11 @@ pins and share links already supply complete boxes. - **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load. - **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing. +- **Ground-snap clock rollback recovery.** If the wall clock moves before the + sampling-window start, ground snap opens a new window while retaining the + normal four-samples-per-250-ms budget. Retry timestamps more than the maximum + 30-second backoff ahead of the current clock are ignored. A smaller rollback + can retain a bounded retry delay of up to 30 seconds. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. diff --git a/src/services/groundSnap.js b/src/services/groundSnap.js index 159cd66fd..eecb1fa34 100644 --- a/src/services/groundSnap.js +++ b/src/services/groundSnap.js @@ -307,10 +307,20 @@ export function createGroundSnap({ groundFloor }) { } const now = Date.now(); const entry = entries.get(icao); - if (entry && now < entry.nextRetryMs) return heldSnapM(entry, surfacePos); // backoff in force + // Backoff in force, unless it ends further off than any backoff can: + // a retry stamped before the clock stepped back would otherwise wait for + // the clock to catch up. + if ( + entry && + now < entry.nextRetryMs && + entry.nextRetryMs - now <= RETRY_MAX_MS + ) + return heldSnapM(entry, surfacePos); // Per-window budget: deny WITHOUT a retry stamp so the overflow simply // tries again next tick instead of waiting out a backoff it didn't earn. - if (now - windowStartMs > SAMPLE_WINDOW_MS) { + // A clock that stepped back also opens a new window, or none would open + // until it caught up. + if (now < windowStartMs || now - windowStartMs > SAMPLE_WINDOW_MS) { windowStartMs = now; windowCount = 0; } diff --git a/src/services/groundSnap.test.mjs b/src/services/groundSnap.test.mjs new file mode 100644 index 000000000..8dfe42c46 --- /dev/null +++ b/src/services/groundSnap.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGroundSnap } from './groundSnap.js'; + +const groundFloor = { + cachedMeshFloor: () => null, + meshFloorPreferred: () => false, + reportValidatedMeshFloorCell: () => {}, +}; + +/** A viewer whose ground is a loaded 3D tileset, counting height samples. */ +function makeViewer() { + const calls = { samples: 0 }; + const tileset = { show: true, tilesLoaded: true }; + return { + calls, + scene: { + primitives: { length: 1, get: () => tileset }, + sampleHeight() { + calls.samples += 1; + return 1650; + }, + globe: { getHeight: () => 1600 }, + }, + }; +} + +const atDenverAirport = Cesium.Cartesian3.fromDegrees(-104.6737, 39.8561, 0); + +test('a clock that steps back opens a new sample window instead of denying samples until it catches up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const viewer = makeViewer(); + const snap = createGroundSnap({ groundFloor }); + const icaos = ['a00001', 'a00002', 'a00003', 'a00004', 'a00005', 'a00006']; + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal(viewer.calls.samples, 4, 'four samples per 250 ms window'); + t.mock.timers.setTime(1_000_000 - 60_000); + for (const icao of icaos) snap.heightFor(viewer, icao, atDenverAirport); + assert.equal( + viewer.calls.samples, + 6, + 'the two denied aircraft sample in the new window', + ); +}); + +test('a retry backoff stamped before the clock stepped back does not wait for it to catch up', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 10_000_000 }); + const viewer = makeViewer(); + const tileset = viewer.scene.primitives.get(0); + const snap = createGroundSnap({ groundFloor }); + // Tiles still streaming: a miss, backed off for 2 s. + tileset.tilesLoaded = false; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + tileset.tilesLoaded = true; + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), null); + assert.equal(viewer.calls.samples, 0, 'the backoff holds'); + // The clock steps back an hour: the stamp is an hour off, past any backoff. + t.mock.timers.setTime(10_000_000 - 3_600_000); + assert.equal(snap.heightFor(viewer, 'a00001', atDenverAirport), 1650); + assert.equal(viewer.calls.samples, 1); +}); From a43ff8619ea2966cd4ab51382f6d77b0f9e7379f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 26 Oct 2026 00:04:43 +0000 Subject: [PATCH 168/178] fix(search): normalize geocoder bias across the antimeridian (#1272) Keep geocoder viewport bias near the antimeridian and omit misleading wrapped viewboxes. Contribution by @asturksever. --- changelog.d/1272-search-issue-fix.md | 1 + docs/CURRENT-STATE.md | 7 ++++++- src/keylessGeocoder.js | 25 ++++++++++++++++++++----- src/keylessGeocoder.test.mjs | 11 +++++++++++ src/nominatimGeocode.js | 4 +++- src/nominatimGeocode.test.mjs | 9 +++++++++ 6 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1272-search-issue-fix.md diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md new file mode 100644 index 000000000..48b803534 --- /dev/null +++ b/changelog.d/1272-search-issue-fix.md @@ -0,0 +1 @@ +- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c5cb8f905..8be9af204 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5197,9 +5197,14 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. Invalid/wrapped bounds +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. +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 +request viewbox and continues searching without that geographic preference. + ### Map Stack Switcher (June 2026) - `src/mapStackController.js` switches between Google Photorealistic 3D (`photoreal`, the default when a Google or ion key is present), keyless Esri World Imagery (the zero-key default landing, with keyless terrain), Bing Aerial / Aerial-with-Labels via Cesium ion world imagery (require `CESIUM_ION_TOKEN`), and OSM tile fallback. Bing Road is **retired**: it is gone from `MAP_STACKS`, from the `set_map_stack` enum, and from the voice aliases (road phrasings now resolve to OSM, the one shipped road basemap). An old `map=bing-road` link is simply an unknown id and takes `setStack()`'s existing photoreal fallback with the Google 3D tile lit — pinned live in `scripts/qa-map-source-tray.mjs`. diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 6033a6776..7ca99a96d 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -206,12 +206,27 @@ export function photonSearchUrl( url.searchParams.set('limit', String(limit)); const corners = String(bias ?? '').split('|'); - if (corners.length === 2) { - const [swLat, swLng] = corners[0].split(',').map(Number); - const [neLat, neLng] = corners[1].split(',').map(Number); - if ([swLat, swLng, neLat, neLng].every(Number.isFinite)) { + const tokens = corners.flatMap((corner) => corner.split(',')); + if ( + corners.length === 2 && + corners.every((corner) => corner.split(',').length === 2) && + tokens.every((token) => token.trim() !== '') + ) { + const [swLat, swLng, neLat, neLng] = tokens.map(Number); + if ( + [swLat, swLng, neLat, neLng].every(Number.isFinite) && + Math.abs(swLat) <= 90 && + Math.abs(neLat) <= 90 && + swLat <= neLat && + Math.abs(swLng) <= 180 && + Math.abs(neLng) <= 180 + ) { + const midpoint = (swLng + neLng + (swLng > neLng ? 360 : 0)) / 2; url.searchParams.set('lat', String((swLat + neLat) / 2)); - url.searchParams.set('lon', String((swLng + neLng) / 2)); + url.searchParams.set( + 'lon', + String(midpoint > 180 ? midpoint - 360 : midpoint), + ); } } return url.toString(); diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 2ce8a70b4..1a702fd02 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -452,3 +452,14 @@ function readGeocodingConsumer(file) { ? [file, 'annotations/resolver.js'] : [file]; return files.map((name) => fs.readFileSync(path.join(ROOT, 'src', name), 'utf8')).join('\n'); } + + +test('wrapped view bias stays near the antimeridian and malformed coordinates are omitted', () => { + for (const [bias, expected] of [['-20,170|-10,-171', 179.5], ['50,175|60,-165', -175], ['0,180|10,-180', 180], ['0,-180|10,180', 0]]) { + const params = new URL(photonSearchUrl('island', { bias })).searchParams; + assert.equal(Number(params.get('lon')), expected); + } + for (const bias of ['-91,0|0,1', '0,181|1,0', '10,0|0,1', ',|,', '0,1,2|3,4', '0,1|Infinity,2']) { + assert.equal(new URL(photonSearchUrl('island', { bias })).searchParams.has('lon'), false); + } +}); diff --git a/src/nominatimGeocode.js b/src/nominatimGeocode.js index b7dd6145a..4a5a1948e 100644 --- a/src/nominatimGeocode.js +++ b/src/nominatimGeocode.js @@ -166,6 +166,8 @@ export function nominatimViewboxFromBounds(bounds) { const neLat = nominatimLatitude(match[3]); const neLng = nominatimLongitude(match[4]); if ([swLat, swLng, neLat, neLng].some((value) => value === null)) return null; - if (swLat >= neLat) return null; + // One Nominatim viewbox cannot express a wrapped interval. Omit this soft + // preference rather than biasing the search toward the opposite hemisphere. + if (swLat >= neLat || swLng > neLng) return null; return `${swLng},${neLat},${neLng},${swLat}`; } diff --git a/src/nominatimGeocode.test.mjs b/src/nominatimGeocode.test.mjs index b139ad820..8517649d5 100644 --- a/src/nominatimGeocode.test.mjs +++ b/src/nominatimGeocode.test.mjs @@ -173,3 +173,12 @@ test('a viewport hint is sent only when every edge is a real coordinate', () => `${JSON.stringify(bounds)} must not become a viewbox`, ); }); + +test('wrapped viewports omit the Nominatim bias without rejecting the search', () => { + assert.equal(nominatimViewboxFromBounds('-20,170|-10,-171'), null); + assert.equal(nominatimViewboxFromBounds('50,175|60,-165'), null); + assert.equal( + nominatimViewboxFromBounds('-20,170|-10,180'), + '170,-10,180,-20', + ); +}); From 2006c035d9e14e63f8a3942c3d2694988a08f316 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 17:21:41 -0700 Subject: [PATCH 169/178] chore: remove four modules nothing in the app uses (#1403) * chore: remove four modules nothing in the app uses src/app/sourceSlot.js re-exported src/sources/sourceSlot.js, which every caller imports directly. src/data/flowTiles.js and src/data/issPass.js were reached only by their own tests. src/data/localGeojson.js wrapped localGeojsonCore.js with the app's services, which the catalog already passes itself. The local GeoJSON layer tests now build layers the way the catalog does, so they keep covering localGeojsonCore.js; only the test of the wrapper's own default host goes. Boundary lists, format scope and docs drop the removed paths. * docs: changelog for #1403 --- changelog.d/1403-remove-unused-modules.md | 5 + docs/CODE-BOUNDARIES.md | 2 - docs/CURRENT-STATE.md | 2 +- docs/INFRASTRUCTURE-LAYERS.md | 5 +- scripts/check-import-directions.mjs | 8 - scripts/format-scope.json | 2 - scripts/package-boundaries.json | 6 - src/app/sourceSlot.js | 2 - src/app/sourceSlot.test.mjs | 24 --- src/data/flowTiles.js | 6 - src/data/flowTiles.test.mjs | 162 ------------------- src/data/issPass.js | 46 ------ src/data/issPass.test.mjs | 42 ----- src/data/localGeojson.analyst.test.mjs | 13 +- src/data/localGeojson.js | 29 ---- src/data/localGeojson.test.mjs | 23 ++- src/data/localGeojsonLifecycle.test.mjs | 10 +- src/data/localGeojsonLod.js | 8 +- src/data/tomtomTiles.js | 2 +- src/layers/traffic/flowDecode.js | 2 +- src/overlays/worldOverlayAllocation.test.mjs | 2 +- 21 files changed, 46 insertions(+), 355 deletions(-) create mode 100644 changelog.d/1403-remove-unused-modules.md delete mode 100644 src/app/sourceSlot.js delete mode 100644 src/app/sourceSlot.test.mjs delete mode 100644 src/data/flowTiles.js delete mode 100644 src/data/flowTiles.test.mjs delete mode 100644 src/data/issPass.js delete mode 100644 src/data/issPass.test.mjs delete mode 100644 src/data/localGeojson.js diff --git a/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md new file mode 100644 index 000000000..da95c7dcd --- /dev/null +++ b/changelog.d/1403-remove-unused-modules.md @@ -0,0 +1,5 @@ +- Remove four modules nothing in the app imports: `src/app/sourceSlot.js`, + `src/data/flowTiles.js`, `src/data/issPass.js` and `src/data/localGeojson.js`. + The local GeoJSON layer tests now build layers the way the catalog does, so + they keep covering `localGeojsonCore.js`. Package exports are unchanged + (samehkhamis, #1403). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9085a7b72..9f8e0f629 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -81,8 +81,6 @@ moves it. Do not label all ingestion modules platform-independent by filename. | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `src/data/manager.js` | Lifecycle/panel compatibility and existing tests; normal assembly constructs lifecycle and presentation separately | | `src/data/.js` | Legacy default instances; new assembly calls layer factories with supplied sources | -| `src/data/localGeojson.js` | Infrastructure compatibility factories; catalog imports services from `src/app/localGeojsonServices.js` directly | -| `src/app/sourceSlot.js` | Re-export for direct callers/tests; portable slot ownership is `src/sources/sourceSlot.js` | | `src/app/sources.js`, `src/services/application.js` | Existing default/override API; current application construction supplies its own instances | | `server/providers/local.js` | Existing Node composition barrel and provider helper re-exports | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8be9af204..3c53c93a2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4662,7 +4662,7 @@ current route throughout enabling, enabled, disabling, and uncertain lifecycle renders. Both routes preserve Radio power, playback, station, filter, and volume state. -Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojson.js` (Vite `?url` assets) when toggled on. +Bundled datasets live in `src/data/local_data/` with per-folder provenance READMEs; they are lazily loaded via `src/data/localGeojsonCore.js` (Vite `?url` assets) when toggled on. A bundled dataset that fails to load is a broken install, not a blip, so it is never swallowed: `localGeojson.js` guards `response.ok`, reports `error` + diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 76567e605..31d3cb4cb 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -53,9 +53,8 @@ Cesium installation shared with its viewer; it must not bundle separate copies. `gods-eye-view/infrastructure/geojson` exports the lower-level `createLocalGeoJsonLayer(options, services)` and existing infrastructure overlay helpers. `gods-eye-view/infrastructure/lod` exports the existing LOD policy helpers. -The standalone app's `src/data/localGeojson.js` keeps its original single-argument -factory and supplies its existing functions. Other consumers should use the package -exports, which do not import standalone application globals. +Consumers use the package exports, which do not import standalone application +globals. Dataset files and source/license notices remain under `src/data/local_data/`. Their input counts are 4,351 datacenter features and 704 dam features. Cesium may diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 3f4fec617..b6d8dba67 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -153,14 +153,6 @@ export function checkImportDirections(root) { file, 'Actions must consume feed state without the manager facade', ); - if ( - file === 'src/app/constructCatalog.js' && - to === 'src/data/localGeojson.js' - ) - report( - file, - 'Catalog must use the services owner without compatibility layer construction', - ); } return record; } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f88be2b6b..d62e8bb47 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -518,7 +518,6 @@ "src/layers/bikeshare/selection.js", "src/data/traffic.js", "src/data/bikeshare.js", - "src/data/flowTiles.js", "src/layers/traffic/source.test.mjs", "src/layers/bikeshare/source.test.mjs", "src/layers/bikeshare/proxyRoute.test.mjs", @@ -651,7 +650,6 @@ "src/agent/worldPacket.test.mjs", "src/voice/layerSummary.js", "build/application-html.js", - "src/app/sourceSlot.js", "src/app/sources.js", "src/ui/composition.js", "src/services/application.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cddb29c40..89589091a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1163,7 +1163,6 @@ "satellites-layer": { "exports": ["./layers/satellites"], "modules": [ - "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", "src/sources/tle.js", @@ -1853,14 +1852,12 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -2672,13 +2669,11 @@ "src/data/iconOrientation.js", "src/data/infrastructure.js", "src/data/installationFeedback.js", - "src/data/issPass.js", "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", - "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/local_data/natural_earth/marine.json", @@ -3663,7 +3658,6 @@ "exports": ["./layers/transit/standalone"], "modules": [ "src/cyberSonar.js", - "src/app/sourceSlot.js", "src/celestialRing.js", "src/data/approxDistance.js", "src/data/cameraSensitivity.js", diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js deleted file mode 100644 index fe7590e3b..000000000 --- a/src/app/sourceSlot.js +++ /dev/null @@ -1,2 +0,0 @@ -// Compatibility entry; source contracts are owned by sources/. -export { createSourceSlot } from '../sources/sourceSlot.js'; diff --git a/src/app/sourceSlot.test.mjs b/src/app/sourceSlot.test.mjs deleted file mode 100644 index 1b1face0c..000000000 --- a/src/app/sourceSlot.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createSourceSlot } from './sourceSlot.js'; - -test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => { - let finish; - const slot = createSourceSlot({ read: async () => 'default' }, ['read']); - const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) }); - const pending = slot.source.read(); - const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } }); - old(); - finish('stale'); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(await slot.source.read(), 'replacement'); - assert.equal(slot.source.attribution.name, 'Replacement'); - stop(); - assert.throws(() => slot.source.read(), /not configured/); -}); - -test('an invalid replacement leaves the working source intact', async () => { - const slot = createSourceSlot({ read: async () => 42 }, ['read']); - assert.throws(() => slot.configure({}), TypeError); - assert.equal(await slot.source.read(), 42); -}); diff --git a/src/data/flowTiles.js b/src/data/flowTiles.js deleted file mode 100644 index 07efb0d9a..000000000 --- a/src/data/flowTiles.js +++ /dev/null @@ -1,6 +0,0 @@ -import { createFlowTileSource } from '../layers/traffic/flowSource.js'; -export { tilesForBounds } from './tomtomTiles.js'; -export { decodeFlowTile } from '../layers/traffic/flowDecode.js'; -const source = createFlowTileSource(); -export const { fetchFlowForBounds, getFlowSessionStats, resetFlowTileCache } = - source; diff --git a/src/data/flowTiles.test.mjs b/src/data/flowTiles.test.mjs deleted file mode 100644 index 61a7ddb78..000000000 --- a/src/data/flowTiles.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -// src/data/flowTiles.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { - decodeFlowTile, - fetchFlowForBounds, - tilesForBounds, - getFlowSessionStats, - resetFlowTileCache, -} from './flowTiles.js'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Real TomTom flow tile, downtown Austin z12 x935 y1686 (probed live 2026-07-16). -const FIXTURE = path.join(__dirname, 'fixtures', 'tomtom-flow-austin-12-935-1686.pbf'); -const FIXTURE_TILE = { z: 12, x: 935, y: 1686 }; - -function loadFixture() { - return fs.readFileSync(FIXTURE); -} - -// ── decodeFlowTile against the real fixture ───────────────── - -test('fixture decode: more than 50 flow segments', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.length > 50, `got ${segments.length}`); -}); - -test('fixture decode: every trafficLevel is within [0, 1]', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Number.isFinite(s.trafficLevel), `non-finite level: ${s.trafficLevel}`); - assert.ok(s.trafficLevel >= 0 && s.trafficLevel <= 1, `level out of range: ${s.trafficLevel}`); - } -}); - -test('fixture decode: all coordinates land in downtown Austin', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.ok(Array.isArray(s.coords) && s.coords.length >= 2, 'polyline too short'); - for (const [lon, lat] of s.coords) { - assert.ok(lon >= -98.0 && lon <= -97.5, `lon out of Austin range: ${lon}`); - assert.ok(lat >= 30.0 && lat <= 30.5, `lat out of Austin range: ${lat}`); - } - } -}); - -test('fixture decode: congestion exists (at least one trafficLevel < 1)', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - assert.ok(segments.some((s) => s.trafficLevel < 1), 'no congested segment found'); -}); - -test('fixture decode: segment shape is {coords, trafficLevel, roadType, closure}', () => { - const segments = decodeFlowTile(loadFixture(), FIXTURE_TILE.z, FIXTURE_TILE.x, FIXTURE_TILE.y); - for (const s of segments) { - assert.equal(typeof s.roadType, 'string'); - assert.equal(typeof s.closure, 'boolean'); - } - // The fixture carries real closures — closure decoding is exercised, not vacuous. - assert.ok(segments.some((s) => s.closure === true), 'expected at least one closure in fixture'); -}); - -test('decode of a non-MVT buffer returns [] (defensive)', () => { - assert.deepEqual(decodeFlowTile(Buffer.from('not a protobuf tile'), 12, 935, 1686), []); -}); - -// ── tilesForBounds (re-exported slippy math) ──────────────── - -test('tilesForBounds: 30.2672,-97.7431 @ z12 -> covers x935 y1686', () => { - const tiles = tilesForBounds({ - south: 30.2672 - 0.001, north: 30.2672 + 0.001, - west: -97.7431 - 0.001, east: -97.7431 + 0.001, - }, 12); - assert.ok( - tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686), - `fixture tile missing: ${JSON.stringify(tiles)}` - ); -}); - -// ── fetchFlowForBounds (stubbed fetch: cache + abort) ─────── - -/** Bounds fully inside the fixture tile. */ -const FIXTURE_BOUNDS = { south: 30.24, north: 30.26, west: -97.76, east: -97.74 }; - -function stubFetch(impl) { - const original = globalThis.fetch; - globalThis.fetch = impl; - return () => { globalThis.fetch = original; }; -} - -test('fetchFlowForBounds: fetches covering tiles via /api/tomtom and decodes', async () => { - resetFlowTileCache(); - const calls = []; - const restore = stubFetch(async (url) => { - calls.push(String(url)); - return new Response(loadFixture(), { - status: 200, - headers: { 'Content-Type': 'application/x-protobuf' }, - }); - }); - try { - const segments = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls.length, 1); - assert.match(calls[0], /^\/api\/tomtom\/flow\/12\/935\/1686\.pbf$/); - assert.ok(segments.length > 50); - assert.ok(getFlowSessionStats().tilesFetched >= 1); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: decode cache serves repeat calls within TTL (no refetch)', async () => { - resetFlowTileCache(); - let calls = 0; - const restore = stubFetch(async () => { - calls += 1; - return new Response(loadFixture(), { status: 200 }); - }); - try { - const first = await fetchFlowForBounds(FIXTURE_BOUNDS); - const second = await fetchFlowForBounds(FIXTURE_BOUNDS); - assert.equal(calls, 1, 'second call must be served from the decode cache'); - assert.equal(second.length, first.length); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: aborted signal rejects (AbortSignal-aware)', async () => { - resetFlowTileCache(); - const restore = stubFetch(async (url, opts) => { - // Mimic real fetch abort semantics. - if (opts?.signal?.aborted) { - const err = new Error('aborted'); - err.name = 'AbortError'; - throw err; - } - return new Response(loadFixture(), { status: 200 }); - }); - try { - const controller = new AbortController(); - controller.abort(); - await assert.rejects( - fetchFlowForBounds(FIXTURE_BOUNDS, { signal: controller.signal }), - (err) => err.name === 'AbortError' - ); - } finally { - restore(); - } -}); - -test('fetchFlowForBounds: non-OK tile responses reject when nothing succeeds', async () => { - resetFlowTileCache(); - const restore = stubFetch(async () => new Response(JSON.stringify({ error: 'no_key' }), { status: 503 })); - try { - await assert.rejects(fetchFlowForBounds(FIXTURE_BOUNDS)); - } finally { - restore(); - } -}); diff --git a/src/data/issPass.js b/src/data/issPass.js deleted file mode 100644 index c3807d59f..000000000 --- a/src/data/issPass.js +++ /dev/null @@ -1,46 +0,0 @@ -// src/data/issPass.js -/** - * Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the - * original findNextIssPass signature and defaults still work. - */ -import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js'; - -export { lookAnglesAt }; - -/** - * Predict the next ISS pass over an observer location. - * @param {Object} options - * @param {Object} options.satrec SGP4 satellite record - * @param {number} options.latDeg Observer latitude in degrees [-90, 90] - * @param {number} options.lonDeg Observer longitude in degrees [-180, 180] - * @param {number} options.fromMs UTC start time in milliseconds - * @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees - * @param {number} [options.horizonHours=24] Maximum search window in hours - * @param {number} [options.coarseStepSec=30] Coarse search step in seconds - * @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility - * @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass - * @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null} - */ -export function findNextIssPass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg = 10, - horizonHours = 24, - coarseStepSec = 30, - fineStepSec = 5, - requireVisible = false, -}) { - return findNextSatellitePass({ - satrec, - latDeg, - lonDeg, - fromMs, - minElevDeg, - horizonHours, - coarseStepSec, - fineStepSec, - requireVisible, - }); -} diff --git a/src/data/issPass.test.mjs b/src/data/issPass.test.mjs deleted file mode 100644 index aaaa87a55..000000000 --- a/src/data/issPass.test.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// src/data/issPass.test.mjs -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { twoline2satrec } from 'satellite.js'; -import { findNextIssPass, lookAnglesAt } from './issPass.js'; -import { findNextSatellitePass } from './satellitePass.js'; - -// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC). -const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927'; -const L2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537'; -const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 }; -const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0); // just after the TLE epoch - -test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 }); - assert.ok(pass, 'expected a pass within 24h at 30°N for a 51.6° inclination orbit'); - assert.ok(pass.riseMs > FROM_MS); - assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs); - assert.ok(pass.maxElevDeg >= 10); - assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360); - - // Independent re-check: elevation at the reported peak really is the max-ish - // and ≥ threshold; one minute before rise it is below threshold. - const peak = lookAnglesAt(satrec, pass.maxElevMs, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(peak && Math.abs(peak.elevDeg - pass.maxElevDeg) < 1.5); - const before = lookAnglesAt(satrec, pass.riseMs - 60_000, AUSTIN.latDeg, AUSTIN.lonDeg); - assert.ok(before && before.elevDeg < 10); -}); - -test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => { - const satrec = twoline2satrec(L1, L2); - const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 }; - assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 })); - assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 })); -}); - -test('returns null when no pass clears an absurd threshold', () => { - const satrec = twoline2satrec(L1, L2); - const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 }); - assert.equal(pass, null); -}); diff --git a/src/data/localGeojson.analyst.test.mjs b/src/data/localGeojson.analyst.test.mjs index 061a7f27c..33d35a039 100644 --- a/src/data/localGeojson.analyst.test.mjs +++ b/src/data/localGeojson.analyst.test.mjs @@ -2,7 +2,18 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { + createLocalGeoJsonLayer as createLayer, + mapAnalystRecord, +} from './localGeojsonCore.js'; + +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); const DC_RAW = { id: '1176042553', diff --git a/src/data/localGeojson.js b/src/data/localGeojson.js deleted file mode 100644 index 61f6c3abf..000000000 --- a/src/data/localGeojson.js +++ /dev/null @@ -1,29 +0,0 @@ -import { localGeoJsonServices } from '../app/localGeojsonServices.js'; -import { - createLocalGeoJsonLayer as createLayer, - createLocalInfrastructureOverlayPublisher as createPublisher, -} from './localGeojsonCore.js'; - -export * from './localGeojsonCore.js'; - -/** Create a layer using the standalone app's operations; retain overlay overrides. */ -export function createLocalGeoJsonLayer(options) { - return createLayer(options, { - ...localGeoJsonServices, - overlayHost: - options.overlayHost === undefined - ? localGeoJsonServices.overlayHost - : options.overlayHost, - }); -} - -/** Create an overlay publisher using the standalone app's host by default. */ -export function createLocalInfrastructureOverlayPublisher(options) { - return createPublisher({ - ...options, - host: - options.host === undefined - ? localGeoJsonServices.overlayHost - : options.host, - }); -} diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c4499914..13cde65a5 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -6,13 +6,14 @@ import { GROUND_SAMPLE_MAX_ARMED_RETRIES, LOCAL_OVERLAY_COHORT_LIMIT, LOCAL_STEM_TIP_EPSILON_M, - createLocalGeoJsonLayer, + createLocalGeoJsonLayer as createLayer, createLocalInfrastructureOverlayEntry, createLocalInfrastructureOverlayPublisher, localDatasetError, localInfrastructureOverlayCopy, selectLocalInfrastructureOverlayCohort, -} from './localGeojson.js'; +} from './localGeojsonCore.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; import { layerFeedState } from './manager.js'; import { INFRA_LOD_ACTIVE_MIN, INFRA_LOD_ACTIVE_MAX } from './localGeojsonLod.js'; import { @@ -21,6 +22,13 @@ import { _resetRenderGovernorForTest, } from '../renderGovernor.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost, + }); + class MockLayerEvent { constructor() { this.listeners = new Set(); @@ -1337,14 +1345,3 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects'); }); - -test('standalone publisher retains its default host for an undefined override', () => { - const publisher = createLocalInfrastructureOverlayPublisher({ - sourceId: 'local-default-host-test', host: undefined, - }); - assert.doesNotThrow(() => { - publisher.show(); - publisher.hide(); - publisher.destroy(); - }); -}); diff --git a/src/data/localGeojsonLifecycle.test.mjs b/src/data/localGeojsonLifecycle.test.mjs index 245867155..8b5621ec4 100644 --- a/src/data/localGeojsonLifecycle.test.mjs +++ b/src/data/localGeojsonLifecycle.test.mjs @@ -1,9 +1,17 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLocalGeoJsonLayer } from './localGeojson.js'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { createLocalGeoJsonLayer as createLayer } from './localGeojsonCore.js'; import { getContextStore } from './contextStore.js'; +/** The catalog's services, with the overlay host each test supplies. */ +const createLocalGeoJsonLayer = (options) => + createLayer(options, { + ...localGeoJsonServices, + overlayHost: options.overlayHost, + }); + const dataset = JSON.stringify({ type: 'Feature', id: 'dam', properties: { name: 'Test dam' }, geometry: { type: 'Polygon', coordinates: [[[0, 0], [0.01, 0], [0, 0.01], [0, 0]]] }, diff --git a/src/data/localGeojsonLod.js b/src/data/localGeojsonLod.js index cfd082c04..4a2dd92da 100644 --- a/src/data/localGeojsonLod.js +++ b/src/data/localGeojsonLod.js @@ -5,7 +5,7 @@ * Pure selection policy for the bundled local-infrastructure layers * (`local-datacenters`, `local-dams`, and any future `createLocalGeoJsonLayer` * dataset). Cesium-specific projection, occlusion, and stem geometry stay in - * `localGeojson.js`; this module turns the resulting in-view records into a + * `localGeojsonCore.js`; this module turns the resulting in-view records into a * bounded "active" set — the only records that layer should spend per-frame * stem-geometry and ground-sample work on. * @@ -80,7 +80,7 @@ export const INFRA_LOD_INCUMBENT_BONUS = 250; /** * Distance at or beyond which the proximity penalty is fully applied (m). - * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in localGeojson.js. + * Matches LOCAL_OVERLAY_MAX_DISTANCE_M's order of magnitude in infrastructureOverlayEntry.js. */ export const INFRA_LOD_FAR_M = 12_000_000; @@ -97,7 +97,7 @@ export const INFRA_LOD_MAX_DISTANCE_PENALTY = 200; * (callers only compare it), but every input is normalized so the output is * always a finite number. * - * @param {number} priority Existing label-priority score (localGeojson.js). + * @param {number} priority Existing label-priority score (localGeojsonCore.js). * @param {number} distanceM Camera→feature distance in metres. * @param {boolean} isIncumbent Record currently holds an active stem. * @param {object} [options] @@ -297,7 +297,7 @@ export function applyInfraEvictionGrace({ * ------------------------------------------------------------------ */ /** - * Probe window for the motion fallback. `localGeojson.js` marks its selection + * Probe window for the motion fallback. `localGeojsonCore.js` marks its selection * dirty on `moveEnd`, which covers drags, one-shot `setView`, and voice * `flyTo` — but a tracked-entity follow camera, a Cockpit view, a route * flight, or a continuous orbit never emits one. Without a fallback the diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index af4fc95b3..26ec539ab 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -2,7 +2,7 @@ * @file Pure tile math + budget accounting for the TomTom traffic-flow proxy. * * Shared by the `/api/tomtom` vite plugin (server-side: coordinate validation, - * daily budget governor) and `src/data/flowTiles.js` (client-side: which tiles + * daily budget governor) and `src/layers/traffic/flowSource.js` (client-side: which tiles * cover the current traffic fetch bounds). Zero dependencies, Cesium-free, so * both sides can unit-test against it with node:test. * diff --git a/src/layers/traffic/flowDecode.js b/src/layers/traffic/flowDecode.js index 691496554..9a0c248dd 100644 --- a/src/layers/traffic/flowDecode.js +++ b/src/layers/traffic/flowDecode.js @@ -19,7 +19,7 @@ import { VectorTile } from '@mapbox/vector-tile'; * tree-shakeable; decoding happens client-side so the proxy stays a dumb * binary cache. * - * @module data/flowTiles + * @module layers/traffic/flowDecode */ const FLOW_LAYER_NAME = 'Traffic flow'; diff --git a/src/overlays/worldOverlayAllocation.test.mjs b/src/overlays/worldOverlayAllocation.test.mjs index e29785d48..2ff6d6c8c 100644 --- a/src/overlays/worldOverlayAllocation.test.mjs +++ b/src/overlays/worldOverlayAllocation.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojson.js'; +import { LOCAL_OVERLAY_COHORT_LIMIT } from '../data/localGeojsonCore.js'; import { FIRMS_AMBIENT_COHORT_LIMIT } from '../data/firmsLabels.js'; import { vesselOverlayCohortLimit } from '../data/vesselLabels.js'; import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js'; From 3a28dcab0775388af2c42ae1dbd8fdb8c1d1f5e8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 17:32:15 -0700 Subject: [PATCH 170/178] fix(docker): align health probe port parsing and document health status --- Dockerfile | 2 +- changelog.d/1394-docker-healthcheck.md | 2 ++ docs/CURRENT-STATE.md | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1394-docker-healthcheck.md diff --git a/Dockerfile b/Dockerfile index 4628279af..433d170a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ EXPOSE 4173 # always on the Host allowlist, and the generous start period covers Vite's # first-run dependency optimisation. HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ - CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] + CMD ["node", "-e", "fetch('http://127.0.0.1:'+(Number.parseInt(process.env.PORT,10)||4173)+'/').then((r)=>process.exit(r.ok?0:1),()=>process.exit(1))"] # The launcher is `npm run dev` plus one step: it trusts the container's # gateway address for Provider Settings, because that is where Docker delivers # the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs. diff --git a/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md new file mode 100644 index 000000000..75ead9667 --- /dev/null +++ b/changelog.d/1394-docker-healthcheck.md @@ -0,0 +1,2 @@ +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c53c93a2..63d523c28 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6228,3 +6228,12 @@ when their footprints touch or contain one another, never across a seam toleranc - `scripts/qa-form-fields.mjs` exercises native selections, number validity, calibration commit/cancel and stale-camera/disposal guards against an installed controller with fixture ports, plus default/Cyber and narrow translated layouts. + +## Docker health reporting + +The Docker image probes its local HTTP root with Node every 30 seconds, using +the launcher's parsed `PORT` (default 4173). A successful HTTP response marks +the container healthy. Checks time out after 10 seconds, with a 30-second +startup grace period and three consecutive failures before reporting unhealthy. +This reports app HTTP availability, not individual provider health, and does not +configure automatic restarts. From 2a17491c8bd5afe0e7cf01ac9137361941329713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1l=20G=C3=BCven=C3=A7?= Date: Mon, 26 Oct 2026 03:54:16 +0300 Subject: [PATCH 171/178] fix(docker): let SELinux hosts read the bind-mounted checkout (#1391) Allow the Docker checkout bind mount on SELinux hosts. Contribution by @anilguvenc. --- README.md | 2 ++ changelog.d/1391-docker-selinux.md | 4 ++++ compose.yaml | 5 ++++- docs/CURRENT-STATE.md | 4 ++++ src/dockerLauncher.test.mjs | 1 + 5 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1391-docker-selinux.md diff --git a/README.md b/README.md index 80f052571..ca90bf2c0 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ env UID=1026 GID=100 docker compose up -d Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you. +On SELinux hosts (Fedora, RHEL), compose mounts the checkout with `:z` so the container may read it; Docker may relabel the checkout for container access, which can take a moment on a large checkout. Without that, the start fails with `Cannot find module '/app/scripts/docker-start.mjs'`. + Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md new file mode 100644 index 000000000..186147a2e --- /dev/null +++ b/changelog.d/1391-docker-selinux.md @@ -0,0 +1,4 @@ +- Start the Docker install on SELinux hosts such as Fedora and RHEL. Compose now + mounts the checkout with `:z`; without it the container could not read `/app` + and stopped with "Cannot find module '/app/scripts/docker-start.mjs'" + (Anıl Güvenç, @anilguvenc, #1391). diff --git a/compose.yaml b/compose.yaml index c2d3bf3d4..38cf438ca 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,5 +22,8 @@ services: # UI land in the .env on your host instead of vanishing with the # container. node_modules stays in the image: sharp and friends are # compiled for Linux and must not be shadowed by a host install. - - .:/app + # `:z` relabels the checkout for containers on SELinux hosts (Fedora, + # RHEL); without it the container cannot read /app at all and Node + # reports the launcher as "Cannot find module". Elsewhere it is ignored. + - .:/app:z - /app/node_modules diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 63d523c28..98214e541 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +Docker Compose mounts the checkout with the shared SELinux `:z` label so +containers can read it on enforcing SELinux hosts. The separate image-owned +`node_modules` volume and loopback-only port publishing remain unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft diff --git a/src/dockerLauncher.test.mjs b/src/dockerLauncher.test.mjs index 213fe59dc..4e97849c0 100644 --- a/src/dockerLauncher.test.mjs +++ b/src/dockerLauncher.test.mjs @@ -75,4 +75,5 @@ test('the Docker install path keeps its loopback-only, launcher-owned contract', assert.match(compose, /^\s+- GEV_KEY_SETUP_TRUSTED_PEERS$/m, 'the operator policy reaches the launcher without a hard-coded address'); assert.match(compose, /^\s+- GEV_ALLOWED_HOSTS$/m, 'the host allowlist reaches Vite'); assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace'); + assert.match(compose, /^\s+- \.:\/app:z$/m, 'SELinux hosts can read the bind-mounted checkout'); }); From a15fdd31340872a32e84453d1e59b48eb519b720 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:59:09 -0400 Subject: [PATCH 172/178] fix(hud): carry rounded DMS seconds into the minutes (#1339) Carry rounded HUD coordinate seconds into minutes and degrees. Contribution by @rudycelekli. --- changelog.d/1339-hud-dms-carry.md | 3 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- src/hud.js | 11 ++-- src/hudDms.test.mjs | 106 ++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1339-hud-dms-carry.md create mode 100644 src/hudDms.test.mjs diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 98214e541..bfbfcca90 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,12 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so containers can read it on enforcing SELinux hosts. The separate image-owned `node_modules` volume and loopback-only port publishing remain unchanged. +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d62e8bb47..20e28122a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1087,5 +1087,6 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", - "src/voice/globeResetConfirmation.test.mjs" + "src/voice/globeResetConfirmation.test.mjs", + "src/hudDms.test.mjs" ] diff --git a/src/hud.js b/src/hud.js index cf7983da7..1428f8503 100644 --- a/src/hud.js +++ b/src/hud.js @@ -574,11 +574,12 @@ export class IntelHUD { * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. */ _toDMS(decimal, type) { - const abs = Math.abs(decimal); - const deg = Math.floor(abs); - const minFloat = (abs - deg) * 60; - const min = Math.floor(minFloat); - const sec = ((minFloat - min) * 60).toFixed(2); + // Round once, in hundredths of a second, so a value that rounds up to + // 60.00" carries into the minutes and degrees instead of printing 60. + const hundredths = Math.round(Math.abs(decimal) * 360000); + const deg = Math.floor(hundredths / 360000); + const min = Math.floor(hundredths / 6000) % 60; + const sec = ((hundredths % 6000) / 100).toFixed(2); let dir; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs new file mode 100644 index 000000000..064906b5f --- /dev/null +++ b/src/hudDms.test.mjs @@ -0,0 +1,106 @@ +// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to +// two decimals, so a value within 0.005" of the next whole minute rounds up. +// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a +// coordinate. Ordinary decimal inputs land there through binary floating +// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00". +// +// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM +// loader cannot see, so this installs the same one-specifier stub as +// hudAltitudeDatum.test.mjs before importing it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type); + +test('a rounded-up second carries into the minutes', () => { + assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`); + assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`); + assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`); +}); + +test('a carry through 59 minutes reaches the degrees', () => { + assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`); + assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`); + assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`); +}); + +test('ordinary values keep their existing format', () => { + assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`); + assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`); + assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`); + assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`); + assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`); + assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`); +}); + +test('the telemetry tick paints the carried value into both readouts', () => { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { + latitude: (37.75 * Math.PI) / 180, + longitude: (-122.35 * Math.PI) / 180, + height: 1500, + }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + let hud; + try { + hud = new IntelHUD(viewer); + hud._updateCameraData(); + const latlon = elements.get('hud-latlon').textContent; + const bottom = elements.get('hud-bottom-line').textContent; + assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`); + assert.match(bottom, /LON: 122°21'00\.00"W/); + assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/); + } finally { + hud?.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); From 0dce2f8d04dc2048cf080d2e8894596f2ff5976a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 18:47:57 -0700 Subject: [PATCH 173/178] fix: complete PR #948 integration and documentation --- SECURITY.md | 5 +++++ docs/CURRENT-STATE.md | 6 ++++++ src/tooling/viteBuild.test.mjs | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index 1baad029f..4423eead3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,11 @@ God's Eye View is a local-first client for **public** data. It is built for exploration, demos, and learning — not as a hardened production service. This document explains the security model so you can run it safely and report issues responsibly. +Vite's development file server also denies `.gev-logs/`, `.gev-cache/`, and +the package checkout's root `dist/` directory. Dependency `dist/` directories +remain available. This filesystem deny list does not filter `public/` assets or +preview output; never put private artifacts in a public asset directory. + ## Reporting a vulnerability Please report security issues **privately** — do not open a public issue for anything exploitable. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfbfcca90..da8203264 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5385,6 +5385,12 @@ pins and share links already supply complete boxes. ## Auth + Launch +Vite's development filesystem deny list excludes local `.gev-logs/` and +`.gev-cache/` contents and the package checkout's root `dist/`, while allowing +dependency `dist/` assets. Checkout glob characters are escaped. This does not +apply to `public/` assets or built-preview serving. + + - Recommended launcher: `./scripts/dev-fresh.sh` (also: `dev-secure.sh` for stricter bindings, `dev-cctv.sh` for CCTV source-pack tuning) - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 2ca929176..075363e83 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -30,6 +31,20 @@ 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/**')); + const root = fileURLToPath(new URL('../../', import.meta.url)) + .replace(/\\/g, '/') + .replace(/\/$/, ''); + assert.deepEqual( + config.server.fs.deny.filter((pattern) => pattern.endsWith('/dist/**')), + [`${escapeFsDenyPath(root)}/dist/**`], + 'only project build output is denied, not dependency dist directories', + ); + assert.equal( + escapeFsDenyPath('C:/Users/a/project (1) [test]{copy}'), + 'C:/Users/a/project \\(1\\) \\[test\\]\\{copy\\}', + ); 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 1663733b58a33bfe1cdd9b233879ccebdcd9445e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 18:49:31 -0700 Subject: [PATCH 174/178] style: format Vite deny-list test imports --- src/tooling/viteBuild.test.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 075363e83..2a4988e30 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -6,7 +6,11 @@ import { rm, writeFile } from 'node:fs/promises'; import { request } from 'node:http'; import path from 'node:path'; import { build, createServer, preview } from 'vite'; -import { BROWSER_CSP, createBrowserViteConfig, escapeFsDenyPath } 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'; import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js'; From 3a724f055abece7a33005363b60eac183a5b35ee Mon Sep 17 00:00:00 2001 From: NukeThemAII <125676612+NukeThemAII@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:32 +0700 Subject: [PATCH 175/178] fix(pinokio): open the app in the system browser (#1222) Open the Pinokio app in the system browser while retaining application framing protection. Contribution by @NukeThemAII. --- README.md | 6 ++++-- changelog.d/1222-pinokio-open-in-browser.md | 3 +++ docs/CURRENT-STATE.md | 6 +++++- pinokio/pinokio.js | 4 +++- src/pinokioLauncherContract.test.mjs | 3 +++ 5 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1222-pinokio-open-in-browser.md diff --git a/README.md b/README.md index ca90bf2c0..0a7509efa 100644 --- a/README.md +++ b/README.md @@ -111,13 +111,15 @@ app's **POWER UP** panel; [Keys & Costs](#-api-keys) explains the options. Available on **Windows, macOS, and Linux**. The Pinokio maintainer reports cross-platform testing of the fixed installer. The launcher installs the -locked dependencies, finds a free local port, and opens the app. +locked dependencies, finds a free local port, and opens the app in your system +browser. The Server menu item keeps the launcher console available. **Tried before and installation failed?** Update Pinokio and try again. Version 8.2 fixes the launcher installation issue; [details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g). -**Pinokio opens but the globe is very choppy?** Before treating that as a GPU +**Using an older launcher or opening the embedded view manually?** If the globe +is very choppy, before treating that as a GPU compatibility or minimum-hardware problem, copy the local server URL shown by Pinokio and open the same URL in your normal hardware-accelerated browser. One Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md new file mode 100644 index 000000000..bc6b62402 --- /dev/null +++ b/changelog.d/1222-pinokio-open-in-browser.md @@ -0,0 +1,3 @@ +- Open God's Eye View from Pinokio in the system browser using the supported + `popout` menu option. The application's framing policy is unchanged + (NukeThemAII, #1222). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfbfcca90..439b29284 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5389,7 +5389,11 @@ pins and share links already supply complete boxes. - A successful Pinokio install writes the owner-only `pinokio/.installed` marker. The nested launcher menu resolves that marker from its own directory: an absent marker exposes Install, a present marker exposes Start, and a - running server with a captured ready URL exposes Open God's Eye View. + running server with a captured ready URL exposes Open God's Eye View. That + default item uses Pinokio's `popout` option to open the system browser; the + Server item retains the launcher console. The app's framing headers stay + unchanged. This does not assume all Pinokio desktop versions enforce those + headers in their embedded view. - Build gate: `npm run build` - Network access: local-only by default (`HOST=localhost` in dev-fresh.sh); LAN is an explicit opt-in via `HOST=0.0.0.0` (launcher prints a key-exposure warning + LAN URL; see SECURITY.md) - OpenSky default mode: OAuth (`OPENSKY_AUTH_MODE=oauth`; `anon` works without credentials) diff --git a/pinokio/pinokio.js b/pinokio/pinokio.js index d781c1a58..49c42a361 100644 --- a/pinokio/pinokio.js +++ b/pinokio/pinokio.js @@ -22,8 +22,10 @@ module.exports = { if (starting) { const local = info.local('start.js'); if (local?.url) { + // Open in the system browser without changing the app's framing policy. + // This also avoids relying on Pinokio's embedded browser behavior. return [ - { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url }, + { default: true, icon: 'fa-solid fa-earth-americas', text: 'Open God\'s Eye View', href: local.url, popout: true }, { icon: 'fa-solid fa-terminal', text: 'Server', href: 'start.js' }, ]; } diff --git a/src/pinokioLauncherContract.test.mjs b/src/pinokioLauncherContract.test.mjs index 24c9a9908..78748ff32 100644 --- a/src/pinokioLauncherContract.test.mjs +++ b/src/pinokioLauncherContract.test.mjs @@ -113,6 +113,9 @@ test('Pinokio menu resolves the nested install marker and exposes each lifecycle runtime.url = url; const items = await menu(kernel, info); assert.equal(items.filter((item) => item.default).length, 1); + const ready = installed && running === 'start.js' && Boolean(url); + assert.equal(items[0].popout, ready ? true : undefined); + assert.ok(items.slice(1).every((item) => item.popout === undefined)); return items.map(({ text, href, default: isDefault = false }) => ({ text, href, default: isDefault })); }; From 06c51d3438cbcdc0367220ae997b62583a8b0527 Mon Sep 17 00:00:00 2001 From: nu-stats <113466924+nu-stats@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:07:12 -0400 Subject: [PATCH 176/178] perf(weather): wake a staged frame on a 150 ms timer, not every render (#1345) Wake staged weather imagery on a bounded timer instead of requesting every render. Contribution by @nu-stats. --- changelog.d/1345-weather-staging-wake.md | 3 ++ docs/CURRENT-STATE.md | 4 +++ src/layers/weather/rendering.js | 21 +++++++++-- src/layers/weather/weather.test.mjs | 46 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1345-weather-staging-wake.md diff --git a/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md new file mode 100644 index 000000000..fec4d54ec --- /dev/null +++ b/changelog.d/1345-weather-staging-wake.md @@ -0,0 +1,3 @@ +- Wake staged weather imagery on a 150 ms timer while waiting for readiness, + avoiding a continuous render loop and cancelling the wake when work ends + (@nu-stats, #1345). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b09cef331..4b9e306ea 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -306,6 +306,10 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. +A staged weather frame with loaded tiles and no pending requests rechecks +readiness on a single 150 ms wake timer instead of requesting every render. +The existing readiness conditions and two settled frames still govern promotion; +completion, cancellation, replacement and teardown clear the wake timer. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index b25bcceb2..0f2fad68b 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -14,6 +14,8 @@ const GLOBAL_TILE_MAXIMUM_LEVEL = 3; // Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB. const MAX_MOSAICS = 6; const MAX_PREFETCH_TILES = 8; +// How often a staged frame waiting out its quiet interval rechecks readiness. +export const READY_WAKE_MS = 150; /** Globe imagery: own at most a displayed and a staging frame. Use native Cesium * tile scheduling, projection and texture disposal; the application clock is never touched. */ @@ -126,6 +128,7 @@ function createGlobeRendering({ frame.offRender?.(); frame.offCamera?.(); clearTimeout(frame.timeout); + clearTimeout(frame.wake); for (const request of frame.requests) request.cancel?.(); frame.requests.clear(); frame.retries.clear(); @@ -277,6 +280,8 @@ function createGlobeRendering({ frame.offCamera?.(); frame.offCamera = null; clearTimeout(frame.timeout); + clearTimeout(frame.wake); + frame.wake = null; frame.offAbort?.(); if (ok) { lastError = null; @@ -450,8 +455,20 @@ function createGlobeRendering({ if (++settled >= 2) finish(true); else viewer.scene.requestRender(); } else settled = 0; - if (incoming === frame && frame.pending === 0 && frame.loaded > 0) - viewer.scene.requestRender(); + // Waiting out the quiet interval needs another readiness check + // later, not a render every frame: wake on a timer so the render + // governor's frame cap still holds while a frame is staged. + if ( + incoming === frame && + frame.pending === 0 && + frame.loaded > 0 && + !frame.wake + ) + frame.wake = setTimeout(() => { + frame.wake = null; + if (!frame.closed && incoming === frame) + viewer.scene.requestRender(); + }, READY_WAKE_MS); }); viewer.scene.requestRender(); onChange(); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 486d42520..a42485ed0 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; import * as Cesium from 'cesium'; @@ -242,6 +242,50 @@ test('a ready weather frame does not wait for unrelated terrain, but requires su h.rendering.clear(); }); +test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const h = renderingHarness({ now: () => now }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + const tile = deferred(); + h.providers[0].response = tile; + const request = h.providers[0].requestImage(0, 0, 0, {}); + tile.resolve({}); + await request; + const before = h.renders(); + for (let i = 0; i < 10; i++) h.postRender.emit(); + assert.equal( + h.renders(), + before, + 'renders while waiting do not request further renders', + ); + t.mock.timers.tick(READY_WAKE_MS - 1); + assert.equal(h.renders(), before); + t.mock.timers.tick(1); + assert.equal(h.renders(), before + 1, 'one wake per interval'); + now = 200; + h.postRender.emit(); + h.postRender.emit(); + assert.equal(await loaded, true); + const settledRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); + + const abandoned = h.rendering.setFrame(snapshot, times[1]); + const second = deferred(); + h.providers.at(-1).response = second; + const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); + second.resolve({}); + await secondRequest; + h.postRender.emit(); + h.rendering.clear(); + assert.equal(await abandoned, false); + const clearedRenders = h.renders(); + t.mock.timers.tick(READY_WAKE_MS * 4); + assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); +}); + test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { let now = 0; const h = renderingHarness({ now: () => now }); From a7fcb3c2322fbffdbc24b81f98b45ef9fafe8339 Mon Sep 17 00:00:00 2001 From: Jenil Patel Date: Sun, 25 Oct 2026 21:13:38 -0500 Subject: [PATCH 177/178] fix(maps): avoid 404 on keyless Google tiles-token probe (#1066) Return an uncached unavailable-token response for keyless Google tile-token requests. Contribution by @dvilrgamerz. --- changelog.d/1066-keyless-google-token.md | 3 + docs/CURRENT-STATE.md | 6 ++ server/standalone/google-token-unavailable.js | 27 ++++++ server/standalone/vite.config.js | 2 + src/maps/googleTokens.js | 1 + src/maps/googleTokens.test.mjs | 9 ++ src/tooling/googleTokenUnavailable.test.mjs | 94 +++++++++++++++++++ src/tooling/viteBuild.test.mjs | 9 +- 8 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1066-keyless-google-token.md create mode 100644 server/standalone/google-token-unavailable.js create mode 100644 src/tooling/googleTokenUnavailable.test.mjs diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md new file mode 100644 index 000000000..c07cbade8 --- /dev/null +++ b/changelog.d/1066-keyless-google-token.md @@ -0,0 +1,3 @@ +- Keyless standalone startup answers an unavailable Google tile-token endpoint + with uncached 204 instead of a browser-visible 404. Existing providers take + precedence, and unrelated API routes behave as before. (dvilrgamerz, #1066) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b9e306ea..0ace86811 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1853,6 +1853,12 @@ supported. `.env.example` and `pinokio/_ENVIRONMENT` document both entries. The Street View headings tool uses the same server-first selection after resolving environment overrides per variable; its explicit `--key` wins. +In standalone development and preview servers, a missing Google tile-token +provider returns an uncached `204 No Content` for `GET /api/google/tiles-token` +(and HEAD) instead of a browser-visible 404. The fallback is registered after +real token providers and before the unknown-API handler, so existing Google +browser keys, provider-issued tokens, and unrelated API routes remain unchanged. + ## Infrastructure marker visibility Datacenter/dam registration uses fresh reusable factories with the application's diff --git a/server/standalone/google-token-unavailable.js b/server/standalone/google-token-unavailable.js new file mode 100644 index 000000000..6089b5941 --- /dev/null +++ b/server/standalone/google-token-unavailable.js @@ -0,0 +1,27 @@ +/** + * Treat an unavailable Google Maps tile-token endpoint as a supported, silent + * outcome. Install after real token providers and before the unknown-API 404. + */ +export function googleTokenUnavailablePlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const pathname = String(req.url || '').split('?')[0]; + if ( + (req.method !== 'GET' && req.method !== 'HEAD') || + pathname !== '/api/google/tiles-token' + ) { + next(); + return; + } + + res.writeHead(204, { 'Cache-Control': 'no-store' }); + res.end(); + }); + }; + + return { + name: 'google-token-unavailable', + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffe5124ce..f095ec2ac 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -5,6 +5,7 @@ import { createBrowserViteConfig } from '../../build/vite.js'; import { localProviderPlugins } from '../providers/local.js'; import { localMcpPlugin } from '../mcp/plugin.js'; import { apiNotFoundPlugin } from './api-not-found.js'; +import { googleTokenUnavailablePlugin } from './google-token-unavailable.js'; import { standaloneVoiceTools } from './voiceTools.js'; import { unwatchedRuntimeDirsPlugin } from './unwatched-cache.js'; @@ -21,6 +22,7 @@ export default defineConfig(({ command, mode }) => { plugins: [ ...localProviderPlugins({ realtime: { tools }, gemini: { tools } }), localMcpPlugin(), + googleTokenUnavailablePlugin(), apiNotFoundPlugin(), unwatchedRuntimeDirsPlugin(), ], diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 884c5b199..089730139 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,6 +38,7 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); + if (response.status === 204) return null; if (!response.ok) { await response.body?.cancel(); return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index d8a1e84ed..10213db0c 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -79,3 +79,12 @@ test('a server that does not offer tokens yields none', async () => { assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null); } }); + +test('an empty 204 response explicitly means no Google token is available', async () => { + const { calls, fetchImpl } = server([ + new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }), + ]); + const tokens = createGoogleTokenSource({ fetchImpl }); + assert.equal(await tokens.token(), null); + assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]); +}); diff --git a/src/tooling/googleTokenUnavailable.test.mjs b/src/tooling/googleTokenUnavailable.test.mjs new file mode 100644 index 000000000..e0ee19232 --- /dev/null +++ b/src/tooling/googleTokenUnavailable.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { googleTokenUnavailablePlugin } from '../../server/standalone/google-token-unavailable.js'; + +async function withServer({ beforeFallback } = {}, check) { + const middlewares = []; + const app = { + middlewares: { + use: (handler) => middlewares.push(handler), + }, + }; + if (beforeFallback) app.middlewares.use(beforeFallback); + googleTokenUnavailablePlugin().configureServer(app); + app.middlewares.use((_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unknown API route' })); + }); + + const server = createServer((req, res) => { + let index = 0; + const next = () => middlewares[index++]?.(req, res, next); + next(); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const url = 'http://127.0.0.1:' + address.port; + try { + await check(url); + } finally { + server.close(); + await once(server, 'close'); + } +} + +test('keyless GET and HEAD return uncached 204 instead of 404', async () => { + await withServer({}, async (url) => { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url + '/api/google/tiles-token?probe=1', { + method, + }); + assert.equal(response.status, 204); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await response.text(), ''); + } + }); +}); + +test('the existing client treats unavailable tokens as null', async () => { + await withServer({}, async (url) => { + const source = createGoogleTokenSource({ + fetchImpl: (path, options) => fetch(new URL(path, url), options), + }); + assert.equal(await source.token(), null); + }); +}); + +test('unrelated API paths and unsupported methods remain 404', async () => { + await withServer({}, async (url) => { + for (const [path, method] of [ + ['/api/google/tiles-token/extra', 'GET'], + ['/api/not-a-token', 'GET'], + ['/api/google/tiles-token', 'POST'], + ]) { + const response = await fetch(new URL(path, url), { method }); + assert.equal(response.status, 404); + } + }); +}); + +test('a real token provider registered first still wins', async () => { + await withServer( + { + beforeFallback: (req, res, next) => { + if (req.url !== '/api/google/tiles-token') return next(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + accessToken: 'server-token', + expiresAt: 10000000000000, + }), + ); + }, + }, + async (url) => { + const response = await fetch(url + '/api/google/tiles-token'); + assert.equal(response.status, 200); + assert.equal((await response.json()).accessToken, 'server-token'); + }, + ); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 2a4988e30..a9e8c5398 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -117,7 +117,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); const standaloneProviders = config.plugins - .slice(4, -5) + .slice(4, -6) .map((plugin) => plugin.name); assert.equal( standaloneProviders.filter((name) => name === 'gemini-live-proxy').length, @@ -131,9 +131,10 @@ test('root config retains existing named exports and standalone provider order', standaloneProviders.filter((name) => name !== 'gemini-live-proxy'), providers.localProviderPlugins().map((plugin) => plugin.name), ); - assert.equal(config.plugins.at(-6).name, 'gev-key-setup'); - // The local MCP route follows every provider and precedes the API fallback. - assert.equal(config.plugins.at(-5).name, 'local-mcp'); + assert.equal(config.plugins.at(-7).name, 'gev-key-setup'); + // Token fallback follows real providers and local MCP before the API 404. + assert.equal(config.plugins.at(-6).name, 'local-mcp'); + assert.equal(config.plugins.at(-5).name, 'google-token-unavailable'); assert.equal(config.plugins.at(-4).name, 'api-not-found'); // Config-only, so it sits after the provider slice rather than in it. assert.equal(config.plugins.at(-3).name, 'gev-unwatched-runtime-dirs'); From a3a19883b4f9ebb879040e2a6969c689355c3008 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:24:27 -0500 Subject: [PATCH 178/178] feat: add Scope controls and conditional HUD text backplates (#1410) * feat: add scope controls and conditional HUD backplates * docs: assign scope changelog to PR 1410 * fix: stabilize title backplate and brighten secondary labels --------- Co-authored-by: Bilawal Sidhu --- .../1410-scope-controls-hud-readability.md | 8 + docs/CURRENT-STATE.md | 36 +- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 10 +- scripts/qa-scope-hud.mjs | 681 ++++++++++++++++++ src/hud.js | 42 +- src/reasonableDefaults.test.mjs | 49 +- src/scopeMask.js | 46 +- src/scopeMask.test.mjs | 55 +- src/scopeMaskPolicy.js | 11 + src/sharelink.celestial.test.mjs | 20 +- src/sharelink.js | 16 +- src/ui/aircraftDisplay.js | 1 - src/ui/cyberTheme.test.mjs | 30 + src/ui/displayBindings.js | 20 +- src/ui/displayControls.js | 8 +- src/ui/displayControls.test.mjs | 26 + src/ui/frameRateMonitor.js | 2 +- src/ui/styles/command-dock-trays.css | 6 + src/ui/styles/controls.css | 21 + src/ui/styles/overlays.css | 131 ++++ src/ui/templates/display-controls.html | 14 +- src/ui/templates/scene-chrome.html | 6 +- src/ui/visualSettings.js | 16 +- 24 files changed, 1134 insertions(+), 122 deletions(-) create mode 100644 changelog.d/1410-scope-controls-hud-readability.md create mode 100644 scripts/qa-scope-hud.mjs create mode 100644 src/scopeMaskPolicy.js diff --git a/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md new file mode 100644 index 000000000..38cfdf48e --- /dev/null +++ b/changelog.d/1410-scope-controls-hud-readability.md @@ -0,0 +1,8 @@ +- Add a Scope checkbox and extend feathering to 200%, retaining scope settings + across HUD layouts, share links and scenes. First run opens with Scope ON at + 100% feather; explicit settings and historical links retain their values. Give + Tactical, Operator and Minimal text black backplates only when Scope is OFF, + including vertical metadata, without changing the original HUD text color, glow or + opacity. Scope ON retains its original HUD presentation (bilawalsidhu, #1410). + Keep title and tagline positions stable when toggling Scope, include the eye + in the title backing, and brighten the two secondary labels only with Scope OFF. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0ace86811..b4ed2bacb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -196,6 +196,23 @@ Regression coverage lives in `src/tools/area.test.mjs`, `src/tools/queries/environment.test.mjs` and `src/layers/perimeters/cards.test.mjs`. +## Scope display controls + +Display's native Scope checkbox defaults ON with feather at 100%, adjustable +from 0–200%. OFF disables the slider while retaining its value. HUD layout +changes preserve both settings; share links and scenes restore them. The +historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge. + +With Scope OFF, Tactical, Operator and Minimal add text-sized black backplates +at 70% opacity behind telemetry, vertical metadata and scene chrome. Original +HUD text color, glow and opacity are preserved. Scope OFF brightens the tagline +and Active Style label with the shared secondary text color. The title plate +covers the eye and title without moving the title or tagline. Scope ON retains +the original HUD presentation; Cyber retains its existing skin. + +`node scripts/qa-scope-hud.mjs` checks native input, range, layout persistence, +restoration and both Scope presentations. `QA_SCREENSHOT_DIR` saves screenshots. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -5545,10 +5562,11 @@ the `_detectionUserOverridden` / explicit-intent flags that would suppress a separate landed behaviour. Pinned in `src/reasonableDefaults.test.mjs` (feather, detection, OUTSIDE opacity) and `src/data/layerState.test.mjs` (3D). -**The 2026-08-24 defaults** (superseding the interim 08-23 values of 8%/3%): +**Current defaults** (scope feather revised to 100% on 2026-10-25; +detection and outside opacity retain the 2026-08-24 defaults): the first-run look is Detection DENSE -`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, scope feather `11%`, -and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so +`75%`, ELASTIC allocation, Fade `7%`, OUTSIDE opacity `1%`, Scope ON with feather +`100%`, and 3D fleet mode PROXIMITY, with `AIRCRAFT_BRACKET_FLOOR_ANCHOR` at `0.01` so brackets keep their approved brightness exactly at the new OUTSIDE default. Two terms that must never be conflated: scope FEATHER softens the black scope-mask edge; detection FADE is the label/card fading band around the keyhole. The @@ -5618,14 +5636,10 @@ easier to meet (detection is now on more often), but does not create it. calibrated so `AIRCRAFT_BRACKET_ALPHA_FLOOR` (0.35) lands exactly at the default, and the mapping follows bracket brightness rather than slider position. The `ko` PARSE fallback stays at `5`. -- **Scope feather: `11%`** — a soft scope-mask edge (moved `0% → 8% → 11%`; `0%` hard crop for one day, - `35%` before that). `SCOPE_FEATHER_RATIO_DEFAULT` in `src/scopeMask.js`, - mirrored by `#scope-feather-slider`'s markup value AND readout and - `_scopeFeatherPct` in `sharelink.js`. The slider is untouched and still spans - 0–100, and an explicit `0` is still the hard-crop path — pinned, so moving the - default cannot quietly delete it. The `scf` PARSE fallback deliberately stays - at `35`: a link predating `scf` was authored when 35 was what its author saw, - and restoring the author's view is what a share link is for. +- **Scope: `ON`, feather: `100%`**. `SCOPE_FEATHER_RATIO_DEFAULT` in + `src/scopeMask.js` matches the slider, readout and share generator. The range + is 0–200%; explicit 0% is a hard edge. Missing-`scf` links retain the historical + 35% parse fallback. - **3D aircraft models: `ON`, mode `proximity`** — see the 3D Aircraft section above and the DISPLAY-rail entry below (was `OFF`). - Style shader starting params: CRT/NVG/FLIR pixelation `1.2` (just above the native `1.0` floor); thermal/FLIR ships an optional Ironbow "Predator" palette (`palette` uniform, default `0` = accurate grayscale). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 20e28122a..e60413624 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -194,6 +194,7 @@ "src/ui/visualInput.js", "src/ui/visualInput.test.mjs", "scripts/qa-visual-input.mjs", + "scripts/qa-scope-hud.mjs", "scripts/qa-segmented-controls.mjs", "src/ui/displayControls.js", "src/ui/segmentedControl.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 89589091a..d15897175 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -805,7 +805,8 @@ "src/ui/keyGuidance.js", "src/ui/layerNames.js", "src/ui/statusText.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/scopeMaskPolicy.js" ], "external": ["cesium"] }, @@ -2574,7 +2575,8 @@ "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", "src/ui/statusBadge.js", - "src/sceneCommandRelease.js" + "src/sceneCommandRelease.js", + "src/scopeMaskPolicy.js" ], "external": [ "hls.js", @@ -3295,7 +3297,9 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/locales/zh-CN.json", + "src/scopeMaskPolicy.js", + "src/ui/displayControls.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-scope-hud.mjs b/scripts/qa-scope-hud.mjs new file mode 100644 index 000000000..7fece45ee --- /dev/null +++ b/scripts/qa-scope-hud.mjs @@ -0,0 +1,681 @@ +#!/usr/bin/env node +/** + * Scope and HUD browser acceptance. Requires the development server. + * Run: node scripts/qa-scope-hud.mjs + * Optional: QA_BASE_URL, QA_SCREENSHOT_DIR (screenshots stay outside the repo). + * Proves a fresh boot defaults to Scope ON at 100%, native checkbox click/Space, + * feather 0/11/100/200, layout independence, + * share/scene restore, and visible text backing including rotated metadata. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + ...(process.platform === 'darwin' + ? ['--use-angle=metal', '--enable-gpu'] + : ['--use-gl=angle', '--use-angle=swiftshader']), + ], +}); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, passed, detail) => { + console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`); + if (!passed) { + failures += 1; + if (detail) console.log(JSON.stringify(detail)); + } +}; +const readScope = () => + page.evaluate(() => { + const scope = window.__godsEyeView.styleManager.getVisualState().scope; + const canvas = document.getElementById('scope-mask'); + const context = canvas.getContext('2d'); + const radialX = Math.round( + canvas.width / 2 + Math.min(canvas.width, canvas.height) * 0.35, + ); + return { + enabled: scope.enabled, + feather: scope.featherPct / 100, + cornerAlpha: context.getImageData(1, 1, 1, 1).data[3], + radialAlpha: context.getImageData( + radialX, + Math.round(canvas.height / 2), + 1, + 1, + ).data[3], + checked: document.getElementById('scope-toggle').checked, + disabled: document.getElementById('scope-feather-slider').disabled, + value: Number(document.getElementById('scope-feather-slider').value), + readout: document.getElementById('scope-feather-value').textContent, + }; + }); + +const readHudGlyphs = () => + page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const probe = document.createElement('span'); + probe.style.display = 'none'; + hud.appendChild(probe); + const baseline = { + color: getComputedStyle(probe).color, + shadow: getComputedStyle(probe).textShadow, + }; + probe.remove(); + const glyphs = [ + '.hud-mode', + '.hud-system', + '.hud-summary-label', + '.hud-summary', + '.hud-orbital', + '#hud-mgrs', + '#hud-coll', + ].map((selector) => { + const el = hud.querySelector(selector); + const style = getComputedStyle(el); + let opacity = 1; + for ( + let current = el; + current && current !== hud; + current = current.parentElement + ) + opacity *= Number(getComputedStyle(current).opacity); + return { + selector, + color: style.color, + shadow: style.textShadow, + opacity, + }; + }); + return { baseline, glyphs }; + }); + +async function settleTextBackings(enabled) { + // The pre-existing active-style value transitions all properties for 300ms. + // Wait for its backing to finish changing before inspecting or capturing it. + await page + .waitForFunction( + (scopeEnabled) => { + const expected = scopeEnabled + ? 'rgba(0, 0, 0, 0)' + : 'rgba(0, 0, 0, 0.7)'; + return [ + ...document.querySelectorAll( + '#intel-hud .hud-readout, .scene-text-holdout', + ), + ].every( + (el) => + getComputedStyle(el).backgroundColor === expected && + getComputedStyle(el).boxShadow === 'none', + ); + }, + { timeout: 2_000 }, + enabled, + ) + .catch(() => null); +} + +const readSceneChrome = () => + page.evaluate(() => { + const title = document.querySelector('#title-bar h1'); + const eye = title.querySelector('.title-logo'); + const subtitle = document.querySelector('#title-bar .subtitle'); + const label = document.querySelector('#style-indicator .indicator-label'); + const rect = (el, text = false) => { + if (!el.getClientRects().length) return null; + const range = document.createRange(); + range.selectNodeContents(el); + const box = text + ? range.getBoundingClientRect() + : el.getBoundingClientRect(); + return { + left: box.left, + top: box.top, + right: box.right, + bottom: box.bottom, + }; + }; + const titleBox = rect(title); + const eyeBox = rect(eye); + const backing = getComputedStyle(title, '::before'); + const plate = { + left: titleBox.left + parseFloat(backing.left), + top: titleBox.top + parseFloat(backing.top), + right: titleBox.right - parseFloat(backing.right), + bottom: titleBox.bottom - parseFloat(backing.bottom), + }; + const contains = (box) => + Object.entries(box).every(([side, value]) => + ['left', 'top'].includes(side) + ? plate[side] <= value + 0.5 + : plate[side] >= value - 0.5, + ); + const probe = document.createElement('span'); + document.body.appendChild(probe); + probe.style.color = 'var(--text-dim)'; + const dim = getComputedStyle(probe).color; + probe.style.color = 'var(--text-secondary)'; + const secondary = getComputedStyle(probe).color; + probe.remove(); + return { + positions: { + title: titleBox, + eye: eyeBox, + text: rect(title.querySelector('[translate="no"]'), true), + subtitle: rect(subtitle, true), + }, + titleUnbacked: backing.backgroundColor === 'rgba(0, 0, 0, 0)', + titleCovered: + backing.position === 'absolute' && + backing.backgroundColor === 'rgba(0, 0, 0, 0.7)' && + contains(titleBox) && + contains(eyeBox), + leftAligned: + !rect(subtitle) || Math.abs(plate.left - rect(subtitle).left) <= 0.5, + colors: [getComputedStyle(subtitle).color, getComputedStyle(label).color], + dim, + secondary, + }; + }); + +function checkSceneChrome(name, before, after) { + const stable = Object.keys(before.positions).every((key) => { + const a = before.positions[key]; + const b = after.positions[key]; + return a && b + ? Object.keys(a).every((side) => Math.abs(a[side] - b[side]) <= 0.5) + : a === b; + }); + check(`${name}: title, eye and subtitle stay in place across Scope`, stable, { + before, + after, + }); + check( + `${name}: OFF title plate covers the eye and aligns with the subtitle`, + before.titleUnbacked && after.titleCovered && after.leftAligned, + after, + ); + check( + `${name}: secondary labels brighten only with Scope OFF`, + before.colors.every((color) => color === before.dim) && + after.colors.every((color) => color === after.secondary), + { before: before.colors, after: after.colors }, + ); +} + +async function openApplication(url) { + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => window.__godsEyeView?.styleManager?._applicationShortcuts, + { timeout: 60_000 }, + ); + await page.waitForFunction( + () => { + const cover = document.getElementById('loading-screen'); + return ( + cover?.classList.contains('hidden') && + Number(getComputedStyle(cover).opacity) === 0 + ); + }, + { timeout: 60_000 }, + ); +} + +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + // This browser has a fresh profile. Start without authored state so the + // first assertion exercises the defaults rather than a share-link restore. + url.hash = ''; + await openApplication(url); + const fresh = await readScope(); + check( + 'bare-URL fresh boot opens Scope ON with 100% feather', + fresh.enabled && + fresh.checked && + !fresh.disabled && + fresh.feather === 1 && + fresh.value === 100 && + fresh.readout === '100%' && + fresh.cornerAlpha > 0, + fresh, + ); + + // Use a known city view for canvas and readability checks below. + url.hash = + 'v=2&lat=30.2672&lon=-97.7431&alt=2500&heading=0&pitch=-45&roll=0&map=esri&sc=1&scf=100&l='; + // Changing only the hash does not rerun startup restoration. Leave the app + // document first so this fixture is applied by a new application boot. + await page.goto('about:blank'); + await openApplication(url); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + manager.setPanelCollapsed('pp-toggles', false, { + persist: false, + syncShare: false, + }); + manager.hud.setMode('on'); + manager.setStyle('normal'); + }); + const initial = await readScope(); + check( + 'Austin fixture restores Scope ON and 100% feather', + initial.enabled && + initial.checked && + !initial.disabled && + initial.feather === 1 && + initial.value === 100 && + initial.readout === '100%' && + initial.cornerAlpha > 0, + initial, + ); + const name = await page.$eval('#scope-toggle', (el) => ({ + type: el.type, + label: el.labels?.[0]?.textContent.trim(), + })); + check( + 'Scope uses a native checkbox with a visible label', + name.type === 'checkbox' && name.label?.includes('Scope'), + name, + ); + await page.click('#scope-toggle'); + const off = await readScope(); + check( + 'click disables Scope and its feather slider', + !off.enabled && + !off.checked && + off.disabled && + off.feather === 1 && + off.cornerAlpha === 0, + off, + ); + await page.focus('#scope-toggle'); + await page.keyboard.press('Space'); + const on = await readScope(); + check( + 'Space re-enables the native checkbox without losing feather', + on.enabled && + on.checked && + !on.disabled && + on.feather === 1 && + on.cornerAlpha > 0, + on, + ); + + const paintedAlpha = []; + for (const pct of [0, 11, 100, 200]) { + await page.$eval( + '#scope-feather-slider', + (el, value) => { + el.value = String(value); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, + pct, + ); + const state = await readScope(); + paintedAlpha.push(state.radialAlpha); + check( + `feather ${pct}% reaches the mask and readout`, + state.value === pct && + state.feather === pct / 100 && + state.readout === `${pct}%`, + state, + ); + } + check( + 'painted gradient widens beyond the old 100% ceiling', + paintedAlpha[0] === 0 && + paintedAlpha[1] === 0 && + paintedAlpha[2] > 0 && + paintedAlpha[3] > paintedAlpha[2], + paintedAlpha, + ); + await page.focus('#scope-feather-slider'); + await page.keyboard.press('Home'); + check('native Home reaches the hard edge', (await readScope()).feather === 0); + await page.keyboard.press('End'); + check( + 'native End reaches the expanded 200% maximum', + (await readScope()).feather === 2, + ); + await page.click('#scope-toggle'); + for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { + await page.select('#hud-layout-select', layout); + const state = await readScope(); + check( + `Scope OFF and 200% survive ${layout}`, + !state.enabled && + !state.checked && + state.disabled && + state.feather === 2 && + state.value === 200 && + state.cornerAlpha === 0, + state, + ); + } + + const restored = await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const links = manager.shareLinkManager; + manager._syncShareState(); + const params = links._buildHashParams(); + const encoded = { enabled: params.get('sc'), feather: params.get('scf') }; + await manager.applyVisualState({ scope: { enabled: true, featherPct: 0 } }); + await manager._visualSettings.restoreShareState({ + scopeEnabled: encoded.enabled === '1', + scopeFeatherPct: Number(encoded.feather), + }); + const shared = manager.getVisualState().scope; + await manager.applyVisualState({ + scope: { enabled: true, featherPct: 200 }, + }); + const scene = manager.getVisualState().scope; + await manager.applyVisualState({ + scope: { enabled: false, featherPct: 200 }, + }); + return { encoded, shared, scene }; + }); + check( + 'share state serializes and restores Scope OFF with 200%', + restored.encoded.enabled === '0' && + restored.encoded.feather === '200' && + restored.shared.enabled === false && + restored.shared.featherPct === 200, + restored, + ); + check( + 'scene state restores Scope ON with 200%', + restored.scene.enabled === true && restored.scene.featherPct === 200, + restored, + ); + + for (const layout of ['minimal', 'operator', 'tactical']) { + await page.select('#hud-layout-select', layout); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: true, featherPct: 100 }, + }), + ); + await settleTextBackings(true); + const originalGlyphs = await readHudGlyphs(); + const originalChrome = await readSceneChrome(); + const original = await page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const wrappers = [...hud.querySelectorAll('.hud-readout')]; + const scene = [...document.querySelectorAll('.scene-text-holdout')]; + const unexpectedBackings = [...wrappers, ...scene] + .map((el) => { + const style = getComputedStyle(el); + return { + selector: el.id || el.className, + background: style.backgroundColor, + shadow: style.boxShadow, + }; + }) + .filter( + (style) => + style.background !== 'rgba(0, 0, 0, 0)' || style.shadow !== 'none', + ); + return { + markerAbsent: !document.documentElement.hasAttribute('data-scope-mask'), + unbacked: unexpectedBackings.length === 0, + unexpectedBackings, + wrappersTransparent: wrappers.every( + (el) => getComputedStyle(el).display === 'contents', + ), + operatorFrame: + hud.dataset.variant !== 'operator' || + (getComputedStyle(hud.querySelector('.hud-corner')) + .backgroundColor === 'rgba(0, 0, 0, 0.36)' && + getComputedStyle(hud.querySelector('.hud-top-bar')) + .backgroundImage !== 'none'), + }; + }); + check( + `${layout}: Scope ON retains the original presentation without new backplates`, + original.markerAbsent && + original.unbacked && + original.wrappersTransparent && + original.operatorFrame, + original, + ); + check( + `${layout}: original HUD color, glow and metadata alpha remain intact`, + originalGlyphs.glyphs.every( + (glyph) => + glyph.color === originalGlyphs.baseline.color && + glyph.shadow === originalGlyphs.baseline.shadow, + ) && + originalGlyphs.glyphs.find( + (glyph) => glyph.selector === '.hud-summary-label', + ).opacity === 0.55 && + originalGlyphs.glyphs.find((glyph) => glyph.selector === '.hud-orbital') + .opacity === 0.7 && + originalGlyphs.glyphs.find((glyph) => glyph.selector === '#hud-coll') + .opacity === 0.5, + originalGlyphs, + ); + if (process.env.QA_SCREENSHOT_DIR) { + fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join( + process.env.QA_SCREENSHOT_DIR, + `scope-on-${layout}.png`, + ), + }); + } + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: false }, + }), + ); + await settleTextBackings(false); + const backedGlyphs = await readHudGlyphs(); + checkSceneChrome(layout, originalChrome, await readSceneChrome()); + check( + `${layout}: Scope OFF adds backing without changing glyph styles`, + JSON.stringify(backedGlyphs) === JSON.stringify(originalGlyphs), + backedGlyphs, + ); + const coverage = await page.evaluate(() => { + const hud = document.getElementById('intel-hud'); + const walker = document.createTreeWalker(hud, NodeFilter.SHOW_TEXT); + const missing = []; + let visible = 0; + let node; + while ((node = walker.nextNode())) { + if ( + !node.textContent.trim() || + node.parentElement.closest('.hud-bracket') + ) + continue; + const range = document.createRange(); + range.selectNodeContents(node); + if ( + ![...range.getClientRects()].some( + (rect) => rect.width > 0 && rect.height > 0, + ) + ) + continue; + visible += 1; + const backing = node.parentElement.closest('.hud-readout'); + const color = backing && getComputedStyle(backing).backgroundColor; + if ( + !backing || + color !== 'rgba(0, 0, 0, 0.7)' || + getComputedStyle(backing).boxShadow !== 'none' || + backing.parentElement.closest('.hud-readout') + ) + missing.push(node.textContent.trim()); + } + const wrapped = [...hud.querySelectorAll('.hud-readout')].filter( + (el) => el.offsetWidth > 0 && el.offsetHeight > 0, + ); + const empty = wrapped.filter((el) => !el.innerText.trim()).length; + const overlaps = []; + for (const parent of new Set(wrapped.map((el) => el.parentElement))) { + const siblings = wrapped.filter((el) => el.parentElement === parent); + for (let i = 1; i < siblings.length; i += 1) { + const previous = siblings[i - 1].getBoundingClientRect(); + const current = siblings[i].getBoundingClientRect(); + const horizontal = + Math.min(previous.right, current.right) - + Math.max(previous.left, current.left); + const vertical = + Math.min(previous.bottom, current.bottom) - + Math.max(previous.top, current.top); + // Horizontal bars share a vertical band. Only intersecting text boxes + // can stack their backing alpha; allow subpixel layout rounding. + if (horizontal > 0.5 && vertical > 0.5) { + overlaps.push({ + parent: parent.className, + previous: siblings[i - 1].innerText, + current: siblings[i].innerText, + vertical, + }); + } + } + } + const lineIds = [ + 'hud-mgrs', + 'hud-latlon', + 'hud-gsd', + 'hud-alt', + 'hud-timestamp', + 'hud-bottom-line', + ]; + const multiline = lineIds.filter((id) => { + const el = document.getElementById(id); + if (!el.getClientRects().length) return false; + const range = document.createRange(); + range.selectNodeContents(el); + return ( + range.getBoundingClientRect().height > + parseFloat(getComputedStyle(el).lineHeight) * 1.5 + ); + }); + const edges = [...hud.querySelectorAll('.hud-edge')] + .filter((el) => getComputedStyle(el).display !== 'none') + .map((el) => { + const box = el.querySelector('.hud-readout').getBoundingClientRect(); + return ( + getComputedStyle(el).transform !== 'none' && + box.height > box.width && + Number(getComputedStyle(el).opacity) === 1 + ); + }); + const broadBackings = [ + ...hud.querySelectorAll('.hud-corner, .hud-top-bar, .hud-bottom-bar'), + ].filter( + (el) => + el.offsetWidth > 0 && + getComputedStyle(el).backgroundColor !== 'rgba(0, 0, 0, 0)', + ); + return { + scopeOff: document.documentElement.dataset.scopeMask === 'off', + visible, + missing, + empty, + overlaps, + multiline, + edges, + broadBackings: broadBackings.length, + }; + }); + check( + `${layout}: all visible HUD text has one backing`, + coverage.scopeOff && + coverage.visible > 0 && + coverage.missing.length === 0 && + coverage.empty === 0 && + coverage.broadBackings === 0, + coverage, + ); + check( + `${layout}: adjacent readout backings do not overlap`, + coverage.overlaps.length === 0, + coverage, + ); + check( + `${layout}: desktop telemetry remains single line; rotated backing follows the labels`, + coverage.multiline.length === 0 && coverage.edges.every(Boolean), + coverage, + ); + if (process.env.QA_SCREENSHOT_DIR) { + fs.mkdirSync(process.env.QA_SCREENSHOT_DIR, { recursive: true }); + await page.screenshot({ + path: path.join( + process.env.QA_SCREENSHOT_DIR, + `scope-off-${layout}.png`, + ), + }); + } + } + const details = await page.evaluate(() => { + const summary = document.getElementById('hud-summary'); + summary.textContent = 'LONG TELEMETRY SUMMARY '.repeat(30); + const ais = document.getElementById('hud-ais-vessel'); + ais.textContent = 'AIS: 4 VESSELS\nLATEST OBSERVATION'; + return { + summaryClipped: + summary.scrollWidth > summary.clientWidth && + getComputedStyle(summary).textOverflow === 'ellipsis', + aisMultiline: + getComputedStyle(ais).whiteSpace === 'pre-line' && + ais.textContent.split('\n').length === 2 && + ais.getBoundingClientRect().height + 0.5 >= + parseFloat(getComputedStyle(ais).lineHeight) * 2, + recColor: getComputedStyle(document.getElementById('hud-rec-dot')).color, + }; + }); + check( + 'summary clipping, multiline AIS and red REC dot retain their behavior', + details.summaryClipped && + details.aisMultiline && + details.recColor === 'rgb(255, 51, 51)', + details, + ); + await page.setViewport({ width: 390, height: 844 }); + await page.select('#hud-layout-select', 'minimal'); + const narrow = await page.evaluate(() => + [...document.querySelectorAll('#intel-hud .hud-readout')] + .filter( + (el) => + el.getClientRects().length && + getComputedStyle(el).display !== 'contents', + ) + .every((el) => { + const box = el.getBoundingClientRect(); + return box.left >= 0 && box.right <= innerWidth; + }), + ); + check('narrow HUD text backings stay within the viewport', narrow); + for (const layout of ['minimal', 'operator', 'tactical']) { + await page.select('#hud-layout-select', layout); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: true }, + }), + ); + await settleTextBackings(true); + const before = await readSceneChrome(); + await page.evaluate(() => + window.__godsEyeView.styleManager.applyVisualState({ + scope: { enabled: false }, + }), + ); + await settleTextBackings(false); + checkSceneChrome(`narrow ${layout}`, before, await readSceneChrome()); + } + check('runtime has no uncaught browser errors', errors.length === 0, errors); +} finally { + await browser.close(); +} +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/hud.js b/src/hud.js index 1428f8503..954e3ea94 100644 --- a/src/hud.js +++ b/src/hud.js @@ -282,28 +282,28 @@ export class IntelHUD {
- TOP SECRET // SI-TK // NOFORN - ${this._missionId} - PAGE 1/1 + TOP SECRET // SI-TK // NOFORN + ${this._missionId} + PAGE 1/1
┌
-
TOP SECRET // SI-TK // NOFORN
-
${this._missionId} ${this._sensorId}
-
${t(MODE_LABELS.normal)}
+
TOP SECRET // SI-TK // NOFORN
+
${this._missionId} ${this._sensorId}
+
${t(MODE_LABELS.normal)}
-
${t('SUMMARY')}
-
${t('Awaiting telemetry...')}
+
${t('SUMMARY')}
+
${t('Awaiting telemetry...')}
-
● ${t('REC')} 2026-01-01 00:00:00Z
-
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
+
● ${t('REC')} 2026-01-01 00:00:00Z
+
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -311,33 +311,33 @@ export class IntelHUD {
└
-
MGRS: ---
-
--°--'--"N ---°--'--"W
+
MGRS: ---
+
--°--'--"N ---°--'--"W
-
GSD: --m NIIRS: --
-
ALT: --m SUN: --° EL
-
${t('AIS: --')}
+
GSD: --m NIIRS: --
+
ALT: --m SUN: --° EL
+
${t('AIS: --')}
┘
-
COLL: --:--:--Z
-
ONA: --°
+
COLL: --:--:--Z
+
ONA: --°
-
BAND: PAN
-
BITS: 11
-
LVL: 1A
+
BAND: PAN
+
BITS: 11
+
LVL: 1A
- LAT: -- LON: -- MGRS: --- + LAT: -- LON: -- MGRS: ---
`; this._el.dataset.variant = this._variant; diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index dfd951377..766079aa4 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -11,9 +11,8 @@ import { expandApplicationHtml } from '../build/application-html.js'; // actually decides fresh-boot layer state — including the early return that // makes each layer's own initializer the operative default. // 2. Scope feather. Owner: "I like to hide feather" set it to 0% on -// 2026-08-22; the owner revised that to 8% on 2026-08-23 and locked 11% -// on 2026-08-24, a soft -// edge. The hard crop is still one drag away, and that is pinned too. +// 2026-08-22; later revisions were 8%, then 11%, then 100% on +// 2026-10-25. Scope stays ON on a fresh boot. The hard crop remains available. // 3. Detection ON (Dense @ 75%) for EVERY style, Normal included. // Owner: "detect should also be on by default." // 4. Detection OUTSIDE opacity 1% (owner final lock, 2026-08-24; 3% on 08-23, 5% before), with @@ -38,6 +37,7 @@ import { AIRCRAFT_BRACKET_FLOOR_ANCHOR } from './data/detectionPolicy.js'; import { SCOPE_FEATHER_RATIO_DEFAULT, getScopeMaskFeather, + isScopeMaskEnabled, scopeMaskGeometry, setScopeMaskFeather, } from './scopeMask.js'; @@ -75,32 +75,40 @@ function managerForHash(hash) { } // --------------------------------------------------------------------------- -// 2. Scope feather — a subtle soft edge on a first run +// 2. Scope ON with 100% feather on a first run // --------------------------------------------------------------------------- -test('first run opens with a subtle scope feather, at every surface that decides it', () => { - assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 0.11, - 'owner final lock 2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings'); - assert.equal(getScopeMaskFeather(), 0.11, +test('first run opens with Scope ON and 100% feather at every default surface', () => { + assert.equal(isScopeMaskEnabled(), true, 'the live mask starts enabled'); + assert.equal(SCOPE_FEATHER_RATIO_DEFAULT, 1, 'owner preference 2026-10-25'); + assert.equal(getScopeMaskFeather(), 1, 'and the live module starts there, not merely documents it'); // The slider and its readout are the same default rendered as markup — a // fresh boot applies no restore, so a stale value here would show one number // over a mask drawn at another. - assert.match(indexHtml, /id="scope-feather-slider"[^>]*\svalue="11"/, - 'index.html: the feather slider ships at 11'); - assert.match(indexHtml, /id="scope-feather-value"[^>]*>11%]*\svalue="100"/, + 'index.html: the feather slider ships at 100'); + assert.match(indexHtml, /id="scope-feather-value"[^>]*>100%]*\schecked\s*\/>/, + 'the native Scope checkbox starts checked'); + // The link this session generates must describe the mask this session draws, // for the window before the first _syncShareState. - assert.match(shareSource, /this\._scopeFeatherPct = 11;/, + assert.match(shareSource, /this\._scopeFeatherPct = 100;/, 'sharelink.js: the generator starts from the same value the mask starts at'); + const params = managerForHash('')._buildHashParams(); + assert.equal(params.get('sc'), '1'); + assert.equal(params.get('scf'), '100'); }); -test('an explicit feather still wins over the subtle default', () => { +test('an explicit feather still wins over the 100% default', () => { // A link is authored state. The new default governs a session that said // nothing; it must never overwrite one that said something. + assert.equal(managerForHash('#lat=10&lon=20&scf=11').parseInitialHash().scopeFeatherPct, 11, + 'links carrying the previous default preserve their authored feather'); assert.equal(managerForHash('#lat=10&lon=20&scf=35').parseInitialHash().scopeFeatherPct, 35); assert.equal(managerForHash('#lat=10&lon=20&scf=64').parseInitialHash().scopeFeatherPct, 64); assert.equal(managerForHash('#lat=10&lon=20&scf=0').parseInitialHash().scopeFeatherPct, 0, @@ -113,23 +121,28 @@ test('an explicit feather still wins over the subtle default', () => { 'a pre-scf link restores the author\'s view, not the new default'); }); -test('the subtle default did not weaken the feather control, and 0 is still reachable', () => { +test('share links preserve explicit Scope OFF and feather choices over the defaults', () => { + const shared = managerForHash('#lat=10&lon=20&sc=0&scf=11').parseInitialHash(); + assert.equal(shared.scopeEnabled, false); + assert.equal(shared.scopeFeatherPct, 11); +}); + +test('the 100% default retains the expanded feather range and hard edge', () => { // The cheap way to move a default would be to nerf the control. Prove the // slider still spans its full range and the geometry is still DERIVED from // the ratio — a check that would pass vacuously if it only ever saw one value. - assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="100"/, + assert.match(indexHtml, /id="scope-feather-slider"[^>]*\smin="0"[^>]*\smax="200"/, 'the slider still offers the whole range'); const previous = getScopeMaskFeather(); try { - for (const ratio of [0.35, 0.7, 1]) { + for (const ratio of [0.35, 0.7, 1, 1.75, 2]) { setScopeMaskFeather(ratio); const geo = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; assert.ok(Math.abs((geo.outerR - geo.innerR) - keyholeR * ratio) < 1e-9, `feather ${ratio} must still widen the band to that fraction of the keyhole`); } - // The new default is a real, narrow band — not the hard crop, and nowhere - // near the retired 35 % halo. + // The first-run value draws a band derived from the same keyhole radius. setScopeMaskFeather(SCOPE_FEATHER_RATIO_DEFAULT); const soft = scopeMaskGeometry(1200, 900); const keyholeR = 900 * 0.5 * KEYHOLE_OUTER_RADIUS; diff --git a/src/scopeMask.js b/src/scopeMask.js index 775a257f7..c43108377 100644 --- a/src/scopeMask.js +++ b/src/scopeMask.js @@ -1,4 +1,5 @@ import { getKeyholeGeometry } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX } from './scopeMaskPolicy.js'; /** * Scope mask — the app's signature circular viewport treatment, made real. @@ -41,25 +42,10 @@ import { getKeyholeGeometry } from './celestialRing.js'; /** Matches the page background the emergent scope faded into. */ const SCOPE_OUTSIDE_COLOR = { r: 5, g: 5, b: 8 }; /** - * Default edge feather as a fraction of the keyhole radius. - * - * 0.11 since 2026-08-24 (owner final lock; 0.08 on 08-23, hard-crop 0 on - * 08-22 — this supersedes both), REVISING the 2026-08-22 ruling that - * set it to zero: a subtle soft edge rather than either the hard crop or the - * retired 35 % halo. The slider is untouched and still spans 0..100; this is - * only where it STARTS. `setScopeMaskFeather` is unchanged, so any feather a - * share link carries, or the operator dials in, overrides this immediately, and - * the hard-crop path at 0 is still reachable from the handle. - * - * Keep in lockstep with `#scope-feather-slider`'s markup value AND readout in - * index.html and `_scopeFeatherPct` in sharelink.js — a fresh boot applies no - * restore, so those literals ARE the first-run state. NOT the `scf` PARSE - * fallback, which stays at 35 on purpose: a link predating that field was - * authored when 35 was what its author saw, and a link from the feather-0 era - * carries `scf=0` explicitly because the generator always writes the field. - * Pinned in reasonableDefaults.test.mjs. + * First-run feather ratio; the Display slider and share defaults use 100%. + * The historical missing-scf parse fallback remains 35%. */ -export const SCOPE_FEATHER_RATIO_DEFAULT = 0.11; +export const SCOPE_FEATHER_RATIO_DEFAULT = 1; /** * Terminus opacity at/above SCOPE_TERMINUS_FAR_M — slightly translucent so * faint stars survive in the corners at globe scale. @@ -344,7 +330,10 @@ function teardownDevicePixelRatioWatch() { export function scopeMaskGeometry(width, height, featherRatio = _featherRatio) { const keyhole = getKeyholeGeometry(width, height); if (!(keyhole.radius > 0)) return null; - const feather = Math.max(0, Math.min(1, Number(featherRatio) || 0)); + const feather = Math.max( + 0, + Math.min(SCOPE_FEATHER_RATIO_MAX, Number(featherRatio) || 0), + ); // Feather straddles the keyhole edge so the visible radius stays anchored // to the shared geometry every keyhole consumer (labels, cards) fades on. const half = keyhole.radius * feather * 0.5; @@ -423,6 +412,14 @@ function draw() { _painted = true; } +/** Expose only the OFF state; an absent attribute preserves the original HUD. */ +function syncScopeMaskAppearance(enabled = _enabled) { + const dataset = globalThis.document?.documentElement?.dataset; + if (!dataset) return; + if (enabled) delete dataset.scopeMask; + else dataset.scopeMask = 'off'; +} + /** * Install the scope mask into the viewer container. Idempotent. * @param {Cesium.Viewer} viewer @@ -430,6 +427,7 @@ function draw() { */ export function installScopeMask(viewer) { if (_canvas || !viewer?.container) return; + syncScopeMaskAppearance(); _container = viewer.container; _viewer = viewer; _canvas = document.createElement('canvas'); @@ -505,6 +503,7 @@ export function setScopeMaskEnabled(enabled) { const next = Boolean(enabled); const reEnabled = next && !_enabled; _enabled = next; + syncScopeMaskAppearance(); if (reEnabled) { // The camera moved freely while the scope was off and nothing sampled it, // so the painted terminus can be a whole altitude band stale. Re-sync once @@ -522,11 +521,14 @@ export function isScopeMaskEnabled() { } /** - * @param {number} ratio - Edge feather as a fraction of keyhole radius [0,1]. + * @param {number} ratio - Edge feather as a fraction of keyhole radius [0,2]. * @returns {void} */ export function setScopeMaskFeather(ratio) { - _featherRatio = Math.max(0, Math.min(1, Number(ratio) || 0)); + _featherRatio = Math.max( + 0, + Math.min(SCOPE_FEATHER_RATIO_MAX, Number(ratio) || 0), + ); draw(); } @@ -540,6 +542,7 @@ export function getScopeMaskFeather() { * @returns {void} */ export function destroyScopeMask() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); @@ -553,6 +556,7 @@ export function destroyScopeMask() { /** Test seam. */ export function _resetScopeMaskForTest() { + syncScopeMaskAppearance(true); _resizeObserver?.disconnect(); _resizeObserver = null; teardownDevicePixelRatioWatch(); diff --git a/src/scopeMask.test.mjs b/src/scopeMask.test.mjs index 7f5dcba62..d37b6b23b 100644 --- a/src/scopeMask.test.mjs +++ b/src/scopeMask.test.mjs @@ -18,6 +18,7 @@ import { SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT, SCOPE_FEATHER_RATIO_DEFAULT, + getScopeMaskFeather, SCOPE_OUTSIDE_ALPHA, SCOPE_TERMINUS_ALPHA_NEAR, SCOPE_TERMINUS_FAR_M, @@ -26,6 +27,7 @@ import { _resetScopeMaskForTest, } from './scopeMask.js'; import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { SCOPE_FEATHER_RATIO_MAX, clampScopeFeatherPct } from './scopeMaskPolicy.js'; beforeEach(() => _resetScopeMaskForTest()); @@ -47,16 +49,34 @@ test('zero feather produces a hard edge exactly at the keyhole radius', () => { assert.equal(geo.outerR, keyholeR); }); -test('feather ratio is clamped to [0,1] and bad input falls back to 0', () => { +test('feather ratio reaches the center at 200% and clamps larger input', () => { const wide = scopeMaskGeometry(1000, 800, 7); - const one = scopeMaskGeometry(1000, 800, 1); - assert.equal(wide.innerR, one.innerR); - assert.equal(wide.outerR, one.outerR); + const maximum = scopeMaskGeometry(1000, 800, SCOPE_FEATHER_RATIO_MAX); + const keyholeR = 800 * 0.5 * KEYHOLE_OUTER_RADIUS; + assert.equal(maximum.innerR, 0); + assert.equal(maximum.outerR, keyholeR * 2); + assert.equal(wide.innerR, maximum.innerR); + assert.equal(wide.outerR, maximum.outerR); + for (const ratio of [1, 1.5, 2]) { + setScopeMaskFeather(ratio); + assert.equal(getScopeMaskFeather(), ratio); + const geometry = scopeMaskGeometry(1000, 800); + assert.equal((geometry.innerR + geometry.outerR) / 2, keyholeR); + assert.equal(geometry.outerR - geometry.innerR, keyholeR * ratio); + } + setScopeMaskFeather(7); + assert.equal(getScopeMaskFeather(), SCOPE_FEATHER_RATIO_MAX); const nan = scopeMaskGeometry(1000, 800, Number.NaN); const zero = scopeMaskGeometry(1000, 800, 0); assert.equal(nan.innerR, zero.innerR); }); +test('UI and share feather percentages share one bounded integer range', () => { + for (const [value, expected] of [[0, 0], [11, 11], [175.6, 176], [200, 200], [350, 200], [-4, 0], [Number.NaN, 0]]) { + assert.equal(clampScopeFeatherPct(value), expected); + } +}); + test('degenerate viewport yields null instead of a broken gradient', () => { assert.equal(scopeMaskGeometry(0, 0), null); assert.equal(scopeMaskGeometry(-5, 100), null); @@ -136,12 +156,14 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { removeEventListener: (type, fn) => listeners.delete(fn), }), }; - globalThis.document = { createElement: () => canvas }; + const documentElement = { dataset: {} }; + globalThis.document = { documentElement, createElement: () => canvas }; globalThis.ResizeObserver = class { observe() {} disconnect() {} }; const container = { clientWidth: width, clientHeight: height, appendChild() {} }; return { canvas, container, + documentElement, /** Canvas work counters: backing-store resizes, clears, fills. */ ops, /** Every fillStyle string assigned since install (hard-crop path). */ @@ -160,6 +182,29 @@ function stubScopeMaskDom({ width = 1000, height = 800, dpr = 1 } = {}) { }; } +test('scope appearance follows direct state changes and install/destroy lifecycles', () => { + const dom = stubScopeMaskDom(); + try { + setScopeMaskEnabled(false); + assert.equal(dom.documentElement.dataset.scopeMask, 'off'); + delete dom.documentElement.dataset.scopeMask; + installScopeMask({ container: dom.container }); + assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'install publishes retained OFF state'); + destroyScopeMask(); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'destroy clears the document marker'); + installScopeMask({ container: dom.container }); + assert.equal(dom.documentElement.dataset.scopeMask, 'off', 'reinstall retains the preference'); + setScopeMaskEnabled(true); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'ON uses the original presentation'); + setScopeMaskEnabled(false); + _resetScopeMaskForTest(); + assert.equal(dom.documentElement.dataset.scopeMask, undefined, 'test reset clears the marker'); + } finally { + destroyScopeMask(); + dom.restore(); + } +}); + test('a DPR change with no resize still repaints the backing store', () => { const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 }); try { diff --git a/src/scopeMaskPolicy.js b/src/scopeMaskPolicy.js new file mode 100644 index 000000000..13515fb43 --- /dev/null +++ b/src/scopeMaskPolicy.js @@ -0,0 +1,11 @@ +/** A 200% feather reaches the center while keeping its midpoint on the keyhole. */ +export const SCOPE_FEATHER_RATIO_MAX = 2; +const SCOPE_FEATHER_MAX_PCT = SCOPE_FEATHER_RATIO_MAX * 100; + +/** Normalize a UI/share-link feather percentage to the supported integer range. */ +export function clampScopeFeatherPct(value) { + return Math.max( + 0, + Math.min(SCOPE_FEATHER_MAX_PCT, Math.round(Number(value) || 0)), + ); +} diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 230843584..746d5b3bc 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -99,6 +99,22 @@ test('Nepal locator token is valid in v2 share links', () => { assert.equal(parsed.layerStateInvalid, false); }); +test('scope feather above 100% survives share parsing and serialization', () => { + const manager = makeManager('#v=2&lat=10&lon=20&sc=0&scf=200'); + const parsed = manager.parseInitialHash(); + assert.equal(parsed.scopeEnabled, false); + assert.equal(parsed.scopeFeatherPct, 200); + assert.equal(makeManager('#lat=10&lon=20&scf=175').parseInitialHash().scopeFeatherPct, 175); + assert.equal(makeManager('#lat=10&lon=20&scf=999').parseInitialHash().scopeFeatherPct, 200); + manager.onToggleChange(false, true, { scopeEnabled: false, scopeFeatherPct: 200 }); + const params = manager._buildHashParams(); + assert.equal(params.get('sc'), '0'); + assert.equal(params.get('scf'), '200'); + manager.onToggleChange(false, true, { scopeFeatherPct: 999 }); + assert.equal(manager._buildHashParams().get('scf'), '200'); + clearTimeout(manager._debounceTimer); +}); + test('share-link serialization emits the current celestial state', () => { const manager = makeManager(); manager.onToggleChange(false, false, { celestialRingEnabled: false }); @@ -498,8 +514,8 @@ test('every explicit visual UI gesture claims restore authority before it mutate ['cycleDetection: () => {', 'toggleCctv: () =>', 'cycleDetectionMode()', 'detection hotkey'], ['toggleBloom:', 'setBloomIntensity:', 'this._setBloomEnabled(', 'toggleBloom control'], ['setBloomIntensity:', 'toggleSharpen:', 'this._setBloomIntensity(', 'setBloomIntensity control'], - ['toggleSharpen:', 'toggleScope:', 'this._setSharpenEnabled(', 'toggleSharpen control'], - ['toggleScope:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'toggleScope control'], + ['toggleSharpen:', 'setScopeEnabled:', 'this._setSharpenEnabled(', 'toggleSharpen control'], + ['setScopeEnabled:', 'setScopeFeather:', 'setScopeMaskEnabled(', 'setScopeEnabled control'], ['setScopeFeather:', 'setSharpenIntensity:', 'setScopeMaskFeather(', 'setScopeFeather control'], ['setSharpenIntensity:', 'setHudLayout:', 'this._applySharpenIntensity(', 'setSharpenIntensity control'], ['setHudLayout:', 'toggleCleanView:', 'this._setHudVariant(', 'setHudLayout control'], diff --git a/src/sharelink.js b/src/sharelink.js index 7e0e78b35..c184a59f5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, encodeLayerStateParams, @@ -112,10 +113,7 @@ export class ShareLinkManager { this._detectionOutsideOpacityPct = 1; this._celestialRingEnabled = false; this._scopeEnabled = true; - // Feather opens on a soft 11% scope-mask edge (owner final lock 2026-08-24, - // superseding the 08-22 hard-crop and 08-23 8% rulings) — mirrors - // SCOPE_FEATHER_RATIO_DEFAULT in scopeMask.js and the slider's markup value. - this._scopeFeatherPct = 11; + this._scopeFeatherPct = 100; // null = the altitude-adaptive terminus (the default). A number pins the // outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17) this._scopeTerminusPct = null; @@ -227,10 +225,7 @@ export class ShareLinkManager { // (`_scopeFeatherPct` in the constructor tracks the default: that one // mirrors live state for the link this session generates, so it must match // the mask, not the archive.) - scopeFeatherPct: Math.max( - 0, - Math.min(100, Math.round(parseOr(params.get('scf'), 35))), - ), + scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)), // Absent (or non-numeric) `sce` = adaptive (null), the default behavior; // a value pins the terminus opacity percent, clamped into the SUPPORTED // 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in @@ -494,10 +489,7 @@ export class ShareLinkManager { if (typeof extras.scopeEnabled === 'boolean') this._scopeEnabled = extras.scopeEnabled; if (typeof extras.scopeFeatherPct === 'number') { - this._scopeFeatherPct = Math.max( - 0, - Math.min(100, Math.round(extras.scopeFeatherPct)), - ); + this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct); } if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null; else if (typeof extras.scopeTerminusPct === 'number') { diff --git a/src/ui/aircraftDisplay.js b/src/ui/aircraftDisplay.js index ba325f6e9..6e14e0adf 100644 --- a/src/ui/aircraftDisplay.js +++ b/src/ui/aircraftDisplay.js @@ -70,7 +70,6 @@ export class AircraftDisplay { // a screen reader without this. It matters more now that the button ships // ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive // tech reported was an unpressed-looking control over an armed layer. - // Mirrors #scope-toggle, which has always carried aria-pressed. this._models3dBtn?.setAttribute( 'aria-pressed', String(this._models3dEnabled), diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 68710a36a..03ee75f08 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -1,3 +1,4 @@ +import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; @@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author assert.equal(voice.owner.activeStyle, 'snow'); }); +test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => { + const { owner } = styleOwner(t, 'tactical'); + const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() }; + t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); }); + Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather }); + owner._scopeBtn = { checked: true }; + owner._scopeFeatherSlider = { value: '11', disabled: false }; + owner._scopeFeatherValue = {}; + await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 }); + assert.equal(owner._scopeBtn.checked, false); + assert.equal(owner._scopeFeatherSlider.disabled, true); + assert.equal(owner._scopeFeatherSlider.value, '200'); + for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) { + owner._setHudVariant(layout, { applyVisualDefaults: true }); + assert.equal(isScopeMaskEnabled(), false, layout); + assert.equal(getScopeMaskFeather(), 2, layout); + assert.equal(owner._scopeBtn.checked, false, layout); + } + await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } }); + assert.equal(isScopeMaskEnabled(), true); + assert.equal(getScopeMaskFeather(), 1.75); + assert.equal(owner._scopeBtn.checked, true); + assert.equal(owner._scopeFeatherSlider.disabled, false); + assert.equal(owner._scopeFeatherValue.textContent, '175%'); + await owner.applyVisualState({ scope: { featherPct: 500 } }); + assert.equal(getScopeMaskFeather(), 2); + assert.equal(owner._scopeFeatherSlider.value, '200'); +}); + test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => { const { owner, hud } = styleOwner(t, 'tactical'); owner.setStyle('noir'); diff --git a/src/ui/displayBindings.js b/src/ui/displayBindings.js index 329681798..3ae874c3c 100644 --- a/src/ui/displayBindings.js +++ b/src/ui/displayBindings.js @@ -1,6 +1,7 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { createFrameRateMonitor } from './frameRateMonitor.js'; import { bindApplicationShortcuts } from './visualInput.js'; -import { bindDisplayControls } from './displayControls.js'; +import { bindDisplayControls, syncScopeControls } from './displayControls.js'; import { canonicalizeDensity } from '../data/detectionPolicy.js'; /** Own keyboard/display event subscriptions; settings remain with their state owners. */ @@ -92,6 +93,11 @@ export class DisplayBindings { }, }); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + isScopeMaskEnabled(), + ); this._displayControls?.destroy(); this._displayControls = bindDisplayControls({ elements: { @@ -100,7 +106,7 @@ export class DisplayBindings { bloomSlider: this._bloomSlider, sharpenButton: this._sharpenBtn, sharpenSlider: this._sharpenSlider, - scopeButton: this._scopeBtn, + scopeToggle: this._scopeBtn, scopeFeatherSlider: this._scopeFeatherSlider, hudLayout: this._hudLayoutSelect, hudButton: this._hudBtn, @@ -134,17 +140,15 @@ export class DisplayBindings { this.shareLinkManager?.claimRestoreLane?.('visual'); this._setSharpenEnabled(!this.sharpenEnabled); }, - toggleScope: () => { + setScopeEnabled: (enabled) => { this.shareLinkManager?.claimRestoreLane?.('visual'); - const next = !isScopeMaskEnabled(); - setScopeMaskEnabled(next); - this._scopeBtn.classList.toggle('active', next); - this._scopeBtn.setAttribute('aria-pressed', String(next)); + setScopeMaskEnabled(enabled); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled); this._syncShareState(); }, setScopeFeather: (value) => { this.shareLinkManager?.claimRestoreLane?.('visual'); - const pct = Math.max(0, Math.min(100, value || 0)); + const pct = clampScopeFeatherPct(value); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; setScopeMaskFeather(pct / 100); diff --git a/src/ui/displayControls.js b/src/ui/displayControls.js index e0d76d338..ceab61d91 100644 --- a/src/ui/displayControls.js +++ b/src/ui/displayControls.js @@ -18,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) { for (const [name, action] of [ ['bloomButton', 'toggleBloom'], ['sharpenButton', 'toggleSharpen'], - ['scopeButton', 'toggleScope'], ['cleanViewButton', 'toggleCleanView'], ['cleanViewExitButton', 'exitCleanView'], ['celestialButton', 'toggleCelestial'], @@ -34,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) { ['scopeFeatherSlider', 'setScopeFeather'], ]) listen(elements[name], 'input', action, integer); + listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked); listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value); listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value); for (const [name, action] of [ @@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) { }, }; } + +/** Keep the native Scope checkbox and feather availability in step with the mask. */ +export function syncScopeControls(toggle, featherSlider, enabled) { + if (toggle) toggle.checked = enabled; + if (featherSlider) featherSlider.disabled = !enabled; +} diff --git a/src/ui/displayControls.test.mjs b/src/ui/displayControls.test.mjs index 705a9f775..f7c5bcafb 100644 --- a/src/ui/displayControls.test.mjs +++ b/src/ui/displayControls.test.mjs @@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned' assert.deepEqual(calls, ['b']); two.destroy(); }); + +test('the native Scope checkbox reads checked state on change and disposes its listener', () => { + const scopeToggle = element(); + scopeToggle.checked = true; + const scopeFeatherSlider = element('200'); + const calls = []; + const controls = bindDisplayControls({ + elements: { scopeToggle, scopeFeatherSlider }, + actions: { + setScopeEnabled: (value) => calls.push(['enabled', value]), + setScopeFeather: (value) => calls.push(['feather', value]), + }, + }); + scopeToggle.checked = false; + scopeToggle.dispatchEvent(new Event('change')); + scopeToggle.checked = true; + scopeToggle.dispatchEvent(new Event('change')); + scopeFeatherSlider.dispatchEvent(new Event('input')); + controls.destroy(); + scopeToggle.dispatchEvent(new Event('change')); + assert.deepEqual(calls, [ + ['enabled', false], + ['enabled', true], + ['feather', 200], + ]); +}); diff --git a/src/ui/frameRateMonitor.js b/src/ui/frameRateMonitor.js index 3bad73a20..2f6ab8e8d 100644 --- a/src/ui/frameRateMonitor.js +++ b/src/ui/frameRateMonitor.js @@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) { if (!host || !frameEvent) return { destroy() {} }; const readout = documentRef.createElement('div'); - readout.className = 'frame-rate-readout'; + readout.className = 'frame-rate-readout scene-text-holdout'; readout.hidden = true; // i18n-ignore: FPS is a unit code, the same in every language readout.textContent = 'FPS —'; diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css index eef3c6001..547037de7 100644 --- a/src/ui/styles/command-dock-trays.css +++ b/src/ui/styles/command-dock-trays.css @@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has( bottom: calc(2vh + 5.4rem); } +:root[data-scope-mask='off'] + #intel-hud[data-variant='minimal'] + .hud-bottom-left { + bottom: calc(2vh + 6.5rem); +} + #intel-hud[data-variant='minimal'] .hud-bottom-bar { bottom: calc(2vh + 9rem); } diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index f11378bb9..59f275109 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -501,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn { color: var(--accent); } +.pp-scope-toggle input { + width: 14px; + height: 14px; + margin: 0 0 0 auto; + accent-color: var(--accent); + cursor: pointer; +} + +.pp-scope-toggle:has(input:checked) .pp-label { + color: var(--accent); +} + +.pp-scope-toggle:has(input:focus-visible) { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +#scope-slider-row:has(input:disabled) { + opacity: 0.45; +} + /* ── Bloom Slider (inline with toggle) ───────── */ .pp-toggle-group { display: flex; diff --git a/src/ui/styles/overlays.css b/src/ui/styles/overlays.css index 227d2d6d0..7f491439b 100644 --- a/src/ui/styles/overlays.css +++ b/src/ui/styles/overlays.css @@ -371,3 +371,134 @@ #intel-hud[data-variant='minimal'] .hud-summary-wrap { max-width: 280px; } + +/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */ +.hud-readout { + display: contents; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + .hud-readout, +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout { + background: rgba(0, 0, 0, 0.7); + box-shadow: none; + border-radius: 2px; + padding-block: 0; + padding-inline: 6px; + margin-block: 0; + margin-inline: -6px; + width: fit-content; + max-width: 100%; + box-sizing: content-box; + white-space: nowrap; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + .hud-readout { + display: block; + width: max-content; + min-width: 0; + box-sizing: content-box; + white-space: nowrap; +} + +:root[data-scope-mask='off'] + #intel-hud:is( + [data-variant='minimal'], + [data-variant='operator'], + [data-variant='tactical'] + ) + :is(.hud-top-right, .hud-bottom-right) + .hud-readout, +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .scene-text-holdout { + margin-inline-start: auto; +} + +/* Preserve glyph opacity without dimming the vertical backplates. */ +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + .hud-edge { + opacity: 1; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='operator'], [data-variant='tactical']) + .hud-edge + .hud-readout + > * { + opacity: 0.5; +} + +:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner, +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) { + background: transparent; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) { + width: fit-content; + max-width: calc(100vw - 120px); + gap: 24px; +} + +:root[data-scope-mask='off'] + #intel-hud:is([data-variant='minimal'], [data-variant='operator']) + :is(.hud-top-bar, .hud-bottom-bar) + .hud-readout { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + h1::before { + content: ''; + position: absolute; + inset: -0.12em -6px; + z-index: -1; + border-radius: 2px; + background: rgba(0, 0, 0, 0.7); +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + :is(#title-bar .subtitle, #style-indicator .indicator-label) { + color: var(--text-secondary); +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle { + margin-top: 8px; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #style-indicator + .indicator-value { + margin-top: 4px; +} + +:root[data-scope-mask='off'] + #intel-hud[data-variant='minimal'] + .hud-readout:has(> :is(.hud-classification, .hud-system)) { + display: none; +} + +:root[data-scope-mask='off']:not([data-ui-theme='cyber']) + #title-bar + .frame-rate-readout { + margin-top: 6px; +} diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html index f31453b6c..f6af31127 100644 --- a/src/ui/templates/display-controls.html +++ b/src/ui/templates/display-controls.html @@ -106,19 +106,15 @@
- + +
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}%`;