From 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 001/131] 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/131] 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 d16620a29ff3d8e696c0bada22d32608ca3a21a2 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 11 Oct 2026 17:51:36 +0530 Subject: [PATCH 003/131] fix: guard scene director callbacks --- src/director/clock.js | 8 ++- src/director/clock.test.mjs | 29 ++++++++++ .../interactions/interactions.test.mjs | 55 +++++++++++++++++++ src/director/interactions/session.js | 15 +++-- src/director/playback.js | 17 +++++- src/director/playback.test.mjs | 32 +++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..496616516 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -178,6 +178,61 @@ test('synchronous stop before execution prevents any side effect; rejection unlo assert.equal(calls, 2); }); +test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => { + let executed = 0; + let throwOnBusy = true; + let throwOnEnd = true; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => { + executed++; + return true; + }, + changed: (state) => { + if (throwOnBusy && state.busy) throw new Error('start failed'); + if (throwOnEnd && !state.busy && state.selected === 'a') { + throw new Error('end failed'); + } + }, + }); + session.activate([{ id: 'a' }]); + const result = await session.dispatch('a'); + assert.equal(result, true); + assert.equal(executed, 1); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: 'a', + count: 1, + }); + assert.equal(warnings.length, 2); + assert.equal( + warnings[0][0], + '[Scenes] Interaction session observer failed:', + ); + assert.equal( + warnings[1][0], + '[Scenes] Interaction session observer failed:', + ); + throwOnBusy = false; + throwOnEnd = false; + assert.equal(await session.dispatch('a'), true); + assert.equal(executed, 2); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js index 2f305b675..f731492e2 100644 --- a/src/director/interactions/session.js +++ b/src/director/interactions/session.js @@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) { selected = null; let actions = new Map(); const state = () => ({ active, busy, selected, count: actions.size }); + function notify() { + try { + changed(state()); + } catch (error) { + console.warn('[Scenes] Interaction session observer failed:', error); + } + } function clear() { active = false; busy = false; @@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { actions.clear(); controller?.abort(); controller = null; - changed(state()); + notify(); } return { clear, @@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { clear(); actions = new Map(items.map((item) => [item.id, item])); active = !!actions.size; - changed(state()); + notify(); }, async dispatch(id) { const item = actions.get(id); @@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { controller = current; busy = true; selected = id; - changed(state()); + notify(); let abort; try { const cancelled = new Promise((resolve) => { @@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { if (controller === current) { busy = false; controller = null; - changed(state()); + notify(); } } }, diff --git a/src/director/playback.js b/src/director/playback.js index 4fa97df1a..29759871e 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -53,6 +53,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; + let runError = null; try { if (!queue.length || cancelled()) { return { @@ -68,8 +69,9 @@ export async function playSceneQueue( for (let index = 0; index < queue.length && !cancelled(); index++) { const { scene, shot } = queue[index]; if (activeScene && activeScene.id !== scene.id) { - await adapter.releaseScene(activeScene); + const toRelease = activeScene; activeScene = null; + await adapter.releaseScene(toRelease); if (cancelled()) break; } activeScene = scene; @@ -82,8 +84,19 @@ export async function playSceneQueue( } if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; + } catch (error) { + runError = error; + throw error; } finally { - if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene); + if (releaseOnFinish && activeScene) { + const toRelease = activeScene; + activeScene = null; + try { + await adapter.releaseScene(toRelease); + } catch (error) { + if (!runError) throw error; + } + } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index f1d8f0921..d6c0b6451 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -196,3 +196,35 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy (error) => error === failure, ); }); + +test('a phase failure is preserved when final scene release also throws', async () => { + const phaseError = new Error('travel failed'); + const releaseError = new Error('release failed'); + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); +}); +test('a failing inter-scene release releases the active scene only once', async () => { + const releaseError = new Error('release failed'); + const released = []; + const f = fixture({ + releaseScene: async (scene) => { + released.push(scene.id); + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a'), f), + (error) => error === releaseError, + ); + assert.deepEqual(released, ['a']); +}); \ No newline at end of file From d2de6258ae6b5776b11c074c3666d444e3b3cae7 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 14:50:47 +0530 Subject: [PATCH 004/131] docs: document director playback error handling --- changelog.d/1014-director-callback-failures.md | 1 + docs/CURRENT-STATE.md | 4 ++++ src/director/playback.js | 1 + src/director/playback.test.mjs | 1 + 4 files changed, 7 insertions(+) create mode 100644 changelog.d/1014-director-callback-failures.md diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md new file mode 100644 index 000000000..65118b7a9 --- /dev/null +++ b/changelog.d/1014-director-callback-failures.md @@ -0,0 +1 @@ +Director clock listeners, interaction observers and scene release failures no longer leave the director inconsistent, and a phase error is kept when cleanup also fails (deepeshbind, #1014). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..f40ae5afb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,9 @@ # God's Eye View Current State +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing diff --git a/src/director/playback.js b/src/director/playback.js index 29759871e..175534434 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -35,6 +35,7 @@ export function buildPlaybackQueue( * releaseScene(scene, token?) leaves a previously loaded scene before playback * (false refuses that initial handoff). Between scenes and during final cleanup * it receives no cancelled token, so resource release can finish after Stop. + * The first error wins when the final release also fails. * An empty/already-cancelled queue acquires and releases no resources. * * @param {Array<{scene:Object, shot:Object}>} queue Normalized, fixed shot queue. diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index d6c0b6451..73de9b444 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -213,6 +213,7 @@ test('a phase failure is preserved when final scene release also throws', async (error) => error === phaseError, ); }); + test('a failing inter-scene release releases the active scene only once', async () => { const releaseError = new Error('release failed'); const released = []; From abf2ce1c9a6fe7422c02469c069b6a539597f835 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sat, 17 Oct 2026 19:40:12 +0530 Subject: [PATCH 005/131] fix: guard scene director callbacks --- src/director/playback.js | 6 +++++- src/director/playback.test.mjs | 34 ++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/director/playback.js b/src/director/playback.js index 175534434..2e243d748 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -95,7 +95,11 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (!runError) throw error; + if (runError) { + console.warn('[Scenes] Scene release failed:', error); + } else { + throw error; + } } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 73de9b444..be56079e2 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -200,18 +200,28 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy test('a phase failure is preserved when final scene release also throws', async () => { const phaseError = new Error('travel failed'); const releaseError = new Error('release failed'); - const f = fixture({ - travel: async () => { - throw phaseError; - }, - releaseScene: async () => { - throw releaseError; - }, - }); - await assert.rejects( - playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), - (error) => error === phaseError, - ); + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene release failed:'); + assert.equal(warnings[0][1], releaseError); + } finally { + console.warn = origWarn; + } }); test('a failing inter-scene release releases the active scene only once', async () => { From 9011918731b4a3d2444c7c11e1a472195dce1035 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 006/131] 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 3109ddabb80a5e9c28a9a889b45c5f39cf00ad4f Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 18 Oct 2026 13:37:27 +0530 Subject: [PATCH 007/131] add trailing newline to playback test --- src/director/playback.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index be56079e2..0141f2b01 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -238,4 +238,4 @@ test('a failing inter-scene release releases the active scene only once', async (error) => error === releaseError, ); assert.deepEqual(released, ['a']); -}); \ No newline at end of file +}); From 843c8b1a6bba03073b58e9a00e60467891827a4b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:31 +0700 Subject: [PATCH 008/131] 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 009/131] 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 010/131] 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 011/131] 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 012/131] 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 013/131] 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 014/131] 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 015/131] 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 016/131] 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 76199c07eeda0f88e8cb46474f57453cd3dd5e38 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:27:25 +0100 Subject: [PATCH 017/131] fix(director): keep a falsy phase error over a release failure runError doubled as the failure flag, so a phase that threw a falsy value was still masked by a final release error (ianblenke's nit). Track failure with a boolean and cover it with a test. Make the changelog fragment a wrapped bullet like the other fragments. Co-Authored-By: Claude Opus 5.5 --- .../1014-director-callback-failures.md | 5 ++++- src/director/playback.js | 6 ++--- src/director/playback.test.mjs | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/changelog.d/1014-director-callback-failures.md b/changelog.d/1014-director-callback-failures.md index 65118b7a9..f3877eca4 100644 --- a/changelog.d/1014-director-callback-failures.md +++ b/changelog.d/1014-director-callback-failures.md @@ -1 +1,4 @@ -Director clock listeners, interaction observers and scene release failures no longer leave the director inconsistent, and a phase error is kept when cleanup also fails (deepeshbind, #1014). +- Director clock listeners, interaction observers and scene release failures + no longer leave the director inconsistent: each scene is released once, and + a phase error is kept (with the release failure logged) when cleanup also + fails (deepeshbind, #1014). diff --git a/src/director/playback.js b/src/director/playback.js index 2e243d748..3c060b2bb 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -54,7 +54,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; - let runError = null; + let runFailed = false; try { if (!queue.length || cancelled()) { return { @@ -86,7 +86,7 @@ export async function playSceneQueue( if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; } catch (error) { - runError = error; + runFailed = true; throw error; } finally { if (releaseOnFinish && activeScene) { @@ -95,7 +95,7 @@ export async function playSceneQueue( try { await adapter.releaseScene(toRelease); } catch (error) { - if (runError) { + if (runFailed) { console.warn('[Scenes] Scene release failed:', error); } else { throw error; diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index 0141f2b01..820e8186e 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -239,3 +239,25 @@ test('a failing inter-scene release releases the active scene only once', async ); assert.deepEqual(released, ['a']); }); + +test('a falsy phase failure is still preserved over a release failure', async () => { + const releaseError = new Error('release failed'); + const origWarn = console.warn; + console.warn = () => {}; + try { + const f = fixture({ + travel: async () => { + throw undefined; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === undefined, + ); + } finally { + console.warn = origWarn; + } +}); From 6a5374435fec4940a780d98960d0a501c630db53 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 14:52:46 +0100 Subject: [PATCH 018/131] docs(current-state): move the #1014 note into its topical section Four open PRs prepended their notes at the top of CURRENT-STATE.md, so merging any one of them made the others conflict. Place this note in its own section instead. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2fd0aaa0c..055e5f7d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,9 +1,5 @@ # God's Eye View Current State -Director clock listeners, interaction observers and scene release failures no -longer leave the director inconsistent, and a phase error is kept when cleanup -also fails. - 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, @@ -986,6 +982,10 @@ Shot selection updates the highlight without replacing the row, preserving native double-click rename. Replacing rows revokes their old listeners. Disposal stops controls immediately; late file and failed-action completions cannot update removed presentation. +Director clock listeners, interaction observers and scene release failures no +longer leave the director inconsistent, and a phase error is kept when cleanup +also fails. + ## Cockpit component ownership Cockpit presentation is separated from its camera/controller behavior. Existing From 657fd589d7c30c41b111b1e46351b20c92a50c49 Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:17 -0600 Subject: [PATCH 019/131] 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 020/131] 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 ec0985618c5697776ef9ed1402e868d5e00719cc Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 01:25:40 +0100 Subject: [PATCH 021/131] 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 022/131] docs: record polling fix --- changelog.d/1282-polling-issue-fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1282-polling-issue-fix.md diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md new file mode 100644 index 000000000..82f9a0fba --- /dev/null +++ b/changelog.d/1282-polling-issue-fix.md @@ -0,0 +1 @@ +- Pause periodic network updates in hidden tabs (asturksever, #1282). From e4955f351ff8c7068748b22e9673e7b61edd7d92 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:07:13 +0100 Subject: [PATCH 023/131] test: qualify Transit visibility and city feed ownership --- changelog.d/1282-polling-issue-fix.md | 2 ++ scripts/qa-transit-browser.mjs | 5 ++++ scripts/qa-transit-controls.mjs | 5 ++++ scripts/qa-transit.mjs | 42 ++++++++++++++++++++------- src/tooling/transitQa.test.mjs | 13 +++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 82f9a0fba..c7b0dec0a 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1 +1,3 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). +- Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). +- Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). diff --git a/scripts/qa-transit-browser.mjs b/scripts/qa-transit-browser.mjs index eb7b59985..23925c743 100644 --- a/scripts/qa-transit-browser.mjs +++ b/scripts/qa-transit-browser.mjs @@ -47,6 +47,7 @@ export async function reportTransitVisibility(page, label) { const layer = app.dataManager.layers.get('transit').module; const state = layer._transitStateForTest(); return { + pageVisibility: document.visibilityState, total: state._vehicles.size, visible: state._visible.size, moving: state._moving.size, @@ -59,6 +60,10 @@ export async function reportTransitVisibility(page, label) { }; }); console.log(` ${label}: ${JSON.stringify(result)}`); + if (result.pageVisibility !== 'visible') + throw new Error( + `${label}: Transit QA requires a visible page; hidden pages deliberately pause polling`, + ); if (result.renderLoop === false || result.renderErrors.length) throw new Error( `${label}: Cesium render loop stopped or raised an error: ${JSON.stringify(result.renderErrors)}`, diff --git a/scripts/qa-transit-controls.mjs b/scripts/qa-transit-controls.mjs index b76e14706..70ed1503c 100644 --- a/scripts/qa-transit-controls.mjs +++ b/scripts/qa-transit-controls.mjs @@ -1,6 +1,7 @@ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const BOSTON = { id: 'boston', + feedId: 'mbta', label: 'Boston (MBTA)', lat: 42.3554, lon: -71.0605, @@ -11,6 +12,7 @@ const LIVE_CITIES = [ BOSTON, { id: 'austin', + feedId: 'capmetro-austin', label: 'Austin (CapMetro)', lat: 30.2672, lon: -97.7431, @@ -19,6 +21,7 @@ const LIVE_CITIES = [ }, { id: 'minneapolis', + feedId: 'metrotransit-msp', label: 'Minneapolis (Metro Transit)', lat: 44.9778, lon: -93.265, @@ -27,6 +30,7 @@ const LIVE_CITIES = [ }, { id: 'helsinki', + feedId: 'hsl-helsinki', label: 'Helsinki (HSL)', lat: 60.1699, lon: 24.9384, @@ -35,6 +39,7 @@ const LIVE_CITIES = [ }, { id: 'oslo', + feedId: 'entur-norway', label: 'Oslo (Entur)', lat: 59.9139, lon: 10.7522, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 7d83d77a4..fb0c66098 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -266,6 +266,7 @@ const browser = await puppeteer.launch({ executablePath: chromeExecutablePath(), args: [ '--no-sandbox', + '--disable-backgrounding-occluded-windows', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--window-size=1920,1080', @@ -391,22 +392,42 @@ try { orientation: { heading: 0, pitch: (-40 * Math.PI) / 180, roll: 0 }, }); }, city); - await page.waitForFunction( - () => - (window.__godsEyeView.dataManager.layers - .get('transit') - .module.getStats().count || 0) > 0, - { timeout: 90_000 }, - ); + const arrived = await page + .waitForFunction( + (feedId) => + [ + ...window.__godsEyeView.dataManager.layers + .get('transit') + .module._transitStateForTest() + ._vehicles.keys(), + ].some((key) => key.startsWith(`${feedId}:`)), + { timeout: 90_000 }, + city.feedId, + ) + .then( + () => true, + () => false, + ); + if (!arrived) { + check( + `${city.label}: live feed arrived`, + false, + `No vehicles from ${city.feedId}; retained vehicles from other cities cannot qualify this view`, + { unexercised: true }, + ); + continue; + } await wait(9_000); - const fleet = await page.evaluate(() => { + const fleet = await page.evaluate((feedId) => { const layer = window.__godsEyeView.dataManager.layers.get('transit').module; return { - count: layer._transitStateForTest()._vehicles.size, + count: [...layer._transitStateForTest()._vehicles.keys()].filter( + (key) => key.startsWith(`${feedId}:`), + ).length, stats: layer.getStats(), }; - }); + }, city.feedId); check( `${city.label}: vehicles are rendered`, fleet.count >= 20, @@ -428,6 +449,7 @@ try { .module._transitStateForTest(); let best = null; for (const entry of state._vehicles.values()) { + if (!entry.key.startsWith(`${c.feedId}:`)) continue; const d = Math.hypot(entry.to.lat - c.lat, entry.to.lon - c.lon); if (!best || d < best.d) best = { d, lat: entry.to.lat, lon: entry.to.lon, key: entry.key }; diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 51e4d5c44..634538c2a 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { getTransitFeed } from '../data/transitFeeds.js'; import { chooseLiveCities, selectStyle, @@ -101,6 +102,18 @@ test('live visual cities follow local operating hours in summer and winter', () const cities = chooseLiveCities(now); assert.ok(cities.length >= 1); for (const city of cities) { + const feed = getTransitFeed(city.feedId); + assert.ok(feed, `${city.id} has a registered feed`); + const distanceKm = + 111.32 * + Math.hypot( + feed.center.lat - city.lat, + (feed.center.lon - city.lon) * Math.cos((city.lat * Math.PI) / 180), + ); + assert.ok( + distanceKm < feed.loadRadiusKm, + `${city.id} is inside its feed's admission region`, + ); const local = Number( new Intl.DateTimeFormat('en', { timeZone: city.zone, From 2c8b3593d1af5ae2291c178f9105da0a01060223 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:19:11 +0100 Subject: [PATCH 024/131] test: observe a poll transition in the Transit anchor probe --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-transit.mjs | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index c7b0dec0a..f3aede4e4 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -1,3 +1,4 @@ - Pause periodic network updates in hidden tabs (asturksever, #1282). - Report and guard browser visibility during Transit QA so hidden-page polling pauses cannot masquerade as playback regressions (asturksever, #1282). - Require each live city to supply its own feed vehicles in Transit QA; retained vehicles cannot qualify another city (asturksever, #1282). +- Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index fb0c66098..628983e74 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -40,6 +40,7 @@ import { chromeExecutablePath } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; +import { TRANSIT_POLL_MS } from '../src/layers/transit/policy.js'; import { reduceAnchor, @@ -1631,6 +1632,7 @@ try { m[3] * v[0] + m[7] * v[1] + m[11] * v[2] + m[15] * v[3], ]; const frames = []; + const observedPolls = new Set(); const publicationsBefore = state._cardPublications || 0; let headSamples = 0, headErrorPx = 0, @@ -1647,6 +1649,13 @@ try { const proj = scene.camera.frustum.projectionMatrix; const p = entry.marker.position; const clip = mul(proj, mul(view, [p.x, p.y, p.z, 1])); + if ( + entry.marker.show !== false && + rect && + Number.isFinite(entry.pollSeq) + ) { + observedPolls.add(entry.pollSeq); + } frames.push({ t: performance.now() - t0, mx: ((clip[0] / clip[3]) * 0.5 + 0.5) * canvas.clientWidth, @@ -1692,7 +1701,15 @@ try { headSamples++; } } - if (performance.now() - t0 >= durationMs) finish(); + const elapsed = performance.now() - t0; + // Keep at least 20 seconds of motion and require an observed poll + // transition. A fixed 20s wall-clock window can miss a 15s poll + // when an earlier refresh is still settling. + if ( + (elapsed >= 20_000 && observedPolls.size >= 2) || + elapsed >= durationMs + ) + finish(); }; let remove; const finish = () => { @@ -1700,6 +1717,7 @@ try { remove?.(); resolve({ selected: true, + elapsedMs: performance.now() - t0, frames, textPublishes: (state._cardPublications || 0) - publicationsBefore, @@ -1714,7 +1732,7 @@ try { }); }, target.key, - 20_000, + 3 * TRANSIT_POLL_MS, ); } check( @@ -1735,8 +1753,9 @@ try { ); check( 'card text publication stays throttled while the anchor runs at frame rate', - anchor.textPublishes > 0 && anchor.textPublishes <= 20_000 / 250 + 3, - `${anchor.textPublishes} text publications in 20 s`, + anchor.textPublishes > 0 && + anchor.textPublishes <= anchor.elapsedMs / 250 + 3, + `${anchor.textPublishes} text publications in ${(anchor.elapsedMs / 1000).toFixed(1)} s`, ); } else { check( From 5b9c726f33e7eb6e9af400f981f42ae2ef243628 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:37:57 +0100 Subject: [PATCH 025/131] 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 026/131] test: avoid concurrent format-scope append conflicts --- scripts/format-scope.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b894cbe9a..92bd228ba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -4,6 +4,7 @@ "scripts/format-scope.json", "scripts/package-boundaries.json", "scripts/format.mjs", + "scripts/qa-layer-visibility.mjs", "scripts/next-layer-state-token.mjs", "scripts/check-layer-state-tokens.mjs", "scripts/qa-layer-token-twochar.mjs", @@ -1070,6 +1071,5 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/terrainHeightsBounds.test.mjs", - "scripts/qa-layer-visibility.mjs" + "src/terrainHeightsBounds.test.mjs" ] From 56be69d065c4e77d46ec034075327155eb520940 Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 15:12:33 +0100 Subject: [PATCH 027/131] test: restore minimized Transit windows before screenshots --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-chrome.mjs | 27 +++++++++++++ scripts/qa-transit-recovery.mjs | 4 +- scripts/qa-transit-scenes.mjs | 13 +++--- scripts/qa-transit.mjs | 14 ++++--- src/qaChrome.test.mjs | 57 ++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 4af89eeee..9931385bf 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -4,3 +4,4 @@ - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). - Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). - Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 47eafc94d..943e39df5 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,7 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -294,11 +294,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); From 1f77d2290cb8d4d2ef194f6819466f9c98c27d77 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:10:49 -0400 Subject: [PATCH 028/131] feat: add S2 GhostMaps live intel layer Adds an "S2 GhostMaps" layer (Events group) that renders S2 Underground's GhostMaps KMZ exports (Common Intelligence Picture, Kinetic 30d, Border Crisis, Iran CIP/Kinetic, Yemen FLOT, latest incident drop). - server/providers/ghostMaps.js: watcher polls the repo's git smart-HTTP ref advertisement (default every 15s, GHOSTMAPS_POLL_SECONDS), fetches the tree only on a new head, caches commit-pinned files by blob sha and pushes catalog updates to browsers over SSE (/api/ghostmaps/events). - src/layers/ghostMaps: KML reader, ArcGIS popup-table parsing, per-product data sources, product chips, horizon-culled pins, report cards. - Unit tests, QA gate (scripts/qa-ghostmaps.mjs), credits (CC BY-NC-SA 4.0), i18n, voice manifest, layer-state token 5. Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 1 + changelog.d/0000-s2-ghostmaps-layer.md | 7 + docs/CURRENT-STATE.md | 20 + package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 49 +- scripts/qa-ghostmaps.mjs | 645 ++++++++++++++++++++ server/providers/ghostMaps.js | 431 +++++++++++++ server/providers/ghostMaps.test.mjs | 227 +++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/ghostMaps.js | 27 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 8 + src/data/geoFileImport.js | 11 +- src/data/layerState.js | 5 + src/data/layerState.test.mjs | 26 +- src/data/layerStateTokenLedger.test.mjs | 57 +- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/i18n/locales/es.json | 30 +- src/i18n/locales/ru.json | 32 +- src/i18n/same-as-english.json | 4 + src/layers/ghostMaps/catalog.js | 245 ++++++++ src/layers/ghostMaps/catalog.test.mjs | 165 +++++ src/layers/ghostMaps/index.js | 708 ++++++++++++++++++++++ src/layers/ghostMaps/kml.js | 198 ++++++ src/layers/ghostMaps/records.js | 165 +++++ src/layers/ghostMaps/records.test.mjs | 106 ++++ src/layers/ghostMaps/source.js | 81 +++ src/tooling/previewServing.test.mjs | 2 + src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/voice/actionSchemas.test.mjs | 3 +- src/voice/layerManifest.js | 14 + 36 files changed, 3247 insertions(+), 52 deletions(-) create mode 100644 changelog.d/0000-s2-ghostmaps-layer.md create mode 100644 scripts/qa-ghostmaps.mjs create mode 100644 server/providers/ghostMaps.js create mode 100644 server/providers/ghostMaps.test.mjs create mode 100644 src/app/layers/ghostMaps.js create mode 100644 src/layers/ghostMaps/catalog.js create mode 100644 src/layers/ghostMaps/catalog.test.mjs create mode 100644 src/layers/ghostMaps/index.js create mode 100644 src/layers/ghostMaps/kml.js create mode 100644 src/layers/ghostMaps/records.js create mode 100644 src/layers/ghostMaps/records.test.mjs create mode 100644 src/layers/ghostMaps/source.js diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 61b971b71..ca774b47c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -38,6 +38,7 @@ How to read this: | **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" | | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | +| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/0000-s2-ghostmaps-layer.md new file mode 100644 index 000000000..b78d6a042 --- /dev/null +++ b/changelog.d/0000-s2-ghostmaps-layer.md @@ -0,0 +1,7 @@ +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..1e2fabc51 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3507,6 +3507,7 @@ its criteria cannot be silently ignored. | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen @@ -4994,6 +4995,25 @@ are omitted rather than framing the wrong part of the globe. - Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`. - The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`. +### S2 GhostMaps (October 2026) + +- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. +- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. +- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. + - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. + - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. + - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. + - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. +- **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. + - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. + - Lines and polygon outlines are ground-clamped polylines. + - Styles, StyleMaps and inline styles are honoured. Icons come only from images packed in the same KMZ and at most 512 KB. Anything else uses a per-product round marker. + - Network links, overlays, models and remote hrefs are never followed. Hidden placemarks are skipped. Bounds are 20,000 placemarks and 250,000 positions per product. +- **Reports.** Clicking a marker opens a card with the product, folder, edition date and the placemark's report fields. `src/layers/ghostMaps/records.js` reads ArcGIS popup tables as text only: `` and internal fields are dropped and values are capped. Description markup is never inserted into the page. The first `http(s)` research link opens from the card. The card is `stateless`, so it comes and goes with the click, as CCTV cards do. It is also `horizonCull`ed, so it hides when its marker turns behind the planet. Picks are registered under `s2-ghostmaps`. +- **Validation.** Unit tests: `src/layers/ghostMaps/*.test.mjs` cover editions, the catalog, ref parsing, description tables and wrapping; `server/providers/ghostMaps.test.mjs` covers commit detection, events, pinned downloads, cache pruning, restart and routing. `node scripts/qa-ghostmaps.mjs --url http://localhost:4173` covers the live load, the open stream, picks and cards (asserted on the overlay host's diagnostics, since the `desynchronized` overlay canvas can read back a stale buffer headless), far-side culling, a synthetic push, chip persistence, disable and phone width. + ### Import Your Own Files (October 2026) - DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools. diff --git a/package.json b/package.json index 241c59e59..9256107e4 100644 --- a/package.json +++ b/package.json @@ -168,6 +168,7 @@ "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/gnss": "./src/layers/gnss/index.js", + "./layers/ghostmaps": "./src/layers/ghostMaps/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -244,6 +245,7 @@ "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", "./layers/gnss/source": "./src/layers/gnss/source.js", + "./layers/ghostmaps/source": "./src/layers/ghostMaps/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 83e961d8b..46d4fa3c4 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -865,6 +865,17 @@ "src/layers/gnss/source.test.mjs", "src/layers/gnss/ownership.test.mjs", "scripts/qa-gnss.mjs", + "server/providers/ghostMaps.js", + "server/providers/ghostMaps.test.mjs", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.test.mjs", + "src/layers/ghostMaps/records.test.mjs", + "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index bf33bebc7..ddd3f6bdf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2285,6 +2285,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2916,6 +2922,12 @@ "src/layers/gnss/index.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", + "src/app/layers/ghostMaps.js", + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3067,7 +3079,10 @@ "src/layers/airQuality/source.js", "src/ui/statusText.js", "src/ui/layerNames.js", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/data/geoFileImport.js" ], "external": [ "hls.js", @@ -3759,6 +3774,38 @@ ], "external": [] }, + "ghostmaps": { + "exports": ["./layers/ghostmaps"], + "modules": [ + "src/layers/ghostMaps/index.js", + "src/layers/ghostMaps/catalog.js", + "src/layers/ghostMaps/records.js", + "src/layers/ghostMaps/kml.js", + "src/layers/ghostMaps/source.js", + "src/sources/httpBody.js", + "src/data/geoFileImport.js", + "src/i18n/marker.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/data/geoImportImages.js", + "src/data/geoImportPins.js", + "src/layers/streetLevel/tileMath.js", + "src/layers/streetLevel/view.js" + ], + "external": ["cesium"] + }, + "ghostmaps-source": { + "exports": ["./layers/ghostmaps/source"], + "modules": [ + "src/layers/ghostMaps/source.js", + "src/layers/ghostMaps/catalog.js", + "src/sources/httpBody.js", + "src/i18n/marker.js" + ], + "external": [] + }, "perimeters-source": { "exports": ["./layers/perimeters/source"], "modules": [ diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs new file mode 100644 index 000000000..83dc8ed97 --- /dev/null +++ b/scripts/qa-ghostmaps.mjs @@ -0,0 +1,645 @@ +#!/usr/bin/env node +/** + * qa-ghostmaps.mjs — headless proof for the S2 Underground GhostMaps layer + * (id `s2-ghostmaps`): S2's public KMZ intel products, kept current from + * github.com/s2underground/GhostMaps. + * + * Drives the REAL app in headless Chromium against a dev server (keyless). + * + * (i) LIVE — enabling the layer loads the default products (Common + * Intelligence Picture and the 30-day kinetic pull) from the live + * repository through /api/ghostmaps, one data source per product, + * matching the proxy's catalog commit; the row shows one chip per + * catalog product with the defaults active, and the page holds an + * open event stream to /api/ghostmaps/events. + * (ii) PICK — a Common Intelligence Picture marker at the screen centre + * picks as this layer's entity; clicking it paints its report card + * and clicking empty ground clears it. + * (iii) PUSH (synthetic) — the event stream is replaced in the page and a + * catalog naming a new edition of one product is pushed: only that + * product reloads (a new data source replaces the old one, no + * duplicates), the other keeps its data source. A catalog without a + * product removes it. + * (iv) CHIPS — clicking an active product chip removes that product, + * clicking it again restores it; the choice survives a reload. + * (v) DISABLE — every GhostMaps data source leaves the viewer. + * (vi) VIEWPORT — at a 390x844 phone viewport the open row and its chips + * fit the panel width with no horizontal overflow. + * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: + * seen from over the Indian Ocean no pin clearly behind the planet is + * shown; back over North America the near-side pins show again. + * + * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). + * + * Run: node scripts/qa-ghostmaps.mjs --url http://localhost:4173 + * Optional: --timeout-ms 180000 for a slow boot, --shots . + * Exits non-zero on any FAIL. Does not commit anything. + */ + +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const getOpt = (name, dflt) => { + const i = argv.indexOf(name); + return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; +}; +const APP_URL = getOpt('--url', 'http://localhost:4173'); +const HEADFUL = argv.includes('--headful'); +const TIMEOUT_MS = Number(getOpt('--timeout-ms', '90000')); +const SHOTS_DIR = path.resolve( + getOpt('--shots', path.join(__dirname, '..', 'qa-shots')), +); +const LAYER_ID = 's2-ghostmaps'; + +const results = []; +function record(name, ok, detail) { + results.push({ name, ok, detail }); + const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m'; + console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function boot(page) { + await page.goto(`${APP_URL}/?welcome=0`, { + waitUntil: 'domcontentloaded', + timeout: TIMEOUT_MS, + }); + await page.waitForFunction( + () => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager, + { timeout: TIMEOUT_MS }, + ); + await sleep(5000); + await page.keyboard.press('Escape'); +} + +/** GhostMaps data sources on the viewer: name → entity count. */ +function ghostSources(prefix) { + const { dataSources } = window.__godsEyeView.viewer; + const out = []; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (ds.name?.startsWith(prefix)) + out.push({ name: ds.name, count: ds.entities.values.length }); + } + return out; +} + +function paintedOverlayPixels() { + const canvas = document.getElementById('world-overlay-canvas'); + if (!canvas) return -1; + const pixels = canvas + .getContext('2d') + .getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++; + return count; +} + +async function enableAndSettle(page) { + await page.evaluate((id) => { + const panel = document.getElementById('data-panel'); + if (panel?.classList.contains('collapsed')) + panel.querySelector('[data-collapse-target="data-panel"]')?.click(); + return window.__godsEyeView.dataManager.setEnabled(id, true); + }, LAYER_ID); + await page.waitForFunction( + (id) => { + const stats = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return !stats.loading && stats.count > 0; + }, + { timeout: TIMEOUT_MS, polling: 500 }, + LAYER_ID, + ); +} + +const stats = (page) => + page.evaluate( + (id) => window.__godsEyeView.dataManager.layers.get(id).module.getStats(), + LAYER_ID, + ); +const sources = (page) => + page.evaluate(`(${ghostSources})(${JSON.stringify(`${LAYER_ID}:`)})`); + +async function showRow(page) { + await page.evaluate((id) => { + document + .querySelector(`[data-layer-id="${id}"]`) + ?.scrollIntoView({ block: 'center' }); + }, LAYER_ID); + await sleep(400); +} + +async function main() { + fs.mkdirSync(SHOTS_DIR, { recursive: true }); + const browser = await puppeteer.launch({ + headless: !HEADFUL, + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + defaultViewport: { width: 1440, height: 900 }, + }); + try { + // ---- (i) LIVE --------------------------------------------------------- + const page = await browser.newPage(); + // Start clean once; a reload must keep what the page stored. + await page.evaluateOnNewDocument(() => { + try { + if (sessionStorage.getItem('qa-ghostmaps-clean')) return; + localStorage.clear(); + sessionStorage.setItem('qa-ghostmaps-clean', '1'); + } catch { + /* sandboxed frames have no storage */ + } + }); + page.on('pageerror', (error) => + console.log(` [page error] ${error.message}`), + ); + await boot(page); + const catalog = await page.evaluate(() => + fetch('/api/ghostmaps/catalog').then((r) => r.json()), + ); + record( + 'LIVE: proxy catalog names a commit and the default products', + /^[0-9a-f]{40}$/.test(catalog.commit || '') && + ['cip', 'kinetic-30d'].every((id) => + catalog.products.some((p) => p.id === id && p.defaultOn), + ), + `${catalog.commit?.slice(0, 7)} "${catalog.message}", ${catalog.products.length} products`, + ); + const t0 = Date.now(); + await enableAndSettle(page); + const live = await stats(page); + const liveSources = await sources(page); + record( + 'LIVE: default products load as one data source each', + liveSources.length === 2 && + liveSources.every((s) => s.count > 0) && + live.products.sort().join() === 'cip,kinetic-30d', + `${liveSources.map((s) => `${s.name}=${s.count}`).join(', ')} in ${Date.now() - t0} ms`, + ); + record( + 'LIVE: layer reports the proxy commit and no error', + live.commit === catalog.commit && !live.error, + `commit ${live.commit?.slice(0, 7)}, error ${live.error}`, + ); + await showRow(page); + const chips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] .data-toggle-chip`, + ), + ].map((chip) => ({ + id: chip.dataset.chipId, + active: chip.getAttribute('aria-pressed') === 'true', + })), + LAYER_ID, + ); + record( + 'LIVE: one chip per product, defaults pressed', + chips.length === catalog.products.length && + chips.every( + (chip) => + chip.active === + catalog.products.find((p) => p.id === chip.id)?.defaultOn, + ), + chips.map((c) => `${c.id}${c.active ? '*' : ''}`).join(' '), + ); + const stream = await page + .waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers.get(id).module.getStats() + .stream === 'open', + { timeout: 15000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record('LIVE: the /api/ghostmaps/events stream is open', stream); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + + // ---- (ii) PICK ---------------------------------------------------------- + const target = await page.evaluate((id) => { + const gev = window.__godsEyeView; + const ds = [...Array(gev.viewer.dataSources.length).keys()] + .map((i) => gev.viewer.dataSources.get(i)) + .find((item) => item.name === `${id}:cip`); + const entity = ds.entities.values.find((e) => e.billboard); + const carto = gev.viewer.scene.globe.ellipsoid.cartesianToCartographic( + entity.position.getValue(gev.viewer.clock.currentTime), + ); + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: carto.longitude, + latitude: carto.latitude, + height: 40000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + return entity.id; + }, LAYER_ID); + await page + .waitForFunction( + () => window.__godsEyeView.viewer.scene.globe.tilesLoaded, + { timeout: 30000 }, + ) + .catch(() => {}); + // The horizon cull reveals the pin on the frame after the camera moves. + await page + .waitForFunction( + () => { + const v = window.__godsEyeView.viewer; + v.scene.requestRender?.(); + const c = v.scene.canvas; + return v.scene + .drillPick({ x: c.clientWidth / 2, y: c.clientHeight / 2 }, 8) + .some((p) => + String(p?.id?.id ?? p?.id ?? '').startsWith('ghostmaps:cip:'), + ); + }, + { timeout: 15000, polling: 500 }, + ) + .catch(() => {}); + const probe = await page.evaluate(() => { + const v = window.__godsEyeView.viewer; + const c = v.scene.canvas; + const box = c.getBoundingClientRect(); + const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 }; + const picked = v.scene.drillPick(centre, 8); + // Empty ground: the first point below the marker (the card is drawn + // above it) that picks nothing at all; markers can be dense in a city. + let empty = null; + for (let r = 120; r < 420 && !empty; r += 60) + for (const a of [2, 1, 3, 0, 4]) { + const p = { + x: centre.x + r * Math.cos((a * Math.PI) / 4), + y: centre.y + r * Math.sin((a * Math.PI) / 4), + }; + if (!v.scene.pick(p)) { + empty = p; + break; + } + } + empty ||= { x: centre.x + 400, y: centre.y + 300 }; + return { + ids: picked.map((p) => String(p?.id?.id ?? p?.id ?? '')), + centre: { x: centre.x + box.left, y: centre.y + box.top }, + empty: { x: empty.x + box.left, y: empty.y + box.top }, + }; + }); + record( + 'PICK: the centred marker picks as a GhostMaps entity', + probe.ids.some((id) => id.startsWith('ghostmaps:cip:')), + `${target} → [${probe.ids.slice(0, 3).join(', ')}]`, + ); + const before = await page.evaluate(paintedOverlayPixels); + await page.mouse.click(probe.centre.x, probe.centre.y); + const painted = await page + .waitForFunction(`(${paintedOverlayPixels})() > ${before + 100}`, { + timeout: 10000, + polling: 250, + }) + .then( + () => true, + () => false, + ); + const after = await page.evaluate(paintedOverlayPixels); + record( + 'PICK: clicking the marker paints its report card', + painted, + `${before} → ${after} pixels`, + ); + await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-card.png') }); + const cardState = () => + page.evaluate((id) => { + const d = window.__gevWorldOverlay?.getDiagnostics?.(); + return { + entries: d?.entriesBySource?.[id] ?? null, + painted: d?.paintedBySource?.[id] ?? null, + }; + }, LAYER_ID); + const selectedState = await cardState(); + record( + 'PICK: the overlay host holds and paints exactly one card', + selectedState.entries === 1 && selectedState.painted === 1, + JSON.stringify(selectedState), + ); + await page.mouse.click(probe.empty.x, probe.empty.y); + // The overlay canvas is `desynchronized`, so headless read-back can + // return a stale buffer; the host's own model is the authority here. + const cleared = await page + .waitForFunction( + (id) => { + const d = window.__gevWorldOverlay.getDiagnostics(); + return ( + d.entriesBySource?.[id] === 0 && !(d.paintedBySource?.[id] > 0) + ); + }, + { timeout: 10000, polling: 250 }, + LAYER_ID, + ) + .then( + () => true, + () => false, + ); + record( + 'PICK: clicking empty ground clears the card', + cleared, + JSON.stringify(await cardState()), + ); + + // ---- (vii) FAR SIDE --------------------------------------------------- + // Pins skip the depth test; with Google 3D tiles nothing else hides the + // far side. A pin clearly behind the planet (sphere test with margin) must + // be hidden, and pins facing the camera must still show. + const horizonCounts = (lon, lat) => + page.evaluate( + async (id, lo, la) => { + const gev = window.__godsEyeView; + const d2r = Math.PI / 180; + try { + gev.viewer.camera.cancelFlight(); + } catch { + /* no flight */ + } + gev.viewer.camera.setView({ + destination: + gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({ + longitude: lo * d2r, + latitude: la * d2r, + height: 18_000_000, + }), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + gev.viewer.scene.requestRender?.(); + await new Promise((r) => setTimeout(r, 1200)); + const c = gev.viewer.camera.positionWC; + const norm = (v) => Math.hypot(v.x, v.y, v.z); + let far = 0; + let nearShown = 0; + for (let i = 0; i < gev.viewer.dataSources.length; i++) { + const ds = gev.viewer.dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + for (const e of ds.entities.values) { + if (!e.billboard) continue; + const p = e.position.getValue(gev.viewer.clock.currentTime); + // Angle from the camera's nadir to the pin, against the + // geometric horizon angle; the cull's 1 km margin shows pins up + // to about 1° past it, so "clearly behind" is beyond 2°. + const angle = Math.acos( + (p.x * c.x + p.y * c.y + p.z * c.z) / (norm(p) * norm(c)), + ); + const horizon = Math.acos(norm(p) / norm(c)); + const margin = (2 * Math.PI) / 180; + if (e.show && angle > horizon + margin) far++; + if (e.show && angle < horizon - margin) nearShown++; + } + } + return { far, nearShown }; + }, + LAYER_ID, + lon, + lat, + ); + const fromIndianOcean = await horizonCounts(80, -30); + record( + 'FAR SIDE: pins behind the planet are hidden', + fromIndianOcean.far === 0, + JSON.stringify(fromIndianOcean), + ); + await page.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-far-side.png'), + }); + const fromAmerica = await horizonCounts(-98, 39); + record( + 'FAR SIDE: pins facing the camera show again', + fromAmerica.far === 0 && fromAmerica.nearShown > 100, + JSON.stringify(fromAmerica), + ); + + // ---- (iv) CHIPS ------------------------------------------------------- + const clickChip = async (id) => { + // Row controls render on the next frame after the layer settles. + await page.waitForSelector( + `[data-layer-id="${LAYER_ID}"] .data-toggle-chip[data-chip-id="${id}"]`, + { timeout: 15000 }, + ); + return page.evaluate( + (layer, chip) => + document + .querySelector( + `[data-layer-id="${layer}"] .data-toggle-chip[data-chip-id="${chip}"]`, + ) + ?.click(), + LAYER_ID, + id, + ); + }; + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + !window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 15000 }, + LAYER_ID, + ); + const without = await sources(page); + record( + 'CHIPS: turning a product off removes only its data source', + without.length === 1 && without[0].name === `${LAYER_ID}:cip`, + without.map((s) => s.name).join(', '), + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await sleep(3000); + await enableAndSettle(page); + const kept = await stats(page); + record( + 'CHIPS: the product choice survives a reload', + kept.products.join() === 'cip', + kept.products.join(', '), + ); + await showRow(page); + await clickChip('kinetic-30d'); + await page.waitForFunction( + (id) => + window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats() + .products.includes('kinetic-30d'), + { timeout: 30000 }, + LAYER_ID, + ); + record( + 'CHIPS: turning it back on restores it', + (await sources(page)).length === 2, + ); + + // ---- (v) DISABLE ------------------------------------------------------ + await page.evaluate( + (id) => window.__godsEyeView.dataManager.setEnabled(id, false), + LAYER_ID, + ); + await sleep(500); + record( + 'DISABLE: every GhostMaps data source leaves the viewer', + (await sources(page)).length === 0, + ); + await page.close(); + + // ---- (iii) PUSH (synthetic) ---------------------------------------------- + const push = await browser.newPage(); + await push.evaluateOnNewDocument(() => { + localStorage.clear(); + // A controllable stand-in for the real stream. + class QaEventSource { + constructor(url) { + this.url = url; + this.listeners = new Set(); + (window.__qaStreams ||= []).push(this); + } + addEventListener(type, listener) { + if (type === 'catalog') this.listeners.add(listener); + } + removeEventListener(type, listener) { + this.listeners.delete(listener); + } + close() { + this.closed = true; + } + emit(catalog) { + for (const listener of this.listeners) + listener({ data: JSON.stringify(catalog) }); + } + } + window.EventSource = QaEventSource; + }); + await boot(push); + await enableAndSettle(push); + const identity = () => + push.evaluate((id) => { + const { dataSources } = window.__godsEyeView.viewer; + window.__qaIds ||= new WeakMap(); + window.__qaNext ||= 1; + const out = {}; + for (let i = 0; i < dataSources.length; i++) { + const ds = dataSources.get(i); + if (!ds.name?.startsWith(`${id}:`)) continue; + if (!window.__qaIds.has(ds)) + window.__qaIds.set(ds, window.__qaNext++); + out[ds.name] = window.__qaIds.get(ds); + } + return out; + }, LAYER_ID); + const first = await identity(); + const pushed = Date.now(); + await push.evaluate(async (id) => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + const kinetic = catalog.products.find((p) => p.id === 'kinetic-30d'); + kinetic.version = 'f'.repeat(40); + catalog.message = 'QA push'; + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }, LAYER_ID); + await push.waitForFunction( + (id) => { + const layer = window.__godsEyeView.dataManager.layers.get(id).module; + return ( + !layer.getStats().loading && + layer.getRowControls().info.includes('QA push') + ); + }, + { timeout: 20000, polling: 100 }, + LAYER_ID, + ); + await sleep(500); + const second = await identity(); + record( + 'PUSH: a new edition reloads only that product', + Object.keys(second).length === 2 && + second[`${LAYER_ID}:kinetic-30d`] !== + first[`${LAYER_ID}:kinetic-30d`] && + second[`${LAYER_ID}:cip`] === first[`${LAYER_ID}:cip`], + `${JSON.stringify(first)} → ${JSON.stringify(second)} in ${Date.now() - pushed} ms`, + ); + await push.evaluate(async () => { + const catalog = await fetch('/api/ghostmaps/catalog').then((r) => + r.json(), + ); + catalog.products = catalog.products.filter((p) => p.id !== 'kinetic-30d'); + window.__qaStreams + .filter((s) => !s.closed) + .forEach((s) => s.emit(catalog)); + }); + await sleep(1000); + const third = await identity(); + record( + 'PUSH: a product leaving the catalog leaves the globe', + Object.keys(third).join() === `${LAYER_ID}:cip`, + Object.keys(third).join(', '), + ); + await push.close(); + + // ---- (vi) VIEWPORT ---------------------------------------------------- + const phone = await browser.newPage(); + await phone.setViewport({ width: 390, height: 844, isMobile: true }); + await phone.evaluateOnNewDocument(() => localStorage.clear()); + await boot(phone); + await enableAndSettle(phone); + await showRow(phone); + const fit = await phone.evaluate((id) => { + const row = document.querySelector(`[data-layer-id="${id}"]`); + const panel = document.getElementById('data-panel'); + const controls = row?.querySelector('.data-toggle-controls'); + return { + row: Boolean(row), + overflow: controls ? controls.scrollWidth - controls.clientWidth : -1, + pageOverflow: + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + panelRight: panel?.getBoundingClientRect().right, + width: window.innerWidth, + }; + }, LAYER_ID); + record( + 'VIEWPORT: chips fit the phone-width panel', + fit.row && fit.overflow <= 1 && fit.pageOverflow <= 0, + JSON.stringify(fit), + ); + await phone.screenshot({ + path: path.join(SHOTS_DIR, 'ghostmaps-phone.png'), + }); + await phone.close(); + } finally { + await browser.close(); + } + const failed = results.filter((r) => !r.ok); + console.log( + `\n${results.length - failed.length}/${results.length} passed. Screenshots: ${SHOTS_DIR}`, + ); + process.exit(failed.length ? 1 : 0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js new file mode 100644 index 000000000..dd30fd986 --- /dev/null +++ b/server/providers/ghostMaps.js @@ -0,0 +1,431 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { + GHOSTMAPS_MAX_FILE_BYTES, + GHOSTMAPS_REPO, + buildGhostMapsCatalog, + ghostMapsRawUrl, + parseGitRefAdvertisement, +} from '../../src/layers/ghostMaps/catalog.js'; +import { + readResponseBytesCapped, + readResponseJsonCapped, + readResponseTextCapped, +} from './common/http.js'; + +// S2 Underground GhostMaps (public GitHub repository of KMZ intel products). +// The repository is not ours, so there is no webhook: the watcher reads the +// branch head through git's smart-HTTP ref advertisement (~0.5 KB, outside the +// REST API rate limit) every few seconds, and only a new commit costs REST +// calls (tree + commit). Browsers hear about a new commit over server-sent +// events and fetch only the products whose blob changed. Files are fetched by +// commit-pinned raw URLs and kept on disk by blob sha. + +const DEFAULT_POLL_MS = 15_000; +const MIN_POLL_MS = 5_000; +const FAILURE_BACKOFF_MS = [15_000, 30_000, 60_000, 120_000, 300_000]; +const TIMEOUT_MS = 15_000; +const FILE_TIMEOUT_MS = 120_000; +const HEARTBEAT_MS = 25_000; +const MAX_SUBSCRIBERS = 64; +const MAX_REF_BYTES = 64 * 1024; +const MAX_TREE_BYTES = 16 * 1024 * 1024; +const MAX_COMMIT_BYTES = 2 * 1024 * 1024; +const USER_AGENT = 'gods-eye-view-ghostmaps/1.0'; +const REPO_PATH = `${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}`; + +/** Poll interval from GHOSTMAPS_POLL_SECONDS, bounded below at 5 s. */ +export function ghostMapsPollMs(raw = process.env.GHOSTMAPS_POLL_SECONDS) { + const seconds = Number(raw); + if (!raw || !Number.isFinite(seconds)) return DEFAULT_POLL_MS; + return Math.max(MIN_POLL_MS, Math.round(seconds * 1000)); +} + +/** + * The repository watcher and file cache, independent of HTTP routing. + * @returns {{start: Function, stop: Function, catalog: Function, + * checkNow: Function, file: Function, subscribe: Function}} + */ +export function createGhostMapsWatcher({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), + pollMs = ghostMapsPollMs(), + cacheDir = path.join(process.cwd(), '.gev-cache', 'ghostmaps'), + token = () => String(process.env.GITHUB_TOKEN || '').trim(), + log = (...args) => console.log(...args), + warn = (...args) => console.warn(...args), +} = {}) { + let state = { + commit: null, + committedAt: null, + message: null, + products: [], + checkedAt: null, + changedAt: null, + error: null, + }; + let timer = null; + let running = false; + let checking = null; + let failures = 0; + let diskLoaded = false; + const listeners = new Set(); + const downloads = new Map(); + + const apiHeaders = () => ({ + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + ...(token() ? { Authorization: `Bearer ${token()}` } : {}), + }); + + async function get(url, headers, timeoutMs = TIMEOUT_MS) { + const response = await fetchImpl(url, { + headers, + redirect: 'follow', + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw Object.assign(new Error(`HTTP ${response.status} from ${url}`), { + status: response.status, + }); + } + return response; + } + + async function readHead() { + const response = await get( + `${GHOSTMAPS_REPO.url}.git/info/refs?service=git-upload-pack`, + { 'User-Agent': USER_AGENT }, + ); + const sha = parseGitRefAdvertisement( + await readResponseTextCapped(response, MAX_REF_BYTES), + ); + if (!sha) throw new Error('GhostMaps branch head not advertised'); + return sha; + } + + async function readCommit(sha) { + const api = `https://api.github.com/repos/${REPO_PATH}`; + const tree = await readResponseJsonCapped( + await get(`${api}/git/trees/${sha}?recursive=1`, apiHeaders()), + MAX_TREE_BYTES, + ); + if (!Array.isArray(tree?.tree)) throw new Error('GhostMaps tree malformed'); + if (tree.truncated) warn('[ghostmaps] repository tree listing truncated'); + let committedAt = null; + let message = null; + try { + const commit = await readResponseJsonCapped( + await get(`${api}/commits/${sha}`, apiHeaders()), + MAX_COMMIT_BYTES, + ); + committedAt = Date.parse(commit?.commit?.committer?.date) || null; + message = String(commit?.commit?.message || '') + .split('\n')[0] + .slice(0, 200); + } catch { + // The commit date is decoration; the tree is what matters. + } + return { products: buildGhostMapsCatalog(tree.tree), committedAt, message }; + } + + async function loadDisk() { + if (diskLoaded) return; + diskLoaded = true; + try { + const saved = JSON.parse( + await fsp.readFile(path.join(cacheDir, 'catalog.json'), 'utf8'), + ); + if (/^[0-9a-f]{40}$/.test(saved?.commit) && Array.isArray(saved.products)) + state = { ...state, ...saved, error: null }; + } catch { + /* first run */ + } + } + + async function saveDisk() { + try { + await fsp.mkdir(cacheDir, { recursive: true }); + const { commit, committedAt, message, products, changedAt } = state; + await fsp.writeFile( + path.join(cacheDir, 'catalog.json'), + JSON.stringify({ commit, committedAt, message, products, changedAt }), + ); + } catch (error) { + warn('[ghostmaps] could not save catalog:', error.message); + } + } + + function emit() { + const event = publicCatalog(); + for (const listener of listeners) { + try { + listener(event); + } catch { + /* a broken subscriber must not stop the rest */ + } + } + } + + async function check() { + await loadDisk(); + const head = await readHead(); + state.checkedAt = now(); + if (head === state.commit) return false; + const next = await readCommit(head); + const previous = new Map(state.products.map((p) => [p.id, p.version])); + const changed = next.products + .filter((p) => previous.get(p.id) !== p.version) + .map((p) => p.id); + state = { + ...state, + ...next, + commit: head, + changedAt: now(), + error: null, + }; + log( + `[ghostmaps] commit ${head.slice(0, 8)}${ + next.message ? ` "${next.message}"` : '' + }; updated: ${changed.join(', ') || 'none'}`, + ); + await saveDisk(); + emit(); + // Warm the default products so the first browser load is local. + for (const product of state.products) + if (product.defaultOn) file(product.id).catch(() => {}); + return true; + } + + /** Run one check now, coalescing with one already in flight. */ + function checkNow() { + if (!checking) + checking = check() + .then( + (changed) => { + failures = 0; + return changed; + }, + (error) => { + failures += 1; + state.error = error.message; + state.checkedAt = now(); + warn('[ghostmaps] check failed:', error.message); + emit(); + return false; + }, + ) + .finally(() => { + checking = null; + }); + return checking; + } + + function schedule() { + clearTimeout(timer); + if (!running) return; + const delay = failures + ? FAILURE_BACKOFF_MS[Math.min(failures, FAILURE_BACKOFF_MS.length) - 1] + : pollMs; + timer = setTimeout(async () => { + await checkNow(); + schedule(); + }, delay); + timer.unref?.(); + } + + /** Start watching (idempotent); the first check runs immediately. */ + function start() { + if (running) return checking || Promise.resolve(false); + running = true; + const first = checkNow(); + first.then(schedule); + return first; + } + + function stop() { + running = false; + clearTimeout(timer); + timer = null; + } + + function publicCatalog() { + return { + repo: GHOSTMAPS_REPO.url, + branch: GHOSTMAPS_REPO.branch, + commit: state.commit, + committedAt: state.committedAt, + message: state.message, + checkedAt: state.checkedAt, + changedAt: state.changedAt, + pollMs, + error: state.error, + products: state.products.map( + ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }) => ({ + id, + title, + defaultOn, + fileName, + fileTitle, + version, + size, + dateMs, + }), + ), + }; + } + + /** + * The bytes of one product's current edition, from disk or a commit-pinned + * download. Only catalog paths are ever fetched. + */ + async function file(id) { + const product = state.products.find((p) => p.id === id); + if (!product) return null; + const target = path.join(cacheDir, 'files', `${product.version}.kmz`); + try { + return { product, bytes: await fsp.readFile(target) }; + } catch { + /* not cached yet */ + } + const commit = state.commit; + let pending = downloads.get(product.version); + if (!pending) { + pending = (async () => { + const response = await get( + ghostMapsRawUrl(commit, product.path), + { 'User-Agent': USER_AGENT }, + FILE_TIMEOUT_MS, + ); + const bytes = Buffer.from( + await readResponseBytesCapped(response, GHOSTMAPS_MAX_FILE_BYTES), + ); + await fsp.mkdir(path.dirname(target), { recursive: true }); + const temp = `${target}.${process.pid}.tmp`; + await fsp.writeFile(temp, bytes); + await fsp.rename(temp, target); + await pruneFiles(); + return bytes; + })().finally(() => downloads.delete(product.version)); + downloads.set(product.version, pending); + } + return { product, bytes: await pending }; + } + + /** Keep only files that a current product still points to. */ + async function pruneFiles() { + const keep = new Set(state.products.map((p) => `${p.version}.kmz`)); + const dir = path.join(cacheDir, 'files'); + for (const name of await fsp.readdir(dir).catch(() => [])) { + if (!keep.has(name) && !name.endsWith('.tmp')) + await fsp.rm(path.join(dir, name), { force: true }).catch(() => {}); + } + } + + function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } + + return { + start, + stop, + checkNow, + catalog: publicCatalog, + file, + subscribe, + subscribers: () => listeners.size, + }; +} + +/** Same-origin GhostMaps routes for dev and preview. */ +export function ghostMapsProxy({ watcher = createGhostMapsWatcher() } = {}) { + const json = (res, status, value) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + }); + res.end(JSON.stringify(value)); + }; + + function events(req, res) { + if (watcher.subscribers() >= MAX_SUBSCRIBERS) + return json(res, 429, { error: 'too_many_subscribers' }); + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + const send = (catalog) => + res.write(`event: catalog\ndata: ${JSON.stringify(catalog)}\n\n`); + res.write('retry: 5000\n\n'); + send(watcher.catalog()); + const unsubscribe = watcher.subscribe(send); + const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); + heartbeat.unref?.(); + const close = () => { + clearInterval(heartbeat); + unsubscribe(); + }; + req.on('close', close); + res.on('error', close); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const url = new URL(req.url || '/', 'http://local'); + const match = /^\/file\/([a-z0-9-]{1,40})$/.exec(url.pathname); + if (!['/catalog', '/events'].includes(url.pathname) && !match) + return json(res, 404, { error: 'unknown_route' }); + // Watching starts with the first request, so an install that never turns + // the layer on never contacts GitHub. + const first = watcher.start(); + if (url.pathname === '/catalog') { + if (!watcher.catalog().commit) await first; + return json(res, 200, watcher.catalog()); + } + if (url.pathname === '/events') return events(req, res); + try { + if (!watcher.catalog().commit) await first; + const found = await watcher.file(match[1]); + if (!found) return json(res, 404, { error: 'unknown_product' }); + const pinned = url.searchParams.get('v') === found.product.version; + res.writeHead(200, { + 'Content-Type': 'application/vnd.google-earth.kmz', + 'Content-Length': found.bytes.length, + 'X-GhostMaps-Version': found.product.version, + // A versioned URL names one immutable blob. + 'Cache-Control': pinned + ? 'private, max-age=31536000, immutable' + : 'no-store', + }); + return res.end(found.bytes); + } catch { + return json(res, 502, { error: 'ghostmaps_unavailable' }); + } + } + + return { + name: 'ghostmaps', + configureServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/ghostmaps', handler); + }, + }; +} diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs new file mode 100644 index 000000000..6251bf4de --- /dev/null +++ b/server/providers/ghostMaps.test.mjs @@ -0,0 +1,227 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + createGhostMapsWatcher, + ghostMapsPollMs, + ghostMapsProxy, +} from './ghostMaps.js'; + +const MASTER = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/Master Database/'; +const KINETIC = + 'ArcGIS Data for ATAK (KMZs)/Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz'; +const sha = (c) => c.repeat(40); + +/** A fake GitHub: ref advertisement, tree, commit and raw files. */ +function fakeGitHub() { + const repo = { head: sha('1'), trees: new Map(), calls: [] }; + repo.trees.set(sha('1'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_15_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('a'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + const body = (text, status = 200) => + new Response(text, { + status, + headers: { 'content-length': String(Buffer.byteLength(text)) }, + }); + const fetchImpl = async (url) => { + repo.calls.push(url); + if (url.endsWith('/info/refs?service=git-upload-pack')) + return body( + `001e# service=git-upload-pack\n0000\n003d${repo.head} refs/heads/main\n0000`, + ); + const tree = /git\/trees\/([0-9a-f]{40})/.exec(url); + if (tree) return body(JSON.stringify({ tree: repo.trees.get(tree[1]) })); + if (/\/commits\//.test(url)) + return body( + JSON.stringify({ + commit: { + message: 'Oct 18 Update\nmore', + committer: { date: '2026-10-18T00:00:00Z' }, + }, + }), + ); + if (url.startsWith('https://raw.githubusercontent.com/')) + return body('KMZ!'); + return body('nope', 404); + }; + return { repo, fetchImpl }; +} + +async function withCache(fn) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'ghostmaps-')); + try { + await fn(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('a new commit is announced once, naming only the products that changed', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + + assert.equal(await watcher.checkNow(), true); + const first = watcher.catalog(); + assert.equal(first.commit, sha('1')); + assert.equal(first.message, 'Oct 18 Update'); + assert.deepEqual( + first.products.map((p) => p.id), + ['cip', 'kinetic-30d'], + ); + assert.equal(events.length, 1); + + // Same head: one ref read, no REST calls, no event. + repo.calls.length = 0; + assert.equal(await watcher.checkNow(), false); + assert.equal(repo.calls.length, 1); + assert.equal(events.length, 1); + + // A new upload: the CIP gets a newer edition, the 30-day pull is untouched. + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + ...repo.trees.get(sha('1')), + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + ]); + assert.equal(await watcher.checkNow(), true); + assert.equal(events.length, 2); + const cip = events[1].products.find((p) => p.id === 'cip'); + assert.equal(cip.version, sha('c')); + assert.match(cip.fileName, /October_18/); + assert.equal( + events[1].products.find((p) => p.id === 'kinetic-30d').version, + sha('b'), + ); + // Repository paths stay on the server. + assert.equal('path' in cip, false); + watcher.stop(); + })); + +test('files download by commit-pinned URL, cache by blob, and stale blobs are pruned', () => + withCache(async (cacheDir) => { + const { repo, fetchImpl } = fakeGitHub(); + const watcher = createGhostMapsWatcher({ + fetchImpl, + cacheDir, + log: () => {}, + warn: () => {}, + }); + await watcher.checkNow(); + const found = await watcher.file('kinetic-30d'); + assert.equal(found.bytes.toString(), 'KMZ!'); + const raw = repo.calls.filter((url) => + url.includes('raw.githubusercontent'), + ); + assert.ok(raw.every((url) => url.includes(`/GhostMaps/${sha('1')}/`))); + assert.ok( + raw.some((url) => url.endsWith('/Kinetic_Activities_Last_30_Days.kmz')), + ); + const before = repo.calls.length; + await watcher.file('kinetic-30d'); + assert.equal(repo.calls.length, before, 'second read comes from disk'); + assert.equal(await watcher.file('not-a-product'), null); + + // When the edition changes, the old blob leaves the disk cache. + await watcher.file('cip'); + repo.head = sha('2'); + repo.trees.set(sha('2'), [ + { + path: `${MASTER}S2U_Common_Intelligence_Picture_October_18_2026_Export_1.kmz`, + type: 'blob', + size: 4, + sha: sha('c'), + }, + { path: KINETIC, type: 'blob', size: 4, sha: sha('b') }, + ]); + await watcher.checkNow(); + await watcher.file('cip'); + const files = (await readdir(path.join(cacheDir, 'files'))).sort(); + assert.deepEqual(files, [`${sha('b')}.kmz`, `${sha('c')}.kmz`]); + watcher.stop(); + })); + +test('the catalog survives a restart from disk, and upstream failures are reported', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + const quiet = { log: () => {}, warn: () => {} }; + await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const offline = createGhostMapsWatcher({ + fetchImpl: async () => new Response('down', { status: 503 }), + cacheDir, + ...quiet, + }); + assert.equal(await offline.checkNow(), false); + const catalog = offline.catalog(); + assert.equal(catalog.commit, sha('1')); + assert.equal(catalog.products.length, 2); + assert.match(catalog.error, /HTTP 503/); + })); + +test('poll interval is configurable with a 5 s floor', () => { + assert.equal(ghostMapsPollMs(undefined), 15000); + assert.equal(ghostMapsPollMs('30'), 30000); + assert.equal(ghostMapsPollMs('1'), 5000); + assert.equal(ghostMapsPollMs('soon'), 15000); +}); + +test('routes: unknown paths and methods are refused without contacting GitHub', async () => { + let started = 0; + const watcher = { + start: () => { + started += 1; + return Promise.resolve(false); + }, + catalog: () => ({ commit: sha('1'), products: [] }), + file: async () => null, + subscribe: () => () => {}, + subscribers: () => 0, + }; + let handler; + ghostMapsProxy({ watcher }).configureServer({ + middlewares: { use: (_route, fn) => (handler = fn) }, + }); + const call = async (method, url) => { + const res = { + destroyed: false, + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = String(body ?? ''); + }, + }; + await handler({ method, url }, res); + return res; + }; + assert.equal((await call('GET', '/nope')).status, 404); + assert.equal((await call('POST', '/catalog')).status, 405); + assert.equal((await call('GET', '/file/../../etc')).status, 404); + assert.equal(started, 0); + assert.equal((await call('GET', '/file/cip')).status, 404); + const catalog = await call('GET', '/catalog'); + assert.equal(catalog.status, 200); + assert.equal(JSON.parse(catalog.body).commit, sha('1')); + assert.equal(started, 2); +}); diff --git a/server/providers/local.js b/server/providers/local.js index 5d39c3a01..d9e7b7919 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -26,6 +26,7 @@ import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js'; +import { ghostMapsProxy } from './ghostMaps.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -64,6 +65,7 @@ function localProviderPlugins({ realtime, gemini } = {}) { mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + ghostMapsProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 832c174bc..7c149ab59 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -27,6 +27,7 @@ import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationAirQuality } from './layers/airQuality.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js'; +import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -126,6 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), + createApplicationGhostMaps(), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 9e1059c33..eb9e88d31 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 32); + assert.equal(first.layers.length, 33); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -61,6 +61,7 @@ test('catalogs construct distinct layers and classification from their supplied ); assert.ok(first.get('fire-perimeters')); assert.ok(first.get('gnss-integrity')); + assert.ok(first.get('s2-ghostmaps')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -154,7 +155,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 32); + assert.equal(catalog.layers.length, 33); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js new file mode 100644 index 000000000..5e373c3d8 --- /dev/null +++ b/src/app/layers/ghostMaps.js @@ -0,0 +1,27 @@ +import * as Cesium from 'cesium'; +import { + createGhostMapsLayer, + createGhostMapsSource, +} from '../../layers/ghostMaps/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +export function createApplicationGhostMaps(options) { + return createGhostMapsLayer({ + source: createGhostMapsSource(), + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 422308f1e..bfb031026 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 32); + assert.equal(actual.layers.length, 33); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a86f43465..e7a523766 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -441,6 +441,14 @@ export const DATA_CREDITS = [ '(earthdata.nasa.gov/firms), ' + 'part of NASA’s Earth Observing System Data and Information System (EOSDIS)', }, + { + key: 's2-ghostmaps', + html: + 'S2 GhostMaps intel products: S2 Underground © by S2A1, ' + + 'github.com/s2underground/GhostMaps, ' + + 'licensed under CC BY-NC-SA 4.0. ' + + 'Shown as published; reports are S2 assessments, not verified fact.', + }, { key: 'drivebc-cctv', html: diff --git a/src/data/geoFileImport.js b/src/data/geoFileImport.js index fc6a5e079..86c60cb94 100644 --- a/src/data/geoFileImport.js +++ b/src/data/geoFileImport.js @@ -1040,7 +1040,13 @@ export function detachImportedTime(dataSource) { return dataSource; } -function parseXml(text, what) { +/** + * Parse KML or GPX text with the namespace repairs Cesium applies. + * @param {string} text + * @param {string} what Format name for the error message. + * @returns {Document} + */ +export function parseXml(text, what) { // Match the namespace repairs Cesium applies to ordinary exported KML. if (what !== 'GPX') text = String(text).replace(/]*>/i, (root) => { @@ -1073,7 +1079,8 @@ function parseXml(text, what) { return doc; } -function decodeXml(bytes) { +/** Decode XML bytes, honouring a UTF-16 byte-order mark. */ +export function decodeXml(bytes) { const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' diff --git a/src/data/layerState.js b/src/data/layerState.js index dd79eb12a..aa366a0e6 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -675,6 +675,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'x', disposition: 'enabled-only', }), + Object.freeze({ + id: 's2-ghostmaps', + token: '5', + disposition: 'enabled-only', + }), Object.freeze({ id: 'satellites', token: 's', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 77b8f902f..ba5496227 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 31); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31); + assert.equal(REGISTERED_LAYER_IDS.length, 32); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 32); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '5'); + assert.equal(nextLayerStateToken(), '6'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, - alpha: '5', - bravo: '6', + alpha: '6', + bravo: '7', }), - '7', + '8', ); const digitsExhausted = { ...LAYER_STATE_TOKEN_RESERVATIONS, ...Object.fromEntries( - [...'56789'].map((digit) => [`prior-${digit}`, digit]), + [...'6789'].map((digit) => [`prior-${digit}`, digit]), ), }; assert.equal(nextLayerStateToken(digitsExhausted), '00'); @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '5', - next: '6', + future: '6', + next: '7', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 5/, + /next free token 6/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '6', competing: '6' }, ), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 7bcfd96cb..47b55b067 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,7 +128,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 32); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -136,6 +136,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'street-level': '0', 'gnss-integrity': '3', 'air-quality': '4', + 's2-ghostmaps': '5', }, ); assert.throws( @@ -161,9 +162,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '5']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '6']]) .future, - '5', + '6', ); assert.throws( () => @@ -181,14 +182,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '5']] }, + { rows: [...reservationRows, ['retired-layer', '6']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); + assert.equal(nextLayerStateToken(published), '7'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '6', + newcomer: '7', }), true, ); @@ -196,15 +197,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '5', + competing: '6', }), - /next free token 6/, + /next free token 7/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '5', + newcomer: '6', }), /changed or removed: retired-layer/, ); @@ -212,8 +213,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => { +test('PR B manually replaces provisional 6 with 7 after PR A publishes 6', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '6']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -234,8 +235,8 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '5']], - [{ id: 'pr-b', token: '5' }], + [...reservationRows, ['pr-b', '6']], + [{ id: 'pr-b', token: '6' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -243,10 +244,10 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '5']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '6']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '5' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '6' }, ], ); const staleRebase = runCheck(); @@ -254,16 +255,16 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '6'); - const validBaselineRows = [...reservationRows, ['pr-a', '5']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]); + assert.equal(nextLayerStateToken(published), '7'); + const validBaselineRows = [...reservationRows, ['pr-a', '6']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '6' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 6/); + assert.match(helper.stdout, /pr-b: 7/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -271,20 +272,20 @@ test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '5'], ['pr-b', '6']], + [...reservationRows, ['pr-a', '6'], ['pr-b', '7']], [ - { id: 'pr-a', token: '5' }, - { id: 'pr-b', token: '6' }, + { id: 'pr-a', token: '6' }, + { id: 'pr-b', token: '7' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /32 published, 1 new/); + assert.match(corrected.stdout, /33 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'6789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -336,7 +337,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'56789'].map((digit) => [ + const priorDigits = [...'6789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -373,7 +374,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 73); + assert.equal(params.get('l').length, 75); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -391,7 +392,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 32); + assert.equal(Object.keys(published).length, 33); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index dd8dcd7c4..7593425ae 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["radio", "r"], ["recent-imagery", "1"], ["rocket-launches", "x"], + ["s2-ghostmaps", "5"], ["satellites", "s"], ["street-level", "0"], ["telegeography-submarine-cables", "u"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 749123acd..acf4d4bfe 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -995,10 +995,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // list descriptions; the missions still ride existing tools. // Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft. // Air quality (ECCC AQHI) extends the manifest. - assert.equal(block.length, 30655, 'serialized tool schema length drifted'); + // S2 GhostMaps extends the manifest. + assert.equal(block.length, 30723, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e', + '5937f0e90d38bf4109136c71bb9561298dd3fca11464ff980a1f982b6c154e5b', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d86ab843d..90da6d197 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2937,5 +2937,33 @@ "Gemini access was refused. Check the API key and model access.": "Se denegó el acceso a Gemini. Comprueba la clave de API y el acceso al modelo.", "Gemini Live is unavailable for the configured model.": "Gemini Live no está disponible para el modelo configurado.", "Token request is too large": "La solicitud de credencial es demasiado grande", - "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío" + "Use an empty JSON token request": "Usa una solicitud de credencial con un objeto JSON vacío", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Panorama de inteligencia", + "Kinetic 30d": "Cinético 30 d", + "Border Crisis": "Crisis fronteriza", + "Iran Picture": "Panorama de Irán", + "Iran Kinetic": "Irán cinético", + "Yemen FLOT": "FLOT de Yemen", + "Latest Drop": "Última publicación", + "GhostMaps file could not be read": "No se pudo leer el archivo de GhostMaps", + "GhostMaps catalog unavailable": "Catálogo de GhostMaps no disponible", + "Malformed GhostMaps catalog": "Catálogo de GhostMaps mal formado", + "{count} source links ↗ · click card to open the first": { + "one": "{count} enlace a la fuente ↗ · haz clic en la tarjeta para abrirlo", + "many": "{count} de enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero", + "other": "{count} enlaces a fuentes ↗ · haz clic en la tarjeta para abrir el primero" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Informe", + "Open the source for {title}": "Abrir la fuente de {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "EN VIVO · GitHub {commit} · {message} · comprobado cada {seconds} s", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · reconectando para recibir actualizaciones en vivo", + "Connecting to the S2 GhostMaps repository…": "Conectando con el repositorio S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} marcador", + "many": "{count} de marcadores", + "other": "{count} marcadores" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7ee31fd1c..25bcead93 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3004,5 +3004,35 @@ "Gemini access was refused. Check the API key and model access.": "Доступ к Gemini запрещён. Проверьте ключ API и доступ к модели.", "Gemini Live is unavailable for the configured model.": "Gemini Live недоступен для выбранной модели.", "Token request is too large": "Запрос токена слишком велик", - "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON" + "Use an empty JSON token request": "Используйте запрос токена с пустым объектом JSON", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "Разведкартина", + "Kinetic 30d": "Боевые 30 дн.", + "Border Crisis": "Кризис на границе", + "Iran Picture": "Иран: картина", + "Iran Kinetic": "Иран: боевые", + "Yemen FLOT": "Йемен: линия фронта", + "Latest Drop": "Новый выпуск", + "GhostMaps file could not be read": "Не удалось прочитать файл GhostMaps", + "GhostMaps catalog unavailable": "Каталог GhostMaps недоступен", + "Malformed GhostMaps catalog": "Повреждённый каталог GhostMaps", + "{count} source links ↗ · click card to open the first": { + "one": "{count} ссылка на источник ↗ · нажмите на карточку, чтобы открыть", + "few": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую", + "many": "{count} ссылок на источники ↗ · нажмите на карточку, чтобы открыть первую", + "other": "{count} ссылки на источники ↗ · нажмите на карточку, чтобы открыть первую" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Сводка", + "Open the source for {title}": "Открыть источник: {title}", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени", + "Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…", + "{count} placemarks": { + "one": "{count} метка", + "few": "{count} метки", + "many": "{count} меток", + "other": "{count} метки" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку." } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c159beaab..722f1c942 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -74,6 +74,8 @@ "Radio", "Reggae", "Rock", + "S2 GhostMaps", + "S2 · {name}", "SAR", "SE", "SECTOR {lat} {lon}", @@ -131,6 +133,8 @@ "NVG", "R&B", "REC", + "S2 GhostMaps", + "S2 · {name}", "VS", "WHOT/BHOT" ] diff --git a/src/layers/ghostMaps/catalog.js b/src/layers/ghostMaps/catalog.js new file mode 100644 index 000000000..4fb68c948 --- /dev/null +++ b/src/layers/ghostMaps/catalog.js @@ -0,0 +1,245 @@ +// S2 Underground GhostMaps catalog: which KMZ in the public repository is the +// current edition of each product. Pure and portable (the server builds the +// catalog, tests pin the rules); no i18n, no DOM, no Cesium. + +export const GHOSTMAPS_REPO = Object.freeze({ + owner: 's2underground', + name: 'GhostMaps', + branch: 'main', + url: 'https://github.com/s2underground/GhostMaps', +}); + +/** Largest KMZ the proxy downloads; matches the browser's import ceiling. */ +export const GHOSTMAPS_MAX_FILE_BYTES = 50 * 1024 * 1024; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; + +/** + * Curated products, in panel order. Each names a series of dated uploads; the + * newest edition wins. Patterns match whole repository paths, so a renamed + * folder fails closed (the product drops out) instead of matching elsewhere. + */ +export const GHOSTMAPS_PRODUCTS = Object.freeze([ + Object.freeze({ + id: 'cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Common Intelligence Picture', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/Master Database/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'kinetic-30d', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Kinetic Activity (30 days)', + defaultOn: true, + pattern: new RegExp( + `^${escape(ARCGIS)}Common Intelligence Picture/30-Day Pulls/Kinetic[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'border-crisis', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Border Crisis Map', + defaultOn: false, + pattern: new RegExp( + `^${escape(ARCGIS)}Border Crisis Map/S2Underground_Border_Crisis_Map[^/]*\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-cip', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Common Intelligence Picture', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Common Intelligence Picture/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'iran-kinetic', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Iran Kinetic Events', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Iran[^/]*/Iran Kinetic Events/[^/]+\\.kmz$`, + 'i', + ), + }), + Object.freeze({ + id: 'yemen-flot', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Yemen FLOT', + defaultOn: false, + pattern: new RegExp( + `^${escape(INCIDENTS)}Yemen[^/]*/Yemen FLOT[^/]*\\.kmz$`, + 'i', + ), + }), +]); + +/** The newest incident product outside the curated series and the archive. */ +export const GHOSTMAPS_LATEST_DROP = Object.freeze({ + id: 'latest-drop', + // i18n-ignore: catalog data; the panel shows its own translated label + title: 'Latest incident drop', + defaultOn: false, +}); + +const LATEST_DROP_EXCLUDED = + /\/(?:0_Archive|Basedata|Reference Material)(?:\/|$)/i; + +function escape(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const MONTHS = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +]; +const DATE_IN_NAME = + /(january|february|march|april|may|june|july|august|september|sept|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|oct|nov|dec)[ _-]?(\d{1,2})(?:st|nd|rd|th)?[ ,_-]+(20\d\d)/i; +const EDITION_IN_NAME = /(?:[ _-]export)?[ _-]v?(\d{1,3})$/i; + +/** + * The date and edition written into an S2 file name, e.g. + * `..._October_16_2026_Export_1.kmz` → 2026-10-16, edition 1. Undated names + * report `dateMs: null` and sort below every dated edition. + * @param {string} fileName + * @returns {{dateMs: ?number, edition: number}} + */ +export function parseGhostMapsEdition(fileName) { + const stem = String(fileName || '').replace(/\.km[lz]$/i, ''); + const match = DATE_IN_NAME.exec(stem); + let dateMs = null; + if (match) { + const month = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()); + const day = Number(match[2]); + const year = Number(match[3]); + const at = Date.UTC(year, month, day); + // Date.UTC rolls 31 Sep into 1 Oct; only an exact calendar date counts. + if (month >= 0 && new Date(at).getUTCDate() === day) dateMs = at; + } + const tail = match ? stem.slice(match.index + match[0].length) : stem; + const edition = Number(EDITION_IN_NAME.exec(tail)?.[1] ?? 0); + return { dateMs, edition }; +} + +function newer(a, b) { + if (!b) return true; + const ad = a.dateMs ?? -Infinity; + const bd = b.dateMs ?? -Infinity; + if (ad !== bd) return ad > bd; + if (a.edition !== b.edition) return a.edition > b.edition; + return a.path > b.path; +} + +/** `S2Underground_Border_Crisis_Map_June_02_2026_v1.kmz` → readable words. */ +export function ghostMapsFileTitle(fileName) { + return String(fileName || '') + .replace(/\.km[lz]$/i, '') + .replace(/^S2Underground_/i, '') + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Build the product catalog from a recursive git tree listing. + * @param {Array<{path: string, type: string, sha: string, size?: number}>} tree + * @returns {Array<{id: string, title: string, defaultOn: boolean, + * path: string, fileName: string, fileTitle: string, version: string, + * size: number, dateMs: ?number, edition: number}>} + */ +export function buildGhostMapsCatalog(tree) { + const best = new Map(); + let drop = null; + for (const entry of Array.isArray(tree) ? tree : []) { + if (entry?.type !== 'blob' || typeof entry.path !== 'string') continue; + if (!/\.kmz$/i.test(entry.path)) continue; + if (!/^[0-9a-f]{40}$/.test(entry.sha || '')) continue; + const size = Number(entry.size); + if (!Number.isFinite(size) || size <= 0 || size > GHOSTMAPS_MAX_FILE_BYTES) + continue; + const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1); + const candidate = { + path: entry.path, + fileName, + version: entry.sha, + size, + ...parseGhostMapsEdition(fileName), + }; + const product = GHOSTMAPS_PRODUCTS.find(({ pattern }) => + pattern.test(entry.path), + ); + if (product) { + if (newer(candidate, best.get(product.id))) + best.set(product.id, candidate); + } else if ( + entry.path.startsWith(INCIDENTS) && + !LATEST_DROP_EXCLUDED.test(`/${entry.path}`) && + candidate.dateMs != null && + newer(candidate, drop) + ) { + drop = candidate; + } + } + const rows = []; + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + const file = + product === GHOSTMAPS_LATEST_DROP ? drop : best.get(product.id); + if (!file) continue; + rows.push({ + id: product.id, + title: product.title, + defaultOn: product.defaultOn, + ...file, + fileTitle: ghostMapsFileTitle(file.fileName), + }); + } + return rows; +} + +/** + * Raw download URL pinned to a commit, so a CDN can never hand back an older + * edition for a newer catalog. + * @param {string} commit 40-hex commit sha. + * @param {string} path Repository path. + */ +export function ghostMapsRawUrl(commit, path) { + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `https://raw.githubusercontent.com/${GHOSTMAPS_REPO.owner}/${GHOSTMAPS_REPO.name}/${commit}/${encoded}`; +} + +/** + * The branch head from a git smart-HTTP ref advertisement + * (`/info/refs?service=git-upload-pack`), or null. + * @param {string} text + * @param {string} [branch] + * @returns {?string} + */ +export function parseGitRefAdvertisement(text, branch = GHOSTMAPS_REPO.branch) { + const ref = `refs/heads/${branch}`; + for (const line of String(text || '').split('\n')) { + // pkt-line: 4 hex length digits, then " " (the first ref line + // also carries a NUL and the capability list). + const match = /^[0-9a-f]{4}([0-9a-f]{40}) ([^\0\s]+)/.exec(line); + if (match && match[2] === ref) return match[1]; + } + return null; +} diff --git a/src/layers/ghostMaps/catalog.test.mjs b/src/layers/ghostMaps/catalog.test.mjs new file mode 100644 index 000000000..74735381d --- /dev/null +++ b/src/layers/ghostMaps/catalog.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildGhostMapsCatalog, + ghostMapsFileTitle, + ghostMapsRawUrl, + parseGhostMapsEdition, + parseGitRefAdvertisement, +} from './catalog.js'; + +const ARCGIS = 'ArcGIS Data for ATAK (KMZs)/'; +const INCIDENTS = 'Incident Response Products for ATAK (KMZs and Documents)/'; +let n = 0; +const blob = (path, size = 1000) => ({ + path, + type: 'blob', + size, + sha: (++n).toString(16).padStart(40, '0'), +}); + +test('editions read the date and export/version number written in the name', () => { + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz', + ), + { dateMs: Date.UTC(2026, 9, 16), edition: 1 }, + ); + assert.deepEqual( + parseGhostMapsEdition( + 'S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz', + ), + { dateMs: Date.UTC(2026, 8, 21), edition: 2 }, + ); + assert.deepEqual( + parseGhostMapsEdition('Reno Wildfires August 24, 2026.kmz'), + { + dateMs: Date.UTC(2026, 7, 24), + edition: 0, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Venezuelan_Crisis_Sep_5_2025_v2.kmz'), + { + dateMs: Date.UTC(2025, 8, 5), + edition: 2, + }, + ); + assert.deepEqual( + parseGhostMapsEdition('Kinetic_Activities_Last_30_Days.kmz'), + { + dateMs: null, + edition: 0, + }, + ); + // An impossible date is not a date. + assert.equal(parseGhostMapsEdition('X_September_31_2026.kmz').dateMs, null); +}); + +test('each product keeps its newest edition by date, then edition number', () => { + const master = `${ARCGIS}Common Intelligence Picture/Master Database/`; + const tree = [ + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_9_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_October_1_2026_Export_1.kmz`, + ), + blob( + `${master}S2Underground_Common_Intelligence_Picture_September_30_2026_Export_2.kmz`, + ), + blob( + `${ARCGIS}Common Intelligence Picture/30-Day Pulls/Kinetic_Activities_Last_30_Days.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v1.kmz`, + ), + blob( + `${ARCGIS}Border Crisis Map/S2Underground_Border_Crisis_Map_September_21_2026_Export_v2.kmz`, + ), + // A European-only subset in a subfolder is not the border map. + blob( + `${ARCGIS}Border Crisis Map/European Layers Only/Europe_Migration_Crisis_June_02_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT.kmz`), + blob(`${INCIDENTS}Yemen 2026/Yemen FLOT_October_10_2026.kmz`), + { path: `${INCIDENTS}Yemen 2026`, type: 'tree', sha: 'f'.repeat(40) }, + ]; + const rows = buildGhostMapsCatalog(tree); + const byId = Object.fromEntries(rows.map((row) => [row.id, row])); + assert.deepEqual( + rows.map(({ id }) => id), + ['cip', 'kinetic-30d', 'border-crisis', 'yemen-flot'], + ); + assert.match(byId.cip.fileName, /October_16_2026/); + assert.equal(byId.cip.defaultOn, true); + assert.equal(byId['kinetic-30d'].defaultOn, true); + assert.match(byId['border-crisis'].fileName, /Export_v2/); + assert.equal(byId['yemen-flot'].fileName, 'Yemen FLOT_October_10_2026.kmz'); + assert.equal(byId.cip.version, tree[1].sha); + assert.equal( + byId.cip.fileTitle, + 'Common Intelligence Picture October 16 2026 Export 1', + ); +}); + +test('latest drop is the newest dated incident file outside curated, archived and base data', () => { + const rows = buildGhostMapsCatalog([ + blob(`${INCIDENTS}Reno Wildfires August 24, 2026.kmz`), + blob( + `${INCIDENTS}Spokane Fire Disaster - August, 2026/SpokaneDisasterAid_August_8_2026.kmz`, + ), + blob( + `${INCIDENTS}0_Archive/Texas Floods/TexasFloods_December_1_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Iran 2026/Basedata/Pipelines_December_1_2026.kmz`), + blob( + `${INCIDENTS}Iran 2026/Iran Kinetic Events/Iran_Kinetic_Events_December_2_2026_v1.kmz`, + ), + blob(`${INCIDENTS}Undated Product.kmz`), + ]); + const drop = rows.find(({ id }) => id === 'latest-drop'); + assert.equal(drop.fileName, 'Reno Wildfires August 24, 2026.kmz'); + assert.ok(rows.some(({ id }) => id === 'iran-kinetic')); +}); + +test('oversized, empty, non-KMZ and malformed entries are skipped', () => { + const path = `${ARCGIS}Common Intelligence Picture/Master Database/S2Underground_Common_Intelligence_Picture_October_16_2026_Export_1.kmz`; + assert.deepEqual( + buildGhostMapsCatalog([ + { ...blob(path), size: 51 * 1024 * 1024 }, + { ...blob(path), size: 0 }, + { ...blob(path), sha: 'not-a-sha' }, + blob(path.replace(/\.kmz$/, '.zip')), + null, + ]), + [], + ); + assert.deepEqual(buildGhostMapsCatalog(undefined), []); +}); + +test('raw URLs are pinned to the commit and encode every path segment', () => { + assert.equal( + ghostMapsRawUrl('a'.repeat(40), 'Folder (KMZs)/Yemen FLOT.kmz'), + `https://raw.githubusercontent.com/s2underground/GhostMaps/${'a'.repeat(40)}/Folder%20(KMZs)/Yemen%20FLOT.kmz`, + ); + assert.equal(ghostMapsFileTitle('S2Underground_A__B.kmz'), 'A B'); +}); + +test('the branch head comes from the smart-HTTP ref advertisement', () => { + const head = '142d7630bc8f1f2c4b96e16ee865ca50d1ab578d'; + const other = 'b'.repeat(40); + const text = [ + '001e# service=git-upload-pack', + `0159${head} HEAD\0multi_ack symref=HEAD:refs/heads/main`, + `003f${other} refs/heads/main-old`, + `003d${head} refs/heads/main`, + '0000', + ].join('\n'); + assert.equal(parseGitRefAdvertisement(text), head); + assert.equal(parseGitRefAdvertisement(text, 'dev'), null); + assert.equal(parseGitRefAdvertisement(''), null); +}); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js new file mode 100644 index 000000000..b0aa7c88b --- /dev/null +++ b/src/layers/ghostMaps/index.js @@ -0,0 +1,708 @@ +import * as Cesium from 'cesium'; +import { + decodeXml, + kmzImageResolver, + kmzMainDocument, + parseXml, + readZipEntries, + resolveKmzPath, +} from '../../data/geoFileImport.js'; +import { + formatNumber, + formatUtcDayMonth, + msg, + t, + tn, +} from '../../i18n/index.js'; +import { createHorizonCull } from '../streetLevel/view.js'; +import { readGhostMapsKml } from './kml.js'; +import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +export * from './catalog.js'; +export * from './records.js'; +export { readGhostMapsKml } from './kml.js'; +export { createGhostMapsSource } from './source.js'; + +export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps'; +export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps'; +const PICK_PREFIX = 'ghostmaps:'; +const STORAGE_KEY = 'gev.ghostmaps.products'; +const ACCENT = '#c8ff00'; +const MAX_ICON_BYTES = 512 * 1024; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); +const MAX_CARD_LINES = 12; + +/** Panel names for the catalog's product ids (the catalog sends English). */ +const PRODUCT_LABELS = Object.freeze({ + cip: msg('Intel Picture'), + 'kinetic-30d': msg('Kinetic 30d'), + 'border-crisis': msg('Border Crisis'), + 'iran-cip': msg('Iran Picture'), + 'iran-kinetic': msg('Iran Kinetic'), + 'yemen-flot': msg('Yemen FLOT'), + 'latest-drop': msg('Latest Drop'), +}); + +/** One distinct fallback marker color per product. */ +const PRODUCT_COLORS = Object.freeze({ + cip: '#c8ff00', + 'kinetic-30d': '#ff5a36', + 'border-crisis': '#ffb547', + 'iran-cip': '#39d0ff', + 'iran-kinetic': '#ff3b8d', + 'yemen-flot': '#5dff9f', + 'latest-drop': '#b48cff', +}); + +function defaultStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +function readStoredSelection(storage) { + try { + const value = JSON.parse(storage?.getItem(STORAGE_KEY) ?? 'null'); + return Array.isArray(value) ? new Set(value.map(String)) : null; + } catch { + return null; + } +} + +function writeStoredSelection(storage, selected) { + try { + storage?.setItem(STORAGE_KEY, JSON.stringify([...selected])); + } catch { + /* private window or blocked storage: the selection lasts this session */ + } +} + +/** A round marker for placemarks whose icon is missing or remote. */ +const markerCache = new Map(); +function markerImage(color) { + if (markerCache.has(color)) return markerCache.get(color); + let url = null; + try { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 20; + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(10, 10, 7, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'rgba(0,0,0,0.85)'; + ctx.stroke(); + url = canvas.toDataURL(); + } catch { + url = null; + } + markerCache.set(color, url); + return url; +} + +const cesiumColor = (rgba, fallback, alpha) => + rgba + ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) + : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); + +function anchorOf(geometry) { + if (geometry.type === 'point') return geometry.position; + const ring = + geometry.type === 'polygon' ? geometry.outer : geometry.positions; + let lon = 0; + let lat = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lon / ring.length, lat / ring.length]; +} + +/** + * S2 Underground GhostMaps: the current editions of S2's public KMZ intel + * products, kept in step with the GitHub repository. The proxy watches the + * branch head and pushes each new commit; only changed products reload. + */ +export function createGhostMapsLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, + openExternal = null, + storage = defaultStorage(), +} = {}) { + if (typeof source?.getCatalog !== 'function') + throw new TypeError('GhostMaps layer requires a catalog source'); + let _viewer = null; + let _enabled = false; + let _catalog = null; + let _lastError = null; + let _unsubscribe = null; + let _stream = 'closed'; + let _queue = Promise.resolve(); + let _clickHandler = null; + let _rowListener = null; + let _selectedKey = null; + let _selectedCardId = null; + let _selectedLink = null; + let _lifetime = new AbortController(); + let _selection = readStoredSelection(storage); + /** productId → {version, dataSource, urls, count, features} */ + const _loaded = new Map(); + /** productId → in-flight version */ + const _loading = new Map(); + /** productId → error text */ + const _failed = new Map(); + + const notifyRow = () => _rowListener?.(); + const render = () => _viewer?.scene?.requestRender?.(); + // Pins skip the depth test, and with Google 3D tiles the globe is hidden, + // so nothing else stops far-side pins drawing through the planet. + const _horizon = createHorizonCull({ + getViewer: () => _viewer, + items: function* () { + for (const item of _loaded.values()) yield* item.pins; + }, + onChange: render, + }); + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function wanted(product) { + return _selection ? _selection.has(product.id) : product.defaultOn; + } + + function selectedIds() { + return (_catalog?.products || []).filter(wanted).map(({ id }) => id); + } + + function unload(id) { + const item = _loaded.get(id); + if (!item) return; + _loaded.delete(id); + _viewer?.dataSources?.remove(item.dataSource, true); + item.urls.forEach((url) => URL.revokeObjectURL(url)); + if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + } + + /** Build one product's data source off-scene, then swap it in. */ + async function load(product, signal) { + const bytes = await source.getFile(product.id, product.version, { signal }); + const entries = await readZipEntries(bytes); + const main = kmzMainDocument(entries); + if (!main) throw new Error(msg('That KMZ holds no KML document.')); + const urls = []; + const resolvePacked = kmzImageResolver(entries, main, (url) => + urls.push(url), + ); + // Icons only: ArcGIS exports also pack multi-megabyte photo attachments, + // which fall back to the product marker instead of being decoded. + const resolveImage = (href) => { + const path = resolveKmzPath(href, main, entries); + return path && entries.get(path).length <= MAX_ICON_BYTES + ? resolvePacked(href) + : null; + }; + let parsed; + try { + parsed = readGhostMapsKml( + parseXml(decodeXml(entries.get(main)), 'KML'), + resolveImage, + ); + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees(...geometry.position); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), + ), + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { + dataSource.entities.add({ + id, + polyline: { + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); + }); + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } + + /** Bring the globe in line with the catalog and the chip selection. */ + function sync() { + const run = _queue.then(async () => { + if (!_enabled || !_viewer || !_catalog) return; + const signal = _lifetime.signal; + const products = new Map(_catalog.products.map((p) => [p.id, p])); + for (const id of [..._loaded.keys()]) { + const product = products.get(id); + if (!product || !wanted(product)) unload(id); + } + for (const product of _catalog.products) { + if (!wanted(product) || signal.aborted) continue; + if (_loaded.get(product.id)?.version === product.version) continue; + _loading.set(product.id, product.version); + _failed.delete(product.id); + notifyRow(); + try { + const next = await load(product, signal); + if (signal.aborted || !_enabled) { + next.urls.forEach((url) => URL.revokeObjectURL(url)); + return; + } + // Cull before the first frame so far-side pins never flash. + _horizon.update(next.pins); + await _viewer.dataSources.add(next.dataSource); + // The new edition is on the globe before the old one leaves. + unload(product.id); + _loaded.set(product.id, next); + } catch (error) { + if (signal.aborted) return; + console.warn(`[Data:GhostMaps] ${product.id}:`, error); + _failed.set( + product.id, + error?.template || + error?.message || + msg('GhostMaps file could not be read'), + ); + } finally { + _loading.delete(product.id); + notifyRow(); + render(); + } + } + }); + _queue = run.catch(() => {}); + return run; + } + + function applyCatalog(catalog) { + _catalog = catalog; + _lastError = catalog.error || null; + notifyRow(); + return sync(); + } + + function toggleProduct(id) { + // The first change pins the whole current selection, defaults included. + if (!_selection) _selection = new Set(selectedIds()); + if (_selection.has(id)) _selection.delete(id); + else _selection.add(id); + writeStoredSelection(storage, _selection); + notifyRow(); + void sync(); + } + + // ---- selection card ---------------------------------------------------- + function featureFor(key) { + const [productId, index] = key.split(':'); + return { + product: _catalog?.products.find((p) => p.id === productId) || null, + feature: _loaded.get(productId)?.features[Number(index)] || null, + }; + } + + function publishCard() { + if (!canSelect()) return; + const { product, feature } = _selectedKey ? featureFor(_selectedKey) : {}; + if (!feature) { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const { fields, links } = parseDescriptionFields(feature.description); + _selectedLink = links[0] || null; + const heading = [t(PRODUCT_LABELS[product?.id] || product?.title || '')]; + if (feature.folder) heading.push(fieldLabel(feature.folder)); + if (product?.dateMs) heading.push(formatUtcDayMonth(product.dateMs)); + const details = [heading.filter(Boolean).join(' · ')]; + for (const { label, value } of fields) { + if (details.length >= MAX_CARD_LINES) break; + // ArcGIS repeats the feature type as the first row; the title has it. + if (value === feature.name) continue; + const line = label ? `${fieldLabel(label)}: ${value}` : value; + details.push( + ...wrapCardText(line, 60, Math.min(4, MAX_CARD_LINES - details.length)), + ); + } + if (_selectedLink && openExternal) + details.push( + tn( + links.length, + '{count} source link ↗ · click card to open', + '{count} source links ↗ · click card to open the first', + { count: formatNumber(links.length) }, + ), + ); + const title = t('S2 · {name}', { + name: fieldLabel(feature.name || feature.folder) || t('Report'), + }); + const card = { + id: `ghostmaps-card:${_selectedKey}`, + selected: true, + interactive: Boolean(_selectedLink && openExternal), + title, + details, + accent: PRODUCT_COLORS[product?.id] || ACCENT, + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + // A click card comes and goes with the click, as CCTV cards do, rather + // than holding the label arbiter's 2.5 s minimum lifetime; and it hides + // when its marker turns behind the planet. + stateless: true, + horizonCull: true, + position: Cesium.Cartesian3.fromDegrees(...feature.anchor), + }; + if (card.interactive) { + const link = _selectedLink; + card.accessibilityLabel = t('Open the source for {title}', { title }); + card.activate = () => { + openExternal(link); + return true; + }; + } + _selectedCardId = card.id; + overlayHost.setEntries( + GHOSTMAPS_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + overlayHost.setVisible?.(GHOSTMAPS_OVERLAY_SOURCE_ID, true); + } + + function clearSelection() { + _selectedKey = null; + _selectedCardId = null; + _selectedLink = null; + // An empty publish, as the perimeter cards deselect: it releases the + // selected card's paint lane, which clearing the source alone left drawn. + overlayHost?.setEntries(GHOSTMAPS_OVERLAY_SOURCE_ID, [], CARD_HOST_OPTIONS); + } + + function pickedKey(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':'); + return _loaded.get(productId)?.features[Number(index)] + ? `${productId}:${index}` + : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + picking.registerPickOwner?.( + GHOSTMAPS_LAYER_ID, + (pickedId) => + typeof pickedId === 'string' && pickedId.startsWith(PICK_PREFIX), + ); + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: GHOSTMAPS_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) { + if (_selectedLink && openExternal) openExternal(_selectedLink); + return; + } + const picked = _viewer.scene.pick(click.position); + const key = picked ? pickedKey(picked) : null; + if (key) { + _selectedKey = key; + publishCard(); + render(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(GHOSTMAPS_LAYER_ID, pickId)) + return; + } + if (_selectedKey) { + clearSelection(); + render(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + _clickHandler?.destroy(); + _clickHandler = null; + picking?.unregisterPickOwner?.(GHOSTMAPS_LAYER_ID); + } + + function featureCount() { + let count = 0; + for (const item of _loaded.values()) count += item.features.length; + return count; + } + + const layer = { + id: GHOSTMAPS_LAYER_ID, + name: 'S2 GhostMaps', + icon: '👻', + source: 'S2 Underground', + // Pushes arrive within seconds over the event stream; this interval is + // the backstop if the stream is down. + updateInterval: 300000, + + init(viewer) { + if (_viewer) throw new Error('GhostMaps layer is already initialized'); + _viewer = viewer; + }, + + enable() { + _enabled = true; + _lifetime = new AbortController(); + for (const item of _loaded.values()) item.dataSource.show = true; + installClickHandler(); + if (!_unsubscribe && typeof source.subscribe === 'function') + _unsubscribe = source.subscribe( + (catalog) => { + if (_enabled) void applyCatalog(catalog); + }, + (state) => { + if (_stream === state) return; + _stream = state; + notifyRow(); + }, + ); + }, + + disable() { + _enabled = false; + _lifetime.abort(); + _unsubscribe?.(); + _unsubscribe = null; + _stream = 'closed'; + removeClickHandler(); + clearSelection(); + _horizon.stop(); + for (const id of [..._loaded.keys()]) unload(id); + render(); + }, + + async update(_viewerArg, { signal } = {}) { + if (!_enabled || signal?.aborted) return false; + try { + const catalog = await source.getCatalog({ signal }); + if (!_enabled) return false; + await applyCatalog(catalog); + return true; + } catch (error) { + if (signal?.aborted || !_enabled) return false; + console.warn('[Data:GhostMaps] catalog error:', error); + _lastError = error?.message || msg('GhostMaps catalog unavailable'); + notifyRow(); + // Keep the layer on; the event stream or the next interval recovers. + return true; + } + }, + + destroy() { + layer.disable(); + _viewer = null; + _catalog = null; + _rowListener = null; + }, + + setRowControlsListener(listener) { + _rowListener = listener; + }, + + getRowControls() { + const products = _catalog?.products || []; + const commit = _catalog?.commit; + const info = commit + ? _stream === 'open' + ? t('LIVE · GitHub {commit} · {message} · checked every {seconds}s', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + seconds: formatNumber( + Math.round((_catalog.pollMs || 15000) / 1000), + ), + }) + : t('GitHub {commit} · {message} · reconnecting for live updates', { + commit: commit.slice(0, 7), + message: _catalog.message || '—', + }) + : t('Connecting to the S2 GhostMaps repository…'); + return { + chips: products.map((product) => { + const loaded = _loaded.get(product.id); + const failed = _failed.get(product.id); + return { + id: product.id, + label: t(PRODUCT_LABELS[product.id] || product.title), + active: wanted(product), + busy: _loading.has(product.id), + state: failed ? 'error' : undefined, + color: PRODUCT_COLORS[product.id], + title: [ + product.fileTitle, + loaded + ? tn( + loaded.features.length, + '{count} placemark', + '{count} placemarks', + { + count: formatNumber(loaded.features.length), + }, + ) + : null, + failed ? t(failed) : null, + ] + .filter(Boolean) + .join(' · '), + onClick: () => toggleProduct(product.id), + }; + }), + legend: [], + info, + infoTitle: t( + 'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.', + ), + }; + }, + + getStats() { + return { + count: featureCount(), + products: [..._loaded.keys()], + commit: _catalog?.commit || null, + lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null, + checkedAt: _catalog?.checkedAt || null, + stream: _stream, + loading: _loading.size > 0, + error: _lastError || [..._failed.values()][0] || null, + fallback: false, + }; + }, + + /** Loaded placemarks as plain records, for tools and QA. */ + getAnalystRecords(maxCount = Infinity) { + if (!_enabled) return []; + const out = []; + for (const [productId, item] of _loaded) { + for (const feature of item.features) { + if (out.length >= maxCount) return out; + out.push({ + product: productId, + name: feature.name, + folder: feature.folder, + lon: feature.anchor[0], + lat: feature.anchor[1], + }); + } + } + return out; + }, + }; + return layer; +} diff --git a/src/layers/ghostMaps/kml.js b/src/layers/ghostMaps/kml.js new file mode 100644 index 000000000..92e0c532f --- /dev/null +++ b/src/layers/ghostMaps/kml.js @@ -0,0 +1,198 @@ +// Read the drawable content of a parsed GhostMaps KML document: placemarks +// with their folder, geometry, resolved style and raw description. Network +// links, overlays, models and remote icons are never followed; an icon is +// either an image packed in the same KMZ or the layer's own marker. +import { kmlColor } from './records.js'; + +export const MAX_GHOSTMAPS_FEATURES = 20_000; +export const MAX_GHOSTMAPS_POSITIONS = 250_000; + +const childrenNamed = (el, name) => + [...(el?.children || [])].filter((child) => child.localName === name); +const firstChild = (el, name) => + [...(el?.children || [])].find((child) => child.localName === name) || null; +const descendants = (el, name) => + [...(el?.getElementsByTagName?.('*') || [])].filter( + (child) => child.localName === name, + ); +const text = (el) => (el?.textContent ?? '').trim(); + +function number(el, fallback) { + const value = Number(text(el)); + return el && Number.isFinite(value) ? value : fallback; +} + +/** `lon,lat[,alt] lon,lat ...` → [[lon, lat], ...]; invalid tuples are dropped. */ +export function parseKmlCoordinates(value) { + const out = []; + for (const tuple of String(value ?? '') + .trim() + .split(/\s+/)) { + const [lon, lat] = tuple.split(',').map(Number); + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lat) <= 90 && + Math.abs(lon) <= 180 + ) + out.push([lon, lat]); + } + return out; +} + +function readStyle(styleEl, resolveImage) { + const style = {}; + const icon = firstChild(styleEl, 'IconStyle'); + if (icon) { + const href = text(firstChild(firstChild(icon, 'Icon'), 'href')); + style.icon = href ? resolveImage(href) : null; + style.iconScale = number(firstChild(icon, 'scale'), 1); + style.iconColor = kmlColor(text(firstChild(icon, 'color'))); + } + const line = firstChild(styleEl, 'LineStyle'); + if (line) { + style.lineColor = kmlColor(text(firstChild(line, 'color'))); + style.lineWidth = number(firstChild(line, 'width'), 1); + } + const poly = firstChild(styleEl, 'PolyStyle'); + if (poly) { + style.polyColor = kmlColor(text(firstChild(poly, 'color'))); + style.fill = text(firstChild(poly, 'fill')) !== '0'; + style.outline = text(firstChild(poly, 'outline')) !== '0'; + } + return style; +} + +/** Shared styles by id; a StyleMap resolves to its `normal` pair. */ +function collectStyles(doc, resolveImage) { + const styles = new Map(); + for (const el of descendants(doc, 'Style')) { + const id = el.getAttribute('id'); + if (id) styles.set(id, readStyle(el, resolveImage)); + } + const maps = new Map(); + for (const el of descendants(doc, 'StyleMap')) { + const id = el.getAttribute('id'); + if (!id) continue; + const normal = childrenNamed(el, 'Pair').find( + (pair) => text(firstChild(pair, 'key')) === 'normal', + ); + if (!normal) continue; + const inline = firstChild(normal, 'Style'); + maps.set( + id, + inline + ? readStyle(inline, resolveImage) + : text(firstChild(normal, 'styleUrl')), + ); + } + const resolve = (url, depth = 0) => { + const id = String(url || '').replace(/^#/, ''); + if (!id || url.includes('/') || depth > 2) return {}; + if (styles.has(id)) return styles.get(id); + const mapped = maps.get(id); + if (typeof mapped === 'string') return resolve(mapped, depth + 1); + return mapped || {}; + }; + return resolve; +} + +function readGeometry(el, out, budget) { + switch (el.localName) { + case 'Point': { + const [position] = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (position) { + budget.positions += 1; + out.push({ type: 'point', position }); + } + break; + } + case 'LineString': + case 'LinearRing': { + const positions = parseKmlCoordinates( + text(firstChild(el, 'coordinates')), + ); + if (positions.length >= 2) { + budget.positions += positions.length; + out.push({ type: 'line', positions }); + } + break; + } + case 'Polygon': { + const ring = (boundary) => + parseKmlCoordinates( + text(firstChild(firstChild(boundary, 'LinearRing'), 'coordinates')), + ); + const outer = ring(firstChild(el, 'outerBoundaryIs')); + if (outer.length >= 3) { + const holes = childrenNamed(el, 'innerBoundaryIs') + .map(ring) + .filter((hole) => hole.length >= 3); + budget.positions += + outer.length + holes.reduce((n, hole) => n + hole.length, 0); + out.push({ type: 'polygon', outer, holes }); + } + break; + } + case 'MultiGeometry': + for (const child of el.children) readGeometry(child, out, budget); + break; + default: + // Models, tracks and anything newer are not drawn. + break; + } +} + +/** Name of the nearest enclosing Folder (or Document), for grouping. */ +function folderName(el) { + for (let node = el.parentElement; node; node = node.parentElement) { + if (node.localName === 'Folder' || node.localName === 'Document') { + const name = text(firstChild(node, 'name')); + if (name) return name; + } + } + return ''; +} + +/** + * @param {Document} doc Parsed KML. + * @param {(href: string) => ?string} resolveImage URL for a KMZ-packed image. + * @returns {{features: Array<{name: string, folder: string, + * description: string, style: object, geometries: Array}>, + * positions: number, over: boolean}} + */ +export function readGhostMapsKml(doc, resolveImage = () => null) { + const styleFor = collectStyles(doc, resolveImage); + const features = []; + const budget = { positions: 0 }; + let over = false; + for (const placemark of descendants(doc, 'Placemark')) { + // A hidden placemark (visibility 0) is drawn hidden by Google Earth too. + if (text(firstChild(placemark, 'visibility')) === '0') continue; + const geometries = []; + for (const child of placemark.children) + readGeometry(child, geometries, budget); + if (!geometries.length) continue; + if ( + features.length >= MAX_GHOSTMAPS_FEATURES || + budget.positions > MAX_GHOSTMAPS_POSITIONS + ) { + over = true; + break; + } + const inline = firstChild(placemark, 'Style'); + features.push({ + name: text(firstChild(placemark, 'name')).slice(0, 200), + folder: folderName(placemark).slice(0, 120), + description: text(firstChild(placemark, 'description')), + style: { + ...styleFor(text(firstChild(placemark, 'styleUrl'))), + ...(inline ? readStyle(inline, resolveImage) : {}), + }, + geometries, + }); + } + return { features, positions: budget.positions, over }; +} diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js new file mode 100644 index 000000000..7c4eb58bf --- /dev/null +++ b/src/layers/ghostMaps/records.js @@ -0,0 +1,165 @@ +// Pure helpers for GhostMaps placemarks: description tables to plain fields, +// KML colors, and card text wrapping. Strings in, data out — description +// markup is only ever read as text, never inserted into the page. + +const ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}); + +/** Decode the HTML character references ArcGIS and Google Earth emit. */ +export function decodeHtmlEntities(text) { + return String(text ?? '').replace( + /&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,8});/gi, + (whole, ref) => { + if (ref[0] === '#') { + const code = + ref[1] === 'x' || ref[1] === 'X' + ? parseInt(ref.slice(2), 16) + : parseInt(ref.slice(1), 10); + return code > 0 && + code <= 0x10ffff && + !(code >= 0xd800 && code <= 0xdfff) + ? String.fromCodePoint(code) + : ''; + } + return ENTITIES[ref.toLowerCase()] ?? whole; + }, + ); +} + +/** Markup to one line of plain text. */ +export function htmlToText(html) { + return decodeHtmlEntities( + String(html ?? '') + .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, ' '), + ) + .replace(/\s+/g, ' ') + .trim(); +} + +const EMPTY_VALUES = new Set(['', '', 'null', 'n/a', '-', '']); +// ArcGIS writes geometry type and internal ids into every popup table. +const SKIPPED_LABELS = new Set(['shape', 'objectid', 'fid', 'globalid']); +const MAX_FIELDS = 24; +const MAX_VALUE = 600; +const MAX_LINKS = 6; + +/** + * Fields and links from a placemark description. ArcGIS "popup" exports are a + * two-column table (label, value); anything else becomes one unlabelled field. + * @param {string} html Description as written in the KML (CDATA text). + * @returns {{fields: Array<{label: string, value: string}>, links: string[]}} + */ +export function parseDescriptionFields(html) { + const source = String(html ?? '').replace( + /<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, + ' ', + ); + const fields = []; + const links = []; + const addLinks = (markup) => { + for (const match of markup.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) { + const url = decodeHtmlEntities(match[1]).trim(); + if (/^https?:\/\/[^\s]+$/i.test(url) && !links.includes(url)) + links.push(url); + } + }; + // Innermost rows only: ArcGIS nests the field table inside an outer row, + // and only leaf rows with exactly two cells are label/value pairs. + for (const row of source.matchAll( + /]*>((?:(?!/gi, + )) { + const cells = [ + ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), + ].map((cell) => cell[1]); + if (cells.length !== 2) continue; + const label = htmlToText(cells[0]); + const value = htmlToText(cells[1]); + if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue; + if (EMPTY_VALUES.has(value.toLowerCase())) continue; + addLinks(cells[1]); + // A bare research link is carried by `links`; the card does not need + // the URL twice. + if (/^https?:\/\/\S+$/i.test(value)) continue; + if (fields.length < MAX_FIELDS) + fields.push({ label, value: value.slice(0, MAX_VALUE) }); + } + if (!fields.length && !links.length) { + const text = htmlToText(source); + if (text) fields.push({ label: '', value: text.slice(0, MAX_VALUE) }); + addLinks(source); + } + return { fields, links: links.slice(0, MAX_LINKS) }; +} + +/** + * KML `aabbggrr` to `[r, g, b, a]` in 0..1, or null. + * @param {string} text + * @returns {?number[]} + */ +export function kmlColor(text) { + const hex = String(text ?? '') + .trim() + .replace(/^#/, ''); + if (!/^[0-9a-f]{8}$/i.test(hex)) return null; + const byte = (at) => parseInt(hex.slice(at, at + 2), 16) / 255; + return [byte(6), byte(4), byte(2), byte(0)]; +} + +/** + * Greedy word wrap for canvas card lines; overlong words are cut. + * @param {string} text + * @param {number} width Characters per line. + * @param {number} maxLines Lines kept; the last ends with an ellipsis when cut. + * @returns {string[]} + */ +export function wrapCardText(text, width = 56, maxLines = 4) { + const lines = []; + let line = ''; + let cut = false; + for (let word of String(text ?? '') + .split(/\s+/) + .filter(Boolean)) { + while (word.length > width) { + if (line) { + lines.push(line); + line = ''; + } + lines.push(word.slice(0, width)); + word = word.slice(width); + } + if (!line) line = word; + else if (line.length + 1 + word.length <= width) line += ` ${word}`; + else { + lines.push(line); + line = word; + } + if (lines.length >= maxLines) { + cut = true; + break; + } + } + if (line && lines.length < maxLines) lines.push(line); + else if (line) cut = true; + const kept = lines.slice(0, maxLines); + if (cut && kept.length) { + const last = kept[kept.length - 1]; + kept[kept.length - 1] = `${last.slice(0, width - 1)}…`; + } + return kept; +} + +/** Field labels ArcGIS writes CamelCase; show them as words. */ +export function fieldLabel(label) { + return String(label ?? '') + .replace(/([a-z])([A-Z0-9])/g, '$1 $2') + .replace(/_/g, ' ') + .trim(); +} diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs new file mode 100644 index 000000000..86fecb52b --- /dev/null +++ b/src/layers/ghostMaps/records.test.mjs @@ -0,0 +1,106 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + decodeHtmlEntities, + fieldLabel, + htmlToText, + kmlColor, + parseDescriptionFields, + wrapCardText, +} from './records.js'; + +// The shape ArcGIS Pro writes into every S2 placemark description. +const ARCGIS = ` + + + +
HistoricProtestSite
+ + + + + + + + +
SHAPEPoint
InstallationTypeHistoricProtestSite
OperationalStatusACTIVE
Affiliation<Null>
DescriptionLocals gather here & protest — weekly.
Research1https://x.com/a/status/1
Research2bad
Research3<Null>
`; + +test('ArcGIS popup tables become plain label/value fields and http links', () => { + const { fields, links } = parseDescriptionFields(ARCGIS); + assert.deepEqual(fields, [ + { label: 'InstallationType', value: 'HistoricProtestSite' }, + { label: 'OperationalStatus', value: 'ACTIVE' }, + { label: 'Description', value: 'Locals gather here & protest — weekly.' }, + { label: 'Research2', value: 'bad' }, + ]); + // javascript: and other schemes never become links. + assert.deepEqual(links, ['https://x.com/a/status/1']); +}); + +test('free-form descriptions become one unlabelled field; scripts are dropped', () => { + assert.deepEqual( + parseDescriptionFields( + '

Front line
as of 10 Oct

more', + ), + { + fields: [{ label: '', value: 'Front line as of 10 Oct more' }], + links: ['https://s2.example/x'], + }, + ); + assert.deepEqual(parseDescriptionFields(''), { fields: [], links: [] }); + assert.deepEqual(parseDescriptionFields(undefined), { + fields: [], + links: [], + }); +}); + +test('field values and counts are bounded', () => { + const rows = Array.from( + { length: 40 }, + (_, i) => `K${i}${'x'.repeat(900)}`, + ).join(''); + const { fields } = parseDescriptionFields(`${rows}
`); + assert.equal(fields.length, 24); + assert.equal(fields[0].value.length, 600); +}); + +test('entities and tags decode to text', () => { + assert.equal( + decodeHtmlEntities('<b> AB  &bogus;'), + ' AB &bogus;', + ); + assert.equal(decodeHtmlEntities('��'), ''); + assert.equal(htmlToText('One\n two'), 'One two'); +}); + +test('KML aabbggrr colors convert to rgba', () => { + assert.deepEqual(kmlColor('ff0000ff'), [1, 0, 0, 1]); + assert.deepEqual(kmlColor('8000ff00'), [0, 1, 0, 128 / 255]); + assert.equal(kmlColor('red'), null); + assert.equal(kmlColor(''), null); +}); + +test('card text wraps on words, cuts long words and marks truncation', () => { + assert.deepEqual(wrapCardText('one two three four', 9, 4), [ + 'one two', + 'three', + 'four', + ]); + assert.deepEqual(wrapCardText('abcdefghijkl', 5, 4), [ + 'abcde', + 'fghij', + 'kl', + ]); + assert.deepEqual(wrapCardText('aa bb cc dd ee', 5, 2), ['aa bb', 'cc d…']); + assert.deepEqual(wrapCardText('', 5, 2), []); + for (const line of wrapCardText('word '.repeat(200), 20, 3)) + assert.ok(line.length <= 20); +}); + +test('CamelCase field labels read as words', () => { + assert.equal(fieldLabel('OperationalStatus'), 'Operational Status'); + assert.equal(fieldLabel('Research1'), 'Research 1'); + assert.equal(fieldLabel('Kinetic_Activity'), 'Kinetic Activity'); +}); diff --git a/src/layers/ghostMaps/source.js b/src/layers/ghostMaps/source.js new file mode 100644 index 000000000..c52ebda64 --- /dev/null +++ b/src/layers/ghostMaps/source.js @@ -0,0 +1,81 @@ +import { + readResponseBytesCapped, + readResponseJsonCapped, +} from '../../sources/httpBody.js'; +import { GHOSTMAPS_MAX_FILE_BYTES } from './catalog.js'; + +// A portable export (package.json): no i18n here. Failures stay English data; +// the Layers panel shows them translated. +const msg = (text) => text; + +/** + * Same-origin GhostMaps source: the catalog, one product's KMZ bytes, and a + * push subscription that fires whenever the watcher sees a new commit. + */ +export function createGhostMapsSource({ + fetchImpl = (...args) => globalThis.fetch(...args), + EventSourceImpl = globalThis.EventSource, +} = {}) { + return { + async getCatalog({ signal } = {}) { + const response = await fetchImpl('/api/ghostmaps/catalog', { signal }); + if (!response.ok) + throw new Error( + `GhostMaps catalog unavailable (HTTP ${response.status})`, + ); + const catalog = await readResponseJsonCapped( + response, + 1024 * 1024, + signal, + ); + if (!Array.isArray(catalog?.products)) + throw new Error(msg('Malformed GhostMaps catalog')); + return catalog; + }, + + async getFile(id, version, { signal } = {}) { + const response = await fetchImpl( + `/api/ghostmaps/file/${encodeURIComponent(id)}?v=${encodeURIComponent(version)}`, + { signal }, + ); + if (!response.ok) + throw new Error(`GhostMaps file unavailable (HTTP ${response.status})`); + return readResponseBytesCapped( + response, + GHOSTMAPS_MAX_FILE_BYTES, + signal, + ); + }, + + /** + * Call `onCatalog(catalog)` for every catalog the server pushes, and + * `onState('open' | 'connecting' | 'closed')` as the stream changes. The + * browser's EventSource reconnects by itself. Returns an unsubscribe. + */ + subscribe(onCatalog, onState = () => {}) { + if (typeof EventSourceImpl !== 'function') { + onState('closed'); + return () => {}; + } + const events = new EventSourceImpl('/api/ghostmaps/events'); + const states = ['connecting', 'open', 'closed']; + const report = () => onState(states[events.readyState] ?? 'connecting'); + events.onopen = report; + events.onerror = report; + const listener = (event) => { + try { + const catalog = JSON.parse(event.data); + if (Array.isArray(catalog?.products)) onCatalog(catalog); + } catch { + /* ignore a malformed frame; the next one replaces it */ + } + }; + events.addEventListener('catalog', listener); + report(); + return () => { + events.removeEventListener('catalog', listener); + events.close(); + }; + }, + }; +} diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 04c24b92d..a049b40e5 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -11,6 +11,7 @@ test('data providers have both hooks; credential editing stays development-only' const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); assert.ok(plugins.some(({ name }) => name === 'gnss-integrity')); + assert.ok(plugins.some(({ name }) => name === 'ghostmaps')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); @@ -105,6 +106,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/firms/status', 200], ['/api/fire-perimeters/inciweb/publication/invalid', 400], ['/api/gnss-integrity?lat=invalid', 400], + ['/api/ghostmaps/unknown', 404], ['/api/terrain/heights?points=invalid', 400], ['/api/overpass', 405], ['/api/overpass/status', 200], diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index d9674efea..2c918ad95 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), + 's2-ghostmaps': msg('S2 GhostMaps'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 109d96c60..18b71dabf 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -73,6 +73,7 @@ const PANEL_GROUPS = [ 'local-firms', 'fire-perimeters', 'gnss-integrity', + 's2-ghostmaps', ], }, { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 550d725e4..b295e6f1b 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,7 +38,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // requested radius and center authority in descriptions only. // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. // Air quality (ECCC AQHI) extends the manifest. - '775ba323384b4c1b62fb8415bac52aecc3fc668e20bedcfa57e426dcd1da3969', + // S2 GhostMaps extends the manifest. + '64a7ae9719cbf53f2c5b57739c1e84e8b60dc4ede040decd48a9c06d1ccbcbca', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index eb0f09909..14b515fa6 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -57,6 +57,20 @@ const layers = [ context: false, noQuery: 'navigation-integrity cells; no countable analyst records', }, + { + id: 's2-ghostmaps', + aliases: [ + 's2 ghostmaps', + 'ghost maps', + 'ghostmaps', + 's2 underground', + 's2 intel', + 'intel picture', + 'common intelligence picture', + ], + context: false, + noQuery: 'published third-party intel placemarks; not analyst records', + }, { id: 'flights', track: true, From fb88dd6e1b1889fea7be58ee6d7ebe883f9c9fb1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:51:44 -0700 Subject: [PATCH 029/131] fix(tools): say when get_wind answers from a stale forecast run When the model upstream fails, the wind proxy keeps serving its last run, however old, with manifest.stale set. The wind layer shows that, but get_wind ignored it and gave the old run's wind as current. get_wind now returns stale and adds "(forecast may be stale)" to its summary, the way get_weather and the space tools flag stale data. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- src/tools/queries/atmosphere.js | 7 ++++++- src/tools/queries/atmosphere.test.mjs | 28 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eacfa1147..4c99e2cbc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5827,7 +5827,7 @@ closed. Performance and live agency reliability require recorded validation. ## Wind forecast models -Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. +Wind supports selectable GFS/IFS models with persisted model choice. Each fetch chooses the available cycle and forecast step nearest the current time; rows show both issue and valid UTC timestamps. Grid identities include model, cycle, forecast step and any requested scalar field. The six model/field cache slots each retain the last two issued grids for manifest/grid rollover, coalesce concurrent requests, retain last-good data on failures and retry no faster than once per minute. Model changes clear the old field immediately, cancel old acquisition and ignore superseded results. Last-good data is marked stale; the wind row shows it, and `get_wind` returns `stale` and adds "(forecast may be stale)" to its summary. ### Local Weather review candidate diff --git a/src/tools/queries/atmosphere.js b/src/tools/queries/atmosphere.js index d20a58027..9cbd8fec0 100644 --- a/src/tools/queries/atmosphere.js +++ b/src/tools/queries/atmosphere.js @@ -204,10 +204,14 @@ export const getWind = defineTool({ const speed = windSpeed(vector.u, vector.v); const from = windFrom(vector.u, vector.v); const kph = speed * 3.6; + // When the model upstream fails, the proxy keeps serving the last run it + // has, however old, marked stale. + const stale = snapshot.stale === true; return { summary: `Wind at ${point.label}: ${kph.toFixed(0)} km/h` + - (from === 'Calm' ? ', calm.' : ` from the ${from}.`), + (from === 'Calm' ? ', calm' : ` from the ${from}`) + + (stale ? ' (forecast may be stale).' : '.'), data: { view: suggestView(services, { point, @@ -224,6 +228,7 @@ export const getWind = defineTool({ level: snapshot.level ?? '10 m above ground', model, cycle: snapshot.cycle ?? null, + stale, }, }; }, diff --git a/src/tools/queries/atmosphere.test.mjs b/src/tools/queries/atmosphere.test.mjs index cf19d83f3..57b41b81a 100644 --- a/src/tools/queries/atmosphere.test.mjs +++ b/src/tools/queries/atmosphere.test.mjs @@ -148,6 +148,34 @@ test('wind is sampled from the model grid at the location', async () => { const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } }); assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.'); assert.equal(calm.data.calm, true); + assert.equal(calm.data.stale, false); + // The proxy's last run, served while the model upstream fails. + const cached = composeCatalog({ + tools: coreTools, + services: { + wind: { + getSnapshot: async (options) => ({ + ...(await wind.getSnapshot(options)), + stale: true, + reason: 'Wind upstream unavailable', + }), + }, + }, + }); + const stale = await cached.call('get_wind', { location: { lat: 0, lon: 0 } }); + assert.equal( + stale.summary, + 'Wind at 0.0000, 0.0000: 0 km/h, calm (forecast may be stale).', + ); + assert.equal(stale.data.stale, true); + u.fill(10); + const staleWest = await cached.call('get_wind', { + location: { lat: 30.27, lon: -97.74 }, + }); + assert.equal( + staleWest.summary, + 'Wind at 30.2700, -97.7400: 36 km/h from the W (forecast may be stale).', + ); const down = composeCatalog({ tools: coreTools, services: { wind: { getSnapshot: async () => ({ unavailable: true }) } }, From 661a6cca02a63e2fa6409c4d28ee3e40d7b55f00 Mon Sep 17 00:00:00 2001 From: GPTmadeit <142135280+GPTmadeit@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:25:12 -0400 Subject: [PATCH 030/131] docs(changelog): name S2 GhostMaps fragment after #1297 Co-Authored-By: Claude Opus 5.5 --- .../{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-s2-ghostmaps-layer.md => 1297-s2-ghostmaps-layer.md} (100%) diff --git a/changelog.d/0000-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md similarity index 100% rename from changelog.d/0000-s2-ghostmaps-layer.md rename to changelog.d/1297-s2-ghostmaps-layer.md From 6d3e7c6c67c88fc59f4b69dfed7029f59569e991 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:09:38 -0700 Subject: [PATCH 031/131] 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 032/131] docs(changelog): reword fragment for #1303 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1303-wind-stale.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md index 1a5fcdf32..cb54224a5 100644 --- a/changelog.d/1303-wind-stale.md +++ b/changelog.d/1303-wind-stale.md @@ -1,4 +1,2 @@ -- `get_wind` says when it answers from a stale forecast run. When the model - upstream fails, the wind proxy keeps serving its last run; the tool now - returns `stale` and adds "(forecast may be stale)" instead of giving old - wind as current (quanticsoul4772, #1303). +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). From cb11e7dac855d3cde9a40f26fa031989222e7131 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Thu, 22 Oct 2026 02:42:26 +0200 Subject: [PATCH 033/131] fix: clear GhostMaps outage state on unchanged-head recovery --- changelog.d/1297-s2-ghostmaps-layer.md | 2 + docs/CURRENT-STATE.md | 1 + server/providers/ghostMaps.js | 8 +++- server/providers/ghostMaps.test.mjs | 52 ++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index b78d6a042..e79cc6eff 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -5,3 +5,5 @@ cards on click. `/api/ghostmaps` watches the repository's branch head every 15 s and pushes each new commit to open pages, which reload only the products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index db3ee9750..52fdb1e78 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5128,6 +5128,7 @@ are omitted rather than framing the wrong part of the globe. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. + - After an upstream outage, a successful branch-head check clears the error and pushes a recovered catalog even when the commit is unchanged. Cached products stay in place; subsequent healthy unchanged checks stay silent. - The new catalog goes to `.gev-cache/ghostmaps/catalog.json` and to every open page over server-sent events (`/api/ghostmaps/events`, 25 s heartbeat, at most 64 subscribers). Default products are downloaded at once. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. diff --git a/server/providers/ghostMaps.js b/server/providers/ghostMaps.js index dd30fd986..853fd35e3 100644 --- a/server/providers/ghostMaps.js +++ b/server/providers/ghostMaps.js @@ -172,7 +172,13 @@ export function createGhostMapsWatcher({ await loadDisk(); const head = await readHead(); state.checkedAt = now(); - if (head === state.commit) return false; + if (head === state.commit) { + if (state.error !== null) { + state.error = null; + emit(); + } + return false; + } const next = await readCommit(head); const previous = new Map(state.products.map((p) => [p.id, p.version])); const changed = next.products diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 6251bf4de..5ddef6aa5 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -178,6 +178,58 @@ test('the catalog survives a restart from disk, and upstream failures are report assert.match(catalog.error, /HTTP 503/); })); +test('an unchanged head clears an upstream outage and announces recovery once', () => + withCache(async (cacheDir) => { + const { fetchImpl } = fakeGitHub(); + let unavailable = false; + let checkedAt = 1000; + const requests = []; + const watcher = createGhostMapsWatcher({ + cacheDir, + now: () => checkedAt, + fetchImpl: async (url, options) => { + requests.push(url); + if (unavailable) return new Response('down', { status: 503 }); + return fetchImpl(url, options); + }, + log: () => {}, + warn: () => {}, + }); + const events = []; + watcher.subscribe((catalog) => events.push(catalog)); + await watcher.checkNow(); + await Promise.all([watcher.file('cip'), watcher.file('kinetic-30d')]); + const healthy = watcher.catalog(); + assert.equal(events.length, 1); + + unavailable = true; + checkedAt = 2000; + assert.equal(await watcher.checkNow(), false); + assert.match(watcher.catalog().error, /HTTP 503/); + assert.equal(events.length, 2); + + unavailable = false; + checkedAt = 3000; + requests.length = 0; + assert.equal(await watcher.checkNow(), false, 'no new edition arrived'); + const recovered = watcher.catalog(); + assert.equal(recovered.error, null); + assert.equal(recovered.checkedAt, checkedAt); + assert.equal(recovered.commit, healthy.commit); + assert.equal(recovered.changedAt, healthy.changedAt); + assert.deepEqual(recovered.products, healthy.products); + assert.equal(requests.length, 1, 'recovery needs only the ref read'); + assert.match(requests[0], /\/info\/refs\?service=git-upload-pack$/); + assert.equal(events.length, 3, 'subscribers see the recovered catalog'); + assert.equal(events[2].error, null); + assert.equal(events[2].checkedAt, checkedAt); + + checkedAt = 4000; + assert.equal(await watcher.checkNow(), false); + assert.equal(events.length, 3, 'healthy unchanged polls stay silent'); + watcher.stop(); + })); + test('poll interval is configurable with a 5 s floor', () => { assert.equal(ghostMapsPollMs(undefined), 15000); assert.equal(ghostMapsPollMs('30'), 30000); From 626852401a98929db4f0362420a3407253ad3aa1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:29 +0700 Subject: [PATCH 034/131] 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 035/131] perf(terrain): evict from the front of the point cache instead of sorting it Once the cache was full, every write sorted all 50,000 entries to find the oldest. A Map keeps insertion order, so keep the map in fetch-time order and delete from the front: the resolver re-inserts each point it refetches (an overwrite would leave it in its old place), and the disk load sorts once, since a file written before this change, or a migrated raw-batch cache, is not in age order. Suggested by asturksever on #975. --- server/providers/terrain.js | 21 +++++++++++++++------ src/data/terrainHeightsProxy.js | 3 +++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/server/providers/terrain.js b/server/providers/terrain.js index c678ab7c0..f4817f602 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -90,15 +90,23 @@ export function terrainHeightsProxy(options = {}) { /** * Drop the oldest entries until the map is back inside its ceiling. - * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. - * Terrain does not move, so evicting an old point costs one refetch, never - * a wrong answer. + * The map is kept in fetch-time order (sorted once on load, and the resolver + * re-inserts every point it refetches), so the oldest are at the front and + * no write sorts the map. Terrain does not move, so evicting an old point + * costs one refetch, never a wrong answer. */ function evictOverflow() { - if (mem.size <= CACHE_MAX_ENTRIES) return; + for (const key of mem.keys()) { + if (mem.size <= CACHE_MAX_ENTRIES) return; + mem.delete(key); + } + } + + /** Put the map in fetch-time order; a file is not guaranteed to be. */ + function orderByAge() { const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); - for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) - mem.delete(byAge[i][0]); + mem.clear(); + for (const [key, entry] of byAge) mem.set(key, entry); } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); @@ -165,6 +173,7 @@ export function terrainHeightsProxy(options = {}) { } diskDirty = mem.size > 0; } + orderByAge(); evictOverflow(); } catch { /* no disk cache yet */ diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index 9134f9ec6..c83b30a87 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -233,6 +233,9 @@ export async function resolveTerrainHeightRequest({ for (let i = 0; i < missing.length; i += 1) { const result = fetched[i]; if (validTerrainResult(result)) { + // Re-insert rather than overwrite: a Map keeps an existing key in + // its old position, and the proxy evicts from the front. + cache.delete(missing[i].key); cache.set(missing[i].key, { at: fetchedAt, result }); cacheChanged = true; } else if (absentTerrainDatum(result)) { From 07d6417446f4e1f724e392147bacce73713f8093 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 23 Oct 2026 12:22:42 +0100 Subject: [PATCH 036/131] 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 6b3e8bada3b600fae1a2908eff83ffad4614e8f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 23 Oct 2026 22:52:48 +0400 Subject: [PATCH 037/131] i18n: add Traditional Chinese (zh-TW) Add a complete Taiwan Traditional Chinese catalog, locale selection, Chinese readout formatting, translation notes and regression coverage. Seed translations from #724 and #652 and adapt the zh-CN catalog. Co-authored-by: caxer Co-authored-by: tbdavid2019 Co-authored-by: Enzo Abyie <93532759+abiyeenzo@users.noreply.github.com> Co-authored-by: EDIT-Aeterna <73235923+EDIT-Aeterna@users.noreply.github.com> --- docs/CURRENT-STATE.md | 6 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-TW.md | 36 + scripts/package-boundaries.json | 87 +- src/hudLocalityFormat.js | 3 +- src/i18n/firms-live-zh.test.mjs | 10 +- src/i18n/i18n.test.mjs | 10 +- src/i18n/index.js | 3 +- src/i18n/locale.js | 10 +- src/i18n/locales/zh-TW.json | 2817 +++++++++++++++++++++++++++ src/i18n/same-as-english.json | 55 + src/i18n/zh-TW-display.test.mjs | 60 + src/layers/recentImagery/display.js | 4 +- src/ui/cockpitContext.js | 2 +- src/ui/cockpitPresentation.js | 2 +- src/ui/localePicker.test.mjs | 8 +- 16 files changed, 3070 insertions(+), 47 deletions(-) create mode 100644 docs/translations/zh-TW.md create mode 100644 src/i18n/locales/zh-TW.json create mode 100644 src/i18n/zh-TW-display.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b10f43f3b..69139e6bb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3583,7 +3583,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-TW**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3594,8 +3594,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. Chinese tags map to `zh-CN`; tags containing - `Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop + `navigator.languages` → English. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` + select `zh-TW`. Explicit `Hans` selects `zh-CN` regardless of region. `?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 538f8c50e..5122afa9f 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`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-TW`). ## 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), [Simplified Chinese](./translations/zh-CN.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), [Traditional Chinese](./translations/zh-TW.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-TW.md b/docs/translations/zh-TW.md new file mode 100644 index 000000000..8e25c8540 --- /dev/null +++ b/docs/translations/zh-TW.md @@ -0,0 +1,36 @@ +# Traditional Chinese (`zh-TW`) + +Catalog: `src/i18n/locales/zh-TW.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for Taiwan usage. + +## Style + +- Use Traditional characters and Taiwan terminology. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. +- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. +- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. +- Browser tags with Hant or TW/HK/MO select this Taiwan catalog. Explicit Hans selects Simplified Chinese, including Hans-TW. This is not a separate Hong Kong localization. + +## Plurals + +`Intl.PluralRules('zh-TW')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. + +## Taiwan terminology + +| English | Taiwan | Mainland | +| -------------------- | --------------------- | ------------------- | +| data / information | 資料 / 資訊 | 数据 / 信息 | +| file / folder | 檔案 / 資料夾 | 文件 / 文件夹 | +| settings / save | 設定 / 儲存 | 设置 / 保存 | +| API key / token | API 金鑰 / 權杖 | API 密钥 / 令牌 | +| live / load | 即時 / 載入 | 实时 / 加载 | +| camera / video | 攝影機 / 影片 | 摄像头 / 视频 | +| network / link | 網路 / 連結 | 网络 / 链接 | +| image / imagery | 圖像 / 影像 | 图像 / 影像 | +| click / double-click | 點選 / 按兩下 | 点击 / 双击 | +| response / timeout | 回應 / 逾時 | 响应 / 超时 | +| layer / data layer | 圖層 / 資料圖層 | 图层 / 数据图层 | +| cockpit / context | 駕駛艙 / 態勢 | 座舱 / 态势 | +| air quality | 空氣品質 | 空气质量 | +| transit / bikeshare | 大眾運輸 / 公共自行車 | 公共交通 / 共享单车 | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4a756f656..4f88e858e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -441,7 +441,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -467,7 +468,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -484,7 +486,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -514,6 +517,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -534,7 +538,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -550,7 +555,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -570,6 +576,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -596,6 +603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/layerNames.js" ], "external": [] @@ -630,6 +638,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -646,6 +655,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": [] @@ -801,6 +811,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -921,6 +932,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -987,6 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1033,7 +1046,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1094,7 +1108,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1124,6 +1139,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1153,7 +1169,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1185,6 +1202,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1217,7 +1235,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1260,6 +1279,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1295,6 +1315,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1344,7 +1365,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1373,7 +1395,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1421,7 +1444,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["hls.js", "cesium"] }, @@ -1485,6 +1509,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1513,7 +1538,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1553,7 +1579,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1575,7 +1602,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1631,7 +1659,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -1688,6 +1717,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2552,6 +2582,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3128,6 +3159,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3235,6 +3267,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3256,7 +3289,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -3291,7 +3325,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -3315,7 +3350,8 @@ "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/i18n/locales/zh-TW.json" ], "external": [] }, @@ -3407,6 +3443,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3462,6 +3499,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3715,7 +3753,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3758,7 +3797,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium", "pbf"] }, @@ -3811,7 +3851,8 @@ "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/i18n/locales/zh-TW.json" ], "external": ["cesium"] }, @@ -3828,6 +3869,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -4021,6 +4063,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-TW.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index b508f8d59..697075a82 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) { maximumFractionDigits: digits, }); const label = unitLabel(unit); - if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + if (getLocale().startsWith('zh-')) + return label ? `${number} ${label}` : number; return `${number}${label.toUpperCase()}`; } diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 649842e7f..9698d5dd6 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -5,12 +5,12 @@ 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'], + [0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'], + [120000, '2 分钟前', '2m ago', '2 分鐘前'], + [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en] of cases) { +for (const [age, zh, en, tw] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); + setLocale('zh-TW'); + assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 70eaf7e86..87eeed24a 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -35,12 +35,16 @@ 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. + // Explicit script tags take precedence over regional defaults. 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('zh-TW'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-TW'); + assert.equal(normalizeLocale('zh_HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-MO'), 'zh-TW'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 48b63b2f6..84776e7dd 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,8 +19,9 @@ import { 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' }; +import zhTW from './locales/zh-TW.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index fab95e5cc..4efd4f589 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,7 +8,7 @@ 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', 'zh-CN']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-TW']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ @@ -16,6 +16,7 @@ export const LOCALE_NAMES = Object.freeze({ es: 'Español', ru: 'Русский', 'zh-CN': '简体中文', + 'zh-TW': '繁體中文', }); /** Right-to-left locales. */ @@ -29,11 +30,12 @@ const RTL_LOCALES = Object.freeze([]); export function normalizeLocale(candidate) { if (typeof candidate !== 'string') return null; const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); - // Traditional Chinese readers are not given Simplified: no zh-TW yet. - if (primary === 'zh') + if (primary === 'zh') { + if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? null + ? 'zh-TW' : 'zh-CN'; + } return LOCALES.includes(primary) ? primary : null; } diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json new file mode 100644 index 000000000..6677a9c5d --- /dev/null +++ b/src/i18n/locales/zh-TW.json @@ -0,0 +1,2817 @@ +{ + "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.": "這條線沒有長度——請在更遠處點選。", + " · {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...": "正在設定檢視器…", + "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...": "系統初始化中…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放資料。退出該模式後才能啟用「{layer}」。", + "Data attribution": "資料來源", + "Data provided by:": "資料提供:", + "Close data attribution": "關閉資料來源", + "Light aircraft": "輕型飛機", + "Glider": "滑翔機", + "Turboprop": "渦槳飛機", + "Airliner": "民航客機", + "Widebody": "寬體機", + "Four-engine jet": "四發噴射機", + "Helicopter": "直升機", + "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 潰決洪水", + "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}", + "GEOLOCATIONS · {credit}": "地理定位 · {credit}", + "↗ 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": "A 歷史參考 {before}%,B 災後 {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": "載入下一個場景鏡頭", + "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…": "正在取得地圖標註設施…", + "{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": "已載入地圖標註設施", + "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": "附近的觀測或地圖標註態勢", + "no observed or mapped objects in current feeds": "目前資料來源中沒有觀測或地圖標註的物件", + "Draw": "繪製", + "Directions": "路線規劃", + "Recent imagery": "近期影像", + "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 目視群組", + "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": "其他", + "Included in bundle": "已包含在資源包中", + "Missing bundle file — reimport its bundle": "缺少資源包檔案——請重新匯入其資源包", + "Source configured; file checked when loaded": "資料來源已配置;檔案在載入時校驗", + "Source unavailable": "資料來源不可用", + "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": "雪景", + "ARCTIC": "北極", + "ANTARCTIC": "南極", + "NORTH AMERICA": "北美洲", + "SOUTH AMERICA": "南美洲", + "EUROPE": "歐洲", + "AFRICA": "非洲", + "ASIA": "亞洲", + "OCEANIA": "大洋洲", + "NORTHERN OCEANIC GRID": "北方海洋網格", + "SOUTHERN OCEANIC GRID": "南方海洋網格", + "LOADING": "載入中", + "DEGRADED": "效能受限", + "STALE": "資料過期", + "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} 個金鑰待配置" + }, + "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 登入狀態,請重試。", + "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": "計費 — 需啟用帳單的帳戶", + "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)", + "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}", + "unsaved provider keys": "未儲存的服務供應商金鑰", + "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} 中移除。正在重啟——本頁面將自動重新載入。", + "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": "即時野火偵測", + "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", + "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": "放大以載入地圖標註的攝影機", + "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": "車牌辨識攝影機", + "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": "選擇航班、船舶或地圖標註設施", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以載入觀測 / 地圖標註的鄰近資訊", + "Unavailable": "不可用", + "Focus {label}": "聚焦 {label}", + "Global Context navigation": "全域態勢導航", + "Previous — prior visited contact in the 250 km window": "上一個 — 250 公里範圍內先前造訪的目標", + "PREVIOUS": "上一個", + "FOCUS": "聚焦", + "Next — nearest unvisited contact in the 250 km window": "下一個 — 250 公里範圍內最近的未造訪目標", + "NEXT": "下一個", + "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 以內", + "Flights": "航班", + "Military flights": "軍機", + "AIS vessels": "AIS 船舶", + "Mapped installations": "已標記軍事設施", + "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", + "scanning nearby systems...": "正在掃描附近系統…", + "GBFS fetch error": "GBFS 取得錯誤", + "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}° (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": "投影:關", + "VIEWSHED": "可視域", + "CAL {value}": "CAL {value}", + "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.": "僅覆蓋大西洋及北太平洋東部和中部,並非全球氣旋覆蓋。", + "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/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": "大西洋 · 東/中太平洋", + "{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 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是資料來源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", + "DRIVE": "駕車", + "Drive": "駕車", + "WALK": "步行", + "Walk": "步行", + "BIKE": "騎行", + "Bike": "騎行", + "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": "路線規劃不可用", + "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": "到達目的地", + "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(再次點選取消)", + "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", + "action|CLEAR": "清除", + "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": "逐步導航", + "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": "路線規劃不可用——路線服務無回應", + "Earthquake source unavailable": "地震資料來源不可用", + "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "live feed unavailable": "即時資料來源不可用", + "{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}前)", + "{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}前快取", + "refreshing...": "重新整理中…", + "loading...": "載入中…", + "KEY REQUIRED": "需要金鑰", + "LIVE · updated {age}": "即時 · 更新於 {age}", + "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}", + "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": ",已暫停", + "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} kn · 航跡角 {track}", + "V/S {rate}": "升降率 {rate}", + "V/S {rate} FPM": "升降率 {rate} ft/min", + "POSITION {age}": "位置 {age}前", + "POSITION {seconds} S AGO": "位置 {seconds} 秒前", + "{count} MSGS": { + "other": "{count} 條訊息" + }, + "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": "營運方未知", + "${amount}K": "{amount}K 美元", + "${amount}M": "{amount}M 美元", + "${amount}B": "{amount}B 美元", + "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} 英畝" + }, + "{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 m。", + "Malformed perimeter snapshot": "周界快照格式錯誤", + "radio tag|All": "全部", + "News": "新聞", + "Talk": "談話", + "Weather / Emergency": "氣象/緊急", + "Public Safety": "公共安全", + "Aviation / Marine": "航空/海事", + "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": "藍調", + "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": "世界音樂", + "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}——點選重試", + "load failed": "載入失敗", + "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點選僅顯示核心目錄", + "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...": "正在載入覆蓋範圍…", + "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": "覆蓋瓦片載入失敗", + "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": "未配置街景影像服務供應商", + "(by upload date)": "(按上傳日期)", + "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 need a TomTom key": "TomTom 道路需要 TomTom 金鑰", + "Hybrid needs a TomTom key": "混合模式需要 TomTom 金鑰", + "Local surface still loading": "本機路面仍在載入", + "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 路況", + "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 金鑰以取得即時路況", + "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": "船舶", + "{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 訊息…", + "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 資料", + "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": "天氣資料不可用", + "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³", + "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": "地圖中心在覆蓋範圍之外", + "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} 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 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 m", + "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}", + "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 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° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", + " · calm": " · 靜風", + " from {direction}": ",{direction}風", + "WIND AT {coordinates}": "{coordinates} 的風", + "{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 限制、配額或網路", + "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.": "已儲存的專案無法讀取;儲存已保留。請匯入有效檔案以恢復儲存。", + "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": "全知拉遠", + "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.": "確認匯入此檔案前,不會載入或更改任何內容。", + "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": "資源包已下載", + "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": "目標 — 最近的飛機 · 船舶 · 設施", + "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": "距離", + "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": "未設定串流網址", + "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": "原始預設", + "CCTV ON": "監視器 開", + "CCTV OFF": "監視器 關", + "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": "啟用監視器以載入路口攝影機", + "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", + "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.": "停止自動翻頁。上一頁、下一頁以及「訊號」「新聞」「本機」標籤頁仍可使用。", + "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}", + "{value} KM/H": "{value} km/h", + "CLOUD {pct}%": "雲量 {pct}%", + "CLOUD UNKNOWN": "雲量未知", + "{source} · LOCATION QUERY": "{source} · 位置查詢", + "REGIONAL NEWS": "地區新聞", + "{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": "目標已脫離其資料來源 · 讀數保持最後已知值", + "{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": "可用輸入均為最新 · 不代表安全無虞", + "SQUAWK {code} · {status}": "應答碼 {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B 優先順序 · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", + "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": "觀測/標記訊號", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", + "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", + "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": "態勢", + "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}", + "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小時)", + "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 野火熱點", + "Local ADS-B": "本機 ADS-B", + "Transit": "大眾運輸", + "Recent Imagery": "近期影像", + "Fire Perimeters": "火場周界", + "GNSS Integrity": "GNSS 完好性", + "Rain radar": "降雨雷達", + "Lightning density": "閃電密度", + "Cyclone advisories": "氣旋預警", + "ALPR Cameras": "ALPR 攝影機", + "Live Vessels": "即時船舶", + "Bike Share": "公共自行車", + "Cameras": "攝影機", + "Street Level": "街景", + "Mapped ALPR Cameras": "已標記車牌辨識攝影機", + "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": "移動目標", + "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} 秒後重試", + "{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": "狀態未知", + "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": "接收中", + "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} 個資料塊" + }, + "DSP waiting": "DSP 等待中", + "{level} dBFS audio": "音訊 {level} dBFS", + "audio signal --": "音訊訊號 --", + "audio {state}": "音訊 {state}", + "{count} HEARD": { + "other": "已接收 {count} 架飛機的訊號" + }, + "DISCONNECT": "斷開連線", + "CONNECT": "連線", + "LOCATING…": "定位中…", + "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": "已搜尋的位置", + "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": "駕駛艙重設為完整地球視角", + "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": "面板佈局已更新——位置已重置為新的預設值", + "Austin": "奧斯汀", + "San Francisco": "舊金山", + "New York": "紐約", + "Tokyo": "東京", + "London": "倫敦", + "Paris": "巴黎", + "Dubai": "杜拜", + "Washington DC": "華盛頓特區", + "Tallinn": "塔林", + "Los Angeles": "洛杉磯", + "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": "同步", + "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": "電台生命週期狀態不確定——請使用「啟用」或「停用」進行核對", + "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", + "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": "框選區域內最近的少雲日期 · 雲量指整景影像,並非框選區域", + "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": "載入中", + "coverage|full": "完整", + "coverage|partial": "部分", + "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}", + "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}", + "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": "刪除", + "{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}——資料來源不可用", + "Shared {subject} is unavailable": "分享的 {subject} 不可用", + "ACQUIRING": "取得中", + "SHARED {subject}": "分享的 {subject}", + "subject": "目標", + "LOADING LIVE DATA": "載入即時資料", + "LOAD COMPLETE": "載入完成", + "LOAD CANCELLED": "載入已取消", + "LOAD FAILED": "載入失敗", + "LIVE DATA OFF": "即時資料已關閉", + "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 攝影機", + "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": "街景切換失敗", + "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}", + " or ": " 或 ", + "OPEN {labels}": "開啟 {labels}", + "Open {names} at the centre of the view": "在視野中心開啟 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", + "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}", + "Detection overlay: off": "探測疊加層:關閉", + "DETECT": "偵測", + "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", + "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": "開/關", + "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.": "請檢查麥克風權限與網路連線後再試一次。", + "Stopped by voice": "已透過語音停止", + "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.": "無法啟動語音工作階段。", + "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.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未上報。", + "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} 條", + "{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} 個覆蓋區域" + }, + "degraded": "降級", + "stale": "過期", + "partial": "部分可用", + "fallback": "備用", + "unavailable": "不可用", + "Not answered: {layers}": "未回應:{layers}", + "Toggle voice control": "切換語音控制", + "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 船舶圖層未開啟", + "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} 範圍內", + "Labels on": "標籤已開啟", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已載入的最近 80 個目標", + "{distance} from {location}": "距 {location} {distance}", + "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 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": "在地圖上標註", + "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": "檢查視野狀態", + "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": "推估目的地方向", + "DEST ---°": "目的地 ---°", + "OPTICAL PLANE · 01": "光學平面 · 01", + "VISOR LOCK · ACTIVE": "護目鏡鎖定 · 啟用", + "GROUND SPEED · KTS": "地速 · 節", + "ALTITUDE · 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": "地速", + "KTS": "kn", + "ALTITUDE": "高度", + "FT": "ft", + "Contact cockpit summary": "目標駕駛艙摘要", + "CONTACT": "目標", + "CONTACTS · 250 KM": "目標 · 250 公里", + "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": "推估飛行計畫", + "FROM": "出發", + "TO": "目的", + "Cockpit briefing controls": "駕駛艙簡報控制", + "Previous briefing page": "上一頁簡報", + "Next briefing page": "下一頁簡報", + "Live signals": "即時訊號", + "Latest regional news": "最新區域新聞", + "Location-based information": "在地資訊", + "TEMP": "溫度", + "WIND": "風", + "SKY": "天空", + "PRECIP": "降水", + "MM": "mm", + "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": "顯示", + "Cockpit display options": "駕駛艙顯示選項", + "RADIO": "廣播", + "Cockpit compact Radio controls": "駕駛艙精簡廣播控制", + "Previous filtered radio station": "上一個篩選電台", + "Play selected radio station": "播放所選電台", + "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": "重設", + "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": "天氣", + "Active weather products": "已啟用的天氣產品", + "RECENT IMAGERY": "近期影像", + "Imagery days in the box": "視野內的影像天數", + "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": "拖曳調頻", + "Tune available internet radio stations": "調整可用網路電台", + "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": "電台位置", + "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": "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)": "情報抬頭顯示 (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": "偵測標籤密度", + "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": "瞄準鏡邊緣羽化(瞄準孔半徑百分比)", + "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": "清除畫板上所有標記", + "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": "簡潔介面", + "Bloom / Glow": "光暈/輝光", + "Bloom intensity": "光暈強度", + "Sharpening": "銳利化", + "Sharpen": "銳利化", + "Sharpen intensity": "銳利化強度", + "EXIT CLEAN VIEW": "離開簡潔檢視", + "Return UI controls": "恢復介面控制項", + "Initializing photorealistic world...": "正在初始化擬真世界…", + "DATA LAYERS": "資料圖層", + "ACQUIRING FRAME": "擷取畫面中", + "NEAREST": "最近", + "CCTV camera": "監視器攝影機", + "CALIBRATION": "校正", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", + "Camera pose — click a value to type": "攝影機姿態 — 點選數值即可輸入", + "Heading (compass °) — click to type": "方位角(羅盤 °)— 點選輸入", + "Pitch (° up/down) — click to type": "俯仰角(° 上/下)— 點選輸入", + "Horizontal FOV (°) — click to type": "水平視角(°)— 點選輸入", + "Range / monitor-plane distance (m) — click to type": "距離/投影平面距離(公尺)— 點選輸入", + "Mount height above ground (m) — click to type": "架設離地高度(公尺)— 點選輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)— 點選輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)— 點選輸入", + "SAVE CAL": "儲存校正", + "RESET CAL": "重設校正", + "SCENE SUMMARY": "場景摘要", + "STREET LEVEL": "街景", + "Turn Street Level on or off": "開啟或關閉街景", + "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": "填充:圖像鋪滿畫框並裁剪照片", + "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": "起始時間", + "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": "複製分享連結", + "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": "選擇你的第一個視角", + "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 麥克風按鈕,就能用語音操控地圖。", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "E", + "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 縮小)", + "OK": "正常", + "{category} ({count})": "{category}({count})", + "Layers": "圖層", + "Search": "搜尋", + "More": "更多", + "Panels": "面板" +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c331cd070..48688a0c7 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -192,5 +192,60 @@ "ΔE", "ΔN", "▲ {frp} MW" + ], + "zh-TW": [ + "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/i18n/zh-TW-display.test.mjs b/src/i18n/zh-TW-display.test.mjs new file mode 100644 index 000000000..74b2f714a --- /dev/null +++ b/src/i18n/zh-TW-display.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setLocale, t, tn, getLocale } from './index.js'; +import { resolveLocale, LOCALE_STORAGE_KEY } from './locale.js'; +import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; +import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; +import { READOUT_WORDS } from '../layers/recentImagery/display.js'; +import { cctvSourceStatusLabel } from '../ui/cctvFrames.js'; + +test('Traditional Chinese resolves from URL, saved preference and browser language', () => { + assert.equal( + resolveLocale({ + location: { search: '?lang=zh-TW' }, + storage: { getItem: () => 'en' }, + languages: ['en'], + }), + 'zh-TW', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { + getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'zh-TW' : null), + }, + languages: ['en'], + }), + 'zh-TW', + ); + assert.equal( + resolveLocale({ + location: { search: '' }, + storage: { getItem: () => null }, + languages: ['zh-Hant', 'en'], + }), + 'zh-TW', + ); +}); + +test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => { + const previous = getLocale(); + try { + setLocale('zh-TW'); + 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', + ); + assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); + assert.equal(READOUT_WORDS.daysAgo(0), '今天'); + assert.equal(cctvSourceStatusLabel('ok'), '正常'); + assert.equal(t('SAVE KEYS'), '儲存金鑰'); + assert.equal( + tn(2, 'Click {count} more point.', 'Click {count} more points.'), + '再點選 2 個點。', + ); + } finally { + setLocale(previous); + } +}); diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5c18d8b3f..33a0785b5 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), daysAgo: (delta) => { const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); - return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + return getLocale().startsWith('zh-') + ? 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). diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 852cc32a8..2c65d3bf6 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -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${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? 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 8285b74b3..582d2435a 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -149,7 +149,7 @@ export function formatCockpitDistance(distanceM) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); + return getLocale().startsWith('zh-') ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 7f6d11f9b..5151ad4fc 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', 'zh-CN'], + ['ru', 'en', 'es', 'zh-CN', 'zh-TW'], ); 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', 'zh-CN'], + ['es', 'en', 'ru', 'zh-CN', 'zh-TW'], ); 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', 'zh-CN'], + ['en', 'es', 'ru', 'zh-CN', 'zh-TW'], ); 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, 'zh-CN'); + assert.equal(model.activeOption.code, 'zh-TW'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); From ddae593bb777cf0f60ff5702de965f0101b60cb9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 23 Oct 2026 22:53:15 +0400 Subject: [PATCH 038/131] docs: credit Traditional Chinese translation sources --- changelog.d/1336-traditional-chinese.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1336-traditional-chinese.md diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1336-traditional-chinese.md new file mode 100644 index 000000000..386fd8ed4 --- /dev/null +++ b/changelog.d/1336-traditional-chinese.md @@ -0,0 +1,4 @@ +- Add Traditional Chinese (zh-TW) with Taiwan terminology, language selection, + and localized Chinese readouts. Based on translations by @caxer (#724), + @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) + (German, @gekh, #1336). From 0309a58643d52d90f1d251c8d50f14e0fcd90429 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:10:14 -0400 Subject: [PATCH 039/131] 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 040/131] 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 041/131] 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 042/131] 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 043/131] 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 044/131] 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 045/131] 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 046/131] 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 047/131] 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 048/131] 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 049/131] 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 050/131] changelog: note the rounded-north destination heading --- changelog.d/1352-cockpit-destination-heading.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1352-cockpit-destination-heading.md diff --git a/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md new file mode 100644 index 000000000..c7a5ea9b5 --- /dev/null +++ b/changelog.d/1352-cockpit-destination-heading.md @@ -0,0 +1,3 @@ +- Read a destination heading that rounds to north as 000. 359.6° was shown as + 360° on the destination bearing while the compass tape already uses 000 + (Rudy Celekli, @rudycelekli, #1352). From 5c3b66c2636f43eb09b08d31998b4496037de721 Mon Sep 17 00:00:00 2001 From: manjunath Date: Sat, 24 Oct 2026 15:20:15 +0530 Subject: [PATCH 051/131] feat(voice): reconcile preview and settings with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add the shared Voice Preview and Settings panel with browser-local preview and inactivity preferences, keyboard controls and responsive positioning preserve main’s Rotation and Context behavior, clarify panel routing and refresh schema pins within the unchanged prompt limit --- changelog.d/1299-voice-panel-settings.md | 3 + docs/CURRENT-STATE.md | 19 + index.html | 2 +- scripts/package-boundaries.json | 12 + server/providers/openai/instructions.js | 5 +- server/providers/openai/toolDescriptions.js | 20 +- src/firstRunExperience.test.mjs | 7 +- src/i18n/locales/es.json | 16 +- src/i18n/locales/ru.json | 16 +- src/i18n/locales/zh-CN.json | 16 +- src/keySetup.js | 2 + src/radioMarkup.test.mjs | 35 +- src/standalone/tools.js | 1 + src/ui/styles/voice-card.css | 246 +++++++ src/ui/styles/voice-cost.css | 325 ++++++++- src/voice/actionSchemas.js | 13 + src/voice/actionSchemas.test.mjs | 35 +- src/voice/commands.js | 45 +- src/voice/control.js | 77 +- src/voice/gevActions.js | 188 +++++ src/voice/gevRealtime.js | 9 +- src/voice/panel/actionObserver.js | 178 +++++ src/voice/panel/actionObserver.test.mjs | 498 +++++++++++++ src/voice/panel/host.js | 304 ++++++++ src/voice/panel/host.test.mjs | 756 ++++++++++++++++++++ src/voice/panel/inactivity.js | 77 ++ src/voice/panel/inactivity.test.mjs | 108 +++ src/voice/panel/manualPanel.js | 182 +++++ src/voice/panel/manualPanel.test.mjs | 198 +++++ src/voice/panel/nativeActivity.js | 212 ++++++ src/voice/panel/nativeActivity.test.mjs | 473 ++++++++++++ src/voice/panel/preferences.js | 194 +++++ src/voice/panel/preferences.test.mjs | 272 +++++++ src/voice/panel/testDom.mjs | 204 ++++++ src/voice/sessionCommands.js | 54 +- src/voice/speech.js | 1 + src/voice/voiceCard.js | 59 +- src/voice/voiceCard.test.mjs | 510 +++++++++++-- src/voice/voiceCardPresentation.js | 13 +- 39 files changed, 5274 insertions(+), 111 deletions(-) create mode 100644 changelog.d/1299-voice-panel-settings.md create mode 100644 src/voice/panel/actionObserver.js create mode 100644 src/voice/panel/actionObserver.test.mjs create mode 100644 src/voice/panel/host.js create mode 100644 src/voice/panel/host.test.mjs create mode 100644 src/voice/panel/inactivity.js create mode 100644 src/voice/panel/inactivity.test.mjs create mode 100644 src/voice/panel/manualPanel.js create mode 100644 src/voice/panel/manualPanel.test.mjs create mode 100644 src/voice/panel/nativeActivity.js create mode 100644 src/voice/panel/nativeActivity.test.mjs create mode 100644 src/voice/panel/preferences.js create mode 100644 src/voice/panel/preferences.test.mjs create mode 100644 src/voice/panel/testDom.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md new file mode 100644 index 000000000..60a68e7d3 --- /dev/null +++ b/changelog.d/1299-voice-panel-settings.md @@ -0,0 +1,3 @@ +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts + (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..cb3f133e9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -517,6 +517,25 @@ replacement session; audio meters release failed initialization and reject revok frames. Delayed action results and post-capture continuations cannot resume a stopped conversation or send output into a replacement. See [voice ownership](VOICE-OWNERSHIP.md). +The opt-in voice panel shares Preview and Voice Settings, with browser-local +Preview and inactivity preferences. Voice-requested timeout changes apply directly +without a confirmation question; results distinguish a live change from a saved +browser preference. `set_voice_inactivity_timeout` accepts integer minutes from 1–60, +or 0 for None. Named `voice-settings` and `voice-preview` panel targets exit +Cockpit before opening the requested voice UI. `control_cockpit` also accepts +`voicePanel: "preview" | "settings"` for an explicit exit-and-show request. +Newer manual choices, Stop and provider replacement prevent an older operation +from revealing its UI. Ambiguous requests to open settings ask whether the user +means Voice Settings or Provider Settings before opening either panel. Named +Voice Settings applies directly; Provider Settings opens the existing local +provider dialog when available. Visual Presets targets the visual controls tray, +while Scenes targets saved scenes. Selecting Preview does not enable a disabled +Preview preference. Unverified speech captions use the localized Recent +speech label; ordinary user captions retain YOU. Caption attribution does not +control tool dispatch. + +Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. + Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even after the camera moves. An explicit new scope replaces that basis. Provider instructions require an analyst query for requested lists and use the requested diff --git a/index.html b/index.html index a9874d550..26cf734fb 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - + diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3970a1ee2..2f5737e45 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1652,6 +1652,12 @@ "src/voice/layerSummary.js", "src/voice/voiceCost.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/session.js", "src/voice/sessionCommands.js", "src/voice/realtimeSession.js", @@ -2289,6 +2295,12 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/voice/commands.js", + "src/voice/panel/actionObserver.js", + "src/voice/panel/host.js", + "src/voice/panel/inactivity.js", + "src/voice/panel/manualPanel.js", + "src/voice/panel/nativeActivity.js", + "src/voice/panel/preferences.js", "src/voice/control.js", "src/voice/gevActions.js", "src/agent/worldPacket.js", diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 9735f300c..c9c4a43b4 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -10,7 +10,7 @@ function realtimeInstructions( 'Do not require a wake phrase. Treat direct commands like "zoom into London" or "open datacenters" as GEV control requests.', 'Only control the app by calling the provided tools. Never invent tool names or arguments.', 'Call tools only for clear GEV control, navigation, visual-style, layer, or app-state requests. For ordinary conversation, answer normally without tools.', - 'For requests to open, show, reveal, or focus a menu/panel, call set_panel_open or show_data_layers_menu. "Open Context" means only set_panel_open{panelId:"global-context-panel",open:true}; it does not activate a Context sub-mode. "Open Contacts" means set_context_mode{mode:"contacts"}; that action expands the parent Context panel before activating Contacts.', + 'Panels: set_panel_open or show_data_layers_menu; "Open Context" is only set_panel_open{panelId:"global-context-panel",open:true}; "Open Contacts" is set_context_mode contacts (expands Context itself). Unnamed settings: ask "Voice settings or Provider settings?" first; named panels apply directly. Contacts, Space Missions and Cockpit need explicit naming; opening Context or selecting/viewing an aircraft never enters them. In Cockpit, next/previous uses control_cockpit; track_entity/fly_to_location are refused: never retry, exit Cockpit first.', 'For requests like "show me the datacenter layers", open the data layers menu and focus the matching layer row; do not enable the layer unless the user asks to turn it on.', 'For questions like "what am I looking at?", "what is in view?", "what is this?", "that selected thing", nearby datacenter, dam, cable, ship, or current view contents, call get_entity_context first, then answer from the returned scene/entity context.', 'For "what is this aircraft?", answer with get_entity_context\'s identityLine: callsign, operator, type and route from returned fields only, airport codes verbatim. Other questions about the selection answer from its properties. Never infer them from the callsign.', @@ -18,13 +18,12 @@ function realtimeInstructions( 'For camera-motion requests — "orbit around this", "pan left", "tilt up", "stop moving" — call move_camera. For "fly the route" over a drawn route, call fly_route. Confirm with the RESULTING state ("Orbiting slowly", "Flying the route").', 'Globe spin: plain Rotate, including a repeat after a refusal, calls only move_camera motion rotate_globe; the app checks fit. For rotation, call zoom_to_globe only for this turn\'s explicit user Reset/Show Globe View request, never from your own plan, an earlier refusal or a bare yes/ok. On globe-view-required say "Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’" Do not ask a question or call more tools for that request. Explicit reset-and-rotate: await reset success before rotate_globe; never both together. After a failed reset, do not retry for that request. Local turn/look left/right: move_camera motion rotate in that direction, not pan.', 'analyst_query ANSWERS questions; it never moves the camera or starts tracking. For requests to FOLLOW or TRACK a specific aircraft/ship, call track_entity (get_entity_context first when the target is ambiguous), never analyst_query as the final or only action. For "follow/track the nearest aircraft", first call analyst_query with the aircraft layer(s), sortBy=distance, and limit=1, then call track_entity with the returned aircraft identity in the same turn. The lookup alone does not fulfill a follow/track command.', - 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request. SELECT/FIND never implies Contacts or Cockpit unless the user explicitly asks for either mode.', + 'For a request to enable an aircraft layer and SELECT or FIND the nearest/closest aircraft near a named place — for example, "Turn on flights and select the closest aircraft to Austin" — call select_nearest_aircraft once. It atomically turns on the requested aircraft layer first, waits for location arrival, refreshes that layer for the destination viewport, filters out landed/on-ground records, and selects the nearest airborne result. A healthy fallback feed is valid data, never an enable failure. Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity, set_context_mode, or control_cockpit for the same request.', 'For ANALYTICAL questions about loaded layer data — how many / which / fastest / highest / biggest / nearest / next flights, ships, fires, earthquakes, satellites, launches, storms, transit, bikes, bases, datacenters or dams ("how many flights over Texas", "anything above 40,000 feet") — call analyst_query, not get_entity_context. Always set scope by WHERE: "around here" or "near this" while pointing is kind pointer, never view. Filter on the attribute the user names ("MODIS" → sensor eq MODIS). Say the count with its scope in a few words ("37 loaded flights over Texas") plus one or two examples by name. If the needed layer is off, say so and offer to turn it on. "Which of THOSE…" about the last answer → followUp=true with only the new filter/sort.', 'WORLD QUERIES — the query tools (get_weather, get_wind, search_places, places_nearby, plan_route, get_cyclones, get_fire_perimeters, get_bike_share, get_transit_vehicles, get_traffic_flow, situation_brief, military_awareness and the other get_* and find_* tools you have) look up data anywhere in the world without changing the map and without any layer being on. Use them when the user asks about a place they are not looking at or data whose layer is off ("what is the weather in Tokyo", "how do I drive from Austin to Dallas", "any wildfires in California"). For aircraft, ships, earthquakes, active fires, datacenters, dams and satellites overhead, use analyst_query over the layers instead, turning the layer on first when the user wants it. Use analyst_query and get_entity_context for what the app has loaded or what is on screen. Answer from the result summary and data, mention its source when it has one, and never claim the map changed. If the user also wants to SEE it, use the matching app action (fly_to_location, set_layer_visibility) as well.', 'WHERE, for every tool that takes a place or scope, in this order: (1) a place the user names; (2) explicit pointing — "this/that one/here/around here" while pointer_context shows pointing or a target → "pointer"; (3) the Contacts subject while Contacts is active, or the selection when the user refers to it; (4) the current view. A selected datacenter, dam, fire or cable never silently becomes the centre.', 'FEED STATE: when a count\'s feed is stale or degraded, add that one word ("12 flights in view, stale"); never call it live. If unavailable, give no number: say the feed is unavailable. Fallback feeds are valid data; name them only if asked.', 'COUNTING CONTRACT — what "near" means. (1) While Contacts is ACTIVE, a bare "near / nearby / how many aircraft" means the settled Contacts subject window: its count is contactsWindow.aircraft. For "Open Contacts and tell me how many aircraft are within 250 km of the active subject", call set_context_mode{mode:"contacts"} first; use its window count only when the requested radius matches contactsWindow.radiusKm. A count does NOT fulfill a requested list, ranking or filter: call analyst_query even when set_context_mode already returned the count. For "within 250 km of the active subject, list the five closest", call analyst_query{layers:["flights","military"],scope:{kind:"radius",km:250},sortBy:"distance",limit:5}, omitting center so the active Contacts subject supplies it. Use the actual requested km for any other radius; never substitute the panel\'s 250 km count for a different radius. Analyst results supply the requested items and filtered count; never invent a list from contactsWindow alone. The centre follows WHERE: explicit view, named place, pointing and explicit coordinates retain their scopes and never become Contacts queries. Never invent center coordinates such as 0,0. With Contacts active but no subject yet, use the view and name that scope. (2) With Contacts OFF, "nearby" means in view; "near " means a radius around that place. (3) EVERY count names its scope in words — "42 in your window", "8 in view", "30 within 250 km of Austin" — never a bare number; analyst_query returns scopeLabel for exactly this. Two numbers with named scopes are not a contradiction; say both if asked. (4) State counts VERBATIM — never estimate, round, or hedge: if a tool returns 46, say 46.', - 'While Cockpit is active, navigate with control_cockpit (next/previous, optionally targetLayer or aircraftClass). track_entity and fly_to_location are REFUSED by design while Cockpit owns the camera — that refusal is correct, not an error to retry. To go somewhere else, exit Cockpit first. control_cockpit enter establishes Contacts itself, so do not call set_context_mode before or after it.', 'When the target layer is unknown, OMIT layerId in track_entity so it searches all enabled layers. Passing the wrong layerId ("flights" for a military contact) returns "Nothing matched" even though the contact is loaded.', 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index b8121a201..23d5da9de 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -44,7 +44,7 @@ export const ACTION_DESCRIPTIONS = { }, select_nearest_aircraft: { description: - 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result. This does not open Contacts or Cockpit.', + 'Atomically fly to a place, wait for arrival, enable and load Flights or Military Flights in that viewport, exclude on-ground records, and select/follow the nearest airborne aircraft. Healthy fallback feeds remain usable and are reported in the result.', $position: 1, parameters: { properties: { @@ -111,12 +111,18 @@ export const ACTION_DESCRIPTIONS = { }, }, set_panel_open: { - description: 'Open or close a GEV UI panel/dropdown.', + description: + 'Visual Presets/styles/filters=control-panel; Scenes/saved scenes=scene-panel; Voice Settings=voice-settings; voice control/Preview=voice-preview; Provider Settings/API keys=provider-settings. Voice panels exit Cockpit directly.', + $position: 1, + }, + set_voice_inactivity_timeout: { + description: + 'Set the requested exact inactivity duration directly: integer minutes 1–60, 0=None. Report applied/persisted truthfully; an unsaved live change is not saved.', $position: 1, }, set_context_mode: { description: - 'Enter or exit the Global Context sub-mode used by Contacts and Space Missions. Use Contacts only when the user explicitly requests Contacts, and Space Missions only when explicitly requested. A request to open the parent Context panel alone uses set_panel_open and must not activate either sub-mode. Selecting an aircraft does not imply Context.', + 'Enter/exit Contacts or Space Missions only when explicitly requested; opening Context alone uses set_panel_open.', $position: 1, parameters: { properties: { @@ -129,23 +135,23 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Read or control Cockpit when the user explicitly requests Cockpit: establish Contacts and enter from a selected or tracked aircraft; exit; or navigate nearby Contacts with optional filters. Selecting or viewing an aircraft alone must not enter Cockpit.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { action: { description: - 'previous/next (or prev) navigates through nearby contacts in Cockpit context.', + 'previous/next (or prev) cycles nearby Cockpit contacts.', $position: 2, }, targetLayer: { description: - 'Optional contact layer filter for next/previous (for example military for a military-only cycle).', + 'Optional next/previous layer filter (e.g. military-only).', $position: 2, }, aircraftClass: { description: - 'Optional aircraft class filter (for example helicopter) when using next/previous navigation.', + 'Optional next/previous aircraft class filter (e.g. helicopter).', $position: 1, }, }, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index a02c4391f..b7fa42999 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1010,11 +1010,12 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Globe rotation adds one camera motion and its descriptions; Globe View // framing/default guidance updates only move_camera and mode descriptions. // Radio markup and the complete production-request comparison pin locality. - // Restore the base camera/Globe View wording with the globe-rotation append. - assert.equal(block.length, 30878, 'serialized tool schema length drifted'); + // Panel adds timeout and named voice/provider targets; consolidated guidance + // preserves the mission mapping and globe-rotation arguments. + assert.equal(block.length, 31196, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - '7b95f0f4258896fe5c3316265a19f3a6602809d4e7e681ee7ecd8f2df514ef1f', + '56c91b309fb583c8737a9c3c1530553dcc8159ba2f3c5d4d2db99921f28d8f1e', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fd55b2e25..82551e604 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2947,5 +2947,19 @@ "Layers": "Capas", "Search": "Buscar", "More": "Más", - "Panels": "Paneles" + "Panels": "Paneles", + "Recent speech": "Habla reciente", + "Voice Settings": "Ajustes de voz", + "Preview": "Vista previa", + "Settings": "Ajustes", + "Close voice panel": "Cerrar panel de voz", + "Show Voice Preview": "Mostrar vista previa de voz", + "Voice inactivity timeout": "Tiempo de inactividad de voz", + "Custom": "Personalizado", + "Minutes": "Minutos", + "These preferences stay in this browser.": "Estas preferencias se guardan en este navegador.", + "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", + "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", + "Preview is off.": "La vista previa está desactivada.", + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 442312de2..5358264cf 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3014,5 +3014,19 @@ "Layers": "Слои", "Search": "Поиск", "More": "Ещё", - "Panels": "Панели" + "Panels": "Панели", + "Recent speech": "Недавняя речь", + "Voice Settings": "Настройки голоса", + "Preview": "Предпросмотр", + "Settings": "Настройки", + "Close voice panel": "Закрыть голосовую панель", + "Show Voice Preview": "Показывать предпросмотр голоса", + "Voice inactivity timeout": "Тайм-аут бездействия голоса", + "Custom": "Другое", + "Minutes": "Минуты", + "These preferences stay in this browser.": "Эти настройки сохраняются в этом браузере.", + "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", + "Voice replies appear here.": "Здесь появляются голосовые ответы.", + "Preview is off.": "Предпросмотр отключён.", + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index fa08cca1f..07e7fdd64 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2813,5 +2813,19 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "Recent speech": "最近语音", + "Voice Settings": "语音设置", + "Preview": "预览", + "Settings": "设置", + "Close voice panel": "关闭语音面板", + "Show Voice Preview": "显示语音预览", + "Voice inactivity timeout": "语音空闲超时", + "Custom": "自定义", + "Minutes": "分钟", + "These preferences stay in this browser.": "这些偏好设置仅保存在此浏览器中。", + "Your choice applies now, but could not be saved.": "选择已生效,但无法保存。", + "Voice replies appear here.": "语音回复显示在此处。", + "Preview is off.": "预览已关闭。", + "Automatic stop is unavailable for this voice connection.": "此语音连接不支持自动停止。" } diff --git a/src/keySetup.js b/src/keySetup.js index b67c9df66..550ac6e16 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -395,6 +395,7 @@ export async function initKeySetup({ const destroy = () => { if (disposed) return; disposed = true; + delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); @@ -591,6 +592,7 @@ export async function initKeySetup({ chip.addEventListener('click', openDialog); closeButton?.addEventListener('click', close); applyButton?.addEventListener('click', onApply); + root.dataset.ready = 'true'; // Remove buttons are rendered per row; delegate so re-renders stay wired. rowsHost?.addEventListener('click', (event) => { const authButton = event.target?.closest?.( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 14ea0f3e0..dfe5b4257 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,5 +1,6 @@ import { readRealtimeSource } from './testSupport/readRealtimeSource.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; +import { realtimeInstructions } from '../server/providers/openai/instructions.js'; import { readShellSource } from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; @@ -25,13 +26,14 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 30-tool inventory', () => { +test('Realtime schema exposes the authoritative 31-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 30); + assert.equal(tools.length, 31); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 30, 'tool names are unique'); + assert.equal(new Set(names).size, 31, 'tool names are unique'); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); + assert.ok(names.includes('set_voice_inactivity_timeout')); assert.ok(names.includes('select_nearest_aircraft')); assert.ok(names.includes('control_radio')); assert.ok(names.includes('next_satellite_pass')); @@ -77,13 +79,12 @@ test('the counting contract is stated in the Realtime instructions', () => { }); test('Context panel opening stays distinct from Contacts activation', () => { - const start = voice.indexOf("'For requests to open, show, reveal, or focus a menu/panel"); - assert.ok(start >= 0, 'panel-routing instruction is missing'); - const text = voice.slice(start, voice.indexOf('\n', start)); - assert.match(text, /"Open Context" means only set_panel_open/); - assert.match(text, /does not activate a Context sub-mode/); - assert.match(text, /"Open Contacts" means set_context_mode\{mode:"contacts"\}/); - assert.match(text, /expands the parent Context panel before activating Contacts/); + const text = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.ok(text, 'panel-routing instruction is missing'); + assert.match(text, /"Open Context" is only set_panel_open\{panelId:"global-context-panel",open:true\}/); + assert.match(text, /opening Context or selecting\/viewing an aircraft never enters them/); + assert.match(text, /"Open Contacts" is set_context_mode contacts/); + assert.match(text, /expands Context itself/); }); test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { @@ -99,14 +100,14 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(text, /nearest airborne result/); assert.match(text, /healthy fallback feed is valid data/i); assert.match(text, /Do not also call fly_to_location, set_layer_visibility, analyst_query, track_entity/); - assert.match(text, /SELECT\/FIND never implies Contacts or Cockpit/); + const routing = realtimeInstructions().split('\n').find(line => line.startsWith('Panels:')); + assert.match(routing, /Contacts, Space Missions and Cockpit need explicit naming/); assert.match(text, /set_context_mode, or control_cockpit/); const byName = new Map(realtimeTools().map((tool) => [tool.name, tool])); - assert.match(byName.get('set_context_mode').description, /explicitly requests/); - assert.match(byName.get('set_context_mode').description, /selecting an aircraft does not imply Context/i); - assert.match(byName.get('control_cockpit').description, /explicitly requests Cockpit/); - assert.match(byName.get('control_cockpit').description, /must not enter Cockpit/); + assert.match(byName.get('set_context_mode').description, /only when explicitly requested/); + assert.match(routing, /selecting\/viewing an aircraft never enters them/); + assert.match(byName.get('control_cockpit').description, /Explicit Cockpit requests only/); assert.equal( byName.get('fly_to_location').parameters.properties.waitForArrival.type, 'boolean', @@ -117,7 +118,6 @@ test('nearest-aircraft selection stays out of Contacts and Cockpit', () => { assert.match(nearest.description, /Atomically/); assert.match(nearest.description, /exclude on-ground records/); assert.match(nearest.description, /fallback feeds remain usable/); - assert.match(nearest.description, /does not open Contacts or Cockpit/); }); test('the two Context/Cockpit tools pin their enums and required arguments', () => { @@ -153,7 +153,7 @@ test('the edited existing tools changed exactly as intended', () => { const panel = byName.get('set_panel_open'); assert.deepEqual( panel.parameters.properties.panelId.enum, - ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel'], + ['data-panel', 'location-bar', 'control-panel', 'cctv-panel', 'radio-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', 'weather-panel', 'recent-imagery-panel', 'voice-settings', 'voice-preview', 'provider-settings'], ); assert.deepEqual(panel.parameters.required, ['panelId', 'open']); @@ -183,6 +183,7 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'set_context_mode', 'control_cockpit', 'set_panel_open', + 'set_voice_inactivity_timeout', 'get_current_view_state', 'fly_to_location', 'select_nearest_aircraft', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index eec4d0796..fdeb08d37 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -19,6 +19,7 @@ export function createStandaloneTools(options) { ...options, voice: { supportedProviders: ['openai', 'gemini'], + panel: true, toolCatalog: loadToolCatalog, ...options?.voice, }, diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index ef3d77055..5afe57a26 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle { animation: none; } } + +/* Opt-in shared panel: header stays fixed while only the body scrolls. */ +#gev-voice-control .gev-voice-panel { + position: absolute; + bottom: calc(100% + 0.7rem); + left: 50%; + z-index: 10; + display: flex; + flex-direction: column; + width: clamp(18rem, 26vw, 22rem); + max-width: calc(100vw - 1.5rem); + max-height: min( + 28rem, + max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px))) + ); + box-sizing: border-box; + padding: 0.6rem 0.7rem 0.65rem; + color: rgba(223, 245, 250, 0.92); + background: rgba(7, 22, 31, 0.96); + border: 1px solid rgba(54, 220, 255, 0.34); + border-radius: 0.6rem; + box-shadow: + 0 0.85rem 2rem rgba(0, 0, 0, 0.58), + 0 0 1.2rem rgba(0, 212, 255, 0.08); + backdrop-filter: blur(24px) saturate(1.3); + -webkit-backdrop-filter: blur(24px) saturate(1.3); + color-scheme: dark; + font: 0.75rem/1.45 var(--font-sans); + text-align: left; + transform: translateX(-50%); +} +:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel { + background-color: var(--cyber-panel-surface); + background-image: var(--cyber-panel-surface-detail); + border-color: var(--glass-border); + box-shadow: var(--cyber-panel-shadow); +} +#gev-voice-control .gev-voice-panel[hidden], +#gev-voice-control .gev-voice-panel [hidden] { + display: none; +} +#gev-voice-control .gev-voice-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--glass-border); +} +.gev-voice-panel-title { + color: var(--voice-panel-accent, var(--accent)); + font: 600 0.65rem/1 var(--font-mono); + letter-spacing: 0.14em; +} +.gev-voice-panel-tabs { + margin-left: auto; +} +#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + padding: 0; +} +#gev-voice-control .gev-voice-panel-tab-icon { + font-size: 18px; +} +.gev-voice-panel-header [role='tablist'] { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + min-width: 0; +} +#gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, select, input[type='number']) { + min-height: 2rem; + box-sizing: border-box; + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font: inherit; +} +#gev-voice-control .gev-voice-panel .gev-voice-panel-header button { + padding: 0 0.6rem; + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.04em; + line-height: 1; + cursor: pointer; +} +#gev-voice-control #gev-voice-settings-close { + width: 2rem; + padding: 0; + font-size: 1rem; +} +#gev-voice-control .gev-voice-panel-header button:hover { + color: var(--text-primary); + border-color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + box-shadow: 0 -2px 0 currentColor inset; +} +#gev-voice-control .gev-voice-panel [role='tab']:focus, +#gev-voice-control .gev-voice-panel :focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +.gev-voice-panel-body { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + padding: 0.55rem 0.75rem 3px 3px; + line-height: 1.45; + overflow-wrap: anywhere; +} +.gev-voice-panel-body .gev-voice-card-who { + overflow-wrap: break-word; +} +.gev-voice-panel-body p { + margin: 0; +} +.gev-voice-preference { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + color: var(--text-primary); +} +.gev-voice-preference-toggle { + flex-direction: row-reverse; + justify-content: space-between; + min-height: 2.75rem; + font-weight: 600; + cursor: pointer; +} +.gev-voice-preference-toggle input { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--voice-panel-accent, var(--accent)); +} +.gev-voice-setting-title { + margin-bottom: 0.4rem; + font-weight: 600; +} +.gev-voice-timeout-group { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-timeout-controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +#gev-voice-timeout-select { + flex: 1 1 auto; + min-width: 0; + padding: 0 0.45rem; +} +#gev-voice-timeout-input { + flex: 0 0 4.5rem; + width: 4.5rem; + padding: 0 0.45rem; + font-variant-numeric: tabular-nums; +} +.gev-voice-timeout-unit { + order: 1; + color: var(--text-dim); + font-size: 0.63rem; +} +.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden]) + .gev-voice-timeout-unit { + display: none; +} +.gev-voice-settings-note { + color: rgba(190, 221, 230, 0.72); + font-size: 0.63rem; + line-height: 1.45; +} +.gev-voice-settings-note:not(:empty) { + margin-top: 0.25rem; +} +#gev-voice-control .gev-voice-panel .gev-voice-card { + position: static; + width: 100%; + max-height: none; + overflow: visible; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + animation: none; +} +#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) { + z-index: 101; +} +#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray { + display: none; +} +#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel { + bottom: min( + calc(100% + 4.8rem + var(--gev-provider-lift)), + calc(100dvh - 7.75rem) + ); +} + +#command-dock.dock-has-pinned-tray + #gev-voice-control:not([data-provider-choice='true']) + .gev-voice-panel { + bottom: min( + calc(100% + 1.7rem + var(--dock-pinned-stack-height)), + calc(100dvh - 18rem) + ); +} +@media (pointer: coarse) { + #gev-voice-control + .gev-voice-panel + :is( + .gev-voice-panel-header button, + select, + input[type='number'], + .gev-voice-card-dismiss + ) { + min-height: 44px; + } + #gev-voice-control + .gev-voice-panel + :is(.gev-voice-panel-header button, .gev-voice-card-dismiss) { + min-width: 44px; + } +} + +/* A connection error remains reachable even while the manual panel is open. */ +#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray { + z-index: 12; +} diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index b43f40168..9d655e484 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -111,7 +111,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost { padding-left: 0.1rem; } -html:not([lang='en']) #command-dock .gev-voice-tier-btn { +html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn { min-width: 0; padding: 0 0.2rem; } @@ -232,3 +232,326 @@ html:not([lang='en']) pointer-events: auto; } } + +/* The opt-in provider and gear share a discoverable row at the existing anchor. */ +#gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-settings-gap: 0.75rem; + --gev-voice-tray-clearance: 0px; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + ); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 46px 6px 6px; + border-color: rgba(123, 189, 211, 0.28); + background: rgba(9, 18, 27, 0.88); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46); + color-scheme: dark; + opacity: 1; + pointer-events: auto; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + > span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +#gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 34px; + font-size: 0.7rem; +} +#gev-voice-control .gev-voice-settings-button { + position: absolute; + top: 1rem; + right: 0.25rem; + z-index: 11; + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + color: rgba(190, 221, 230, 0.72); + background: rgba(9, 18, 27, 0.92); + border: 1px solid var(--glass-border); + border-radius: 0.3rem; + font-size: 18px; + line-height: 1; + cursor: pointer; +} +#gev-voice-control[data-provider-choice='true']:has( + .gev-voice-provider-field:not([hidden]) + ) + .gev-voice-settings-button { + top: auto; + right: 7px; + bottom: calc( + 100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px + ); +} +#gev-voice-control + .gev-voice-settings-button:is(:hover, [aria-expanded='true']) { + color: var(--voice-panel-accent, var(--accent)); + border-color: currentColor; + background: rgba(0, 212, 255, 0.12); +} +#gev-voice-control .gev-voice-settings-button:focus-visible { + outline: 2px solid var(--voice-panel-accent, var(--accent)); + outline-offset: 2px; +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) { + --voice-panel-accent: var(--cyber-red-bright); +} +:root[data-ui-theme='cyber'] + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field { + background: var(--cyber-panel-surface); + border-color: var(--glass-border); +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-settings-button { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field:not([hidden]) { + padding-right: 56px; + } + #gev-voice-control:has(> .gev-voice-settings-button) + .gev-voice-provider-field + select { + min-height: 44px; + } +} + +/* Keep the row and popup on the same lift, with a small gap above pinned + trays. On very short screens the shared lift still yields to the viewport. */ +#command-dock.dock-has-pinned-tray + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-voice-settings-gap: 0.375rem; + --gev-provider-lift: min( + calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)), + max(0px, calc(100dvh - 20rem)) + ); +} + +/* Match the attribution-aware tray offsets without moving the trays. The + popup can scroll before the shared viewport budget caps its lift. */ +@media (max-width: 900px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(3rem - 1.2rem); + } +} +@media (max-width: 720px) { + #gev-voice-control:has(> .gev-voice-settings-button) { + --gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem); + } +} + +/* Short touch landscapes have room beside the dock, not above its pinned + stack. Keep the same chooser and gear together in that empty lower gutter; + the open popup remains a bounded, scrollable overlay above the microphone. */ +@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) { + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ), + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + --gev-provider-lift: 0px; + --gev-landscape-row-width: clamp( + 108px, + calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem), + calc(100% - 2px) + ); + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-provider-field:not([hidden]) { + left: calc(50% - 50vw + 0.5rem); + right: auto; + bottom: 0.375rem; + width: var(--gev-landscape-row-width); + box-sizing: border-box; + } + #command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > .gev-voice-settings-button { + left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px); + right: auto; + top: auto; + bottom: calc(0.375rem + 7px); + } +} + +/* The revealed row clears pinned wings without making the Voice column's + noninteractive gaps cover their controls. Keep hit surfaces stable so hover + promotion cannot oscillate when the pointer crosses root padding. */ +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + pointer-events: none; +} +#command-dock + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + > :is( + #gev-voice-button, + .gev-voice-heading, + .gev-voice-readout, + .gev-voice-provider-field, + .gev-voice-settings-button, + .gev-voice-panel, + .gev-voice-error-tray + ) { + pointer-events: auto; +} +@media (hover: hover) and (pointer: fine) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):is( + :hover, + [data-row-grace], + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) { + z-index: 101; + } +} +@media (hover: none), (pointer: coarse) { + #command-dock.dock-has-pinned-tray + > #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) { + z-index: 101; + } +} + +/* Pointer grace crosses pinned trays without covering their controls. Keyboard + focus, the provider select and an open panel hold the shared row visible. */ +@media (hover: hover) and (pointer: fine) { + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: + opacity 160ms ease, + transform 180ms ease; + } + #command-dock + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ):not(:hover):not([data-row-grace]):not( + :has( + :focus-visible, + #gev-voice-provider:focus, + .gev-voice-panel[data-view='settings']:not([hidden]) + ) + ) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + } + #command-dock:not(.dock-has-pinned-tray) + #gev-voice-control[data-provider-choice='true']:has( + > .gev-voice-settings-button + ) + .gev-voice-provider-field::after { + content: ''; + position: absolute; + top: 100%; + right: 0; + left: 0; + height: calc(var(--gev-voice-settings-gap) + 8px); + } +} + +/* Existing tier and estimate controls are readable Settings content in panel mode. */ +.gev-voice-cost-settings { + display: grid; + gap: 0.55rem; + margin-top: 0.75rem; + padding-top: 0.7rem; + border-top: 1px solid var(--glass-border); +} +.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has( + #gev-voice-cost-value[hidden] + ), +.gev-voice-cost-setting-row:has(> [hidden]) { + display: none; +} +.gev-voice-cost-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 2rem; + color: var(--text-primary); + font-size: 0.68rem; +} +.gev-voice-cost-setting-row > span { + min-width: 0; +} +#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + flex: 0 0 auto; + min-height: 2rem; + min-width: 3.25rem; + padding: 0.3rem 0.55rem; + font-size: 0.67rem; +} +.gev-voice-cost-settings .gev-voice-cost-value { + flex: 0 0 auto; + font-size: 0.75rem; +} +.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] { + color: var(--voice-panel-accent, var(--accent)); +} +#gev-voice-control .gev-voice-settings-icon { + font-size: 18px; + line-height: 1; +} +@media (pointer: coarse) { + #gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn { + min-height: 44px; + min-width: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + #command-dock + #gev-voice-control:has(> .gev-voice-settings-button) + :is(.gev-voice-provider-field, .gev-voice-settings-button) { + transition: none; + } +} diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 3f464cad1..bf7e2f66d 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -178,6 +178,9 @@ const schemas = [ 'global-context-panel', 'weather-panel', 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', ], }, open: { @@ -187,6 +190,15 @@ const schemas = [ required: ['panelId', 'open'], }, }, + { + name: 'set_voice_inactivity_timeout', + parameters: { + type: 'object', + additionalProperties: false, + properties: { minutes: { type: 'integer', minimum: 0, maximum: 60 } }, + required: ['minutes'], + }, + }, { name: 'set_context_mode', parameters: { @@ -223,6 +235,7 @@ const schemas = [ aircraftClass: { type: 'string', }, + voicePanel: { type: 'string', enum: ['preview', 'settings'] }, }, required: ['action'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d65d7d8db..5cb882527 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -32,7 +32,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea .digest('hex'); assert.equal( digest, - // Re-derived for the voice layer manifest (generated layer enums and + // Re-derived for Provider Settings and explicit Visual Presets/Scenes routing, + // plus the deliberate direct Panel tool/target additions and voice layer manifest (generated layer enums and // aliases), point-and-ask (pointer sentinels, referent args) and the // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves @@ -41,8 +42,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Air quality (ECCC AQHI) extends the manifest. // Globe rotation adds one motion; visual keyhole fit and explicit // reset guidance/defaults update only move_camera and mode descriptions. - // Restore base descriptions; append only globe-rotation defaults and fit guidance. - '967b5a043b812e2f8c1e190fc571cf12b960c9d4486df736902221aab4d99cf6', + // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance + // is consolidated without changing either feature's arguments. + 'b2cd2decb77fb3f8f0ab65e7907c221580e21ee3e7e77581f1b52bf62126ae86', ); }); @@ -100,12 +102,18 @@ test('metadata cannot add tools, fields, types or enum values', () => { test('all legacy action arguments are byte-identical after removing the deliberate additions', () => { const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter( - (tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name), + (tool) => + ![ + 'next_satellite_pass', + 'set_cyber_sonar', + 'set_voice_inactivity_timeout', + ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by // layerManifest.test.mjs; the two shipped right-rail panels are additive. const property = (name) => legacy.find((tool) => tool.name === name).parameters.properties; + delete property('control_cockpit').voicePanel; delete property('set_layer_visibility').layerId.enum; delete property('show_data_layers_menu').layerId.enum; delete property('get_entity_context').layerId.enum; @@ -123,7 +131,14 @@ test('all legacy action arguments are byte-identical after removing the delibera motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( - (id) => !['weather-panel', 'recent-imagery-panel'].includes(id), + (id) => + ![ + 'weather-panel', + 'recent-imagery-panel', + 'voice-settings', + 'voice-preview', + 'provider-settings', + ].includes(id), ); for (const tool of legacy) { // Point-and-ask adds `referent` arguments and 'pointer' enum values. @@ -228,3 +243,13 @@ test('globe reset guidance requires a separate explicit command after refusal', assert.match(paragraph, /bare yes\/ok/); assert.match(paragraph, /Do not ask a question/); }); + +test('direct voice timeout schema accepts None and exact integers, rejects malformed duration', () => { + const tool = GEV_REALTIME_TOOLS.find( + (tool) => tool.name === 'set_voice_inactivity_timeout', + ); + for (const minutes of [0, 1, 5, 60]) + assert.equal(validateArgs(tool.parameters, { minutes }).length, 0); + for (const minutes of [-1, 61, 1.5, '5']) + assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); +}); diff --git a/src/voice/commands.js b/src/voice/commands.js index c5f92b209..1fed3bc12 100644 --- a/src/voice/commands.js +++ b/src/voice/commands.js @@ -2,17 +2,50 @@ import { createVoiceCommands as bindVoiceCommands } from './sessionCommands.js'; import { createRealtimeSession } from './realtimeSession.js'; import { createGeminiSession } from './geminiSession.js'; import { createProviderCommands } from './providerCommands.js'; +import { createVoicePanelHost } from './panel/host.js'; /** Default composition; callers may supply another session adapter factory. */ export function createVoiceCommands(options = {}) { + const panelHost = + options.panel === true + ? createVoicePanelHost({ + storage: options.panelStorage, + clock: options.panelClock, + readCockpitState: options.styleManager?.getCockpitState + ? () => options.styleManager.getCockpitState() + : undefined, + }) + : null; + const bind = (settings) => + bindVoiceCommands({ + ...settings, + ...(panelHost + ? { + panelHost, + nativePanelProvider: settings.createController + ? null + : settings.createSession === createRealtimeSession + ? 'openai' + : settings.createSession === createGeminiSession + ? 'gemini' + : null, + } + : {}), + }); + let commands; // Generic hosts retain the existing default; standalone opts in explicitly. if (!options.createSession && options.supportedProviders !== undefined) - return createProviderCommands(options, { - bind: bindVoiceCommands, + commands = createProviderCommands(options, { + bind, factories: { openai: createRealtimeSession, gemini: createGeminiSession }, }); - return bindVoiceCommands({ - createSession: createRealtimeSession, - ...options, - }); + else commands = bind({ createSession: createRealtimeSession, ...options }); + if (panelHost) { + const lifetime = commands.signal ?? commands.session?.signal; + lifetime?.addEventListener('abort', () => panelHost.destroy(), { + once: true, + }); + if (lifetime?.aborted) panelHost.destroy(); + } + return commands; } diff --git a/src/voice/control.js b/src/voice/control.js index c77197412..af9d37cbc 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -27,7 +27,7 @@ export function voiceStatusLabel(status) { } /** Build the voice control independently of its connection backend. */ -export function createVoiceControl({ reset = false } = {}) { +export function createVoiceControl({ reset = false, panel = false } = {}) { let root = document.getElementById('gev-voice-control'); if (root && reset) { root.remove(); @@ -43,13 +43,18 @@ export function createVoiceControl({ reset = false } = {}) { ${t('Voice provider')} + ${panel ? `` : ''}
${t('AI AGENT')}
${voiceStatusLabel('idle')}
-
+ ${ + panel + ? '' + : `
~$0.00 -
+
` + }
+ + + + +
+
+

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

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

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

+

NO PLACE LEFT BEHIND

- ACTIVE STYLE - NORMAL + ACTIVE STYLE + NORMAL
diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index c804ea342..1d5f16a28 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -1,3 +1,5 @@ +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { syncScopeControls } from './displayControls.js'; import { STYLE_STATUS_LABELS } from './visualPresets.js'; import { syncSegmentedControl } from './segmentedControl.js'; import { t } from '../i18n/index.js'; @@ -136,11 +138,10 @@ export class VisualSettings { } if (typeof scopeEnabled === 'boolean') { setScopeMaskEnabled(scopeEnabled); - this._scopeBtn?.classList.toggle('active', scopeEnabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled)); + syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled); } if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct))); + const pct = clampScopeFeatherPct(scopeFeatherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; @@ -1302,11 +1303,14 @@ export class VisualSettings { const scopeState = state.scope || {}; if (typeof scopeState.enabled === 'boolean') { setScopeMaskEnabled(scopeState.enabled); - this._scopeBtn?.classList.toggle('active', scopeState.enabled); - this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled)); + syncScopeControls( + this._scopeBtn, + this._scopeFeatherSlider, + scopeState.enabled, + ); } if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) { - const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct))); + const pct = clampScopeFeatherPct(scopeState.featherPct); this._scopeFeatherSlider.value = String(pct); if (this._scopeFeatherValue) this._scopeFeatherValue.textContent = `${pct}%`; From 4a18af54267a39484c9f01a5f352b81362a48f9b Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:24:34 +0100 Subject: [PATCH 112/131] fix: address GhostMaps resource and source ownership review findings --- changelog.d/1297-s2-ghostmaps-layer.md | 3 + docs/CURRENT-STATE.md | 1 + scripts/format-scope.json | 1 + server/providers/ghostMaps.test.mjs | 17 +- src/app/constructCatalog.js | 2 +- src/app/layers/ghostMaps.js | 6 +- src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 64 ++++++++ src/layers/ghostMaps/index.js | 201 ++++++++++++------------ src/layers/ghostMaps/lifecycle.test.mjs | 180 +++++++++++++++++++++ src/standalone/layerSources.js | 2 + 11 files changed, 372 insertions(+), 106 deletions(-) create mode 100644 src/layers/ghostMaps/lifecycle.test.mjs diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index e79cc6eff..938f5f89b 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -7,3 +7,6 @@ products that changed (Black Pine Defense). - Clear a temporary GhostMaps upstream error and notify open pages when the repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 52fdb1e78..72f6db4b8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5133,6 +5133,7 @@ are omitted rather than framing the wrong part of the globe. - The layer reloads only products whose blob changed. The new edition's data source is added before the old one is removed. A product that leaves the catalog leaves the globe. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. +- **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 71d51d253..879ef83a6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -884,6 +884,7 @@ "src/layers/ghostMaps/source.js", "src/layers/ghostMaps/catalog.test.mjs", "src/layers/ghostMaps/records.test.mjs", + "src/layers/ghostMaps/lifecycle.test.mjs", "scripts/qa-ghostmaps.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", diff --git a/server/providers/ghostMaps.test.mjs b/server/providers/ghostMaps.test.mjs index 5ddef6aa5..9723436d2 100644 --- a/server/providers/ghostMaps.test.mjs +++ b/server/providers/ghostMaps.test.mjs @@ -65,6 +65,17 @@ async function withCache(fn) { } } +// checkNow announces the catalog before background warm-up finishes. Join the +// same file reads before changing editions or removing the temporary cache. +async function warmDefaults(watcher) { + await Promise.all( + watcher + .catalog() + .products.filter((product) => product.defaultOn) + .map((product) => watcher.file(product.id)), + ); +} + test('a new commit is announced once, naming only the products that changed', () => withCache(async (cacheDir) => { const { repo, fetchImpl } = fakeGitHub(); @@ -78,6 +89,7 @@ test('a new commit is announced once, naming only the products that changed', () watcher.subscribe((catalog) => events.push(catalog)); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); const first = watcher.catalog(); assert.equal(first.commit, sha('1')); assert.equal(first.message, 'Oct 18 Update'); @@ -105,6 +117,7 @@ test('a new commit is announced once, naming only the products that changed', () }, ]); assert.equal(await watcher.checkNow(), true); + await warmDefaults(watcher); assert.equal(events.length, 2); const cip = events[1].products.find((p) => p.id === 'cip'); assert.equal(cip.version, sha('c')); @@ -165,7 +178,9 @@ test('the catalog survives a restart from disk, and upstream failures are report withCache(async (cacheDir) => { const { fetchImpl } = fakeGitHub(); const quiet = { log: () => {}, warn: () => {} }; - await createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }).checkNow(); + const online = createGhostMapsWatcher({ fetchImpl, cacheDir, ...quiet }); + await online.checkNow(); + await warmDefaults(online); const offline = createGhostMapsWatcher({ fetchImpl: async () => new Response('down', { status: 503 }), cacheDir, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7c149ab59..59edc02ad 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -127,7 +127,7 @@ export function createApplicationCatalog({ createApplicationGnssIntegrity({ source: sources['gnss-integrity'], }), - createApplicationGhostMaps(), + createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index 5e373c3d8..ccca18850 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,8 +1,5 @@ import * as Cesium from 'cesium'; -import { - createGhostMapsLayer, - createGhostMapsSource, -} from '../../layers/ghostMaps/index.js'; +import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; @@ -10,7 +7,6 @@ import { isPointerFree } from '../../data/inputOwnership.js'; /** Wire the S2 Underground GhostMaps layer into the application catalog. */ export function createApplicationGhostMaps(options) { return createGhostMapsLayer({ - source: createGhostMapsSource(), overlayHost: { setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 0fc619142..442598658 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -48,6 +48,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), 'air-quality': contract(['air-quality'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), + 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index bfb031026..5baf01dab 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -86,6 +86,70 @@ test('standalone defaults allow exact replacements and explicit removal, and rej ); }); +test('GhostMaps obeys source omission, replacement and explicit removal', async (t) => { + let fetches = 0; + t.mock.method(globalThis, 'fetch', async () => { + fetches++; + throw new Error('Unexpected standalone acquisition'); + }); + const missing = catalog({}, t); + assert.equal(missing.getSourceAvailability('s2-ghostmaps')?.available, false); + const manager = new LayerLifecycle( + {}, + { getSourceAvailability: missing.getSourceAvailability }, + ); + manager.register(missing.get('s2-ghostmaps')); + assert.equal(await manager.setEnabled('s2-ghostmaps', true), false); + assert.equal(manager.layers.get('s2-ghostmaps').initialized, false); + assert.equal( + createStandaloneLayerSources({ 's2-ghostmaps': null })['s2-ghostmaps'], + null, + ); + + let reads = 0; + let subscriptions = 0; + let unsubscribed = 0; + const source = { + getCatalog: async () => { + reads++; + return { commit: 'a'.repeat(40), products: [] }; + }, + getFile: async () => { + throw new Error('Empty catalog must not read files'); + }, + subscribe: () => { + subscriptions++; + return () => { + unsubscribed++; + }; + }, + }; + const supplied = createStandaloneLayerSources({ 's2-ghostmaps': source }); + assert.equal(supplied['s2-ghostmaps'], source); + const configured = catalog(supplied, t); + assert.equal( + configured.getSourceAvailability('s2-ghostmaps').available, + true, + ); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'document'); + globalThis.document = { addEventListener() {}, removeEventListener() {} }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'document', previous); + else delete globalThis.document; + }); + const layer = configured.get('s2-ghostmaps'); + t.after(() => layer.destroy()); + layer.init({ scene: { requestRender() {} } }); + layer.enable(); + await layer.update(); + layer.disable(); + assert.equal(reads, 1); + assert.equal(subscriptions, 1); + assert.equal(unsubscribed, 1); + assert.equal(layer.getStats().commit, 'a'.repeat(40)); + assert.equal(fetches, 0); +}); + test('empty Street Level uses the shared unavailable lifecycle without initialization', async (t) => { const actual = catalog({}, t); const manager = new LayerLifecycle( diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index b0aa7c88b..01d564d05 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -195,7 +195,9 @@ export function createGhostMapsLayer({ /** Build one product's data source off-scene, then swap it in. */ async function load(product, signal) { const bytes = await source.getFile(product.id, product.version, { signal }); + signal.throwIfAborted(); const entries = await readZipEntries(bytes); + signal.throwIfAborted(); const main = kmzMainDocument(entries); if (!main) throw new Error(msg('That KMZ holds no KML document.')); const urls = []; @@ -210,117 +212,118 @@ export function createGhostMapsLayer({ ? resolvePacked(href) : null; }; - let parsed; try { - parsed = readGhostMapsKml( + const parsed = readGhostMapsKml( parseXml(decodeXml(entries.get(main)), 'KML'), resolveImage, ); - } catch (error) { - urls.forEach((url) => URL.revokeObjectURL(url)); - throw error; - } - entries.clear(); - signal.throwIfAborted(); - const color = PRODUCT_COLORS[product.id] || ACCENT; - const dataSource = new Cesium.CustomDataSource( - `${GHOSTMAPS_LAYER_ID}:${product.id}`, - ); - const features = []; - const pins = []; - dataSource.entities.suspendEvents(); - parsed.features.forEach((feature, index) => { - const key = `${product.id}:${index}`; - const { style } = feature; - feature.geometries.forEach((geometry, part) => { - const id = `${PICK_PREFIX}${key}:${part}`; - if (geometry.type === 'point') { - const position = Cesium.Cartesian3.fromDegrees(...geometry.position); - const entity = dataSource.entities.add({ - id, - position, - billboard: { - image: style.icon || markerImage(color), - scale: Math.min( - 1.5, - Math.max(0.35, (style.iconScale ?? 1) * 0.7), - ), - color: - style.icon && style.iconColor - ? cesiumColor(style.iconColor) - : undefined, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Ground-clamped icons would sink into terrain and 3D tiles; the - // horizon cull hides the far side instead (see `_horizon`). - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - pins.push({ - position, - get show() { - return entity.show; - }, - set show(value) { - entity.show = value; - }, - }); - } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), + entries.clear(); + signal.throwIfAborted(); + const color = PRODUCT_COLORS[product.id] || ACCENT; + const dataSource = new Cesium.CustomDataSource( + `${GHOSTMAPS_LAYER_ID}:${product.id}`, + ); + const features = []; + const pins = []; + dataSource.entities.suspendEvents(); + parsed.features.forEach((feature, index) => { + const key = `${product.id}:${index}`; + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `${PICK_PREFIX}${key}:${part}`; + if (geometry.type === 'point') { + const position = Cesium.Cartesian3.fromDegrees( + ...geometry.position, + ); + const entity = dataSource.entities.add({ + id, + position, + billboard: { + image: style.icon || markerImage(color), + scale: Math.min( + 1.5, + Math.max(0.35, (style.iconScale ?? 1) * 0.7), ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) + color: + style.icon && style.iconColor + ? cesiumColor(style.iconColor) + : undefined, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + // Ground-clamped icons would sink into terrain and 3D tiles; the + // horizon cull hides the far side instead (see `_horizon`). + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + pins.push({ + position, + get show() { + return entity.show; + }, + set show(value) { + entity.show = value; + }, + }); + } else if (geometry.type === 'line') { dataSource.entities.add({ - id: `${id}:outline`, + id, polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), material: cesiumColor(style.lineColor, color), clampToGround: true, }, }); - } + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }); + if (style.outline !== false) + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }); + } + }); + features.push({ + name: feature.name, + folder: feature.folder, + description: feature.description, + anchor: anchorOf(feature.geometries[0]), + }); }); - features.push({ - name: feature.name, - folder: feature.folder, - description: feature.description, - anchor: anchorOf(feature.geometries[0]), - }); - }); - dataSource.entities.resumeEvents(); - return { version: product.version, dataSource, urls, features, pins }; + dataSource.entities.resumeEvents(); + return { version: product.version, dataSource, urls, features, pins }; + } catch (error) { + urls.forEach((url) => URL.revokeObjectURL(url)); + throw error; + } } /** Bring the globe in line with the catalog and the chip selection. */ diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs new file mode 100644 index 000000000..51d546017 --- /dev/null +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { createGhostMapsLayer } from './index.js'; + +function element(localName, children = [], value = '', attributes = {}) { + const node = { + localName, + children, + get textContent() { + return value + children.map((child) => child.textContent).join(''); + }, + getAttribute: (key) => attributes[key] ?? null, + getElementsByTagName(name) { + return children.flatMap((child) => [ + ...(name === '*' || name === child.localName ? [child] : []), + ...child.getElementsByTagName(name), + ]); + }, + }; + for (const child of children) child.parentElement = node; + return node; +} + +function documentFixture() { + return element('document', [ + element('kml', [ + element('Document', [ + element( + 'Style', + [ + element('IconStyle', [ + element('Icon', [element('href', [], 'icon.png')]), + ]), + ], + '', + { id: 'pin' }, + ), + element('Placemark', [ + element('name', [], 'Test report'), + element('styleUrl', [], '#pin'), + element('Point', [element('coordinates', [], '12,34')]), + ]), + ]), + ]), + ]); +} + +// A stored KMZ with a local icon; the XML DOM double supplies its one point. +function fixtureKmz() { + const files = [ + ['doc.kml', ''], + ['icon.png', 'packed icon'], + ]; + const local = []; + const central = []; + let offset = 0; + for (const [name, value] of files) { + const key = Buffer.from(name); + const bytes = Buffer.from(value); + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt32LE(bytes.length, 18); + header.writeUInt32LE(bytes.length, 22); + header.writeUInt16LE(key.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt32LE(bytes.length, 20); + directory.writeUInt32LE(bytes.length, 24); + directory.writeUInt16LE(key.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, key, bytes); + central.push(directory, key); + offset += header.length + key.length + bytes.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return new Uint8Array(Buffer.concat([...local, directory, end])); +} + +function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); + globalThis.DOMParser = class { + parseFromString() { + return documentFixture(); + } + }; + t.after(() => { + if (previous) Object.defineProperty(globalThis, 'DOMParser', previous); + else delete globalThis.DOMParser; + }); + const created = []; + const revoked = []; + t.mock.method(URL, 'createObjectURL', () => { + const url = `blob:local-${created.length}`; + created.push(url); + onCreateUrl?.(layer); + return url; + }); + t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); + t.mock.method(console, 'warn', () => {}); + const mounted = new Set(); + const viewer = { + scene: { requestRender() {} }, + dataSources: { + async add(dataSource) { + mounted.add(dataSource); + return dataSource; + }, + remove(dataSource) { + return mounted.delete(dataSource); + }, + }, + }; + const layer = createGhostMapsLayer({ + storage: null, + source: { + getCatalog: async () => ({ + products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }], + }), + getFile: async () => { + beforeFileReturns?.(layer); + return fixtureKmz(); + }, + }, + }); + layer.init(viewer); + layer.enable(); + t.after(() => layer.destroy()); + return { layer, viewer, mounted, created, revoked }; +} + +test('cancelling a file read does not allocate packed-image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()), + }); + await layer.update(viewer); + assert.equal(created.length, 0); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 0); +}); + +test('cancellation during parsing releases every packed-image URL', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t, { + onCreateUrl: (layer) => layer.disable(), + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); + +test('entity construction failure releases parsed image URLs', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { + throw new Error('fixture entity failure'); + }); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); + assert.equal(layer.getStats().error, 'fixture entity failure'); +}); + +test('successful load keeps icons until the product is disabled', async (t) => { + const { layer, viewer, mounted, created, revoked } = fixture(t); + await layer.update(viewer); + assert.equal(created.length, 1); + assert.equal(revoked.length, 0); + assert.equal(mounted.size, 1); + assert.equal(layer.getStats().count, 1); + layer.disable(); + assert.deepEqual(revoked, created); + assert.equal(mounted.size, 0); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..693ddd47f 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -19,6 +19,7 @@ import { createWeatherSource } from '../layers/weather/source.js'; import { createCycloneSource } from '../layers/cyclones/source.js'; import { createWindSource } from '../layers/wind/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createGhostMapsSource } from '../layers/ghostMaps/source.js'; import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -50,6 +51,7 @@ export function createStandaloneLayerSources(overrides = {}) { launches: createLaunchSource(), alpr: createAlprTileSource(), firms: createFirmsSource(), + 's2-ghostmaps': createGhostMapsSource(), wind: createWindSource(), weather: createWeatherSource(), cyclones: createCycloneSource(), From 9496ee4d64d771c64cfb555f13350a75c156ac93 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Mon, 26 Oct 2026 16:29:48 +0100 Subject: [PATCH 113/131] fix: complete GhostMaps locale and voice integration with current main --- changelog.d/1297-s2-ghostmaps-layer.md | 2 ++ docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 1 + src/firstRunExperience.test.mjs | 5 +++-- src/i18n/locales/zh-CN.json | 26 +++++++++++++++++++++++++- src/i18n/same-as-english.json | 2 ++ src/voice/actionSchemas.test.mjs | 3 ++- src/voice/layerManifest.js | 4 +++- 8 files changed, 39 insertions(+), 5 deletions(-) diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md index 938f5f89b..7dd52862d 100644 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ b/changelog.d/1297-s2-ghostmaps-layer.md @@ -10,3 +10,5 @@ - Release packed-image resources when a load is cancelled or fails, respect injected or omitted GhostMaps sources, and wait for background cache warm-up before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5527e29b7..8c672d95c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5330,6 +5330,7 @@ request viewbox and continues searching without that geographic preference. - The row reads `LIVE` while the stream is open. A five-minute catalog read is the backstop. - **Files.** `/api/ghostmaps/file/{product}` serves only catalog products. Files are downloaded by commit-pinned raw URLs, so a CDN cannot return an older edition. They are cached on disk by blob sha, and blobs no current product uses are pruned. A request whose `?v=` names the current blob is `immutable`. Repository paths never reach the page. - **Source ownership.** The application catalog receives `sources['s2-ghostmaps']` through the shared source contracts; an omitted or null source stays unavailable without opening a stream or fetching. The standalone source factory supplies the same-origin implementation and accepts replacements. Packed-image URLs are released on cancelled or failed product construction, and remain owned by a successfully loaded product until it is removed. +- **Voice and locales.** S2 can be toggled through its registered id and aliases. The model already sees the S2 GhostMaps brand in the id, and compact alias-hint separators keep the complete voice prompt within its existing 52,000-character limit; accepted aliases are unchanged. The row and reports are translated in Spanish, Russian and Simplified Chinese. - **Rendering.** The page parses the KMZ with the import module's zip, KMZ and XML helpers into one `CustomDataSource` per product. - Points become ground-clamped billboards with the depth test off, so they never sink into terrain or 3D tiles. Because the Google 3D map hides the globe, nothing else would stop far-side pins drawing through the planet. Pins are therefore horizon-culled with Street Level's `createHorizonCull` before their first frame and again whenever the camera moves. - Lines and polygon outlines are ground-clamped polylines. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f99a0f43c..5fb99c576 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3880,6 +3880,7 @@ "src/i18n/locale.js", "src/i18n/locales/es.json", "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json", "src/data/geoImportImages.js", "src/data/geoImportPins.js", "src/layers/streetLevel/tileMath.js", diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 2b18c6825..15598d338 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1054,10 +1054,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Radio markup and the complete production-request comparison pin locality. // Restore the base camera/Globe View wording with the globe-rotation append. // S2 GhostMaps adds its layer id to the generated layer enums. - assert.equal(block.length, 30946, 'serialized tool schema length drifted'); + // Alias-hint separators omit spaces to keep the existing prompt budget. + assert.equal(block.length, 30888, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'be53b22c55e620fb36c6ea0f276bfe5a59de47fa02ead99188ada40c0bf48cc0', + 'b809253254b5589f3c199c218b405d8f661f040962fdd841f1182e5009119420', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e727b8516..ac15a9172 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2818,5 +2818,29 @@ "Layers": "图层", "Search": "搜索", "More": "更多", - "Panels": "面板" + "Panels": "面板", + "S2 GhostMaps": "S2 GhostMaps", + "Intel Picture": "情报态势图", + "Kinetic 30d": "近 30 天军事行动", + "Border Crisis": "边境危机", + "Iran Picture": "伊朗态势图", + "Iran Kinetic": "伊朗军事行动", + "Yemen FLOT": "也门前线", + "Latest Drop": "最新发布", + "GhostMaps file could not be read": "无法读取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目录不可用", + "Malformed GhostMaps catalog": "GhostMaps 目录格式无效", + "{count} source links ↗ · click card to open the first": { + "other": "{count} 个来源链接 ↗ · 点击卡片打开第一个" + }, + "S2 · {name}": "S2 · {name}", + "Report": "报告", + "Open the source for {title}": "打开 {title} 的来源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "实时 · GitHub {commit} · {message} · 每 {seconds} 秒检查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新连接以获取实时更新", + "Connecting to the S2 GhostMaps repository…": "正在连接 S2 GhostMaps 仓库…", + "{count} placemarks": { + "other": "{count} 个地标" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 3a4a9e54a..05f5d3dee 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -169,6 +169,8 @@ "NVG", "REC", "SRC {value}", + "S2 GhostMaps", + "S2 · {name}", "Sentinel-2", "Techno", "Trance", diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 617d0077f..4b88fa12d 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -43,7 +43,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // reset guidance/defaults update only move_camera and mode descriptions. // Restore base descriptions; append only globe-rotation defaults and fit guidance. // S2 GhostMaps adds its layer id to the generated layer enums. - '1f95d78074559217872ccbdfa57d4363264ea88c1a22b259e6ebba3c8189b806', + // Compact alias separators preserve the existing prompt budget. + '0128b5e28a8baf0d6466bfdbc6e7a80fd4bb32d0d22ccadfade2af641714f12a', ); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 14b515fa6..267399e58 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -525,6 +525,8 @@ const SELF_EVIDENT_IDS = new Set([ 'radio', 'wind', 'directions', + // The brand is already the layer id; all spoken aliases remain accepted. + 's2-ghostmaps', ]); /** @@ -539,7 +541,7 @@ export function voiceLayerAliasHint() { entry.toggle && entry.aliases.length && !SELF_EVIDENT_IDS.has(entry.id), ) .map((entry) => `${entry.aliases.slice(0, 2).join('/')}→${entry.id}`) - .join('; '); + .join(';'); } /** Field names that already carry their unit (altitudeM, speedKts, …). */ From 074e2723564f8ad6a57f68bacb0166d41e5c240c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:42:41 -0700 Subject: [PATCH 114/131] fix(voice): recover idle timers and retire panel audio observers --- changelog.d/1299-voice-panel-settings.md | 4 +- docs/CURRENT-STATE.md | 13 +- scripts/format-scope.json | 10 +- src/ui/styles/voice-cost.css | 9 + src/voice/control.js | 3 +- src/voice/panel/nativeActivity.js | 94 +++++++++-- src/voice/panel/nativeActivity.test.mjs | 199 +++++++++++++++++++++++ src/voice/realtimeCost.js | 13 ++ src/voice/realtimeCostPanel.test.mjs | 100 ++++++++++++ 9 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 src/voice/realtimeCostPanel.test.mjs diff --git a/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md index 60a68e7d3..1a95a6ed4 100644 --- a/changelog.d/1299-voice-panel-settings.md +++ b/changelog.d/1299-voice-panel-settings.md @@ -1,3 +1,5 @@ - Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings (manjunath22466, #1299). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7907a9640..4f58da8b9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -588,6 +588,17 @@ Preview preference. Unverified speech captions use the localized Recent speech label; ordinary user captions retain YOU. Caption attribution does not control tool dispatch. +Voice inactivity waits for local input, output and actions to become quiet. +A matching OpenAI response that explicitly completes without audio can restore +idle eligibility; completion of an audio response does not prove playback has +drained. Missing or ambiguous output evidence remains conservative. Gemini +retires its per-audio observer wrappers on close, replacement or session end, +so repeated Stop/Start does not retain old audio instances until the panel closes. +The panel keeps the model tier and regular cost estimate in Voice Settings; +warning, capped and unknown cost states also appear in the dock heading when +Settings is closed. The existing hard cost cap still stops the session and +announces the reason. + Automatic Voice Preview uses output from the active mounted provider, including late replies that may answer an older request. Manual panel choices remain until genuine local input rearms automatic presentation. Preview stays off when disabled, remains hidden in Cockpit, and retires on Stop or provider replacement. Display events do not grant command authority. Contacts nearest-aircraft follow-ups retain the snapshot's distance basis even @@ -5160,7 +5171,7 @@ and unreachable upstream (502/504) separately from road geometry. - **Contact-match ties break on hex ascending.** `track_entity` is a mutation fulfilling "follow that one", and the model's observed answer to a non-ok track result is to retry with guesses rather than ask, so the lookup always commits rather than returning an ambiguity. What it owes the caller is stability: hex is unique and always present, so the same query resolves to the same contact for as long as both are loaded. - **Visual grounding**: at `local` view scale with no structured identity, the client captures the Cesium canvas (≤1200px JPEG, black-frame detection, double-render for freshness) and sends it as `input_image` with a strict "do not invent labels" instruction. - **Context window**: only the latest viewport screenshot stays in context — the client deletes the prior image item (`conversation.item.delete`) before adding a new one (images are the most expensive item, re-billed every turn). Text history is bounded by the **server-side** `truncation: { type: 'retention_ratio', retention_ratio, token_limits.post_instructions }` set in `/api/realtime/token` (cache-friendly batched truncation). There is intentionally **no** client-side per-turn conversation-item cap — deleting from the front of history each turn busts the Realtime prompt cache. A spatially-aware summarize-and-prune policy is specced for a future iteration. -- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): the voice heading row carries a `STD`/`MINI` toggle and a running session-cost readout (`~$0.42`). +- **Model tier + spend guard** (`src/voice/voiceCost.js`, August 2026): hosts without the voice panel keep the `STD`/`MINI` toggle and running session-cost readout (`~$0.42`) in the heading. Panel hosts place these controls in Voice Settings and mirror warning, capped or unknown cost states in the heading. - **Tier selection.** `standard` = `gpt-realtime-2` (default), `mini` = `gpt-realtime-2.1-mini` (~3× cheaper per audio token). The client sends `?tier=` to `/api/realtime/token`; the endpoint resolves it through the shared registry, so an unknown, empty, or hostile value falls back to `standard` rather than reaching OpenAI as a model id. Responses echo `X-GEV-Voice-Tier` / `X-GEV-Voice-Model` (plus `X-GEV-Voice-Tier-Fallback: 1` when a bogus tier was downgraded). Persisted at `godsEyeView.voiceCost.tier`. - **Applies NEXT session.** The model is fixed when the ephemeral token is minted, so a live session always keeps the model it connected with; toggling mid-session only records the preference (the button title says so). The cost tracker's lifetime is the session's lifetime and its model binding is immutable from `start()` to `stop()` — rebuilding it on toggle would erase accrued spend and let repeated toggles bypass the cap. The tracker may only be replaced once the session is FULLY SETTLED (`isVoiceSessionSettled()`: not active **and** no data channel **and** no peer connection) — `!isActive()` alone is not enough, because the `error` status reports inactive while the transport can still deliver a late `response.done`. The toggle itself reads and writes only the persisted preference, never the live tracker. - **Env overrides.** `OPENAI_REALTIME_MODEL` / `OPENAI_REALTIME_MODEL_MINI` remain authoritative per tier, so a drifted upstream model id is a `.env` fix rather than a code change. Because an override can point a tier at any model, the client prices against the model id the server actually echoed, **not** the tier it requested. An unrecognised id is billed at the most expensive known rates plus one console warning — under-metering is what lets a cap be overrun. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e60413624..53e581063 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1089,5 +1089,13 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/voice/globeResetConfirmation.test.mjs", - "src/hudDms.test.mjs" + "src/hudDms.test.mjs", + "src/voice/panel/preferences.test.mjs", + "src/voice/panel/host.test.mjs", + "src/voice/panel/manualPanel.test.mjs", + "src/voice/panel/nativeActivity.test.mjs", + "src/voice/panel/inactivity.test.mjs", + "src/voice/panel/actionObserver.test.mjs", + "src/voice/panel/testDom.mjs", + "src/voice/realtimeCostPanel.test.mjs" ] diff --git a/src/ui/styles/voice-cost.css b/src/ui/styles/voice-cost.css index 9d655e484..4ca1d558d 100644 --- a/src/ui/styles/voice-cost.css +++ b/src/ui/styles/voice-cost.css @@ -62,6 +62,15 @@ color: #ffc46b; } +.gev-voice-cost-warning { + margin-left: auto; + flex: 0 0 auto; +} + +.gev-voice-cost-warning[data-level='unknown'] { + color: #ffc46b; +} + .gev-voice-cost-value[data-level='cap'] { color: #ff6b6b; font-weight: 600; diff --git a/src/voice/control.js b/src/voice/control.js index af9d37cbc..df40be104 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -49,7 +49,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) {
${voiceStatusLabel('idle')}
${ panel - ? '' + ? `` : `
~$0.00 @@ -193,6 +193,7 @@ export function createVoiceControl({ reset = false, panel = false } = {}) { errorDetail: root.querySelector('#gev-voice-error-detail'), tierButton: root.querySelector('#gev-voice-tier'), costValue: root.querySelector('#gev-voice-cost-value'), + costWarning: root.querySelector('#gev-voice-cost-warning'), card: root.querySelector('#gev-voice-card'), }; } diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index 322f14d75..c019f76c8 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -36,6 +36,33 @@ export function observeNativeActivity({ let audio = null, version = 0; const restores = []; + const audioRestores = []; + const pendingOutput = new Map(); + let untrackedOutput = !outputKnown; + function retireAudio() { + audio = null; + for (const restore of audioRestores.splice(0).reverse()) restore(); + } + function refreshOutputKnowledge() { + outputKnown = !untrackedOutput && pendingOutput.size === 0; + } + function hasNoAudioOutput(response) { + return ( + ['completed', 'cancelled', 'failed', 'incomplete'].includes( + response?.status, + ) && + Array.isArray(response.output) && + response.output.every( + (item) => + item?.type === 'function_call' || + (item?.type === 'message' && + Array.isArray(item.content) && + item.content.every((part) => + ['text', 'output_text', 'refusal'].includes(part?.type), + )), + ) + ); + } function snapshot() { const turns = nativeProvider === 'openai' ? controller?._turns : controller; return { @@ -72,7 +99,7 @@ export function observeNativeActivity({ } catch {} } } - function wrap(object, key, after) { + function wrap(object, key, after, ownedRestores = restores) { const original = object?.[key]; if (typeof original !== 'function') return; function wrapped(...args) { @@ -86,7 +113,7 @@ export function observeNativeActivity({ return result; } object[key] = wrapped; - restores.push(() => { + ownedRestores.push(() => { if (object[key] === wrapped) object[key] = original; }); } @@ -104,9 +131,11 @@ export function observeNativeActivity({ (state === 'connecting' && wasConnected) ) { version++; - audio = null; + retireAudio(); inputBusy = false; outputKnown = cleanNativeOutput(); + untrackedOutput = !outputKnown; + pendingOutput.clear(); } } notify(); @@ -127,13 +156,41 @@ export function observeNativeActivity({ inputBusy = true; if (event?.type === 'input_audio_buffer.speech_stopped') inputBusy = false; - // A previous turn's drain does not prove the next transport emits output events. - if (event?.type === 'response.created') outputKnown = false; + const type = event?.type; + const id = event?.response_id || event?.response?.id; + // Keep each unresolved response separate: a later silent reply cannot + // establish that an earlier audio reply has drained. + if (type === 'response.created') { + if (id && pendingOutput.size < 64) + pendingOutput.set(id, { audioSeen: false }); + else untrackedOutput = true; + } + const pending = pendingOutput.get(id); if ( - event?.type === 'output_audio_buffer.started' || - event?.type === 'output_audio_buffer.cleared' + pending && + (type?.startsWith('response.output_audio') || + type?.startsWith('response.audio') || + ['audio', 'output_audio'].includes(event?.part?.type)) ) - outputKnown = true; + pending.audioSeen = true; + if ( + type === 'response.done' && + pending && + !pending.audioSeen && + hasNoAudioOutput(event.response) + ) + pendingOutput.delete(id); + if ( + type === 'output_audio_buffer.started' || + type === 'output_audio_buffer.cleared' + ) { + // Native playback events establish the transport's output lifecycle. + // Generation completion alone never establishes audio drainage. + untrackedOutput = false; + if (id) pendingOutput.delete(id); + else pendingOutput.clear(); + } + refreshOutputKnowledge(); notify(); }); } else { @@ -183,12 +240,23 @@ export function observeNativeActivity({ forwarded[0] = decorated; current = Reflect.apply(original, this, forwarded); if (!dead && stamp === version) { + retireAudio(); audio = current; inputBusy = false; for (const key of ['play', 'clearPlayback', 'close']) - wrap(current, key, () => { - if (audio === current && controller.audio === current) notify(); - }); + wrap( + current, + key, + () => { + if (audio !== current) return; + if (key === 'close') { + retireAudio(); + inputBusy = false; + } + if (controller.audio === current) notify(); + }, + audioRestores, + ); } return current; } @@ -203,7 +271,9 @@ export function observeNativeActivity({ if (dead) return; dead = true; version++; - for (const restore of restores.reverse()) restore(); + retireAudio(); + pendingOutput.clear(); + for (const restore of restores.splice(0).reverse()) restore(); signal?.removeEventListener('abort', destroy); } signal?.addEventListener('abort', destroy, { once: true }); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index 40b298387..f1ac6cd5f 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -471,3 +471,202 @@ test('native typed input observation preserves receiver/result, refuses failed s old.call(controller, 'late'); assert.equal(inputs, 1); }); + +test('explicit silent OpenAI completion rearms expiry, including cancelled and failed responses', () => { + for (const status of ['completed', 'cancelled', 'failed', 'incomplete']) { + const turns = new RealtimeTurns({}); + const controller = { status: 'idle', dc: null, _turns: turns, _input: {} }; + let idle; + let timer; + let expired = 0; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire: () => expired++, + }); + controller.status = 'listening'; + controller.dc = {}; + observer.handle({ type: 'state', state: 'listening' }); + assert.equal(typeof timer, 'function'); + turns.responseActive = true; + turns.observeSpeech({ + type: 'response.created', + response: { id: 'silent' }, + }); + assert.equal(timer, null); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'silent', + status, + output: + status === 'completed' + ? [{ type: 'function_call', name: 'set_panel_open' }] + : [], + }, + }); + // The provider updates generation state after forwarding the raw event. + assert.equal(timer, null); + turns.responseActive = false; + observer.handle({ type: 'completion' }); + assert.equal(observer.snapshot().known, true); + assert.equal(typeof timer, 'function'); + timer(); + assert.equal(expired, 1); + idle.destroy(); + observer.destroy(); + } +}); + +test('silent OpenAI replies cannot clear unresolved older audio or incomplete output metadata', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + const created = (id) => + turns.observeSpeech({ type: 'response.created', response: { id } }); + const done = (id, output) => + turns.observeSpeech({ + type: 'response.done', + response: { id, status: 'completed', output }, + }); + created('audio'); + turns.observeSpeech({ + type: 'response.output_audio.delta', + response_id: 'audio', + }); + created('silent'); + done('silent', []); + assert.equal(observer.snapshot().known, false); + done('audio', []); // Even contradictory completion metadata cannot erase audio evidence. + assert.equal(observer.snapshot().known, false); + turns.observeSpeech({ + type: 'output_audio_buffer.started', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, true); + done('silent', []); + assert.equal(observer.snapshot().outputBusy, true); + turns.observeSpeech({ + type: 'output_audio_buffer.stopped', + response_id: 'audio', + }); + assert.equal(observer.snapshot().outputBusy, false); + created('unknown'); + done('unknown', undefined); + assert.equal(observer.snapshot().known, false); + done('unobserved', []); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('overlapping silent replies resolve independently and audio output items remain unknown until playback', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'idle', dc: null, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + for (const id of ['a', 'b']) + turns.observeSpeech({ type: 'response.created', response: { id } }); + for (const id of ['a', 'b']) { + turns.observeSpeech({ + type: 'response.done', + response: { + id, + status: 'completed', + output: [ + { type: 'message', content: [{ type: 'text', text: 'Done' }] }, + ], + }, + }); + assert.equal(observer.snapshot().known, id === 'b'); + } + turns.observeSpeech({ type: 'response.created', response: { id: 'audio' } }); + turns.observeSpeech({ + type: 'response.done', + response: { + id: 'audio', + status: 'completed', + output: [{ type: 'message', content: [{ type: 'audio' }] }], + }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('silent completion cannot establish output knowledge when observer attached mid-session', () => { + const turns = new RealtimeTurns({}); + const observer = observeNativeActivity({ + adapter: { + controller: { status: 'listening', dc: {}, _turns: turns, _input: {} }, + }, + nativeProvider: 'openai', + }); + turns.observeSpeech({ type: 'response.created', response: { id: 'new' } }); + turns.observeSpeech({ + type: 'response.done', + response: { id: 'new', status: 'completed', output: [] }, + }); + assert.equal(observer.snapshot().known, false); + observer.destroy(); +}); + +test('Gemini restores each retired audio immediately across repeated same-mount restarts', () => { + const originals = new Map(); + const controller = { + input: {}, + createAudio() { + const audio = { pending: 0, play() {}, clearPlayback() {}, close() {} }; + originals.set(audio, { ...audio }); + return audio; + }, + handleMessage() {}, + }; + const observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'gemini', + }); + const assertRestored = (audio) => { + for (const key of ['play', 'clearPlayback', 'close']) + assert.equal(audio[key], originals.get(audio)[key]); + }; + for (let i = 0; i < 5; i++) { + observer.handle({ type: 'state', state: 'connecting' }); + controller.audio = controller.createAudio({}); + const audio = controller.audio; + observer.handle({ type: 'state', state: 'listening' }); + assert.notEqual(audio.close, originals.get(audio).close); + audio.close(); + assertRestored(audio); + controller.audio = null; + observer.handle({ type: 'state', state: 'idle' }); + } + controller.audio = controller.createAudio({}); + const replaced = controller.audio; + controller.audio = controller.createAudio({}); + assertRestored(replaced); + const current = controller.audio; + const replacement = () => {}; + current.play = replacement; + observer.handle({ type: 'state', state: 'error' }); + assert.equal(current.play, replacement); + assert.equal(current.close, originals.get(current).close); + observer.destroy(); +}); diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 98b03781f..ce20e1fe2 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -46,6 +46,17 @@ export class RealtimeCost { */ syncCostUi() { const state = this.costTracker.state(); + const syncWarning = () => { + const warning = this.ui?.costWarning; + const value = this.ui?.costValue; + if (!warning || !value) return; + warning.hidden = !['warn', 'cap', 'unknown'].includes( + value.dataset.level, + ); + warning.textContent = warning.hidden ? '' : value.textContent; + warning.title = value.title; + warning.dataset.level = value.dataset.level; + }; const pendingTier = resolveVoiceModel(this.voiceTier).tier; const isMini = pendingTier === 'mini'; if (this.ui?.tierButton) { @@ -91,6 +102,7 @@ export class RealtimeCost { 'Usage is incomplete because a response was still in flight when the session ended.', ) : ''); + syncWarning(); return; } this.ui.costValue.hidden = false; @@ -114,6 +126,7 @@ export class RealtimeCost { 'Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.', ) : ''); + syncWarning(); } } diff --git a/src/voice/realtimeCostPanel.test.mjs b/src/voice/realtimeCostPanel.test.mjs new file mode 100644 index 000000000..07622f84d --- /dev/null +++ b/src/voice/realtimeCostPanel.test.mjs @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVoiceControl } from './control.js'; +import { RealtimeCost } from './realtimeCost.js'; +import { createVoiceCostTracker, resolveVoiceModel } from './voiceCost.js'; +import { installPanelDom } from './panel/testDom.mjs'; + +function harness(t) { + installPanelDom(t); + const ui = createVoiceControl({ panel: true }); + const stops = []; + const statuses = []; + const cost = new RealtimeCost({ + readUi: () => ui, + readStatus: () => 'listening', + operations: { + isActive: () => true, + stop: (options) => stops.push(options), + setStatus: (...args) => statuses.push(args), + debugLog() {}, + }, + }); + cost.cloudVoiceAuth = 'api-key'; + cost.costTracker = createVoiceCostTracker({ + tier: 'standard', + limits: { warnUsd: 2, capUsd: 5 }, + }); + const recordThreeDollars = () => { + const tokens = Math.ceil( + (3 * 1_000_000) / resolveVoiceModel('standard').rates.textOutput, + ); + const state = cost.costTracker.record({ + output_tokens: tokens, + output_token_details: { text_tokens: tokens }, + }); + cost.applyCost(state); + }; + t.mock.method(console, 'warn', () => {}); + return { cost, ui, stops, statuses, recordThreeDollars }; +} + +test('soft cost warning is visible outside closed Settings, while ordinary spend stays inside', (t) => { + const { cost, ui, recordThreeDollars } = harness(t); + cost.syncCostUi(); + assert.equal(ui.voicePanel.hidden, true); + assert.equal(ui.costValue.checkVisibility(), false); + assert.equal(ui.costWarning.hidden, true); + assert.equal(ui.voicePanel.contains(ui.costWarning), false); + assert.equal(ui.root.querySelectorAll('#gev-voice-cost-value').length, 1); + + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'warn'); + assert.equal(ui.costWarning.textContent, ui.costValue.textContent); + assert.equal(ui.costWarning.title, ui.costValue.title); + assert.match(ui.costWarning.title, /Warns at/); + assert.equal( + ui.voicePanel.hidden, + true, + 'warning never forces Settings open', + ); + + cost.prepareSession(); + cost.syncCostUi(); + assert.equal( + ui.costWarning.hidden, + true, + 'new session clears the old warning', + ); + assert.equal(ui.costWarning.textContent, ''); +}); + +test('closed Settings cannot hide the hard cap or suppress its ordinary stop and status', (t) => { + const { ui, stops, statuses, recordThreeDollars } = harness(t); + recordThreeDollars(); + assert.equal(stops.length, 0); + recordThreeDollars(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'cap'); + assert.deepEqual(stops, [{ preserveStatus: true }]); + assert.equal(statuses.at(-1)[0], 'idle'); + assert.match(statuses.at(-1)[1], /Session ended — cost cap/); +}); + +test('unknown OAuth cost remains visible outside Settings and clears on a fresh API meter', (t) => { + const { cost, ui } = harness(t); + cost.sessionCloudVoiceAuth = 'oauth'; + cost.syncCostUi(); + assert.equal(ui.costWarning.checkVisibility(), true); + assert.equal(ui.costWarning.dataset.level, 'unknown'); + assert.equal(ui.costWarning.textContent, 'COST UNKNOWN'); + assert.match( + ui.costWarning.title, + /USD cost and the API spend cap are unavailable/, + ); + assert.equal(ui.voicePanel.hidden, true); + cost.sessionCloudVoiceAuth = 'api-key'; + cost.syncCostUi(); + assert.equal(ui.costWarning.hidden, true); +}); From ddbcd2053b360a472e522b8c95304b05df165307 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 08:47:54 -0700 Subject: [PATCH 115/131] fix(voice): preserve idle evidence through recoverable response errors --- src/voice/panel/nativeActivity.js | 16 +++- src/voice/panel/nativeActivity.test.mjs | 98 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/voice/panel/nativeActivity.js b/src/voice/panel/nativeActivity.js index c019f76c8..ce9426cdc 100644 --- a/src/voice/panel/nativeActivity.js +++ b/src/voice/panel/nativeActivity.js @@ -16,7 +16,8 @@ export function observeNativeActivity({ : null; let dead = false, connected = false, - inputBusy = false; + inputBusy = false, + recoverableError = false; function cleanNativeOutput() { if (nativeProvider === 'gemini') return true; if (nativeProvider !== 'openai') return false; @@ -122,13 +123,22 @@ export function observeNativeActivity({ if (event?.type === 'state') { const state = event.state; const wasConnected = connected; + const liveError = + state === 'error' && + nativeProvider === 'openai' && + controller.dc?.readyState === 'open'; + const restartingAfterError = state === 'connecting' && recoverableError; + recoverableError = liveError; connected = !['idle', 'connecting', 'error'].includes(state); // Connecting has multiple native phases around audio allocation. Retire // a previous connected generation once, not each phase of this start. + // A failed reply can briefly report error on a live OpenAI transport. + // Preserve its output evidence through recovery to listening; a later + // reconnect still retires the failed connection's generation. if ( state === 'idle' || - state === 'error' || - (state === 'connecting' && wasConnected) + (state === 'error' && !liveError) || + (state === 'connecting' && (wasConnected || restartingAfterError)) ) { version++; retireAudio(); diff --git a/src/voice/panel/nativeActivity.test.mjs b/src/voice/panel/nativeActivity.test.mjs index f1ac6cd5f..81709ce53 100644 --- a/src/voice/panel/nativeActivity.test.mjs +++ b/src/voice/panel/nativeActivity.test.mjs @@ -670,3 +670,101 @@ test('Gemini restores each retired audio immediately across repeated same-mount assert.equal(current.close, originals.get(current).close); observer.destroy(); }); + +test('actual OpenAI failed-response error/listening recovery preserves only proven no-audio knowledge', async (t) => { + const { GevRealtimeController } = await import('../realtimeController.js'); + t.mock.method(console, 'error', () => {}); + for (const [output, known] of [ + [[], true], + [[{ type: 'message', content: [{ type: 'audio' }] }], false], + [undefined, false], + ]) { + let observer, idle, timer; + const states = []; + let expired = 0; + const controller = new GevRealtimeController({ + runner: async () => ({ ok: true }), + backend: {}, + debugSink: null, + ui: { + root: { + dataset: {}, + classList: { remove() {} }, + querySelectorAll: () => [], + }, + status: {}, + detail: {}, + }, + onSessionEvent(event) { + if (event.type === 'state') states.push(event.state); + observer?.handle(event); + }, + }); + observer = observeNativeActivity({ + adapter: { controller }, + nativeProvider: 'openai', + onChange: () => idle?.update(), + }); + idle = createVoiceInactivity({ + readActivity: observer.snapshot, + readPending: () => false, + setTimer(callback) { + timer = callback; + return 1; + }, + clearTimer() { + timer = null; + }, + onExpire() { + expired++; + }, + }); + try { + controller.dc = { + readyState: 'open', + send() {}, + close() { + this.readyState = 'closed'; + }, + }; + controller.setStatus('listening', 'Ready'); + const event = (payload) => + controller.handleRealtimeEvent({ data: JSON.stringify(payload) }); + await event({ + type: 'response.created', + response: { id: 'failed-reply' }, + }); + await event({ + type: 'response.done', + response: { + id: 'failed-reply', + status: 'failed', + output, + status_details: { error: { message: 'Synthetic failed reply' } }, + }, + }); + assert.deepEqual(states.slice(-2), ['error', 'listening']); + assert.equal(controller.dc.readyState, 'open'); + assert.equal(observer.snapshot().known, known); + assert.equal(idle.snapshot().eligible, known); + if (known) { + timer(); + assert.equal(expired, 1); + } else assert.equal(timer, null); + // Fatal teardown can preserve the error status; the next connection must + // still retire the old generation rather than inherit unresolved output. + controller.setStatus('error', 'Transport failed'); + controller.stop({ preserveStatus: true }); + controller.setStatus('connecting', 'Reconnecting'); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + controller.stop(); + assert.equal(observer.snapshot().connected, false); + assert.equal(observer.snapshot().known, true); + } finally { + idle.destroy(); + observer.destroy(); + controller.stop(); + } + } +}); From b7ece1d4d13b551f635a9a5653380b4f40f26d1f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 13:56:19 -0700 Subject: [PATCH 116/131] fix(share): restore a share link opened in an already-open tab (#1416) * fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416 --- changelog.d/1416-share-hash-navigation.md | 4 + src/data/layerStateCoordinator.js | 48 ++- src/locationReadouts.test.mjs | 6 +- src/sharelink.js | 79 +++- src/sharelink.navigation.test.mjs | 425 ++++++++++++++++++++++ src/ui/navigationController.js | 4 +- src/ui/shareRestoration.js | 165 +++++++-- src/ui/visualSettings.js | 15 +- 8 files changed, 688 insertions(+), 58 deletions(-) create mode 100644 changelog.d/1416-share-hash-navigation.md create mode 100644 src/sharelink.navigation.test.mjs diff --git a/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md new file mode 100644 index 000000000..0effac1cb --- /dev/null +++ b/changelog.d/1416-share-hash-navigation.md @@ -0,0 +1,4 @@ +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 687b87dfb..ef3a4ea4a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -148,6 +148,36 @@ export class LayerStateCoordinator { return this.restorePromise; } + /** + * Replace the running layer state with a share opened later in this tab + * (hash navigation). Every in-flight restore and pending shared Follow is + * superseded; the new state restores through the same passive share origin + * as start-up, so recipient-local preferences are never written. A share + * without a layer payload restores the defaults, as a reload of it would. + */ + applyShareLayerState({ + shareLayerState = null, + shareCreatedAtMs = null, + } = {}) { + if (this._destroyed) return Promise.resolve([]); + for (const controller of this._restoreControllers.values()) + controller.abort('superseded-by-share-navigation'); + this._revokePendingTrackingWatch('superseded-by-share-navigation'); + const selected = shareLayerState + ? normalizeLayerState(shareLayerState) + : null; + this._source = selected ? 'share' : 'legacy-share'; + this._shareCreatedAtMs = + selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null; + this._durableState = selected || createDefaultLayerState(); + this.shareLinkManager?.onLayerStateChange?.(); + this._notifyDurableState(); + this.restorePromise = this._restoreSelectedState( + LAYER_RESTORE_ORIGINS.share, + ); + return this.restorePromise; + } + get source() { return this._source; } @@ -312,15 +342,19 @@ export class LayerStateCoordinator { } async _restoreSelectedState(origin) { + // One controller set per run: a superseded run must not clear the newer + // run's controllers when it settles. + const controllers = new Map(); for (const entry of LAYER_STATE_REGISTRY) { - this._restoreControllers.set(entry.id, new AbortController()); + controllers.set(entry.id, new AbortController()); } + this._restoreControllers = controllers; try { await this._waitForRestoreGate(); const enabled = new Set(this._durableState.enabledLayerIds); const settled = await Promise.allSettled( LAYER_STATE_REGISTRY.map(async (entry) => { - const controller = this._restoreControllers.get(entry.id); + const controller = controllers.get(entry.id); const targetEnabled = enabled.has(entry.id); const options = layerOptionsForRestore(this._durableState, entry.id); if (origin === LAYER_RESTORE_ORIGINS.share && options) { @@ -370,7 +404,7 @@ export class LayerStateCoordinator { })); }), ); - this.lastRestoreResults = settled.map((result, index) => { + const results = settled.map((result, index) => { if (result.status === 'fulfilled') return result.value; const entry = LAYER_STATE_REGISTRY[index]; return { @@ -387,9 +421,13 @@ export class LayerStateCoordinator { succeeded: false, }; }); - return this.lastRestoreResults.map((result) => ({ ...result })); + // A run superseded by a newer share keeps the newer run's results. + if (this._restoreControllers === controllers) + this.lastRestoreResults = results; + return results.map((result) => ({ ...result })); } finally { - this._restoreControllers.clear(); + if (this._restoreControllers === controllers) + this._restoreControllers.clear(); this._notifyDurableState(); } } diff --git a/src/locationReadouts.test.mjs b/src/locationReadouts.test.mjs index 9d38592e3..6c63406ee 100644 --- a/src/locationReadouts.test.mjs +++ b/src/locationReadouts.test.mjs @@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => { /stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/, ); - const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {'); + const reassert = ui.indexOf( + " _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {", + ); assert.ok(reassert > 0, '_reassertNavigationHandoff is missing'); assert.match( ui.slice(reassert, reassert + 900), - /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/, + /release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/, ); // …and the policy only reaches `release` after its authority checks pass. diff --git a/src/sharelink.js b/src/sharelink.js index c184a59f5..9668fa979 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -122,6 +122,13 @@ export class ShareLinkManager { this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; + // Each incoming share (start-up or a later hash navigation) gets a token; + // only the newest one may release URL-write suppression. + this._restoreToken = 0; + // The hash this page last read or wrote. A location change back to it is + // the app's own state, never an incoming share. + this._knownHash = null; + this._unwatchNavigation = null; this._restoreAuthority = { visual: 0, map: 0, @@ -152,6 +159,15 @@ export class ShareLinkManager { */ parseInitialHash() { const hash = window.location.hash.slice(1); + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return null; + this._beginIncomingRestore(state); + return state; + } + + /** Parse one hash without side effects. Returns null unless it is a share. */ + _parseShareHash(hash) { if (!hash) return null; const params = new URLSearchParams(hash); @@ -242,15 +258,55 @@ export class ShareLinkManager { panelState: decodePanelStateParams(params), sharedAtMs: decodeShareCreatedAtMs(params), }; + return state; + } + + /** Reserve restore lanes and hold URL writes for one incoming share. */ + _beginIncomingRestore(state) { state.restoreAuthority = { visual: this._restoreAuthority.visual, map: this._restoreAuthority.map, panels: new Map(this._restoreAuthority.panels), }; - - // Hold URL writes until the complete incoming state has been restored. + state.restoreToken = ++this._restoreToken; + // Hold URL writes until the complete incoming state has been restored. A + // debounced write of the previous view must not land over the new link. this._initialRestorePending = true; - return state; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Restore a share link opened in this tab after start-up: a pasted address, + * a followed link, or Back/Forward. The app's own replaceState writes never + * fire these events, and a hash equal to the last one read or written is + * ignored anyway, so only a genuinely different incoming share restores. + */ + watchNavigation(onNavigate) { + this._unwatchNavigation?.(); + if (this._destroyed || typeof onNavigate !== 'function') return; + const target = globalThis.window; + if (typeof target?.addEventListener !== 'function') return; + const handler = () => this._handleLocationChange(onNavigate); + target.addEventListener('hashchange', handler); + target.addEventListener('popstate', handler); + this._unwatchNavigation = () => { + target.removeEventListener('hashchange', handler); + target.removeEventListener('popstate', handler); + this._unwatchNavigation = null; + }; + } + + _handleLocationChange(onNavigate) { + if (this._destroyed) return; + const hash = window.location.hash.slice(1); + // Back/Forward fires both popstate and hashchange for one entry. + if (hash === this._knownHash) return; + this._knownHash = hash; + const state = this._parseShareHash(hash); + if (!state) return; + this._beginIncomingRestore(state); + onNavigate(state); } /** @@ -374,9 +430,13 @@ export class ShareLinkManager { }; } - /** Release initial hash suppression only after every restore owner settles. */ - completeInitialRestore() { + /** + * Release hash suppression only after every restore owner settles. A token + * from a superseded share restore leaves the newer one holding the URL. + */ + completeInitialRestore(restoreToken = this._restoreToken) { if (!this._initialRestorePending) return; + if (restoreToken !== this._restoreToken) return; this._initialRestorePending = false; this._scheduleUpdate(); } @@ -528,11 +588,9 @@ export class ShareLinkManager { if (!params) return; // The hash belongs to this page's address, not the document base the // app may load under. - history.replaceState( - null, - '', - new URL(`#${params.toString()}`, window.location.href).href, - ); + const url = new URL(`#${params.toString()}`, window.location.href); + this._knownHash = url.hash.slice(1); + history.replaceState(null, '', url.href); } /** Build a deterministic snapshot without mutating history. */ @@ -615,6 +673,7 @@ export class ShareLinkManager { this._debounceTimer = null; this._removeCameraChanged?.(); this._removeCameraChanged = null; + this._unwatchNavigation?.(); this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs new file mode 100644 index 000000000..e4e70b629 --- /dev/null +++ b/src/sharelink.navigation.test.mjs @@ -0,0 +1,425 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from './sharelink.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { DataLayerManager } from './data/manager.js'; +import { + LAYER_STATE_REGISTRY, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + decodeLayerStateParams, +} from './data/layerState.js'; +import { LayerStateCoordinator } from './data/layerStateCoordinator.js'; + +const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e'; +const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f'; + +/** + * A window whose address behaves like a tab's: `navigate` is a hash-only + * navigation (new history entry plus hashchange), `back`/`forward` traverse + * entries (popstate plus hashchange), and `history.replaceState` rewrites the + * current entry without firing anything. + */ +function installTab(t, initialHash = '') { + const prior = { + window: globalThis.window, + history: globalThis.history, + }; + const target = new EventTarget(); + const location = new URL( + `http://localhost/${initialHash ? `#${initialHash}` : ''}`, + ); + const entries = [location.hash]; + let index = 0; + const listeners = { hashchange: 0, popstate: 0 }; + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + target.addEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] += 1; + add(type, handler, options); + }; + target.removeEventListener = (type, handler, options) => { + if (type in listeners) listeners[type] -= 1; + remove(type, handler, options); + }; + target.location = location; + globalThis.window = target; + globalThis.history = { + replaceState(_state, _title, next) { + location.hash = new URL(next, location.href).hash; + entries[index] = location.hash; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + const go = (nextIndex) => { + index = nextIndex; + location.hash = entries[index]; + target.dispatchEvent(new Event('popstate')); + target.dispatchEvent(new Event('hashchange')); + }; + return { + listeners, + navigate(hash) { + entries.splice(index + 1); + entries.push(`#${hash}`); + index = entries.length - 1; + location.hash = hash; + target.dispatchEvent(new Event('hashchange')); + }, + back: () => go(index - 1), + forward: () => go(index + 1), + }; +} + +/** Queue timers instead of running them, so delayed work runs only on demand. */ +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return timers; +} + +function fakeViewer() { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; +} + +test('a hash navigation to a different share restores it and holds URL writes', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + const initial = manager.parseInitialHash(); + manager.completeInitialRestore(initial.restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + assert.equal(navigated[0].style, 'normal'); + assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']); + assert.equal(manager._initialRestorePending, true); + // Nothing from the previous view may overwrite the incoming link. + manager._updateHash(); + assert.equal(window.location.hash, `#${SHARE_B}`); + manager.completeInitialRestore(navigated[0].restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test("the app's own hash writes never trigger a restore", (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + manager._updateHash(); + assert.notEqual(window.location.hash, `#${SHARE_A}`); + // replaceState fires nothing, but a stray event for our own hash is ignored. + window.dispatchEvent(new Event('hashchange')); + window.dispatchEvent(new Event('popstate')); + assert.equal(restores, 0); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The app rewrites entry 0 with its own state; Back must return to that. + manager._updateHash(); + const ownHash = window.location.hash.slice(1); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate('not-a-share'); + assert.equal(navigated.length, 0); + assert.equal(manager._initialRestorePending, false); + + tab.navigate(SHARE_B); + assert.equal(navigated.length, 1); + manager.completeInitialRestore(navigated[0].restoreToken); + + tab.back(); // not-a-share + tab.back(); // the app's own rewritten first entry + assert.equal(navigated.length, 2, 'popstate and hashchange restore once'); + assert.equal(window.location.hash.slice(1), ownHash); + assert.equal(navigated[1].lat, 0); + manager.completeInitialRestore(navigated[1].restoreToken); + + tab.forward(); + tab.forward(); + assert.equal(navigated.length, 3); + assert.equal(navigated[2].lat, 48.8584); + manager.destroy(); +}); + +test('a newer navigation supersedes an older restore and its pending write', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + assert.equal(timers.size, 1, 'releasing suppression schedules a write'); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + + tab.navigate(SHARE_B); + assert.equal( + timers.size, + 0, + 'the pending write of the old view is cancelled', + ); + tab.navigate(SHARE_A); + const [older, newer] = navigated; + assert.ok(newer.restoreToken > older.restoreToken); + manager.completeInitialRestore(older.restoreToken); + assert.equal( + manager._initialRestorePending, + true, + 'a superseded restore cannot release writes', + ); + manager.completeInitialRestore(newer.restoreToken); + assert.equal(manager._initialRestorePending, false); + manager.destroy(); +}); + +test('destroy removes the navigation listeners', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + let restores = 0; + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 }); + manager.watchNavigation(() => { + restores += 1; + }); + assert.deepEqual( + tab.listeners, + { hashchange: 1, popstate: 1 }, + 'rewatching replaces', + ); + manager.destroy(); + assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 }); + tab.navigate(SHARE_B); + assert.equal(restores, 0); +}); + +function deferred() { + let resolve; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */ +function restorationHarness(t, initialHash, navigationOverrides = {}) { + const tab = installTab(t, initialHash); + const timers = installManualTimers(t); + + const restores = []; + let navigationGeneration = 0; + const manager = new ShareLinkManager(fakeViewer(), { + onRestore: (state) => { + const gate = deferred(); + restores.push({ state, gate }); + return gate.promise; + }, + isNavigationCurrent: () => false, + }); + const layerCalls = []; + const handoffs = []; + const owner = new ShareRestoration({ + viewer: { canvas: new EventTarget() }, + navigation: { + _beginDeferredNavigation: () => ++navigationGeneration, + _reassertNavigationHandoff: (generation, options) => { + handoffs.push({ generation, options }); + return true; + }, + _stampNavigation() {}, + ...navigationOverrides, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + }); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState(options) { + layerCalls.push(options); + return Promise.resolve([]); + }, + getDurableState: () => null, + cancelPendingShareTracking: () => false, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { tab, timers, manager, owner, restores, layerCalls, handoffs }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => { + const { tab, timers, manager, owner, restores, layerCalls } = + restorationHarness(t, SHARE_A); + assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight'); + + tab.navigate(SHARE_B); + assert.equal(timers.size, 0, 'the delayed start-up restore never runs'); + assert.equal((await owner.initialRestorePromise).status, 'superseded'); + assert.equal(restores.length, 1); + assert.equal(restores[0].state.style, 'normal'); + assert.equal(restores[0].state.mapStack, 'photoreal'); + assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']); + + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); +}); + +test('a navigated share releases the old Follow passively, never as a saved preference', (t) => { + const { tab, handoffs } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + assert.equal(handoffs.length, 1); + assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' }); +}); + +test('a navigated share that fails to start never freezes the address bar', async (t) => { + const settled = []; + let calls = 0; + const { tab, manager } = restorationHarness(t, SHARE_A, { + // Start-up reserves the camera; the navigated share then fails to. + _beginDeferredNavigation: () => { + calls += 1; + if (calls > 1) throw new Error('navigation unavailable'); + return calls; + }, + }); + window.addEventListener('gev:share-navigation-restore-settled', (event) => + settled.push(event.detail), + ); + tab.navigate(SHARE_B); + assert.equal(manager._initialRestorePending, false); + assert.equal(settled[0]?.status, 'failed'); +}); + +test('a second navigation during a restore supersedes it end to end', async (t) => { + const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A); + tab.navigate(SHARE_B); + tab.navigate(SHARE_A); + assert.equal(restores.length, 2); + assert.equal(layerCalls.length, 2); + assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [ + 'earthquakes', + ]); + + // The newer restore finishes first; the older one settling late must not + // release or reopen anything. + restores[1].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, false); + manager._initialRestorePending = true; // observe any late release + restores[0].gate.resolve(); + await flush(); + assert.equal(manager._initialRestorePending, true); +}); + +test('the layer coordinator replaces in-flight share layers with the navigated share', async () => { + const enableGates = new Map(); + const manager = new DataLayerManager({}); + for (const id of REGISTERED_LAYER_IDS) { + manager.register({ + id, + name: id, + icon: '', + source: 'test', + async init() { + return true; + }, + async enable() { + const gate = enableGates.get(id); + if (gate) await gate.promise; + return true; + }, + async update() { + return true; + }, + async disable() { + return true; + }, + }); + } + manager.finalizeRegistrations(LAYER_STATE_REGISTRY); + const quakeGate = deferred(); + enableGates.set('earthquakes', quakeGate); + const writes = []; + const storage = { + getItem: () => null, + setItem: (...args) => writes.push(args), + }; + const sink = { + provider: null, + setLayerStateProvider(provider) { + this.provider = provider; + }, + onLayerStateChange() {}, + }; + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + const first = coordinator.start({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)), + allowLocalState: false, + }); + const second = coordinator.applyShareLayerState({ + shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)), + }); + quakeGate.resolve(); + await Promise.all([first, second]); + const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true; + assert.equal(enabled('earthquakes'), false); + assert.equal(enabled('flights'), true); + assert.deepEqual(sink.provider().enabledLayerIds, ['flights']); + assert.equal(coordinator.source, 'share'); + assert.deepEqual( + writes, + [], + 'a passive share restore never writes local preferences', + ); + + // A share without a layer payload restores the defaults, as a reload does. + await coordinator.applyShareLayerState({ shareLayerState: null }); + assert.equal(coordinator.source, 'legacy-share'); + assert.deepEqual( + sink.provider().enabledLayerIds, + createDefaultLayerState().enabledLayerIds, + ); + assert.equal( + enabled('flights'), + createDefaultLayerState().enabledLayerIds.includes('flights'), + ); + coordinator.destroy(); +}); diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 437b643f3..e2a135142 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -241,7 +241,7 @@ export class NavigationController { }); } - _reassertNavigationHandoff(generation) { + _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) { return reassertNavigationHandoff({ generation, currentGeneration: this._navigationGeneration, @@ -253,7 +253,7 @@ export class NavigationController { // was refused by the cockpit leaves the old readout standing. release: () => { this.clearLocation(); - return this._releaseFollowCamera(); + return this._releaseFollowCamera({ trackingOrigin }); }, }); } diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 52c48c95b..7fd52d917 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -80,6 +80,10 @@ export class ShareRestoration { this._initialShareRestoreTimeout = null; this._layerStateCoordinator = null; this._layerStateRestorePromise = null; + // Bumped by every share opened after start-up; an older restore that + // settles later must neither apply follow-up work nor release URL writes. + this._shareRestoreGeneration = 0; + this._navigatedShareRestoreActive = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -108,42 +112,12 @@ export class ShareRestoration { const applyCamera = Number.isInteger(generation) && this.navigation._reassertNavigationHandoff(generation); - void (async () => { - try { - const share = await this.shareLinkManager.applyState(savedState, { - applyCamera, - navigationToken: generation, - }); - const layers = await (this._layerStateRestorePromise || - Promise.resolve([])); - const tracking = - share.camera === 'applied' - ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() - : { - status: 'superseded', - cleared: - this._layerStateCoordinator?.cancelPendingShareTracking?.( - 'shared-camera-superseded', - { clearSelection: true }, - ) === true, - }; - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'settled', - share, - layers, - tracking, - }); - } catch (error) { - this.shareLinkManager.completeInitialRestore(); - this._settleInitialShareRestore({ - status: 'failed', - error: String(error?.message || error), - share: null, - layers: [], - }); - } - })(); + void this._runShareRestore(savedState, { + applyCamera, + navigationToken: generation, + layersPromise: this._layerStateRestorePromise, + settle: (result) => this._settleInitialShareRestore(result), + }); }, 1500); } else { this.syncShareState(); @@ -155,7 +129,8 @@ export class ShareRestoration { if ( this._disposed || !this._hasShareState || - !this._resolveInitialShareRestore + (!this._resolveInitialShareRestore && + !this._navigatedShareRestoreActive) ) return; stampInitialShareGesture((options) => @@ -176,6 +151,122 @@ export class ShareRestoration { passive: true, }, ); + // A different share opened in this tab later (pasted address, link, + // Back/Forward) runs the same restore as start-up. + this.shareLinkManager?.watchNavigation?.((state) => + this._restoreNavigatedShare(state), + ); + } + + /** + * Apply one incoming share: camera, visual, map and panels, then layers and + * any shared Follow. Only the newest share may finish: a superseded run + * stops before follow-up work and leaves URL writes suppressed for the + * newer one. + */ + async _runShareRestore( + state, + { applyCamera, navigationToken, layersPromise, settle }, + ) { + const generation = this._shareRestoreGeneration; + const current = () => + !this._disposed && generation === this._shareRestoreGeneration; + try { + const share = await this.shareLinkManager.applyState(state, { + applyCamera, + navigationToken, + }); + const layers = await (layersPromise || Promise.resolve([])); + if (!current()) return; + const tracking = + share.camera === 'applied' + ? await this._layerStateCoordinator?.restoreShareTrackingSelection?.() + : { + status: 'superseded', + cleared: + this._layerStateCoordinator?.cancelPendingShareTracking?.( + 'shared-camera-superseded', + { clearSelection: true }, + ) === true, + }; + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ status: 'settled', share, layers, tracking }); + } catch (error) { + if (!current()) return; + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + /** Restore a share opened in this tab after start-up, superseding any other. */ + _restoreNavigatedShare(state) { + if (this._disposed || !state) return; + this._shareRestoreGeneration += 1; + const settle = (result) => { + this._navigatedShareRestoreActive = false; + window.dispatchEvent( + new CustomEvent('gev:share-navigation-restore-settled', { + detail: result, + }), + ); + }; + try { + return this._beginNavigatedShareRestore(state, settle); + } catch (error) { + // Never leave the address bar frozen on a restore that did not start. + this.shareLinkManager.completeInitialRestore(state.restoreToken); + settle({ + status: 'failed', + error: String(error?.message || error), + share: null, + layers: [], + }); + } + } + + _beginNavigatedShareRestore(state, settle) { + // A start-up restore still waiting for its delayed flight never runs. + clearTimeout(this._initialShareRestoreTimeout); + this._initialShareRestoreTimeout = null; + this._settleInitialShareRestore({ + status: 'superseded', + share: null, + layers: [], + }); + this._initialShareState = state; + this._initialShareSelectionSuperseded = false; + this._hasShareState = true; + this._navigatedShareRestoreActive = true; + // A new deferred navigation generation retires any older share flight. + const navigationToken = this.navigation._beginDeferredNavigation( + 'shared view', + { cancelPendingSelection: false }, + ); + // Releasing the previous Follow is part of the passive share restore and + // must not be saved as the recipient's own layer preference. + const applyCamera = + Number.isInteger(navigationToken) && + this.navigation._reassertNavigationHandoff(navigationToken, { + trackingOrigin: 'programmatic', + }); + const layersPromise = this._layerStateCoordinator + ? this._layerStateCoordinator.applyShareLayerState({ + shareLayerState: state.layerState || null, + shareCreatedAtMs: state.sharedAtMs ?? null, + }) + : null; + return this._runShareRestore(state, { + applyCamera, + navigationToken, + layersPromise, + settle, + }); } connect(dataManager) { this._dataManager = dataManager; diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 1d5f16a28..a6f7b5674 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -75,13 +75,24 @@ export class VisualSettings { panelState, styleParams, } = state || {}; - // Ignore the retired 'ai-edit' style from older share links. - if (style && style !== 'normal' && style !== 'ai-edit') { + // Ignore the retired 'ai-edit' style from older share links. Normal is + // the start-up style, but a share opened later in the tab must still leave + // a filtered style for it. + if ( + style && + style !== 'ai-edit' && + (style !== 'normal' || this.activeStyle !== 'normal') + ) { + const alreadyActive = style === this.activeStyle; this.setStyle(style, { applyPreset: true, revealParameters: false, restore: true, }); + // setStyle leaves an already-active style untouched; the incoming share + // still starts from that preset before its own parameters apply. + if (alreadyActive && style !== 'normal') + this._applyStylePresetDefaults(style); } if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) { for (const [uniformName, uniformValue] of Object.entries(styleParams)) { From fefd5cdcf14b80cfb8ac3c7c60f9a489aa2b3c8e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 14:07:58 -0700 Subject: [PATCH 117/131] fix(layers): make Clear All clear the saved and shared layer selection (#1417) * fix(layers): make Clear All clear the saved and shared layer selection After Clear All every layer control was off, but the share URL could keep listing layers (for example l=e.f) and a reload switched them back on. The URL and local preferences are written from the committed layer state, which only explicit visibility changes update. Clear All issued an explicit OFF only for layers it found on at the start of the batch and whose turn was not superseded. A layer already off for a passive reason (isolated by Contacts or another context mode, or released by Contacts' own teardown while the batch ran) never received one, so it stayed in the committed state. Clear All also discards the context snapshot that would have restored those layers, leaving the screen and the URL out of step. Clear All now publishes the set of layers still on once its batch settles, and the layer-state coordinator commits that selection: every saved layer not in it is dropped and its passive restore is cancelled, while a layer a newer explicit request turned back on is kept. A context restore interrupted by Clear All no longer brings its snapshot back after Contacts settles. * docs: changelog for #1417 --- changelog.d/1417-clear-all-saved-layers.md | 3 + src/contextSessionOrdering.test.mjs | 9 ++ src/data/layerState.test.mjs | 132 +++++++++++++++++++++ src/data/layerStateCoordinator.js | 24 ++++ src/data/lifecycle.js | 9 ++ src/ui/contextSession.js | 3 + 6 files changed, 180 insertions(+) create mode 100644 changelog.d/1417-clear-all-saved-layers.md diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md new file mode 100644 index 000000000..8cffbd366 --- /dev/null +++ b/changelog.d/1417-clear-all-saved-layers.md @@ -0,0 +1,3 @@ +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). diff --git a/src/contextSessionOrdering.test.mjs b/src/contextSessionOrdering.test.mjs index 2f9bfeae0..d7127e8d5 100644 --- a/src/contextSessionOrdering.test.mjs +++ b/src/contextSessionOrdering.test.mjs @@ -94,6 +94,15 @@ test('context restore settles the Contacts coordinator before dependency fanout' assert.match(restore, /excludeLayerIds: settleContactsCoordinator[\s\S]*?contactsCoordinatorId/); }); +test('Clear All during the Contacts settle stops the snapshot coming back', () => { + const restore = _restoreContextSession.toString(); + const coordinatorOff = restore.indexOf('const coordinatorSettled = await this._dataManager.setEnabled('); + const cancelled = restore.indexOf('if (restoreState.cancelled) return;', coordinatorOff); + const fanout = restore.indexOf('await this._dataManager.restoreEnabledLayerIds(', coordinatorOff); + assert.ok(cancelled > coordinatorOff, 'the cancellation is checked after Contacts settles'); + assert.ok(fanout > cancelled, 'and before the snapshot fans out'); +}); + test('context restore preserves its exact pending target after a failed transition', () => { const restore = _restoreContextSession.toString(); assert.match(restore, /const replayError = await settleContextIntentReplay\(\{/); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index ba5496227..d05fb9a95 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2858,3 +2858,135 @@ test('explicit provider switches reach durable state without adopting unrelated coordinator.destroy(); } }); + +function enabledLayerParam(sink) { + return new URLSearchParams(encode(sink.provider())).get('l'); +} + +test('Clear All drops a saved layer a context mode had already switched off', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + // Context isolation is a temporary, passive takeover: the saved choice stays. + await manager.setEnabled('earthquakes', false, { origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + 'flights', + ]); + + const updatesBefore = sink.updates; + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(result.targetIds, ['flights']); + assert.ok(sink.updates > updatesBefore, 'the URL is told to refresh'); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), '', 'the URL carries no layers'); + assert.deepEqual( + parseStoredLayerState(storage.getItem(LAYER_STATE_STORAGE_KEY)) + .enabledLayerIds, + [], + 'a reload restores nothing', + ); + coordinator.destroy(); +}); + +test('Clear All drops a saved layer a dependency release superseded mid-batch', async () => { + let manager; + manager = productionManager({ + // Contacts-style owner: its own teardown releases a borrowed dependency + // without awaiting it, which supersedes Clear All's turn for that layer. + 'military-awareness': { + disable() { + void manager.setEnabled('flights', false, { origin: 'programmatic' }); + return true; + }, + }, + }); + const storage = memoryStorage(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { storage }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('military-awareness', true, { origin: 'user' }); + + const result = await manager.clearSelectedLayers({ origin: 'user' }); + assert.equal( + result.items.find((item) => item.id === 'flights')?.superseded, + true, + 'the fixture reproduces the superseded flights turn', + ); + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); + +test('Clear All keeps a layer a newer explicit request turned back on', async () => { + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + }); + await coordinator.start(); + await manager.setEnabled('earthquakes', true, { origin: 'user' }); + await manager.setEnabled('flights', true, { origin: 'user' }); + const clearing = manager.clearSelectedLayers({ origin: 'user' }); + // A direct request issued during the batch owns its layer. + const reenable = manager.setEnabled('earthquakes', true, { origin: 'user' }); + await Promise.all([clearing, reenable]); + assert.deepEqual([...manager.getEnabledLayerIds()], ['earthquakes']); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [ + 'earthquakes', + ]); + assert.equal(enabledLayerParam(sink), 'e'); + coordinator.destroy(); +}); + +test('a passive clear never rewrites saved layers', async () => { + const manager = productionManager(); + const storage = memoryStorage(); + const coordinator = new LayerStateCoordinator(manager, shareSink(), { + storage, + }); + await coordinator.start(); + await manager.setEnabled('flights', true, { origin: 'user' }); + await manager.setEnabled('flights', false, { origin: 'programmatic' }); + const writes = storage.writes.length; + await manager.clearSelectedLayers({ origin: 'programmatic' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['flights']); + assert.equal(storage.writes.length, writes); + coordinator.destroy(); +}); + +test('Clear All cancels a saved restore still waiting to start', async () => { + const gate = deferred(); + const manager = productionManager(); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + restoreGate: gate.promise, + }); + const params = new URLSearchParams('v=2&l=e.f'); + const restore = coordinator.start({ + shareLayerState: decodeLayerStateParams(params), + }); + await manager.clearSelectedLayers({ origin: 'user' }); + assert.deepEqual(coordinator.getDurableState().enabledLayerIds, []); + gate.resolve(); + const results = await restore; + for (const id of ['earthquakes', 'flights']) { + assert.equal(results.find((result) => result.layerId === id).succeeded, false); + } + assert.deepEqual([...manager.getEnabledLayerIds()], []); + assert.equal(enabledLayerParam(sink), ''); + coordinator.destroy(); +}); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index ef3a4ea4a..40208831a 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -250,6 +250,30 @@ export class LayerStateCoordinator { this._revokePendingTrackingWatch('owner-layer-disabled'); } if (!isExplicitLayerStateOrigin(change.origin)) return; + if (change.type === 'selection-cleared') { + // Only layers a newer request turned back on survive Clear All. Any + // other saved layer would reappear from the share link or a reload. + const stillEnabled = new Set(change.enabledLayerIds || []); + const dropped = this._durableState.enabledLayerIds.filter( + (id) => !stillEnabled.has(id), + ); + if (dropped.length === 0) return; + for (const layerId of dropped) { + this._restoreControllers + .get(layerId) + ?.abort('superseded-by-explicit-visibility'); + } + if (dropped.some((id) => SHARE_TRACKING_RESTORE_POLICIES[id])) { + this._revokePendingTrackingWatch('superseded-by-explicit-visibility'); + } + this._commitExplicit({ + ...this._durableState, + enabledLayerIds: this._durableState.enabledLayerIds.filter((id) => + stillEnabled.has(id), + ), + }); + return; + } if (change.type === 'visibility') { this._restoreControllers .get(change.layerId) diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c8946e7bb..6c2290c72 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1650,6 +1650,15 @@ export class LayerLifecycle { } } } + // Clear All owns the whole selection, not only the layers it switched off + // itself. A layer already off for a passive reason (isolated by a context + // mode, released by a dependency owner, or superseded mid-batch) gets no + // OFF of its own here, so publish the set that is still on afterwards. + this._notifyListeners({ + type: 'selection-cleared', + origin, + enabledLayerIds: [...this.getEnabledLayerIds()], + }); const items = targetIds.map((id) => { const state = this.getLayerLifecycleState(id) || { enabled: false, diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 241b5f168..936fc2cb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -86,6 +86,9 @@ export async function _restoreContextSession({ throw error; } } + // Clear All may have run while Contacts settled. It owns the selection + // now, so the snapshot must not come back behind it. + if (restoreState.cancelled) return; await this._dataManager.restoreEnabledLayerIds( restoreState.enabledLayerIds, { From da02fd8645dbc2604e49bde8b4b174fcc3bcba35 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 15:31:51 -0700 Subject: [PATCH 118/131] feat(share): short /share/ links with a compact versioned view format (#1418) * feat(share): add compact versioned share-view codec Add a standalone codec for `#s=` share links: a version byte followed by protobuf-style tag-length-value fields. Settings equal to their default are omitted and refilled from the same table on decode; unknown fields are skipped by wire type, so new fields never break older apps. Views are a place (camera) or a thing (an aircraft, military aircraft, vessel or satellite to follow, with camera offset and last-known fix); an unknown kind falls back to the camera. The decoder bounds its input and rejects malformed payloads with a reason instead of throwing. A converter maps the long form's parsed state to and from the codec state; the long form is unchanged. * fix(share): tighten codec decoding after review Unknown detection mode and allocation names now fall back to their defaults instead of reaching the lenient long-form migration. Future panel values and style-parameter tokens are skipped rather than applied or rejected, and layer options are accepted only under their owner layer. Wrap longitudes before scaling, normalize negative zero, reuse the layer-state tracking ID grammar, and align the format doc with the decoder. * feat(share): let a host share the link its own way ShareLinkManager.buildLink() returns the current share link without copying it, and copyLink() now copies exactly that. The share button passes the link to an optional services.shareLink(url), which resolves to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the service the button copies as before. * feat(share): open and make short /share/ links The share button now makes `/share/` links with the compact share-view code, and the app opens them at start-up through the same restore pipeline as long links. Once the app next writes the address, the share path is replaced (no new history entry) by the app's own address and the live long-form hash, so a reload follows the live view. A host can pass the code as `initialShareCode` instead. A code that fails to decode restores nothing and shows "This share link can't be opened". The code gains a trailing CRC-8 check byte, so any single mistyped character is rejected instead of opening a different view. Thing views no longer store a camera: it is derived from the entity's last-known fix and the follow offset on a sphere, the entity kind is implied by the id's field, and the followed entity's layer bit is implied; an aircraft followed from the app's own camera is a 42-character code. The `#s=` hash form is gone. While the camera follows an aircraft, military aircraft or satellite, the link is a thing view with the camera's offset and the entity's position. Opening one turns the layer on and follows the entity through the existing shared-follow restore, then swings the camera round to the shared offset; a vessel view selects the vessel once the vessel feed has it. If the entity is not reporting within its window, the view stays at its last-known fix and says so. The panel and style-parameter tables now come from the codec, and a host share hook that returns a plain value or throws still gets a toast. * fix(share): encode style settings against the style's own look Settings a style applies when selected (bloom, sharpen, HUD, detection, celestial ring and the style's shader parameters) now default, per field, to what that style applies in a fresh session, read from the tables the app applies (the first-load baseline, the style presets and the shader defaults). The encoder compares against the view's style and the decoder fills omitted settings from the decoded style, so a view that keeps its style's look costs only its camera and style. The previous defaults did not match a fresh session (sharpen, HUD and detection were off), so every link paid for them, and a style's own shader parameters were always written. A test pins the per-style defaults as frozen v1 meanings. A followed aircraft in Noir is now 44 characters and the real long-link example 39. * docs: changelog for #1418 --- changelog.d/1418-short-share-links.md | 5 + docs/SHARE-LINK-FORMAT.md | 364 +++++++ scripts/format-scope.json | 10 +- scripts/package-boundaries.json | 10 + src/app/controls.js | 9 +- src/data/layerState.js | 2 +- src/i18n/locales/es.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/loadingFeedback.test.mjs | 25 +- src/panelStackLayout.test.mjs | 5 +- src/share/follow.js | 155 +++ src/share/legacyShareState.js | 119 +++ src/share/legacyShareState.test.mjs | 308 ++++++ src/share/shareLinkPath.js | 56 + src/share/shareLinkPath.test.mjs | 45 + src/share/viewCodec.js | 1399 +++++++++++++++++++++++++ src/share/viewCodec.test.mjs | 1307 +++++++++++++++++++++++ src/share/wire.js | 259 +++++ src/share/wire.test.mjs | 188 ++++ src/sharelink.celestial.test.mjs | 13 +- src/sharelink.js | 179 ++-- src/testSupport/shareViewFixtures.mjs | 103 ++ src/ui/applicationShell.js | 48 +- src/ui/shareButton.test.mjs | 160 +++ src/ui/shareFollow.js | 190 ++++ src/ui/shareLinks.test.mjs | 554 ++++++++++ src/ui/shareRestoration.js | 174 ++- 28 files changed, 5605 insertions(+), 97 deletions(-) create mode 100644 changelog.d/1418-short-share-links.md create mode 100644 docs/SHARE-LINK-FORMAT.md create mode 100644 src/share/follow.js create mode 100644 src/share/legacyShareState.js create mode 100644 src/share/legacyShareState.test.mjs create mode 100644 src/share/shareLinkPath.js create mode 100644 src/share/shareLinkPath.test.mjs create mode 100644 src/share/viewCodec.js create mode 100644 src/share/viewCodec.test.mjs create mode 100644 src/share/wire.js create mode 100644 src/share/wire.test.mjs create mode 100644 src/testSupport/shareViewFixtures.mjs create mode 100644 src/ui/shareButton.test.mjs create mode 100644 src/ui/shareFollow.js create mode 100644 src/ui/shareLinks.test.mjs diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md new file mode 100644 index 000000000..8dce5761f --- /dev/null +++ b/changelog.d/1418-short-share-links.md @@ -0,0 +1,5 @@ +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md new file mode 100644 index 000000000..f590e02bc --- /dev/null +++ b/docs/SHARE-LINK-FORMAT.md @@ -0,0 +1,364 @@ +# Share-link format + +A share link restores a view: where the camera is, what it follows, the visual +style, data layers and panel layout. There are two forms. + +- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by + `src/sharelink.js`. The address bar follows the live view in this form, and + links already in the wild keep working; the long form is never removed. +- **Short form** — `/share/`, where `` is wherever the app is + served (an origin's root, or a base path) and `` is encoded and + decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and + needs no server. The share button makes this form. The same code opens on + any copy of the app. `src/share/shareLinkPath.js` reads and builds the + address, and `src/share/legacyShareState.js` converts between the long + form's parsed state and the code's state. + +The same view in both forms (a real session's link): + +| Form | Characters | +| ----- | ----------------- | +| Long | 302 (the hash) | +| Short | 39 (the ``) | + +For example, a camera-only place view is a 24-character code (31 with its +created-at time). +Following an aircraft is 42–50: 42 when the link is made from the app's own +follow camera with the fix taken as the link is made, 44 with a fix four +seconds older, and 50 after orbiting to another heading and pitch. A view in +any other style that keeps the style's own look adds only the style (two +bytes): the 42-character followed aircraft in Noir is 44. + +## Opening a short link + +At start-up the app reads `` from an address whose path ends in +`share/` (a host can pass the code instead, as `initialShareCode` to +`createApplicationControls`). A code that decodes runs the same restore as a +long link; once the app next writes the address, `/share/` is replaced +by the app's own address and the long form, without a new history entry, so +a reload follows the live view. A code that is rejected restores nothing: the +app starts as usual and says the link can't be opened. + +A thing view turns on its entity's layer and follows the entity by id, using +the same restore as a long link's followed flight or satellite (and its +expiry windows); a vessel is selected once the vessel feed has it. Opening is +best effort: until the entity is found, and if it is not found in time, the +camera is where the link was made, looking at the entity's last-known fix, +and the app says that the entity isn't reporting and when the fix was taken. +The share button makes a thing view while the camera follows an aircraft, +military aircraft or satellite. A selected vessel is not followed (the camera +stays where the user puts it), so a link made then is a place view; vessel +thing views open, but the app does not make them yet. + +## Payload + +The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: + +1. one **version byte**, currently `1`; +2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type` + followed by its value; +3. one **check byte**: the CRC-8 of everything before it. + +| Wire type | Value | +| --------- | ----------------------------------------------------------- | +| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 | +| 1 fixed64 | 8 bytes (not used by v1; skipped) | +| 2 LEN | varint length, then that many bytes (text or a sub-message) | +| 5 fixed32 | 4 bytes (not used by v1; skipped) | + +Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable +ASCII. Fields are written in ascending order; the decoder accepts any order. + +A field whose value equals its default is **omitted**. The decoder fills every +omitted field from the same defaults table the encoder reads (for a +style-dependent field, the decoded style's defaults), so an empty +payload (code `AQc`: the version byte and its check byte) is the all-default +view. + +### Check byte + +The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial +value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the +ASCII digits `123456789` is `0xF4`). A decoder that computes a different value +rejects the code (`checksum`) before reading any field, so a mistyped code +never opens a different view. A degree-8 CRC catches every error burst of up +to 8 bits; one base64url character is 6 bits, so **every single-character +typo is rejected** (a test tries every substitution at every position). Two +swapped neighbours, a dropped or an extra character are caught with a +probability of about 255 in 256 (many of them already fail as base64url). + +## Field registry (top level) + +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | + +Field 1 absent means `thing` when field 13 is present and `place` otherwise, +so v1 never writes it; a later view kind writes its code. + +Enum codes are the list position (0, 1, 2…). A name not in the list is written +as LEN text in the same field, so the app can add a style or map before this +table learns its code. A new style's settings are encoded against its own +defaults, while a decoder that does not know the style uses Normal's; adding a +style also extends the pinned style-defaults table. + +Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 +`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). + +### Style defaults + +A default marked **style** is what the view's style applies when it is +selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's +first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`), +with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's +shader parameters at the preset's values, else the shader's own. The encoder +compares against the defaults of the view's style, and the decoder fills +omitted settings from the defaults of the style it decoded (an unknown style +uses Normal's). So a view that keeps its style's look costs its camera and +style alone. (A recipient-side preference the app remembers, such as the +detection allocation, is not a style default: a sender who changed it pays +for it.) In v1: + +| Setting | Every style | +| ------------------------- | ------------- | +| bloom, intensity | off, 0 | +| sharpen, intensity | on, 49 | +| HUD visible, variant | on, tactical | +| detection mode, density | DENSE, 75 | +| allocation, fade, outside | ELASTIC, 7, 1 | +| celestial ring | off | + +| Style | Shader parameters (field 27 defaults) | +| ------------ | --------------------------------------------------------------- | +| normal | none | +| retro | pixelation 1, distortion 0, instability 0.42 | +| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 | +| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 | +| anime | saturation 1, edgeThick 0.5 | +| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 | +| snow | density 0.6, wind 0.5 | + +These are frozen like every other default: the codec reads them from the +app's tables today, and a test pins them, so if a table moves the old values +are frozen in the codec rather than changing what shared links mean. + +### Precision + +| Value | Stored as | Matches the long form | +| --------------------------- | ------------------------- | --------------------- | +| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` | +| Altitude, range | whole metres | `Math.round` | +| Heading, pitch, roll | whole degrees | `Math.round` | +| Intensities and percentages | integers | `Math.round` | +| Style parameters | hundredths after clamping | `sp` (× 100) | +| Created-at and observed-at | whole seconds | `at` | + +Angles: heading and roll are normalized into [0, 360) — the long form can +write `roll=360` for a camera at 359.6°, which the compact form stores as 0 — +and written as the nearer signed turn, so 350° costs one byte (−10). +When a state is encoded, out-of-range input is clamped (latitude, pitch) or +wrapped (longitude). A payload that stores a latitude or longitude beyond +±90/±180 cannot come from the encoder and is rejected. + +### Layers (fields 10 and 11) + +A layer's number is derived from its permanent layer-state token +(`src/data/layerStateTokenReservations.json`): a one-character token is its +index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The +token ledger never reassigns a token, so layer numbers never change and new +layers need no change here. + +Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set +when layer _n_ is on. Present-but-empty means "every layer off"; an absent +field leaves the recipient's layers alone (the long form's `l=` versus no `l`). +A thing view's entity layer is always on, so its bit is not written: in a +thing view, present-but-empty means "only the entity's layer", and an absent +field opens with only the entity's layer on (the app writes the field +whenever it has a layer set). + +Field 11 repeats `(layer number, option key, value)` for each option that +differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The +option key is `option token index × 2 + kind`: kind 0 means the value is a +zigzag varint (an option whose long-form spelling is a canonical integer), +kind 1 means a varint length and ASCII text (the long-form spelling). Values +are decoded by the layer-state codec's own option decoders. + +A followed aircraft, military aircraft or satellite is a thing view in the +compact form, not a tracking option: the encoder moves it out of the layer +options, and the converter moves it back for the long form. + +### Panels (field 12) + +Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where +collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that +differ from the frozen default layout are written, plus a collapsed-0 entry +for each panel the state has no opinion about (an older long-form link that +predates the panel); a panel with no entry takes its default. Values of 3 are +reserved: a decoder ignores that entry, as for collapsed 0. + +| # | Panel | Long-form token | Default | +| --- | ---------------------- | --------------- | ------------------- | +| 0 | `control-panel` | `c` | collapsed, unpinned | +| 1 | `location-bar` | `l` | collapsed, unpinned | +| 2 | `data-panel` | `d` | collapsed | +| 3 | `cctv-panel` | `v` | collapsed | +| 4 | `recent-imagery-panel` | `i` | expanded | +| 5 | `street-level-panel` | `t` | collapsed | +| 6 | `radio-panel` | `r` | collapsed | +| 7 | `scene-panel` | `s` | collapsed | +| 8 | `global-context-panel` | `g` | collapsed | +| 9 | `pp-toggles` | `p` | collapsed | +| 10 | `param-slider-panel` | `m` | expanded | + +### Style parameters (field 27) + +Pairs of varints `(token index, value × 100)` for the parameters of the +view's style that differ from its defaults, using the long form's `sp` tokens +(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter +of the style: the stored ones over the style's defaults. A long link without +`sp` converts to the style's defaults. + +## View kinds + +A **place** view (field 13 absent) opens at its camera, fields 2–7. + +A **thing** view (field 13 present) follows an entity. Field 13 is a +sub-message: + +| # | Field | Encoding | Default | +| --- | ----------- | -------------------------------------------------------- | ------- | +| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | | +| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | | +| 3 | vessel | entity id: varint (MMSI) or LEN text | | +| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | | +| 5 | range | uint metres from the entity to the camera | 2000 | +| 6 | heading | angle, the camera's heading while following | 0 | +| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 | +| 8 | fix lat | coord, the entity's last-known latitude | none | +| 9 | fix lon | coord, its last-known longitude | none | +| 10 | fix alt | sint metres | 0 | +| 11 | fix age | sint seconds the fix predates the created-at time | 0 | +| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none | + +The entity kind is the field that carries the id (its code in +`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is +present (two is malformed). Entity ids are the ones the layers look up: an +aircraft's ICAO 24-bit address (six hex digits, stored as a number; other +forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's +NORAD catalog number (decimal numbers; an MMSI with a leading zero stays +text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`. + +Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at +the entity from `range` metres away, heading clockwise from north, in the +entity's local east-north-up frame. The defaults match the app's own follow +camera, which looks north at about −35°, so only its range is written. + +Fields 8–12 are the last-known fix, the entity's position when the link was +made. Fields 8 and 9 are both written whenever there is a fix; their presence +marks it. Its time is the created-at time (field 8 at the top level) minus +field 11, or field 12 when the view has no created-at time. + +**The camera of a thing view with a fix is derived, not stored**: fields 2–7 +are not written, and the camera is where following the fix from the offset +puts it (`followCameraFor` in `src/share/follow.js`): the camera sits +`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up +of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m, +and looks back at the fix (its heading and pitch are those of that direction +at the camera, roll 0; at follow distances they equal h and p). A +decoder ignores fields 2–7 when the fix is present. A thing view without a +fix (one converted from a long link, which records neither offset nor fix) +writes its camera in fields 2–7 like a place view. + +A decoder that meets a view kind or an entity kind it does not know (an id in +a thing field it skips) opens the view as a place at its camera — the derived +one when there is a fix — and reports `fallback`. + +## Decoding rules + +`decodeShareView(code)` takes the bare code and never throws. It returns +`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`. + +Rejected (the whole link): input over 2048 characters, bad base64url +(padding, foreign characters, impossible length, stray bits), fewer than two +bytes, a check byte that does not match (`checksum`), an unknown version byte, +a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an +overlong varint, a LEN longer than what remains (at any level, including +inside the thing sub-message), a reserved wire type, field number 0, a known +field with the wrong wire type or repeated, two entity ids in one thing, a +duplicate panel/option/style entry, non-ASCII text, and a latitude or +longitude (camera or fix) beyond ±90/±180. + +Tolerated and reported in `unknownFields`: unknown fields at any level +(skipped by wire type), unknown enum codes and names (the default is used), +unknown flag bits, layers, panel values and style parameters, and layer +options the layer-state codec rejects or that are filed under a layer other +than their owner. Detection mode and allocation names outside their lists +fall back to the default rather than reaching the long form's lenient +migration. The map stack and HUD variant are open: a name outside their lists +that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what +it means) and is not reported; only a name outside that grammar is. A +created-at time in the future is dropped, as the long form's `at` is. + +## Changing the format + +These rules keep every link that was ever shared working, in every version of +the app that can open it. + +1. **Field numbers are permanent.** Never renumber, never reuse. A field that + is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`. +2. **A field's type and wire type are permanent.** To change one, add a new + field. +3. **Defaults are frozen meanings.** A default is what an omitted field meant + when links were written. If the app's default moves, leave the table alone: + the encoder will start writing the new value explicitly. Style defaults + are read from the app's style tables; when those move, freeze the old + values in the codec (see Style defaults). The same applies + to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults. +4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel + numbers and style parameter tokens only grow. +5. **Add, don't change.** A new setting takes the next free field number + (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + boolean takes the next flag bit instead, which costs nothing when it is at + its default. +6. **New view kinds and entity kinds** get the next code. A new entity kind's + id takes the next free thing field (13 onwards), so older apps skip it and + fall back to the camera. A thing that needs more than an id (for example a + CCTV camera, a radio station or a transit vehicle) adds further thing + fields the same way. +7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT` + (a test fails until it has one). +8. **Bump the version byte only** for a change older decoders must refuse. + Decoders reject any version they do not know. The trailing CRC-8 check + byte belongs to every version. +9. **Derivations are frozen too.** `followCameraFor` and the follow-offset + defaults are part of the format: changing them moves the camera of every + thing link already shared. + +Add a test for every change: a round trip, and a payload written with the new +field that the previous decoder still reads. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 84d2dc323..ee23b8b6a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1109,5 +1109,13 @@ "src/voice/panel/inactivity.test.mjs", "src/voice/panel/actionObserver.test.mjs", "src/voice/panel/testDom.mjs", - "src/voice/realtimeCostPanel.test.mjs" + "src/voice/realtimeCostPanel.test.mjs", + "docs/SHARE-LINK-FORMAT.md", + "src/share/wire.test.mjs", + "src/share/viewCodec.test.mjs", + "src/share/legacyShareState.test.mjs", + "src/testSupport/shareViewFixtures.mjs", + "src/ui/shareLinks.test.mjs", + "src/ui/shareButton.test.mjs", + "src/share/shareLinkPath.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4b4f3f18f..dedf4c272 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -770,6 +770,9 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", + "src/share/follow.js", + "src/data/trackedCamera.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -2195,6 +2198,11 @@ "src/services/meshFloorSampler.js", "src/services/requests.js", "src/services/terrainHeights.js", + "src/share/follow.js", + "src/share/legacyShareState.js", + "src/share/shareLinkPath.js", + "src/share/viewCodec.js", + "src/share/wire.js", "src/sharelink.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -2287,6 +2295,7 @@ "src/ui/scenePresentation.js", "src/ui/actionButton.js", "src/ui/scenes.js", + "src/ui/shareFollow.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", "src/ui/shellFeedback.js", @@ -3283,6 +3292,7 @@ "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", "src/navigationPolicy.js", + "src/share/follow.js", "src/ui/shareRestoration.js", "src/ui/uiLifetime.js", "src/i18n/index.js", diff --git a/src/app/controls.js b/src/app/controls.js index 40b354d06..0b479f43d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { t } from '../i18n/index.js'; -/** Construct the existing controls and camera presentation. */ +/** + * Construct the existing controls and camera presentation. `initialShareCode` + * opens a share code (the `` of `/share/`) at start-up, for a host + * whose address does not carry one. + */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, loaderStatus, @@ -13,6 +17,7 @@ export function createApplicationControls({ catalog, placeSearch, defer, + initialShareCode = null, }) { // Initialize the style manager (post-processing, HUD, locations, share links) const styleManager = new Controls(viewer, { @@ -27,6 +32,8 @@ export function createApplicationControls({ requestServices: operations.requests, mapStackController, placeSearch, + // A share code to open at start-up instead of the page's address. + initialShareCode, }); defer(() => styleManager.orbitController.stop()); defer(() => styleManager.hud.destroy()); diff --git a/src/data/layerState.js b/src/data/layerState.js index f43129896..089c8c08b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/; * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ -const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; +export const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ff8e57a5..2cfa47a69 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -302,6 +302,11 @@ "CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "Link copied!": "¡Enlace copiado!", + "Link shared": "Enlace compartido", + "This share link can't be opened": "Este enlace compartido no se puede abrir", + "{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez", + "Shared vessel is unavailable": "El buque compartido no está disponible", "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3c7ff17b1..b3bdd165f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -294,6 +294,11 @@ "Scene run complete": "Воспроизведение сцены завершено", "CCTV layer unavailable": "Слой CCTV недоступен", "Link copied!": "Ссылка скопирована!", + "Link shared": "Ссылка отправлена", + "This share link can't be opened": "Не удаётся открыть эту ссылку", + "{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})", + "{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место", + "Shared vessel is unavailable": "Судно по ссылке недоступно", "Copy failed": "Не удалось скопировать", "VISUAL PRESETS": "ПРЕСЕТЫ", "LOCATION": "МЕСТО", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index e3eefe7c5..a44973167 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1477,6 +1477,11 @@ "LIVE SIGNALS": "实时信号", "CCTV layer unavailable": "CCTV 图层不可用", "Link copied!": "链接已复制!", + "Link shared": "链接已分享", + "This share link can't be opened": "无法打开此分享链接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置", + "Shared vessel is unavailable": "分享的船只不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index b9903f97e..ff9f63c9c 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d test('share-follow failures use the universal top-center status instead of the bottom toast', () => { const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); - assert.match(handler, /this\.showStatus\(message\)/); - assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); - assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); - assert.doesNotMatch(handler, /this\._showToast\(message\)/); - assert.doesNotMatch(handler, /pushCockpitSignal/); + const present = shellMethod('_presentAfterStartupCover').toString(); + assert.match( + handler, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + ); + assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); + assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); + assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match(present, /canPresentDeferredStatusNotice\(/); + for (const source of [handler, present]) { + assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /pushCockpitSignal/); + } assert.match(handler, /result\.classification === 'pending'/); assert.match(handler, /state: 'acquiring'/); assert.match(handler, /persistent: true/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); - assert.match(handler, /canPresentDeferredStatusNotice\(/); assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); - assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/); + assert.match(handler, /result\.classification === 'followed'/); + assert.match(handler, /result\.classification === 'cancelled'\) return/); }); test('universal notice masks active loading only for its own fixed dwell', () => { diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 2a472e246..a13f5c04b 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c test('share-panel state excludes responsive collapse and preserves recipient preferences', () => { const ui = readShellSource(); - const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); + // The long form spells the share format's panel table (one source of truth). + const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8'); assert.match( ui, @@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre 'pin and unpin must update the share hash even when collapse state is unchanged', ); assert.match(ui, /\{ id: 'param-slider-panel' \}/); - assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/); + assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/); }); test('parameterized Display presets keep one stable scroll owner', () => { diff --git a/src/share/follow.js b/src/share/follow.js new file mode 100644 index 000000000..e24fc003b --- /dev/null +++ b/src/share/follow.js @@ -0,0 +1,155 @@ +/** + * What a share link's thing view follows, and where its camera is: the + * entity kinds and the follow-offset geometry of the share format + * (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code, + * default and formula here is permanent. + * + * Pure and dependency-free, so the app's follow camera and the codec + * (viewCodec.js) share one definition. + */ + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Things a view can follow. `code` is permanent and is also the number of the + * thing-message field that carries this kind's id, so the kind costs nothing + * on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO + * address or a decimal number); any other identifier travels as text. + * `tracking` names the layer-state slot that holds the same selection in the + * long form. + */ +export const SHARE_VIEW_ENTITY_KINDS = freezeAll([ + { + code: 1, + kind: 'aircraft', + layerId: 'flights', + idForm: 'hex24', + tracking: 'flights', + }, + { + code: 2, + kind: 'military_aircraft', + layerId: 'military', + idForm: 'hex24', + tracking: 'military', + }, + { + code: 3, + kind: 'vessel', + layerId: 'ais-live-vessels', + idForm: 'decimal', + tracking: null, + }, + { + code: 4, + kind: 'satellite', + layerId: 'satellites', + idForm: 'decimal', + tracking: 'satellites', + }, +]); +/** + * The frozen meaning of an omitted follow-offset field. An offset whose + * fields all equal these is written as nothing, so it decodes as these. + */ +export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({ + range: 2000, + heading: 0, + pitch: -35, +}); + +const EARTH_RADIUS_M = 6_371_000; +const RAD = Math.PI / 180; + +/** + * The camera's offset from a followed entity in the entity's local east, + * north and up metres: the camera looks along (heading, pitch) at the entity + * from `range` metres away. Heading is clockwise from north; a negative pitch + * looks down, so the camera sits above the entity. + */ +export function followOffsetToEnu({ range, heading, pitch }) { + const horizontal = range * Math.cos(pitch * RAD); + return { + east: -horizontal * Math.sin(heading * RAD), + north: -horizontal * Math.cos(heading * RAD), + up: -range * Math.sin(pitch * RAD), + }; +} + +/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */ +export function followOffsetFromEnu({ east, north, up }) { + const range = Math.hypot(east, north, up); + if (!(range > 0)) return null; + return { + range: Math.round(range), + heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360, + pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD), + }; +} + +/** Earth-centred position of (lat, lon, alt) on the format's sphere. */ +function toEcef(lat, lon, alt) { + const r = EARTH_RADIUS_M + alt; + const cosLat = Math.cos(lat * RAD); + return [ + r * cosLat * Math.cos(lon * RAD), + r * cosLat * Math.sin(lon * RAD), + r * Math.sin(lat * RAD), + ]; +} + +/** The local east, north and up unit vectors at (lat, lon). */ +function enuBasis(lat, lon) { + const sinLat = Math.sin(lat * RAD); + const cosLat = Math.cos(lat * RAD); + const sinLon = Math.sin(lon * RAD); + const cosLon = Math.cos(lon * RAD); + return { + east: [-sinLon, cosLon, 0], + north: [-sinLat * cosLon, -sinLat * sinLon, cosLat], + up: [cosLat * cosLon, cosLat * sinLon, sinLat], + }; +} + +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** + * Where the camera sits while following an entity at `fix` from `offset`, + * and how it looks back at the entity, on a sphere of radius 6 371 000 m. A + * thing view with a fix stores no camera of its own: this is its camera, and + * the place a decoder falls back to. Pure, and computed only from values at + * the format's precision, so encoder and decoder agree. + */ +export function followCameraFor(fix, offset) { + const local = followOffsetToEnu(offset); + const at = toEcef(fix.lat, fix.lon, fix.alt); + const basis = enuBasis(fix.lat, fix.lon); + const camera = [0, 1, 2].map( + (i) => + at[i] + + local.east * basis.east[i] + + local.north * basis.north[i] + + local.up * basis.up[i], + ); + const horizontal = Math.hypot(camera[0], camera[1]); + const lat = Math.atan2(camera[2], horizontal) / RAD; + const lon = Math.atan2(camera[1], camera[0]) / RAD; + const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M; + // The view direction, camera to entity, in the camera's own local frame. + const view = [0, 1, 2].map((i) => at[i] - camera[i]); + const own = enuBasis(lat, lon); + const length = Math.hypot(...view); + if (!(length > 0)) + return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 }; + return { + lat, + lon, + alt, + heading: + (Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360, + pitch: + Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD, + roll: 0, + }; +} diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js new file mode 100644 index 000000000..1f54445e4 --- /dev/null +++ b/src/share/legacyShareState.js @@ -0,0 +1,119 @@ +/** + * Conversion between the long share-link form's parsed state (what + * `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the + * compact share-view state of viewCodec.js. + * + * The two use the same field names, so the conversion is small: the compact + * form adds `view`, and the long form keeps a followed entity as a tracking + * selection inside the layer options instead. + */ + +import { + SHARE_TRACKING_RESTORE_POLICIES, + createDefaultLayerState, + normalizeLayerState, +} from '../data/layerState.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + normalizeShareViewState, +} from './viewCodec.js'; + +/** Fields of the long form's parsed state, in its own order. */ +export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ + 'lat', + 'lon', + 'alt', + 'heading', + 'pitch', + 'roll', + 'style', + 'styleParams', + 'bloom', + 'sharpen', + 'bloomIntensity', + 'bloomVersion', + 'sharpenIntensity', + 'hudVariant', + 'hudVisible', + 'detectionMode', + 'detectionDensity', + 'detectionAllocation', + 'detectionFadePct', + 'detectionOutsideOpacityPct', + 'celestialRing', + 'scopeEnabled', + 'scopeFeatherPct', + 'scopeTerminusPct', + 'mapStack', + 'layerState', + 'layerStateInvalid', + 'panelState', + 'sharedAtMs', +]); + +/** + * A compact share-view state from a parsed long-form state. A tracking + * selection in the layer options becomes a thing view with no offset or + * last-known fix, since the long form records neither. Restore bookkeeping + * (`restoreAuthority`, `layerStateInvalid`) is not part of a view. + */ +export function fromLegacyShareState(parsed) { + if (!parsed || typeof parsed !== 'object') return null; + const picked = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) { + if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key)) + picked[key] = parsed[key]; + } + return normalizeShareViewState(picked); +} + +/** + * The parsed long-form state for a compact share-view state, ready for + * `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer + * set when the view has none), and a followed aircraft, military aircraft or + * satellite goes back into its layer's tracking selection. The long form + * cannot express the rest of a thing view (a vessel, the follow offset, the + * last-known fix); callers that need them read `state.view` directly. + */ +export function toLegacyShareState(input) { + const state = normalizeShareViewState(input); + const entry = + state.view.kind === 'thing' + ? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind) + : null; + let layerState = + entry && !state.layerState + ? normalizeLayerState({ + ...createDefaultLayerState(), + enabledLayerIds: [entry.layerId], + }) + : state.layerState; + const policy = entry?.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if ( + layerState && + policy && + layerState.enabledLayerIds.includes(entry.layerId) + ) { + const id = + entry.kind === 'satellite' + ? Number(state.view.entity.id) + : state.view.entity.id; + layerState = normalizeLayerState({ + ...layerState, + options: { + ...layerState.options, + [policy.optionOwner]: { + ...layerState.options[policy.optionOwner], + [policy.optionKey]: id, + }, + }, + }); + } + const legacy = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key]; + legacy.layerState = layerState; + legacy.layerStateInvalid = false; + return legacy; +} diff --git a/src/share/legacyShareState.test.mjs b/src/share/legacyShareState.test.mjs new file mode 100644 index 000000000..cde3a9ed6 --- /dev/null +++ b/src/share/legacyShareState.test.mjs @@ -0,0 +1,308 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ShareLinkManager } from '../sharelink.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { + HAL_EXAMPLE_HASH, + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './viewCodec.js'; +import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js'; +import { + LEGACY_SHARE_STATE_KEYS, + fromLegacyShareState, + toLegacyShareState, +} from './legacyShareState.js'; + +const DEG = Math.PI / 180; + +function stubViewer(camera = {}) { + return { + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: camera.position ?? { + latitude: 0, + longitude: 0, + height: 0, + }, + heading: camera.heading ?? 0, + pitch: camera.pitch ?? 0, + roll: camera.roll ?? 0, + }, + }; +} + +/** The long form's own parser, run on `hash`. */ +function parseLegacy(hash) { + const body = hash.startsWith('#') ? hash : `#${hash}`; + globalThis.window = { + location: { hash: body, href: `http://localhost/${body}` }, + }; + const manager = new ShareLinkManager(stubViewer()); + const parsed = manager.parseInitialHash(); + manager.destroy(); + return parsed; +} + +/** + * The parsed state as a view: restore bookkeeping removed, and headings and + * rolls wrapped into [0, 360) — the codec's one normalization of values the + * long-form generator writes (Cesium can round 359.6° up to `roll=360`). + */ +function comparable(parsed) { + const out = {}; + for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key]; + // A long link without `sp` leaves the style's parameters as they are; the + // compact form says what they are, the style's own unless changed. + const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams; + out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null; + for (const key of ['heading', 'roll']) + out[key] = ((out[key] % 360) + 360) % 360; + return out; +} + +/** Legacy hash → parsed state → codec → parsed state, asserting equality. */ +function assertLegacyRoundTrip(hash) { + const parsed = parseLegacy(hash); + assert.ok(parsed, `long form parses: ${hash}`); + const state = fromLegacyShareState(parsed); + const encoded = encodeShareView(state); + const decoded = decodeShareView(encoded, { nowMs: Date.now() }); + assert.equal(decoded.ok, true, decoded.reason); + assert.deepEqual(decoded.state, state, 'codec state survives'); + assert.deepEqual( + toLegacyShareState(decoded.state), + comparable(parsed), + 'parsed long-form state survives', + ); + return { parsed, state, encoded }; +} + +test('the HAL example survives the round trip and shrinks to 39 characters', () => { + const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH); + assert.equal(HAL_EXAMPLE_HASH.length, 302); + assert.equal(encoded.length, 39); + assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o'); + assert.equal(parsed.roll, 360); + assert.equal(state.roll, 0); + assert.deepEqual(state.layerState.enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + 'satellites', + ]); + assert.equal(state.layerState.options.flights.models3d, true); + assert.equal(state.layerState.options.wind.overlay, 'none'); + assert.deepEqual( + state.panelState.specs.find((spec) => spec.id === 'cctv-panel'), + { id: 'cctv-panel', collapsed: false, pinned: null }, + ); +}); + +test('links from earlier long-form versions keep their historical meaning', () => { + for (const hash of [ + 'lat=10&lon=20', + 'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal', + 'v=2&lat=1&lon=2&l=f&lo=f.m.a', + 'v=2&lat=1&lon=2&l=', + 'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35', + 'v=2&lat=1&lon=2&ui=v.c.0', + 'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100', + 'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600', + ]) { + assertLegacyRoundTrip(hash); + } + // A pre-v2 link's omitted settings are written explicitly, so the compact + // form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35). + const old = fromLegacyShareState(parseLegacy('lat=10&lon=20')); + assert.deepEqual( + [old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct], + [5, 16, 35], + ); + assert.deepEqual( + [old.bloomIntensity, old.bloomVersion], + [50, 1], + 'legacy bloom scale', + ); + // `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that. + const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f')); + assert.equal(off.layerState.options.flights.models3d, false); +}); + +test('a tracking selection becomes a thing view and back', () => { + const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3'); + assert.deepEqual(flights.state.view.entity, { + kind: 'aircraft', + id: 'a1b2c3', + }); + assert.equal( + flights.state.layerState.options.flights.selectedFlightsTrackingId, + null, + ); + const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234'); + assert.deepEqual(military.state.view.entity, { + kind: 'military_aircraft', + id: '~ae1234', + }); + const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544'); + assert.deepEqual(satellite.state.view.entity, { + kind: 'satellite', + id: '25544', + }); + assert.equal( + toLegacyShareState(satellite.state).layerState.options.satellites + .selectedSatTrackingId, + 25544, + ); +}); + +test('thing views the long form cannot express convert without one', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['ais-live-vessels']; + const vessel = toLegacyShareState({ + lat: 1, + lon: 2, + layerState, + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState, layerState); +}); + +test('a thing view turns its layer on, with or without a layer set', () => { + for (const layerState of [createDefaultLayerState(), null]) { + const legacy = toLegacyShareState({ + layerState, + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } }, + }); + assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']); + assert.equal( + legacy.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + assert.equal(legacy.layerStateInvalid, false); + } + const vessel = toLegacyShareState({ + view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } }, + }); + assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']); + // A place view without a layer set leaves the recipient's layers alone. + assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null); +}); + +/** A long-form link written by the real generator from random live state. */ +function generatedLegacyHash(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager( + stubViewer({ + position: { + latitude: (int(-899999, 899999) / 1e4) * DEG, + longitude: (int(-1799999, 1799999) / 1e4) * DEG, + height: pick(random, [int(-50, 3000), int(0, 30_000_000)]), + }, + heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0), + pitch: int(-90, 0) * DEG, + roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]), + }), + ); + const style = pick(random, VIEW_STYLES); + manager.onStyleChange(style); + const density = pick(random, [0, 25, 50, 75, 100]); + manager.onToggleChange(random() < 0.5, random() < 0.5, { + bloomIntensity: int(0, 100), + bloomVersion: pick(random, [1, 2]), + sharpenIntensity: int(0, 100), + hudVariant: pick(random, HUD_LAYOUTS), + hudVisible: random() < 0.5, + detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionDensity: density, + detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']), + detectionFadePct: int(0, 40), + detectionOutsideOpacityPct: int(0, 100), + celestialRingEnabled: random() < 0.5, + scopeEnabled: random() < 0.5, + scopeFeatherPct: int(0, 200), + scopeTerminusPct: random() < 0.3 ? int(94, 100) : null, + mapStack: pick(random, VIEW_MAPS), + }); + if (random() < 0.8) { + const layerState = randomLayerState(random); + manager.setLayerStateProvider(() => layerState); + } + if (random() < 0.8) { + const panelState = { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({ + id: panel.id, + collapsed: random() < 0.7, + ...(panel.pinnable ? { pinned: random() < 0.3 } : {}), + })), + }; + manager.setPanelStateProvider(() => panelState); + } + const specs = SHARE_VIEW_STYLE_PARAMS[style]; + if (specs && random() < 0.7) { + const values = Object.fromEntries( + specs.map((spec) => [ + spec.key, + spec.min + (spec.max - spec.min) * random(), + ]), + ); + manager.setStyleParamStateProvider(() => values); + } + const params = manager._buildHashParams(); + if (random() < 0.7) + params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000)))); + manager.destroy(); + return params.toString(); +} + +test('links written by the long-form generator survive the compact form', () => { + const random = rng(0xc0dec); + let totalLegacy = 0; + let totalCompact = 0; + for (let i = 0; i < 400; i += 1) { + const hash = generatedLegacyHash(random); + const { encoded } = assertLegacyRoundTrip(hash); + totalLegacy += hash.length; + totalCompact += encoded.length; + } + // Even random states, far from defaults, are under half the length. + assert.ok( + totalCompact * 2 < totalLegacy, + `${totalCompact} vs ${totalLegacy}`, + ); +}); + +test('the converter ignores non-objects and restore bookkeeping', () => { + // A malformed v2 layer list is reported by the long-form parser; the view + // itself simply has no layer set. + const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown'); + assert.equal(invalid.layerStateInvalid, true); + assert.equal(fromLegacyShareState(invalid).layerState, null); + assert.equal(fromLegacyShareState(null), null); + const parsed = parseLegacy(HAL_EXAMPLE_HASH); + assert.ok(parsed.restoreAuthority); + const state = fromLegacyShareState(parsed); + assert.equal(Object.hasOwn(state, 'restoreAuthority'), false); + assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false); +}); + +test('the converter knows every key of the long form’s parsed state', () => { + // A key the converter does not list would silently drop out of share codes. + globalThis.window = { location: { hash: '', href: 'http://localhost/' } }; + const manager = new ShareLinkManager(stubViewer({})); + const parsed = manager._parseShareHash('v=2&lat=1&lon=2'); + assert.deepEqual( + Object.keys(parsed).sort(), + [...LEGACY_SHARE_STATE_KEYS].sort(), + ); + manager.destroy(); +}); diff --git a/src/share/shareLinkPath.js b/src/share/shareLinkPath.js new file mode 100644 index 000000000..ee52a235f --- /dev/null +++ b/src/share/shareLinkPath.js @@ -0,0 +1,56 @@ +/** + * Share-link addresses: a share code travels in the path, `/share/`, + * where `` is wherever the app is served (the origin's root, or a base + * path). The same code opens on any copy of the app. + * + * Pure: no DOM. Callers pass the addresses they have. + */ + +/** The path segment that introduces a share code. */ +export const SHARE_PATH_SEGMENT = 'share'; + +// Any last segment counts as a code here, so a mangled one is refused by the +// decoder (and said so) rather than opening the app at a wrong address. +const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`); + +/** + * The share code and the app's base path for a share-link pathname, or null + * when the pathname is not a share link. `/share/AQc` gives + * `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`. + */ +export function parseSharePath(pathname) { + if (typeof pathname !== 'string') return null; + const match = SHARE_PATH.exec(pathname); + if (!match) return null; + let code = match[2]; + try { + code = decodeURIComponent(code); + } catch { + // Left as written; the decoder refuses it. + } + return { code, basePath: match[1] }; +} + +/** + * The app's root address for `href`: the base path of a share link, or the + * directory of any other page, with no hash. The query is kept. + */ +export function appRootUrl(href) { + const url = new URL(href); + url.hash = ''; + const share = parseSharePath(url.pathname); + url.pathname = share ? share.basePath : new URL('./', url).pathname; + return url; +} + +/** + * The share link for `code` on the app at `href`. The sender's `?lang` stays + * behind: the recipient keeps their own language. + */ +export function shareLinkUrl(code, href) { + const root = appRootUrl(href); + const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root); + url.search = root.search; + url.searchParams.delete('lang'); + return url.href; +} diff --git a/src/share/shareLinkPath.test.mjs b/src/share/shareLinkPath.test.mjs new file mode 100644 index 000000000..aab06741d --- /dev/null +++ b/src/share/shareLinkPath.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js'; + +test('a share path names its code and the app base path', () => { + assert.deepEqual(parseSharePath('/share/AQc'), { + code: 'AQc', + basePath: '/', + }); + assert.deepEqual(parseSharePath('/gev/share/AQc/'), { + code: 'AQc', + basePath: '/gev/', + }); + // A mangled code is still a share link, so the app refuses it out loud. + assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c'); + for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc']) + assert.equal(parseSharePath(path), null, path); + assert.equal(parseSharePath(undefined), null); +}); + +test('the app root drops a share path and the hash, and keeps the query', () => { + assert.equal( + appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href, + 'https://gev.example/?qa=1', + ); + assert.equal( + appRootUrl('http://localhost:4173/gev/share/AQc').href, + 'http://localhost:4173/gev/', + ); + assert.equal( + appRootUrl('https://gev.example/index.html#v=2&lat=1').href, + 'https://gev.example/', + ); +}); + +test('a share link sits under the app root without the sender ?lang', () => { + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'), + 'https://gev.example/share/AQc', + ); + assert.equal( + shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'), + 'https://gev.example/gev/share/AQc?qa=1', + ); +}); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js new file mode 100644 index 000000000..84ba8fbc1 --- /dev/null +++ b/src/share/viewCodec.js @@ -0,0 +1,1399 @@ +/** + * Compact share-view codec, format version 1. + * + * A shared view travels as a short code in a link's path, `/share/`. The + * code is base64url text for one version byte, protobuf-style tag-length-value + * fields and a one-byte CRC-8 check. Settings equal to their default are + * omitted, and the decoder fills them back in from the same defaults table the + * encoder reads. Older decoders skip fields they do not know, so new fields + * never break old links or old apps. + * + * The rules that keep links valid forever (field numbers, codes and defaults + * are permanent) are in docs/SHARE-LINK-FORMAT.md. Read them before changing + * anything in this file. + * + * Pure: no DOM, no Cesium. The long `#v=2&lat=…` form is still parsed by + * sharelink.js; legacyShareState.js converts between the two. + */ + +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_ALPHABET, + REGISTRY_BY_ID, + SHARE_TRACKING_RESTORE_POLICIES, + TRACKING_ID_GRAMMAR, + normalizeLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { + migrateDetectionState, + normalizeAllocationStrategy, +} from '../data/detectionPolicy.js'; +import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; +import { VIEW_STYLES } from '../view/index.js'; +import { + GLOBAL_POST_DEFAULTS, + STYLE_PRESET_DEFAULTS, + STYLES, +} from '../ui/visualPresets.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, +} from './follow.js'; +import { + MAX_ZIGZAG_MAGNITUDE, + ShareWireError, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + asciiText, + decodeBase64Url, + asciiBytes, + crc8, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +export { + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, +} from './follow.js'; + +/** First payload byte. Bump only for a change older decoders must refuse. */ +export const SHARE_VIEW_FORMAT_VERSION = 1; +/** Input bound, checked before any decoding work (1536 payload bytes). */ +export const MAX_SHARE_VIEW_CHARS = 2048; + +/** Created-at and observed-at seconds are stored relative to this instant. */ +export const SHARE_VIEW_EPOCH_S = Date.UTC(2026, 0, 1) / 1000; + +/** Coordinates are stored as integer multiples of this many degrees (≈11 m). */ +const COORDINATE_SCALE = 1e4; +const MAX_ALTITUDE_M = 1e9; +const MAX_FOLLOW_RANGE_M = 1e8; +const MAX_UNSIGNED_SETTING = 1e6; +/** Terminus pin range; mirrors SCOPE_TERMINUS_MIN/MAX_PCT in scopeMask.js. */ +const SCOPE_TERMINUS_MIN_PCT = 94; +const SCOPE_TERMINUS_MAX_PCT = 100; +const NAME_GRAMMAR = /^[A-Za-z0-9_-]{1,32}$/; +const MAX_OPTION_VALUE_CHARS = 256; + +const freezeAll = (rows) => + Object.freeze(rows.map((row) => Object.freeze(row))); + +/** + * Enum code tables. A code is permanent: append new names, never renumber or + * reuse. A name missing from its table still travels, as text in the same + * field (wire type LEN), so the app can grow faster than this table. + */ +export const SHARE_VIEW_ENUMS = Object.freeze({ + viewKind: Object.freeze(['place', 'thing']), + style: Object.freeze([ + 'normal', + 'retro', + 'surveillance', + 'thermal', + 'anime', + 'noir', + 'snow', + ]), + mapStack: Object.freeze([ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]), + hudVariant: Object.freeze(['tactical', 'operator', 'minimal', 'cyber']), + detectionMode: Object.freeze(['OFF', 'SPARSE', 'BALANCED', 'DENSE']), + detectionAllocation: Object.freeze(['ELASTIC', 'WEIGHTED']), +}); + +const ENTITY_BY_KIND = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.kind, e])); +const ENTITY_BY_CODE = new Map(SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e])); + +/** + * Shareable panels, numbered permanently. `collapsed` and `pinned` are the + * frozen meaning of an omitted entry (the layout a fresh session shows today), + * not a live default: if the app's default moves, this table does not. + * `token` is the panel's letter in the long form's `ui` parameter. + */ +export const SHARE_VIEW_PANELS = freezeAll([ + { + number: 0, + id: 'control-panel', + token: 'c', + pinnable: true, + collapsed: true, + }, + { + number: 1, + id: 'location-bar', + token: 'l', + pinnable: true, + collapsed: true, + }, + { number: 2, id: 'data-panel', token: 'd', pinnable: false, collapsed: true }, + { number: 3, id: 'cctv-panel', token: 'v', pinnable: false, collapsed: true }, + { + number: 4, + id: 'recent-imagery-panel', + token: 'i', + pinnable: false, + collapsed: false, + }, + { + number: 5, + id: 'street-level-panel', + token: 't', + pinnable: false, + collapsed: true, + }, + { + number: 6, + id: 'radio-panel', + token: 'r', + pinnable: false, + collapsed: true, + }, + { + number: 7, + id: 'scene-panel', + token: 's', + pinnable: false, + collapsed: true, + }, + { + number: 8, + id: 'global-context-panel', + token: 'g', + pinnable: false, + collapsed: true, + }, + { number: 9, id: 'pp-toggles', token: 'p', pinnable: false, collapsed: true }, + { + number: 10, + id: 'param-slider-panel', + token: 'm', + pinnable: false, + collapsed: false, + }, +]); +const PANEL_BY_NUMBER = new Map(SHARE_VIEW_PANELS.map((p) => [p.number, p])); +const PANEL_BY_ID = new Map(SHARE_VIEW_PANELS.map((p) => [p.id, p])); + +/** + * Shareable visual-preset parameters per style, as in the long form's `sp` + * parameter: values are stored ×100 after clamping to [min, max]. A + * parameter's token is permanent within its style. + */ +export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({ + retro: freezeAll([ + { key: 'pixelation', token: 'p', min: 1, max: 10 }, + { key: 'distortion', token: 'd', min: 0, max: 1 }, + { key: 'instability', token: 'i', min: 0, max: 1 }, + ]), + surveillance: freezeAll([ + { key: 'gain', token: 'g', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'scanlineStr', token: 's', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + ]), + thermal: freezeAll([ + { key: 'sensitivity', token: 's', min: 0, max: 1 }, + { key: 'bloom', token: 'b', min: 0, max: 1 }, + { key: 'mode', token: 'm', min: 0, max: 1 }, + { key: 'pixelation', token: 'p', min: 1, max: 6 }, + { key: 'palette', token: 'a', min: 0, max: 1 }, + ]), + anime: freezeAll([ + { key: 'saturation', token: 's', min: 0, max: 2 }, + { key: 'edgeThick', token: 'e', min: 0, max: 1 }, + ]), + noir: freezeAll([ + { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, + { key: 'grainAmt', token: 'g', min: 0, max: 1 }, + { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, + ]), + snow: freezeAll([ + { key: 'density', token: 'd', min: 0, max: 1 }, + { key: 'wind', token: 'w', min: 0, max: 1 }, + ]), +}); + +/** + * The default of a setting a style applies when it is selected: the value + * comes from SHARE_VIEW_STYLE_DEFAULTS for the view's style, so a view that + * keeps its style's look costs nothing for it. + */ +export const BY_STYLE = 'by-style'; + +/** + * Booleans share one varint (field 9). Bit `bit` is set when the value + * DIFFERS from `default`, so an absent field means "all defaults" and a bit + * an older decoder does not know is harmless. Bits are permanent. + */ +export const SHARE_VIEW_FLAGS = freezeAll([ + { bit: 0, key: 'bloom', default: BY_STYLE }, + { bit: 1, key: 'sharpen', default: BY_STYLE }, + { bit: 2, key: 'hudVisible', default: BY_STYLE }, + { bit: 3, key: 'celestialRing', default: BY_STYLE }, + { bit: 4, key: 'scopeEnabled', default: true }, +]); + +/** + * What an omitted layer option means, per option owner and key. Frozen like + * every other default here: it is today's app default, and it stays even if + * that default moves (the long form learned this with `models3d`). A new + * option needs an entry before it ships; until it has one it is always + * written explicitly. + */ +export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + traffic: Object.freeze({ roadMode: null }), + 'weather-lightning': Object.freeze({ opacity: 'strong' }), + 'weather-radar': Object.freeze({ opacity: 'strong' }), + 'weather-satellite': Object.freeze({ + infrared: 100, + opacity: 'strong', + product: 'clouds-regional', + }), + wind: Object.freeze({ + model: 'gfs', + overlay: 'none', + units: 'km/h', + paused: false, + }), + flights: Object.freeze({ + models3d: true, + models3dMode: 'proximity', + selectedFlightsTrackingId: null, + selectedMilitaryTrackingId: null, + }), + satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }), + cctv: Object.freeze({ + coverageMode: 'on', + showProjection: true, + autoHop: false, + }), + 'recent-imagery': Object.freeze({ + west: null, + south: null, + east: null, + north: null, + a: null, + b: null, + mode: 0, + split: 50, + viirs: false, + }), + 'street-level': Object.freeze({ + mapillary: true, + providerSwitches: '', + pano: 'all', + sinceDays: 0, + }), + radio: Object.freeze({ filter: 'all', volume: 0.8 }), +}); + +/** The frozen meaning of an omitted option; unlisted options are always written. */ +function layerOptionAbsentValue(ownerId, spec) { + const owner = SHARE_VIEW_LAYER_OPTION_ABSENT[ownerId]; + return owner && Object.hasOwn(owner, spec.key) ? owner[spec.key] : undefined; +} + +/** + * The top-level field registry. Numbers are permanent and never reused; a + * retired field stays listed with `retired: true`. Defaults are the frozen + * meaning of an omitted field. Types: + * uint unsigned varint sint zigzag varint + * angle whole degrees in [0, 360), stored as zigzag of (-180, 180] + * coord zigzag varint of degrees × 1e4 + * enum varint code from SHARE_VIEW_ENUMS, or LEN text for other names + * epoch zigzag varint of seconds since SHARE_VIEW_EPOCH_S + * flags varint bitset, see SHARE_VIEW_FLAGS + * bytes LEN, structure documented per field + */ +export const SHARE_VIEW_FIELDS = freezeAll([ + { number: 1, key: 'view', type: 'enum', enum: 'viewKind', default: 'place' }, + { number: 2, key: 'lat', type: 'coord', default: 0 }, + { number: 3, key: 'lon', type: 'coord', default: 0 }, + { number: 4, key: 'alt', type: 'sint', default: 800 }, + { number: 5, key: 'heading', type: 'angle', default: 0 }, + { number: 6, key: 'pitch', type: 'sint', default: -35 }, + { number: 7, key: 'roll', type: 'angle', default: 0 }, + { number: 8, key: 'sharedAtMs', type: 'epoch', default: null }, + { number: 9, key: 'flags', type: 'flags', default: 0 }, + { number: 10, key: 'layerState', type: 'bytes', default: null }, + { number: 11, key: 'layerOptions', type: 'bytes', default: null }, + { number: 12, key: 'panelState', type: 'bytes', default: null }, + { number: 13, key: 'thing', type: 'bytes', default: null }, + { number: 14, key: 'style', type: 'enum', enum: 'style', default: 'normal' }, + { + number: 15, + key: 'mapStack', + type: 'enum', + enum: 'mapStack', + default: 'photoreal', + }, + { + number: 16, + key: 'hudVariant', + type: 'enum', + enum: 'hudVariant', + default: BY_STYLE, + }, + { number: 17, key: 'bloomIntensity', type: 'uint', default: BY_STYLE }, + { number: 18, key: 'bloomVersion', type: 'uint', default: 2 }, + { number: 19, key: 'sharpenIntensity', type: 'uint', default: BY_STYLE }, + { + number: 20, + key: 'detectionMode', + type: 'enum', + enum: 'detectionMode', + default: BY_STYLE, + }, + { number: 21, key: 'detectionDensity', type: 'uint', default: BY_STYLE }, + { + number: 22, + key: 'detectionAllocation', + type: 'enum', + enum: 'detectionAllocation', + default: BY_STYLE, + }, + { number: 23, key: 'detectionFadePct', type: 'uint', default: BY_STYLE }, + { + number: 24, + key: 'detectionOutsideOpacityPct', + type: 'uint', + default: BY_STYLE, + }, + { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, + { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, + { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, +]); +const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); +const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; + +/** + * Fields inside the thing message (top-level field 13). Permanent. Fields 1–4 + * are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is + * present. The follow offset and the last-known fix are flat fields here, not + * sub-messages, to save their tag and length bytes. + */ +export const SHARE_VIEW_THING_FIELDS = Object.freeze({ + range: 5, // uint metres from the entity to the camera + heading: 6, // angle, the camera's heading while following + pitch: 7, // sint whole degrees, the camera's pitch while following + lat: 8, // coord, the entity's last-known latitude + lon: 9, // coord, its last-known longitude + alt: 10, // sint metres, its last-known altitude + ageS: 11, // sint, seconds the fix predates the created-at time (field 8) + observedAt: 12, // epoch, when the fix was taken; only without a created-at +}); + +/** The default of a field or flag for a view in `style`. */ +function defaultOf(entry, style) { + if (entry.default !== BY_STYLE) return entry.default; + const defaults = + SHARE_VIEW_STYLE_DEFAULTS[style] ?? SHARE_VIEW_STYLE_DEFAULTS.normal; + return defaults[entry.key]; +} + +/** + * The complete default state for a view in `style` (Normal when omitted): + * what a payload with only that style decodes to. + */ +export function createDefaultShareViewState(requested = 'normal') { + const style = VIEW_STYLES.includes(requested) ? requested : 'normal'; + const state = { view: { kind: 'place' } }; + for (const field of SHARE_VIEW_FIELDS) { + if (field.key === 'view' || field.key === 'flags') continue; + if (field.key === 'thing' || field.key === 'layerOptions') continue; + state[field.key] = + field.key === 'style' ? style : structuredClone(defaultOf(field, style)); + } + for (const flag of SHARE_VIEW_FLAGS) state[flag.key] = defaultOf(flag, style); + return state; +} + +/* ------------------------------------------------------------------ */ +/* Normalization: the one canonical state both directions agree on. */ +/* ------------------------------------------------------------------ */ + +const finite = (value) => + typeof value === 'number' + ? Number.isFinite(value) + : typeof value === 'string' && + value.trim() !== '' && + Number.isFinite(Number(value)); +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +// `+ 0` turns -0 into 0, which is what the decoder produces. +function intOr(value, fallback, min, max) { + return finite(value) + ? clamp(Math.round(Number(value)), min, max) + 0 + : fallback; +} + +function wrapAngle(value, fallback = 0) { + if (!finite(value)) return fallback; + return ((Math.round(Number(value)) % 360) + 360) % 360; +} + +function latitudeOr(value, fallback) { + if (!finite(value)) return fallback; + return ( + Math.round(clamp(Number(value), -90, 90) * COORDINATE_SCALE) / + COORDINATE_SCALE + + 0 + ); +} + +function longitudeOr(value, fallback) { + if (!finite(value)) return fallback; + let lon = Number(value); + // Wrap before scaling, so a huge finite input cannot overflow to NaN. + if (lon < -180 || lon > 180) lon = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.round(lon * COORDINATE_SCALE) / COORDINATE_SCALE + 0; +} + +function epochMsOr(value) { + if (!finite(value)) return null; + const seconds = Math.floor(Number(value) / 1000); + return seconds >= 1 && Number.isSafeInteger(seconds * 1000) + ? seconds * 1000 + : null; +} + +function nameOr(value, fallback) { + return typeof value === 'string' && NAME_GRAMMAR.test(value) + ? value + : fallback; +} + +function normalizeEntityId(entry, value) { + if (value === null || value === undefined) return null; + const raw = + typeof value === 'number' && Number.isFinite(value) + ? String(value) + : typeof value === 'string' + ? value.trim().toLowerCase() + : null; + if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null; + if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null; + return raw; +} + +function normalizeStyleParams(style, values) { + const registry = SHARE_VIEW_STYLE_PARAMS[style]; + if (!registry || !values || typeof values !== 'object') return null; + const out = {}; + for (const spec of registry) { + if (!Object.hasOwn(values, spec.key) || !finite(values[spec.key])) continue; + out[spec.key] = + Math.round(clamp(Number(values[spec.key]), spec.min, spec.max) * 100) / + 100; + } + return Object.keys(out).length ? out : null; +} + +/** + * What each style leaves the shareable settings at when it is selected in a + * fresh session: the app's first-load baseline (GLOBAL_POST_DEFAULTS), with + * the style's own preset (STYLE_PRESET_DEFAULTS) on top, and its shader + * parameters at their defaults (the preset's, else the shader's). Read from + * the tables the app applies, so a fresh view in any style encodes as its + * camera and style alone. + * + * These are format v1's frozen meanings. If the app's tables move, the test + * that pins this table fails: freeze the old values here then, rather than + * letting old links change meaning. + */ +export const SHARE_VIEW_STYLE_DEFAULTS = Object.freeze( + Object.fromEntries( + VIEW_STYLES.map((style) => { + const g = GLOBAL_POST_DEFAULTS; + const preset = STYLE_PRESET_DEFAULTS[style] ?? {}; + const detection = migrateDetectionState( + preset.detection?.mode?.toUpperCase?.() ?? g.detectionMode, + preset.detection?.densityPct ?? g.detectionDensity, + g.detectionDensity, + ); + const params = {}; + for (const spec of SHARE_VIEW_STYLE_PARAMS[style] ?? []) { + const value = + preset.styleParams?.[style]?.[spec.key] ?? + STYLES[style]?.uniforms?.[spec.key]?.default; + if (finite(value)) params[spec.key] = value; + } + return [ + style, + Object.freeze({ + bloom: preset.bloom?.enabled ?? g.bloom.enabled, + bloomIntensity: preset.bloom?.intensity ?? g.bloom.intensity, + sharpen: preset.sharpen?.enabled ?? g.sharpen.enabled, + sharpenIntensity: preset.sharpen?.intensity ?? g.sharpen.intensity, + hudVariant: preset.hudVariant ?? g.hudVariant, + hudVisible: preset.hudVisible ?? g.hudVisible, + detectionMode: detection.enabled ? detection.profile : 'OFF', + detectionDensity: detection.densityPct, + detectionAllocation: normalizeAllocationStrategy( + g.detectionAllocation, + ), + detectionFadePct: g.detectionFadePct, + detectionOutsideOpacityPct: g.detectionOutsideOpacityPct, + // Leaving Normal switches the ring off (VisualSettings.setStyle). + celestialRing: style === 'normal' ? g.celestialRing : false, + styleParams: Object.freeze(normalizeStyleParams(style, params)), + }), + ]; + }), + ), +); + +function normalizePanelState(panelState) { + if (!panelState || !Array.isArray(panelState.specs)) return null; + const byId = new Map(); + for (const spec of panelState.specs) { + if (spec && PANEL_BY_ID.has(spec.id) && typeof spec.collapsed === 'boolean') + byId.set(spec.id, spec); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + if (!spec) continue; + specs.push({ + id: panel.id, + collapsed: spec.collapsed, + pinned: + panel.pinnable && typeof spec.pinned === 'boolean' ? spec.pinned : null, + }); + } + return specs.length ? { specs } : null; +} + +function normalizeFollowOffset(offset) { + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const source = offset && typeof offset === 'object' ? offset : {}; + return { + range: intOr(source.range, d.range, 1, MAX_FOLLOW_RANGE_M), + heading: wrapAngle(source.heading, d.heading), + pitch: intOr(source.pitch, d.pitch, -90, 90), + }; +} + +function normalizeLastKnown(fix) { + if (!fix || typeof fix !== 'object') return null; + if (!finite(fix.lat) || !finite(fix.lon)) return null; + return { + lat: latitudeOr(fix.lat, 0), + lon: longitudeOr(fix.lon, 0), + alt: intOr(fix.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M), + observedAtMs: epochMsOr(fix.observedAtMs), + }; +} + +function thingView(kind, id, offset = null, lastKnown = null) { + return { + kind: 'thing', + entity: { kind, id }, + offset: normalizeFollowOffset(offset), + lastKnown: normalizeLastKnown(lastKnown), + }; +} + +function normalizeView(view) { + if (view?.kind !== 'thing') return { kind: 'place' }; + const entry = ENTITY_BY_KIND.get(view.entity?.kind); + const id = entry ? normalizeEntityId(entry, view.entity?.id) : null; + if (!id) return { kind: 'place' }; + return thingView(entry.kind, id, view.offset, view.lastKnown); +} + +/** The camera fields of `state`, at the format's precision. */ +function setCamera(state, camera) { + state.lat = latitudeOr(camera.lat, 0); + state.lon = longitudeOr(camera.lon, 0); + state.alt = intOr(camera.alt, 0, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(camera.heading, 0); + state.pitch = intOr(camera.pitch, 0, -90, 90); + state.roll = wrapAngle(camera.roll, 0); +} + +/** + * Lift a long-form tracking selection out of the layer options into a thing + * view. In this format the view, not a layer option, says what is followed. + */ +function liftTrackedEntity(layerState, view) { + let lifted = view; + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const policy = entry.tracking + ? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking] + : null; + if (!policy) continue; + const options = layerState.options[policy.optionOwner]; + const id = normalizeEntityId(entry, options[policy.optionKey]); + options[policy.optionKey] = null; + if (id && lifted.kind === 'place') lifted = thingView(entry.kind, id); + } + return lifted; +} + +/** + * The canonical form of a share-view state. Missing or invalid fields take + * their defaults, numbers take the format's precision, and a long-form + * tracking selection becomes a thing view. A thing view with a last-known fix + * takes its camera from the fix and the follow offset, and a thing view's + * layer is always on. `decodeShareView` returns this + * form, and `encodeShareView(state)` decodes back to `normalizeShareViewState(state)`. + */ +export function normalizeShareViewState(input = {}) { + const source = input && typeof input === 'object' ? input : {}; + const style = VIEW_STYLES.includes(source.style) ? source.style : 'normal'; + // Style-applied settings default to what this style applies. + const d = createDefaultShareViewState(style); + const state = {}; + state.lat = latitudeOr(source.lat, d.lat); + state.lon = longitudeOr(source.lon, d.lon); + state.alt = intOr(source.alt, d.alt, -MAX_ALTITUDE_M, MAX_ALTITUDE_M); + state.heading = wrapAngle(source.heading, d.heading); + state.pitch = intOr(source.pitch, d.pitch, -90, 90); + state.roll = wrapAngle(source.roll, d.roll); + state.style = style; + // The style's own parameters, with any valid ones the source sets on top. + state.styleParams = normalizeStyleParams(style, { + ...d.styleParams, + ...normalizeStyleParams(style, source.styleParams), + }); + for (const flag of SHARE_VIEW_FLAGS) + state[flag.key] = + typeof source[flag.key] === 'boolean' ? source[flag.key] : d[flag.key]; + state.bloomIntensity = intOr( + source.bloomIntensity, + d.bloomIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.bloomVersion = intOr( + source.bloomVersion, + d.bloomVersion, + 0, + MAX_UNSIGNED_SETTING, + ); + state.sharpenIntensity = intOr( + source.sharpenIntensity, + d.sharpenIntensity, + 0, + MAX_UNSIGNED_SETTING, + ); + state.hudVariant = nameOr(source.hudVariant, d.hudVariant); + const detection = migrateDetectionState( + typeof source.detectionMode === 'string' && source.detectionMode + ? source.detectionMode + : d.detectionMode, + finite(source.detectionDensity) + ? Number(source.detectionDensity) + : d.detectionDensity, + d.detectionDensity, + ); + state.detectionMode = detection.enabled ? detection.profile : 'OFF'; + state.detectionDensity = detection.densityPct; + state.detectionAllocation = normalizeAllocationStrategy( + source.detectionAllocation, + d.detectionAllocation, + ); + state.detectionFadePct = intOr( + source.detectionFadePct, + d.detectionFadePct, + 0, + 40, + ); + state.detectionOutsideOpacityPct = intOr( + source.detectionOutsideOpacityPct, + d.detectionOutsideOpacityPct, + 0, + 100, + ); + state.scopeFeatherPct = finite(source.scopeFeatherPct) + ? clampScopeFeatherPct(Number(source.scopeFeatherPct)) + : d.scopeFeatherPct; + state.scopeTerminusPct = intOr( + source.scopeTerminusPct, + null, + SCOPE_TERMINUS_MIN_PCT, + SCOPE_TERMINUS_MAX_PCT, + ); + state.mapStack = nameOr(source.mapStack, d.mapStack); + state.layerState = source.layerState + ? normalizeLayerState(source.layerState) + : null; + state.panelState = normalizePanelState(source.panelState); + state.sharedAtMs = epochMsOr(source.sharedAtMs); + let view = normalizeView(source.view); + if (state.layerState) view = liftTrackedEntity(state.layerState, view); + if (view.kind === 'thing') { + // A fix without its own time is as of the link's creation. + if (view.lastKnown?.observedAtMs === null && state.sharedAtMs !== null) + view.lastKnown.observedAtMs = state.sharedAtMs; + // A thing view's camera is where following it puts the camera, and its + // layer is on: neither is stored separately. + if (view.lastKnown) + setCamera(state, followCameraFor(view.lastKnown, view.offset)); + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + if (state.layerState && !state.layerState.enabledLayerIds.includes(layerId)) + state.layerState = normalizeLayerState({ + ...state.layerState, + enabledLayerIds: [...state.layerState.enabledLayerIds, layerId], + }); + } + return { view, ...state }; +} + +/* ------------------------------------------------------------------ */ +/* Token and id helpers. */ +/* ------------------------------------------------------------------ */ + +/** + * The permanent number of a layer-state token: one character is its index in + * the base-36 alphabet, two characters are 36 + first × 36 + second. The + * token ledger never reassigns a token, so neither does this. + */ +export function layerTokenNumber(token) { + const a = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); + if (token.length === 1) return a; + return 36 + a * 36 + LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); +} + +export function layerTokenFromNumber(number) { + const base = LAYER_STATE_TOKEN_ALPHABET.length; + if (number < base) return LAYER_STATE_TOKEN_ALPHABET[number]; + const rest = number - base; + if (rest >= base * base) return null; + return ( + LAYER_STATE_TOKEN_ALPHABET[Math.floor(rest / base)] + + LAYER_STATE_TOKEN_ALPHABET[rest % base] + ); +} + +const REGISTRY_BY_TOKEN = new Map( + LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), +); +const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char); +const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/; + +function writeEntityId(writer, entry, id) { + const field = entry.code; + if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id)) + return writer.uintField(field, Number.parseInt(id, 16)); + if (entry.idForm === 'decimal' && /^[1-9]\d{0,14}$/.test(id)) + return writer.uintField(field, Number(id)); + return writer.textField(field, id); +} + +/* ------------------------------------------------------------------ */ +/* Encoder. */ +/* ------------------------------------------------------------------ */ + +function writeEnum(writer, field, value) { + const code = SHARE_VIEW_ENUMS[field.enum].indexOf(value); + if (code >= 0) writer.uintField(field.number, code); + else writer.textField(field.number, value); +} + +function encodeLayers(writer, layerState, impliedLayerId) { + const bitset = []; + for (const id of layerState.enabledLayerIds) { + if (id === impliedLayerId) continue; + const number = layerTokenNumber(REGISTRY_BY_ID.get(id).token); + const byte = Math.floor(number / 8); + while (bitset.length <= byte) bitset.push(0); + bitset[byte] |= 1 << (number % 8); + } + writer.bytesField(fieldNumber('layerState'), bitset); + const blob = new WireWriter(); + for (const [ownerId, options] of Object.entries(layerState.options)) { + const ownerToken = REGISTRY_BY_ID.get(ownerId).token; + for (const spec of optionSpecs(ownerId)) { + const current = options[spec.key]; + if (current === null || current === layerOptionAbsentValue(ownerId, spec)) + continue; + const value = spec.encode(options[spec.key]); + blob.varint(layerTokenNumber(ownerToken)); + const isInteger = + CANONICAL_INTEGER.test(value) && + Math.abs(Number(value)) <= MAX_ZIGZAG_MAGNITUDE; + blob.varint(charNumber(spec.token) * 2 + (isInteger ? 0 : 1)); + if (isInteger) blob.varint(zigzag(Number(value))); + else { + const bytes = asciiBytes(value); + blob.varint(bytes.length); + blob.bytes.push(...bytes); + } + } + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('layerOptions'), blob.finish()); +} + +const triState = (value) => (value === null ? 0 : value ? 2 : 1); + +function encodePanels(writer, panelState) { + const byId = new Map(panelState.specs.map((spec) => [spec.id, spec])); + const blob = new WireWriter(); + for (const panel of SHARE_VIEW_PANELS) { + const spec = byId.get(panel.id); + const defaultPinned = panel.pinnable ? false : null; + if ( + spec && + spec.collapsed === panel.collapsed && + spec.pinned === defaultPinned + ) + continue; + const collapsed = spec ? triState(spec.collapsed) : 0; + const pinned = spec ? triState(spec.pinned) : 0; + blob.varint(panel.number * 16 + collapsed * 4 + pinned); + } + writer.bytesField(fieldNumber('panelState'), blob.finish()); +} + +function encodeStyleParams(writer, style, values) { + const defaults = SHARE_VIEW_STYLE_DEFAULTS[style].styleParams ?? {}; + const blob = new WireWriter(); + for (const spec of SHARE_VIEW_STYLE_PARAMS[style]) { + // Only the parameters that differ from the style's own. + if (!Object.hasOwn(values, spec.key)) continue; + if (values[spec.key] === defaults[spec.key]) continue; + blob + .varint(charNumber(spec.token)) + .varint(Math.round(values[spec.key] * 100)); + } + if (blob.bytes.length) + writer.bytesField(fieldNumber('styleParams'), blob.finish()); +} + +const CAMERA_KEYS = new Set(['lat', 'lon', 'alt', 'heading', 'pitch', 'roll']); +const coordinateInt = (degrees) => Math.round(degrees * COORDINATE_SCALE); +const signedAngle = (degrees) => (degrees > 180 ? degrees - 360 : degrees); +const epochSeconds = (ms) => ms / 1000 - SHARE_VIEW_EPOCH_S; + +function encodeThing(writer, view, state) { + const entry = ENTITY_BY_KIND.get(view.entity.kind); + const f = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const thing = new WireWriter(); + writeEntityId(thing, entry, view.entity.id); + const { range, heading, pitch } = view.offset; + if (range !== d.range) thing.uintField(f.range, range); + if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading)); + if (pitch !== d.pitch) thing.sintField(f.pitch, pitch); + const fix = view.lastKnown; + if (fix) { + // Both coordinates are always written: their presence marks the fix. + thing.sintField(f.lat, coordinateInt(fix.lat)); + thing.sintField(f.lon, coordinateInt(fix.lon)); + if (fix.alt) thing.sintField(f.alt, fix.alt); + const observed = fix.observedAtMs; + // An absent age is 0: a fix taken as the link is made costs nothing. + if (observed !== null && state.sharedAtMs !== null) { + if (observed !== state.sharedAtMs) + thing.sintField(f.ageS, (state.sharedAtMs - observed) / 1000); + } else if (observed !== null) + thing.sintField(f.observedAt, epochSeconds(observed)); + } + writer.bytesField(fieldNumber('thing'), thing.finish()); +} + +/** The base64url text for `bytes` followed by their CRC-8. */ +function sealPayload(bytes) { + const sealed = new Uint8Array(bytes.length + 1); + sealed.set(bytes); + sealed[bytes.length] = crc8(bytes); + return encodeBase64Url(sealed); +} + +/** + * Encode a share-view state as its share code: base64url text, the `` + * in `/share/`. The state is normalized first; omitted or default + * fields cost nothing. + */ +export function encodeShareView(input) { + const state = normalizeShareViewState(input); + const thing = state.view.kind === 'thing' ? state.view : null; + // A thing view with a fix derives its camera from it (followCameraFor). + const cameraDerived = Boolean(thing?.lastKnown); + const impliedLayerId = thing + ? ENTITY_BY_KIND.get(thing.entity.kind).layerId + : null; + const writer = new WireWriter(); + writer.bytes.push(SHARE_VIEW_FORMAT_VERSION); + for (const field of SHARE_VIEW_FIELDS) { + if (cameraDerived && CAMERA_KEYS.has(field.key)) continue; + switch (field.key) { + case 'view': + // Absent means `thing` when field 13 is present, else `place`. + if (state.view.kind !== (thing ? 'thing' : 'place')) + writeEnum(writer, field, state.view.kind); + break; + case 'lat': + case 'lon': + if (state[field.key] !== field.default) + writer.sintField(field.number, coordinateInt(state[field.key])); + break; + case 'heading': + case 'roll': + if (state[field.key] !== field.default) + writer.sintField(field.number, signedAngle(state[field.key])); + break; + case 'sharedAtMs': + if (state.sharedAtMs !== null) + writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + break; + case 'flags': { + let bits = 0; + for (const flag of SHARE_VIEW_FLAGS) + if (state[flag.key] !== defaultOf(flag, state.style)) + bits += 2 ** flag.bit; + if (bits) writer.uintField(field.number, bits); + break; + } + case 'layerState': + if (state.layerState) + encodeLayers(writer, state.layerState, impliedLayerId); + break; + case 'layerOptions': + break; // written together with layerState + case 'panelState': + if (state.panelState) encodePanels(writer, state.panelState); + break; + case 'thing': + if (thing) encodeThing(writer, thing, state); + break; + case 'styleParams': + if (state.styleParams) + encodeStyleParams(writer, state.style, state.styleParams); + break; + default: + if (state[field.key] === defaultOf(field, state.style)) break; + if (field.type === 'enum') writeEnum(writer, field, state[field.key]); + else if (field.type === 'sint') + writer.sintField(field.number, state[field.key]); + else writer.uintField(field.number, state[field.key]); + } + } + return sealPayload(writer.finish()); +} + +/* ------------------------------------------------------------------ */ +/* Decoder. */ +/* ------------------------------------------------------------------ */ + +/** Collects every field, code or entry a v1 decoder did not understand. */ +class UnknownLog { + constructor() { + this.entries = []; + } + field(scope, field, wireType) { + this.entries.push({ scope, field, wireType }); + } + code(scope, code) { + this.entries.push({ scope, code }); + } +} + +/** + * Read one message's fields, enforcing each known field's wire types and + * rejecting repeats, skipping (and logging) unknown fields. + * `known` maps field number → allowed wire types. + */ +function readMessage(reader, known, scope, unknown) { + const values = new Map(); + while (!reader.done) { + const { field, wireType } = reader.tag(); + const allowed = known.get(field); + if (!allowed) { + unknown.field(scope, field, wireType); + reader.skip(wireType); + continue; + } + if (!allowed.includes(wireType)) + throw new ShareWireError('wire-type-mismatch', `${scope}.${field}`); + if (values.has(field)) + throw new ShareWireError('duplicate-field', `${scope}.${field}`); + values.set( + field, + wireType === WIRE_LEN + ? { len: reader.lengthDelimited() } + : { varint: reader.varint() }, + ); + } + return values; +} + +const WIRE_TYPES_BY_TYPE = Object.freeze({ + uint: [WIRE_VARINT], + sint: [WIRE_VARINT], + angle: [WIRE_VARINT], + coord: [WIRE_VARINT], + epoch: [WIRE_VARINT], + flags: [WIRE_VARINT], + enum: [WIRE_VARINT, WIRE_LEN], + bytes: [WIRE_LEN], +}); +const TOP_LEVEL_WIRE_TYPES = new Map( + SHARE_VIEW_FIELDS.filter((f) => !f.retired).map((f) => [ + f.number, + WIRE_TYPES_BY_TYPE[f.type], + ]), +); +const THING_WIRE_TYPES = new Map([ + ...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]), + ...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]), +]); + +const unz = unzigzag; +const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']); + +function readEnum(entry, field, unknown) { + if (!entry) return undefined; + if (entry.len) { + const text = asciiText(entry.len.rest()); + // Detection settings are closed sets the app interprets; an unknown name + // must not reach the long form's lenient migration (which turns it ON). + if ( + CLOSED_ENUMS.has(field.key) && + !SHARE_VIEW_ENUMS[field.enum].includes(text) + ) { + unknown.code(`enum:${field.key}`, text); + return undefined; + } + return text; + } + const name = SHARE_VIEW_ENUMS[field.enum][entry.varint]; + if (name === undefined) unknown.code(`enum:${field.key}`, entry.varint); + return name; +} + +function decodeLayers(bitsReader, optionsReader, unknown) { + const enabledLayerIds = []; + const bits = bitsReader.rest(); + for (let byte = 0; byte < bits.length; byte += 1) { + for (let bit = 0; bit < 8; bit += 1) { + if (!(bits[byte] & (1 << bit))) continue; + const number = byte * 8 + bit; + const entry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(number)); + if (entry) enabledLayerIds.push(entry.id); + else unknown.code('layer', number); + } + } + const options = {}; + const seen = new Set(); + const reader = optionsReader; + while (reader && !reader.done) { + const layerNumber = reader.varint(); + const key = reader.varint(); + let value; + if (key % 2 === 0) value = String(unz(reader.varint())); + else { + const length = reader.varint(); + if (length > MAX_OPTION_VALUE_CHARS || length > reader.end - reader.pos) + throw new ShareWireError('truncated', 'layer option'); + value = asciiText(reader.bytes.slice(reader.pos, reader.pos + length)); + reader.pos += length; + } + const slot = `${layerNumber}.${Math.floor(key / 2)}`; + if (seen.has(slot)) throw new ShareWireError('duplicate-entry', slot); + seen.add(slot); + // Options are filed under their owner layer only (never a mirror such as + // `military`), so each option has exactly one spelling. + const layerEntry = REGISTRY_BY_TOKEN.get(layerTokenFromNumber(layerNumber)); + const ownerId = + layerEntry?.optionOwner === layerEntry?.id + ? layerEntry?.optionOwner + : null; + const optionToken = LAYER_STATE_TOKEN_ALPHABET[Math.floor(key / 2)]; + const spec = ownerId + ? optionSpecs(ownerId).find( + (candidate) => candidate.token === optionToken, + ) + : null; + const decoded = spec ? spec.decode(value) : null; + if (decoded === null || decoded === undefined) { + unknown.code('layer-option', slot); + continue; + } + options[ownerId] ??= {}; + options[ownerId][spec.key] = decoded; + } + // Fill every option the payload did not carry with ITS frozen meaning, not + // the app's current default (normalization would substitute that). + for (const ownerId of Object.keys(SHARE_VIEW_LAYER_OPTION_ABSENT)) { + options[ownerId] ??= {}; + for (const spec of optionSpecs(ownerId)) { + if (!Object.hasOwn(options[ownerId], spec.key)) + options[ownerId][spec.key] = layerOptionAbsentValue(ownerId, spec); + } + } + return normalizeLayerState({ enabledLayerIds, options }); +} + +function decodePanels(reader, unknown) { + const entries = new Map(); + while (!reader.done) { + const value = reader.varint(); + const number = Math.floor(value / 16); + if (entries.has(number)) + throw new ShareWireError('duplicate-entry', `panel ${number}`); + entries.set(number, { + collapsed: Math.floor(value / 4) % 4, + pinned: value % 4, + }); + } + for (const [number, entry] of entries) { + if ( + !PANEL_BY_NUMBER.has(number) || + entry.collapsed === 3 || + entry.pinned === 3 + ) + unknown.code('panel', number); + } + const specs = []; + for (const panel of SHARE_VIEW_PANELS) { + const entry = entries.get(panel.number); + if (entry) { + // 0 is "no opinion"; 3 is a value from a newer format, also ignored. + if (entry.collapsed === 0 || entry.collapsed === 3 || entry.pinned === 3) + continue; + specs.push({ + id: panel.id, + collapsed: entry.collapsed === 2, + pinned: + panel.pinnable && entry.pinned !== 0 ? entry.pinned === 2 : null, + }); + } else { + specs.push({ + id: panel.id, + collapsed: panel.collapsed, + pinned: panel.pinnable ? false : null, + }); + } + } + return { specs }; +} + +function decodeStyleParams(reader, style, unknown) { + const registry = SHARE_VIEW_STYLE_PARAMS[style] || []; + const values = {}; + const seen = new Set(); + while (!reader.done) { + const index = reader.varint(); + const token = LAYER_STATE_TOKEN_ALPHABET[index]; + const scaled = reader.varint(); + if (seen.has(index)) + throw new ShareWireError('duplicate-entry', 'style parameter'); + seen.add(index); + const spec = registry.find((candidate) => candidate.token === token); + if (!spec) { + unknown.code('style-param', `${style}.${token ?? '?'}`); + continue; + } + values[spec.key] = clamp(scaled / 100, spec.min, spec.max); + } + return values; +} + +function decodeEntityId(entry, value) { + if (!entry) return null; + if (value.len) return normalizeEntityId(entry, asciiText(value.len.rest())); + if (entry.idForm === 'hex24') + return value.varint <= 0xffffff + ? value.varint.toString(16).padStart(6, '0') + : null; + return normalizeEntityId(entry, String(value.varint)); +} + +function decodeThing(reader, createdAtS, unknown) { + const unknownBefore = unknown.entries.length; + const fields = readMessage(reader, THING_WIRE_TYPES, 'thing', unknown); + const F = SHARE_VIEW_THING_FIELDS; + const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT; + const signed = (n) => unz(fields.get(n)?.varint ?? 0); + const offset = { + range: fields.get(F.range)?.varint ?? d.range, + heading: fields.has(F.heading) ? signed(F.heading) : d.heading, + pitch: fields.has(F.pitch) ? signed(F.pitch) : d.pitch, + }; + let lastKnown = null; + if (fields.has(F.lat) || fields.has(F.lon)) { + let observedAtMs = null; + if (fields.has(F.observedAt)) + observedAtMs = (SHARE_VIEW_EPOCH_S + signed(F.observedAt)) * 1000; + else if (createdAtS !== null) + observedAtMs = (createdAtS - signed(F.ageS)) * 1000; + lastKnown = { + lat: signed(F.lat) / COORDINATE_SCALE, + lon: signed(F.lon) / COORDINATE_SCALE, + alt: signed(F.alt), + observedAtMs, + }; + if (Math.abs(lastKnown.lat) > 90 || Math.abs(lastKnown.lon) > 180) + throw new ShareWireError('out-of-range', 'thing fix'); + } + // The camera this view falls back to, whatever happens to the entity. + const camera = lastKnown + ? followCameraFor(lastKnown, normalizeFollowOffset(offset)) + : null; + const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code)); + if (present.length > 1) + throw new ShareWireError('duplicate-field', 'thing entity id'); + const entry = present[0]; + if (!entry) { + // A newer app's entity kind uses a thing field this decoder skipped. + const newerKind = unknown.entries.length > unknownBefore; + return { + camera, + fallback: newerKind ? 'unknown-entity-kind' : 'missing-entity-kind', + }; + } + const id = decodeEntityId(entry, fields.get(entry.code)); + if (!id) return { camera, fallback: 'invalid-entity-id' }; + return { camera, view: thingView(entry.kind, id, offset, lastKnown) }; +} + +/** + * Decode a share code (the `` in `/share/`). + * + * Returns `{ ok: true, state, unknownFields?, fallback? }` with a normalized + * state, or `{ ok: false, reason }`. It never throws. A code whose check byte + * does not match is rejected (`checksum`), so a mistyped code never opens a + * different view. `unknownFields` lists what a newer encoder wrote that this + * decoder skipped; `fallback` says why a view that asked to follow something + * opens as a place at its camera instead. A created-at time in the future + * (after `nowMs`) is dropped. + */ +export function decodeShareView(code, { nowMs = Date.now() } = {}) { + try { + if (typeof code !== 'string') return { ok: false, reason: 'not-a-string' }; + if (code.length > MAX_SHARE_VIEW_CHARS) + return { ok: false, reason: 'too-long' }; + const sealed = decodeBase64Url(code); + if (sealed.length < 2) return { ok: false, reason: 'truncated' }; + const bytes = sealed.subarray(0, sealed.length - 1); + if (crc8(bytes) !== sealed[sealed.length - 1]) + return { ok: false, reason: 'checksum' }; + if (bytes[0] !== SHARE_VIEW_FORMAT_VERSION) + return { ok: false, reason: 'unsupported-version' }; + const unknown = new UnknownLog(); + const fields = readMessage( + new WireReader(bytes, 1), + TOP_LEVEL_WIRE_TYPES, + 'view', + unknown, + ); + const get = (key) => fields.get(fieldNumber(key)); + + const raw = {}; + for (const field of SHARE_VIEW_FIELDS) { + const entry = fields.get(field.number); + if ( + !entry || + field.type === 'bytes' || + field.type === 'flags' || + field.key === 'view' + ) + continue; + if (field.type === 'enum') + raw[field.key] = readEnum(entry, field, unknown); + else if (field.type === 'coord') + raw[field.key] = unz(entry.varint) / COORDINATE_SCALE; + else if (field.type === 'sint' || field.type === 'angle') + raw[field.key] = unz(entry.varint); + else if (field.type === 'uint') raw[field.key] = entry.varint; + } + for (const key of ['lat', 'lon']) { + raw[key] ??= 0; + if (Math.abs(raw[key]) > (key === 'lat' ? 90 : 180)) + return { ok: false, reason: 'out-of-range' }; + } + + if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) { + if (get('style').len) unknown.code('enum:style', raw.style); + raw.style = undefined; + } + for (const key of ['hudVariant', 'mapStack']) { + if (raw[key] !== undefined && !NAME_GRAMMAR.test(raw[key])) + unknown.code(`enum:${key}`, raw[key]); + } + + const flags = get('flags')?.varint ?? 0; + for (const flag of SHARE_VIEW_FLAGS) + raw[flag.key] = + (Math.floor(flags / 2 ** flag.bit) % 2 === 1) !== + defaultOf(flag, raw.style ?? 'normal'); + const knownBits = 2 ** SHARE_VIEW_FLAGS.length - 1; + if (flags > knownBits) + unknown.code('flags', flags - (flags % (knownBits + 1))); + + let createdAtS = null; + if (get('sharedAtMs')) { + createdAtS = SHARE_VIEW_EPOCH_S + unz(get('sharedAtMs').varint); + const ms = createdAtS * 1000; + raw.sharedAtMs = + createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; + } + + if (get('layerState')) + raw.layerState = decodeLayers( + get('layerState').len, + get('layerOptions')?.len ?? null, + unknown, + ); + else if (get('layerOptions')) + unknown.code('layer-option', 'without-layers'); + if (get('panelState')) + raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('styleParams')) + raw.styleParams = decodeStyleParams( + get('styleParams').len, + raw.style ?? 'normal', + unknown, + ); + + let fallback; + const thing = get('thing') + ? decodeThing(get('thing').len, createdAtS, unknown) + : null; + // A thing with a last-known fix carries the view's camera. + if (thing?.camera) Object.assign(raw, thing.camera); + const viewEntry = get('view'); + const viewKind = viewEntry + ? viewEntry.len + ? asciiText(viewEntry.len.rest()) + : (SHARE_VIEW_ENUMS.viewKind[viewEntry.varint] ?? viewEntry.varint) + : thing + ? 'thing' + : 'place'; + if (viewKind === 'thing') { + if (thing?.view) raw.view = thing.view; + else fallback = thing?.fallback ?? 'missing-thing'; + } else if (viewKind !== 'place') { + unknown.code('enum:view', viewKind); + fallback = 'unknown-view-kind'; + } + + const state = normalizeShareViewState(raw); + const result = { ok: true, state }; + if (unknown.entries.length) result.unknownFields = unknown.entries; + if (fallback) result.fallback = fallback; + return result; + } catch (error) { + if (error instanceof ShareWireError) + return { ok: false, reason: error.reason }; + return { ok: false, reason: 'internal-error' }; + } +} diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs new file mode 100644 index 000000000..4ec2b967c --- /dev/null +++ b/src/share/viewCodec.test.mjs @@ -0,0 +1,1307 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LAYER_STATE_REGISTRY, + LAYER_STATE_TOKEN_RESERVATIONS, + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; +import { VIEW_FOLLOW_KINDS, VIEW_MAPS, VIEW_STYLES } from '../view/index.js'; +import { HUD_LAYOUTS } from '../hudLayoutPolicy.js'; +import { ALLOCATION_STRATEGIES } from '../data/detectionPolicy.js'; +import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from '../bloom.js'; +import { + pick, + randomLayerState, + rng, +} from '../testSupport/shareViewFixtures.mjs'; +import { + SCOPE_TERMINUS_MAX_PCT, + SCOPE_TERMINUS_MIN_PCT, +} from '../scopeMask.js'; +import { + decodePanelStateParams, + decodeStyleParamState, + encodeStyleParamState, +} from '../sharelink.js'; +import { + WIRE_FIXED32, + WIRE_FIXED64, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, +} from './wire.js'; +import { + MAX_SHARE_VIEW_CHARS, + SHARE_VIEW_ENTITY_KINDS, + SHARE_VIEW_ENUMS, + SHARE_VIEW_EPOCH_S, + SHARE_VIEW_FIELDS, + BY_STYLE, + SHARE_VIEW_FLAGS, + SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, + SHARE_VIEW_STYLE_DEFAULTS, + SHARE_VIEW_LAYER_OPTION_ABSENT, + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + createDefaultShareViewState, + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetFromEnu, + followOffsetToEnu, + layerTokenFromNumber, + layerTokenNumber, + normalizeShareViewState, +} from './viewCodec.js'; + +const NOW_MS = Date.UTC(2026, 9, 26, 12, 0, 0); +const decode = (hash) => decodeShareView(hash, { nowMs: NOW_MS }); +/** The payload bytes of a code, without its check byte. */ +const payloadBytes = (code) => decodeBase64Url(code).slice(0, -1); +/** The code for payload bytes: base64url of the bytes and their CRC-8. */ +const hashOf = (bytes) => encodeBase64Url([...bytes, crc8(bytes)]); + +/** Decode and require success, returning the result. */ +function decodeOk(hash) { + const result = decode(hash); + assert.equal(result.ok, true, `decode failed: ${result.reason}`); + return result; +} + +function assertRoundTrip(state) { + const hash = encodeShareView(state); + const result = decodeOk(hash); + assert.deepEqual(result.state, normalizeShareViewState(state)); + assert.equal(result.unknownFields, undefined); + assert.equal(result.fallback, undefined); + assert.equal(encodeShareView(result.state), hash, 'encoding is canonical'); + return hash; +} + +function randomState(random) { + const int = (min, max) => min + Math.floor(random() * (max - min + 1)); + const maybe = (p, value) => (random() < p ? value : undefined); + const style = pick(random, VIEW_STYLES); + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const lat = int(-900000, 900000) / 1e4; + const lon = int(-1800000, 1800000) / 1e4; + const state = { + lat, + lon, + alt: pick(random, [800, int(-400, 50000), int(0, 40_000_000)]), + heading: int(0, 359), + pitch: int(-90, 90), + roll: pick(random, [0, int(0, 359)]), + style, + styleParams: + params && random() < 0.6 + ? Object.fromEntries( + params + .filter(() => random() < 0.7) + .map((s) => [s.key, int(s.min * 100, s.max * 100) / 100]), + ) + : null, + bloomIntensity: maybe(0.5, int(0, 100)), + bloomVersion: maybe(0.3, pick(random, [1, 2])), + sharpenIntensity: maybe(0.5, int(0, 100)), + hudVariant: maybe(0.5, pick(random, [...HUD_LAYOUTS, 'future-hud'])), + detectionMode: maybe(0.6, pick(random, SHARE_VIEW_ENUMS.detectionMode)), + detectionDensity: maybe(0.6, pick(random, [0, 25, 50, 75, 100])), + detectionAllocation: maybe(0.4, pick(random, ALLOCATION_STRATEGIES)), + detectionFadePct: maybe(0.5, int(0, 40)), + detectionOutsideOpacityPct: maybe(0.5, int(0, 100)), + scopeFeatherPct: maybe(0.5, int(0, 200)), + scopeTerminusPct: maybe( + 0.3, + int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT), + ), + mapStack: maybe(0.5, pick(random, [...VIEW_MAPS, 'future-map'])), + layerState: random() < 0.7 ? randomLayerState(random) : null, + panelState: + random() < 0.6 + ? { + specs: SHARE_VIEW_PANELS.filter(() => random() < 0.8).map( + (panel) => ({ + id: panel.id, + collapsed: random() < 0.5, + pinned: panel.pinnable + ? pick(random, [true, false, null]) + : null, + }), + ), + } + : null, + sharedAtMs: maybe(0.7, int(1, 2_100_000_000) * 1000), + }; + for (const flag of SHARE_VIEW_FLAGS) + if (random() < 0.5) state[flag.key] = random() < 0.5; + if (random() < 0.4) { + const entity = pick(random, SHARE_VIEW_ENTITY_KINDS); + const id = + entity.kind === 'satellite' + ? String(int(1, 99999)) + : entity.kind === 'vessel' + ? pick(random, [ + String(int(100000000, 999999999)), + '002442000', + 'tmp-7', + ]) + : pick(random, [ + int(0, 0xffffff).toString(16).padStart(6, '0'), + '~abc123', + 'adsb_x-1', + ]); + state.view = { + kind: 'thing', + entity: { kind: entity.kind, id }, + offset: + random() < 0.6 + ? { range: int(0, 200000), heading: int(0, 359), pitch: int(-90, 90) } + : null, + lastKnown: + random() < 0.6 + ? { + lat: Math.max(-90, Math.min(90, lat + int(-5000, 5000) / 1e4)), + lon: Math.max(-180, Math.min(180, lon + int(-5000, 5000) / 1e4)), + alt: int(-100, 500000), + observedAtMs: maybe(0.8, int(1, 2_100_000_000) * 1000), + } + : null, + }; + } + return state; +} + +test('an all-default view is the version byte and its check byte', () => { + const hash = encodeShareView(createDefaultShareViewState()); + assert.equal(hash, 'AQc'); + assert.deepEqual(decodeOk(hash).state, normalizeShareViewState({})); + assert.deepEqual(normalizeShareViewState({}), createDefaultShareViewState()); +}); + +test('each setting costs bytes only when it differs from its default', () => { + const base = payloadBytes(encodeShareView({})).length; + const changes = { + lat: 10, + lon: 10, + alt: 900, + heading: 1, + pitch: -36, + roll: 1, + sharedAtMs: NOW_MS, + style: 'noir', + mapStack: 'osm', + hudVariant: 'cyber', + bloomIntensity: 1, + bloomVersion: 1, + sharpenIntensity: 50, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 8, + detectionOutsideOpacityPct: 2, + scopeFeatherPct: 99, + scopeTerminusPct: 95, + }; + const defaults = createDefaultShareViewState(); + for (const [key, value] of Object.entries(changes)) { + assert.equal( + payloadBytes(encodeShareView({ [key]: defaults[key] })).length, + base, + `${key} at its default is omitted`, + ); + const changed = { [key]: value }; + if (key === 'detectionMode') changed.detectionDensity = 75; + assert.ok( + payloadBytes(assertRoundTrip(changed)).length > base, + `${key} is encoded`, + ); + } + for (const flag of SHARE_VIEW_FLAGS) { + assert.equal( + payloadBytes(encodeShareView({ [flag.key]: defaults[flag.key] })).length, + base, + ); + assert.equal( + payloadBytes(assertRoundTrip({ [flag.key]: !defaults[flag.key] })).length, + base + 2, + ); + } + // All five booleans flipped still cost one field. + const flipped = Object.fromEntries( + SHARE_VIEW_FLAGS.map((f) => [f.key, !defaults[f.key]]), + ); + assert.equal(payloadBytes(assertRoundTrip(flipped)).length, base + 2); +}); + +test('a view just switched to any style costs its camera and style only', () => { + const camera = { lat: 48.8584, lon: 2.2945, alt: 1200, heading: 120 }; + const cameraOnly = payloadBytes(encodeShareView(camera)).length; + for (const style of VIEW_STYLES) { + // What the app holds right after selecting the style in a fresh session. + const fresh = { ...camera, ...SHARE_VIEW_STYLE_DEFAULTS[style], style }; + const bytes = payloadBytes(assertRoundTrip(fresh)).length; + assert.equal(bytes, cameraOnly + (style === 'normal' ? 0 : 2), style); + // Changes on top of the style's look still travel and round-trip. + const params = SHARE_VIEW_STYLE_PARAMS[style]; + const changed = { + ...fresh, + sharpen: false, + detectionMode: 'OFF', + detectionFadePct: 16, + ...(params + ? { + styleParams: { + ...fresh.styleParams, + [params[0].key]: params[0].min, + }, + } + : {}), + }; + assert.ok(payloadBytes(assertRoundTrip(changed)).length > bytes, style); + } +}); + +test('invalid style parameters keep the style’s own value', () => { + for (const bad of [undefined, NaN, 'x', null]) + assertRoundTrip({ style: 'noir', styleParams: { contrastAmt: bad } }); + assert.equal( + normalizeShareViewState({ style: 'noir', styleParams: { grainAmt: NaN } }) + .styleParams.grainAmt, + 0.5, + ); +}); + +test('every registered field round-trips at once', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [ + 'flights', + 'satellites', + 'radio', + 'recent-imagery', + 'street-level', + ]; + Object.assign(layerState.options.flights, { + models3d: true, + models3dMode: 'all', + }); + Object.assign(layerState.options.radio, { + filter: 'genre:jazz & blues', + volume: 0.35, + }); + Object.assign(layerState.options['recent-imagery'], { + west: -12345678, + east: 12345678, + south: -8505110, + north: 8505110, + a: 'S30:2026-09-18', + b: 'VIIRS:2025-01-02', + mode: 2, + split: 37, + viirs: true, + }); + Object.assign(layerState.options['street-level'], { + mapillary: false, + providerSwitches: 'abc-1*def-0', + sinceDays: 365, + }); + const state = { + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { range: 1500, heading: 300, pitch: -20 }, + lastKnown: { + lat: -33.8601, + lon: 151.2101, + alt: 10668, + observedAtMs: NOW_MS - 12_000, + }, + }, + lat: -33.8568, + lon: 151.2153, + alt: 12000, + heading: 270, + pitch: -12, + roll: 2, + style: 'thermal', + styleParams: { + sensitivity: 0.42, + bloom: 1, + mode: 0, + pixelation: 3.5, + palette: 0.25, + }, + bloom: true, + sharpen: false, + bloomIntensity: 84, + bloomVersion: 1, + sharpenIntensity: 65, + hudVariant: 'operator', + hudVisible: false, + detectionMode: 'SPARSE', + detectionDensity: 100, + detectionAllocation: 'WEIGHTED', + detectionFadePct: 16, + detectionOutsideOpacityPct: 5, + celestialRing: true, + scopeEnabled: false, + scopeFeatherPct: 35, + scopeTerminusPct: 97, + mapStack: 'esri-imagery', + layerState, + panelState: { + specs: SHARE_VIEW_PANELS.map((panel) => ({ + id: panel.id, + collapsed: !panel.collapsed, + pinned: panel.pinnable ? true : null, + })), + }, + sharedAtMs: NOW_MS, + }; + // A thing view derives its camera (roll 0), so the camera fields are + // checked on the same state as a place view. + const place = { ...state }; + delete place.view; + for (const subject of [state, place]) { + const normalized = normalizeShareViewState(subject); + for (const key of Object.keys(subject)) { + if (subject === state && key === 'roll') continue; + assert.notDeepEqual( + normalized[key], + createDefaultShareViewState()[key] ?? undefined, + key, + ); + } + assertRoundTrip(subject); + } +}); + +test('generated states round-trip exactly and encode canonically', () => { + const random = rng(0x5eed); + let things = 0; + for (let i = 0; i < 1500; i += 1) { + const state = randomState(random); + if (state.view?.kind === 'thing') things += 1; + const hash = encodeShareView(state); + const result = decodeShareView(hash, { nowMs: Number.MAX_SAFE_INTEGER }); + assert.equal(result.ok, true, `case ${i}: ${result.reason}`); + assert.deepEqual(result.state, normalizeShareViewState(state), `case ${i}`); + assert.equal(encodeShareView(result.state), hash, `case ${i} canonical`); + assert.ok(hash.length < MAX_SHARE_VIEW_CHARS, `case ${i} size`); + } + assert.ok(things > 400, 'generator covers thing views'); +}); + +test('normalization is idempotent', () => { + const random = rng(42); + for (let i = 0; i < 300; i += 1) { + const once = normalizeShareViewState(randomState(random)); + assert.deepEqual(normalizeShareViewState(once), once); + } +}); + +test('precision: coordinates 1e-4 degrees, angles and lengths whole units', () => { + const { state } = decodeOk( + encodeShareView({ + lat: 30.26724, + lon: -97.74316, + alt: 600.6, + heading: 15.4, + pitch: -30.5, + roll: 359.6, + }), + ); + assert.equal(state.lat, 30.2672); + assert.equal(state.lon, -97.7432); + assert.equal(state.alt, 601); + assert.equal(state.heading, 15); + assert.equal(state.pitch, -30); + assert.equal(state.roll, 0, '359.6 rounds to 360, which is 0'); + // Angles wrap into [0, 360) and are stored as the nearer signed turn. + assert.equal(decodeOk(encodeShareView({ heading: -10 })).state.heading, 350); + assert.equal(payloadBytes(encodeShareView({ heading: 350 })).length, 3); + // Out-of-range input is clamped or wrapped, never rejected. + const wild = decodeOk( + encodeShareView({ lat: 95, lon: 190, pitch: -120 }), + ).state; + assert.deepEqual([wild.lat, wild.lon, wild.pitch], [90, -170, -90]); +}); + +test('defaults travel as frozen meanings that match today’s app defaults', () => { + const defaults = createDefaultShareViewState(); + // When an app default moves, these tests should be updated to document the + // drift; the codec's defaults table must NOT change (see SHARE-LINK-FORMAT). + assert.equal(defaults.bloomIntensity, BLOOM_INTENSITY_DEFAULT); + assert.equal(defaults.bloomVersion, BLOOM_SCALE_VERSION); + assert.equal(defaults.hudVariant, HUD_LAYOUTS[0]); + assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [ + ...HUD_LAYOUTS, + ]); + assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]); + assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]); + assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [ + ...ALLOCATION_STRATEGIES, + ]); + for (const kind of VIEW_FOLLOW_KINDS) + assert.ok( + SHARE_VIEW_ENTITY_KINDS.some((entry) => entry.kind === kind), + kind, + ); +}); + +test('the field registry is well formed and permanent', () => { + const numbers = SHARE_VIEW_FIELDS.map((f) => f.number); + assert.equal(new Set(numbers).size, numbers.length); + assert.equal( + new Set(SHARE_VIEW_FIELDS.map((f) => f.key)).size, + numbers.length, + ); + // Snapshot: changing a number, key or default breaks links in the wild. + assert.deepEqual( + SHARE_VIEW_FIELDS.map((f) => [f.number, f.key, f.default]), + [ + [1, 'view', 'place'], + [2, 'lat', 0], + [3, 'lon', 0], + [4, 'alt', 800], + [5, 'heading', 0], + [6, 'pitch', -35], + [7, 'roll', 0], + [8, 'sharedAtMs', null], + [9, 'flags', 0], + [10, 'layerState', null], + [11, 'layerOptions', null], + [12, 'panelState', null], + [13, 'thing', null], + [14, 'style', 'normal'], + [15, 'mapStack', 'photoreal'], + [16, 'hudVariant', BY_STYLE], + [17, 'bloomIntensity', BY_STYLE], + [18, 'bloomVersion', 2], + [19, 'sharpenIntensity', BY_STYLE], + [20, 'detectionMode', BY_STYLE], + [21, 'detectionDensity', BY_STYLE], + [22, 'detectionAllocation', BY_STYLE], + [23, 'detectionFadePct', BY_STYLE], + [24, 'detectionOutsideOpacityPct', BY_STYLE], + [25, 'scopeFeatherPct', 100], + [26, 'scopeTerminusPct', null], + [27, 'styleParams', BY_STYLE], + ], + ); + assert.deepEqual( + SHARE_VIEW_FLAGS.map((f) => [f.bit, f.key, f.default]), + [ + [0, 'bloom', BY_STYLE], + [1, 'sharpen', BY_STYLE], + [2, 'hudVisible', BY_STYLE], + [3, 'celestialRing', BY_STYLE], + [4, 'scopeEnabled', true], + ], + ); + // The style-applied defaults are frozen meanings too. If the app's style + // tables move and this fails, freeze these values in the codec instead of + // updating the snapshot: links already shared depend on them. + const common = { + bloom: false, + bloomIntensity: 0, + sharpen: true, + sharpenIntensity: 49, + hudVariant: 'tactical', + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + detectionAllocation: 'ELASTIC', + detectionFadePct: 7, + detectionOutsideOpacityPct: 1, + celestialRing: false, + }; + assert.deepEqual(JSON.parse(JSON.stringify(SHARE_VIEW_STYLE_DEFAULTS)), { + normal: { ...common, styleParams: null }, + retro: { + ...common, + styleParams: { pixelation: 1, distortion: 0, instability: 0.42 }, + }, + surveillance: { + ...common, + styleParams: { + gain: 0.18, + bloom: 0.22, + scanlineStr: 0.96, + pixelation: 1, + }, + }, + thermal: { + ...common, + styleParams: { + sensitivity: 0.85, + bloom: 0.2, + mode: 0.33, + pixelation: 1, + palette: 0, + }, + }, + anime: { ...common, styleParams: { saturation: 1, edgeThick: 0.5 } }, + noir: { + ...common, + styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 }, + }, + snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } }, + }); + assert.deepEqual( + SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]), + [ + [1, 'aircraft'], + [2, 'military_aircraft'], + [3, 'vessel'], + [4, 'satellite'], + ], + ); + assert.deepEqual( + SHARE_VIEW_PANELS.map((p) => [p.number, p.id]), + SHARE_VIEW_PANELS.map((p, i) => [i, p.id]), + ); +}); + +test('the panel table agrees with the long form’s panel tokens', () => { + for (const panel of SHARE_VIEW_PANELS) { + const decoded = decodePanelStateParams( + new URLSearchParams(`v=2&ui=${panel.token}.c.1_${panel.token}.p.1`), + ); + assert.deepEqual(decoded.specs, [ + { id: panel.id, collapsed: true, pinned: panel.pinnable ? true : null }, + ]); + } +}); + +test('the style parameter table agrees with the long form’s `sp` codec', () => { + for (const [style, specs] of Object.entries(SHARE_VIEW_STYLE_PARAMS)) { + const values = Object.fromEntries( + specs.map((s, i) => [s.key, s.min + ((s.max - s.min) * (i + 1)) / 7]), + ); + const params = new URLSearchParams('v=2'); + encodeStyleParamState(params, style, values); + const legacy = decodeStyleParamState(params, style); + assert.deepEqual( + normalizeShareViewState({ style, styleParams: values }).styleParams, + legacy, + style, + ); + assert.deepEqual( + decodeOk(encodeShareView({ style, styleParams: values })).state + .styleParams, + legacy, + style, + ); + } +}); + +test('every layer option has a frozen absent meaning', () => { + for (const entry of LAYER_STATE_REGISTRY) { + if (!entry.optionOwner) continue; + for (const spec of optionSpecs(entry.optionOwner)) { + assert.ok( + Object.hasOwn( + SHARE_VIEW_LAYER_OPTION_ABSENT[entry.optionOwner] || {}, + spec.key, + ), + `${entry.optionOwner}.${spec.key} needs an entry in SHARE_VIEW_LAYER_OPTION_ABSENT`, + ); + } + } + // A default-only layer state writes no option bytes at all. + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + const bytes = payloadBytes(encodeShareView({ layerState })); + assert.equal(bytes.includes(0x5a), false, 'no layer-options field'); +}); + +test('layer tokens map to permanent numbers covering the whole ledger', () => { + for (const token of Object.values(LAYER_STATE_TOKEN_RESERVATIONS)) { + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + } + for (const token of ['0', '9', 'a', 'z', '00', '0z', 'zz']) + assert.equal(layerTokenFromNumber(layerTokenNumber(token)), token); + assert.equal(layerTokenNumber('00'), 36); + assert.equal(layerTokenFromNumber(36 + 36 * 36), null); + const numbers = LAYER_STATE_REGISTRY.map((e) => layerTokenNumber(e.token)); + assert.equal(new Set(numbers).size, numbers.length); +}); + +test('layer sets: absent, explicitly empty and populated stay distinct', () => { + assert.equal( + decodeOk(encodeShareView({ layerState: null })).state.layerState, + null, + ); + const empty = decodeOk( + encodeShareView({ layerState: createDefaultLayerState() }), + ).state.layerState; + assert.deepEqual(empty, createDefaultLayerState()); + const all = createDefaultLayerState(); + all.enabledLayerIds = [...REGISTERED_LAYER_IDS]; + assertRoundTrip({ layerState: all }); +}); + +test('panels: only differences from the frozen layout are written', () => { + const defaults = { + specs: SHARE_VIEW_PANELS.map((p) => ({ + id: p.id, + collapsed: p.collapsed, + pinned: p.pinnable ? false : null, + })), + }; + const base = payloadBytes(encodeShareView({})).length; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: defaults })).length, + base + 2, + ); + const oneChanged = structuredClone(defaults); + oneChanged.specs[3].collapsed = false; + assert.equal( + payloadBytes(assertRoundTrip({ panelState: oneChanged })).length, + base + 3, + ); + // A partial panel list (an older link) keeps its gaps as "no opinion". + const partial = { + specs: [{ id: 'cctv-panel', collapsed: false, pinned: null }], + }; + assert.deepEqual( + decodeOk(encodeShareView({ panelState: partial })).state.panelState, + partial, + ); +}); + +test('a place view opens at its camera; a thing view also names what to follow', () => { + const place = decodeOk(encodeShareView({ lat: 1, lon: 2 })).state; + assert.deepEqual(place.view, { kind: 'place' }); + for (const entry of SHARE_VIEW_ENTITY_KINDS) { + const id = entry.idForm === 'hex24' ? '3c6444' : '25544'; + const { state } = decodeOk( + encodeShareView({ + lat: 1, + lon: 2, + view: { kind: 'thing', entity: { kind: entry.kind, id } }, + }), + ); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: entry.kind, id }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + // Without a last-known fix the view keeps its stored camera. + assert.equal(state.lat, 1); + assert.equal(state.lon, 2); + } +}); + +test('thing ids use the compact numeric form when they can, text otherwise', () => { + const size = (kind, id) => + payloadBytes( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).length; + assert.equal(size('aircraft', 'ffffff'), size('aircraft', '000001') + 3); + assert.ok(size('aircraft', '~abc123') > size('aircraft', 'abc123')); + assert.ok( + size('vessel', '366999712') < size('vessel', '002442000'), + 'leading zeros keep the text form', + ); + for (const [kind, id] of [ + ['aircraft', '000000'], + ['vessel', '002442000'], + ['military_aircraft', 'adsb_x-1'], + ['satellite', '1'], + ]) + assert.equal( + decodeOk( + encodeShareView({ view: { kind: 'thing', entity: { kind, id } } }), + ).state.view.entity.id, + id, + ); + // Invalid ids and kinds normalize to a place view. + for (const entity of [ + { kind: 'aircraft', id: 'NOT VALID' }, + { kind: 'satellite', id: 'abc' }, + { kind: 'ufo', id: '1' }, + ]) + assert.deepEqual( + normalizeShareViewState({ view: { kind: 'thing', entity } }).view, + { kind: 'place' }, + ); + assert.equal( + normalizeShareViewState({ + view: { kind: 'thing', entity: { kind: 'aircraft', id: 'ABC123' } }, + }).view.entity.id, + 'abc123', + ); +}); + +test('last-known time is stored relative to the created-at time when there is one', () => { + const lastKnown = { + lat: 10.0012, + lon: 20.0034, + alt: 11000, + observedAtMs: NOW_MS - 15_000, + }; + const view = { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + lastKnown, + }; + const withCreated = assertRoundTrip({ + lat: 10, + lon: 20, + sharedAtMs: NOW_MS, + view, + }); + const withoutCreated = assertRoundTrip({ lat: 10, lon: 20, view }); + assert.deepEqual(decodeOk(withoutCreated).state.view.lastKnown, lastKnown); + // Relative age (15 s) is 1 byte; the absolute time is 4. + assert.ok( + payloadBytes(withCreated).length - 5 < payloadBytes(withoutCreated).length, + ); +}); + +test('tracking selections in layer options become a thing view', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['military']; + layerState.options.flights.selectedMilitaryTrackingId = 'AE1234'; + const state = normalizeShareViewState({ layerState }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'military_aircraft', id: 'ae1234' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT }, + lastKnown: null, + }); + assert.equal( + state.layerState.options.flights.selectedMilitaryTrackingId, + null, + ); + assertRoundTrip({ layerState }); +}); + +test('a created-at time in the future is dropped, like the long form’s `at`', () => { + const hash = encodeShareView({ sharedAtMs: NOW_MS + 60_000 }); + assert.equal(decode(hash).state.sharedAtMs, null); + assert.equal( + decodeShareView(hash, { nowMs: NOW_MS + 60_000 }).state.sharedAtMs, + NOW_MS + 60_000, + ); + // Times on either side of the format epoch survive. + for (const ms of [1000, (SHARE_VIEW_EPOCH_S - 86400) * 1000, NOW_MS]) + assert.equal( + decode(encodeShareView({ sharedAtMs: ms })).state.sharedAtMs, + ms, + ); +}); + +test('a code is the bare base64url payload; URL forms are not codes', () => { + const code = encodeShareView({ lat: 51.5, lon: -0.12 }); + assert.match(code, /^[A-Za-z0-9_-]+$/); + for (const form of [`#${code}`, `s=${code}`, `/share/${code}`]) + assert.equal(decode(form).ok, false, form); +}); + +/* ---------------- forward compatibility ---------------- */ + +/** Append raw fields to an encoded hash, as a newer encoder would. */ +function withExtraFields(hash, write) { + const writer = new WireWriter(); + writer.bytes.push(...payloadBytes(hash)); + write(writer); + return hashOf(writer.finish()); +} + +test('unknown top-level fields of every wire type are skipped and reported', () => { + const state = { + lat: 48.8584, + lon: 2.2945, + style: 'noir', + layerState: { enabledLayerIds: ['flights'] }, + }; + const hash = encodeShareView(state); + const future = withExtraFields(hash, (w) => { + w.uintField(40, 123456); + w.bytesField(41, [9, 9, 9, 0xff, 0]); + w.tag(42, WIRE_FIXED32).bytes.push(1, 2, 3, 4); + w.tag(43, WIRE_FIXED64).bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + w.sintField(2000, -5); + }); + const result = decodeOk(future); + assert.deepEqual(result.state, decodeOk(hash).state); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field, u.wireType]), + [ + ['view', 40, 0], + ['view', 41, 2], + ['view', 42, 5], + ['view', 43, 1], + ['view', 2000, 0], + ], + ); +}); + +test('unknown fields inside a thing view, codes and bits are skipped too', () => { + const thing = new WireWriter(); + thing.uintField(1, 0xa1b2c3).uintField(5, 900); + thing.textField(20, 'cockpit'); // a future thing field + const writer = new WireWriter(); + writer.bytes.push(1); + writer.sintField(2, 100000).uintField(9, 0b100001); // flag bit 5 unknown + writer.bytesField( + 10, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + ); // layer 160 + writer.bytesField(12, new WireWriter().varint(11 * 16 + 4 + 1).finish()); // panel 11 + writer.uintField(14, 99); // style code 99 + writer.bytesField(13, thing.finish()); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: { ...SHARE_VIEW_FOLLOW_OFFSET_DEFAULT, range: 900 }, + lastKnown: null, + }); + assert.equal(result.state.bloom, true); + assert.equal(result.state.style, 'normal'); + // The followed entity's layer is implied. + assert.deepEqual(result.state.layerState.enabledLayerIds, ['flights']); + assert.deepEqual( + result.unknownFields.map((u) => [u.scope, u.field ?? u.code]), + [ + ['enum:style', 99], + ['flags', 32], + ['layer', 160], + ['panel', 11], + ['thing', 20], + ], + ); +}); + +test('an unknown view kind falls back to the stored camera', () => { + const camera = { + lat: 35.6762, + lon: 139.6503, + alt: 2500, + heading: 45, + pitch: -20, + }; + const base = payloadBytes(encodeShareView(camera)); + const expected = decodeOk(encodeShareView(camera)).state; + + const futureKind = new WireWriter(); + futureKind.bytes.push(...base); + futureKind.uintField(1, 7); + futureKind.bytesField(60, [1, 2, 3]); + const kindResult = decodeOk(hashOf(futureKind.finish())); + assert.deepEqual(kindResult.state, expected); + assert.equal(kindResult.fallback, 'unknown-view-kind'); + + const missing = new WireWriter(); + missing.bytes.push(...base); + missing.uintField(1, 1); + assert.equal(decodeOk(hashOf(missing.finish())).fallback, 'missing-thing'); +}); + +test('an unknown or invalid entity falls back to the camera its fix implies', () => { + const fix = { lat: 35.6762, lon: 139.6503, alt: 900, observedAtMs: null }; + const offset = { range: 3000, heading: 90, pitch: -40 }; + const thingOf = (write) => { + const thing = new WireWriter(); + write(thing); + thing + .uintField(5, offset.range) + .sintField(6, offset.heading) + .sintField(7, offset.pitch) + .sintField(8, Math.round(fix.lat * 1e4)) + .sintField(9, Math.round(fix.lon * 1e4)) + .sintField(10, fix.alt); + const writer = new WireWriter(); + writer.bytes.push(1); + writer.bytesField(13, thing.finish()); + return decodeOk(hashOf(writer.finish())); + }; + const expected = normalizeShareViewState(followCameraFor(fix, offset)); + + // A newer app's entity kind: its id field is unknown here. + const future = thingOf((thing) => thing.textField(30, 'cam-77')); + assert.equal(future.fallback, 'unknown-entity-kind'); + assert.deepEqual(future.state, expected); + assert.deepEqual(future.unknownFields, [ + { scope: 'thing', field: 30, wireType: 2 }, + ]); + + const none = thingOf(() => {}); + assert.equal(none.fallback, 'missing-entity-kind'); + assert.deepEqual(none.state, expected); + + const badId = thingOf((thing) => thing.uintField(1, 0x1000000)); + assert.equal(badId.fallback, 'invalid-entity-id'); + assert.deepEqual(badId.state, expected); +}); + +test('enum names outside the code tables travel as text', () => { + const hash = encodeShareView({ + hudVariant: 'future-hud', + mapStack: 'future-map', + }); + const { state, unknownFields } = decodeOk(hash); + assert.equal(state.hudVariant, 'future-hud'); + assert.equal(state.mapStack, 'future-map'); + assert.equal(unknownFields, undefined); + const style = new WireWriter(); + style.bytes.push(1); + style.textField(14, 'thermal'); + assert.equal(decodeOk(hashOf(style.finish())).state.style, 'thermal'); + const unknownStyle = new WireWriter(); + unknownStyle.bytes.push(1); + unknownStyle.textField(14, 'sepia'); + const result = decodeOk(hashOf(unknownStyle.finish())); + assert.equal(result.state.style, 'normal'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:style', code: 'sepia' }, + ]); +}); + +/* ---------------- integrity ---------------- */ + +test('malformed input is rejected with a reason, never thrown', () => { + const good = payloadBytes( + encodeShareView({ + lat: 10, + lon: 20, + layerState: { enabledLayerIds: ['flights'] }, + }), + ); + const cases = { + 'not-a-string': [null, undefined, 42, {}], + 'too-long': ['A'.repeat(MAX_SHARE_VIEW_CHARS + 1)], + 'bad-base64': ['AQ==', 'A*Q', 'AQAAA', 's=AQc', '#AQc'], + empty: [''], + // A lone byte has no room for a check byte. + truncated: ['AQ'], + checksum: [ + encodeBase64Url([1, crc8([1]) ^ 1]), + encodeBase64Url([...good, (crc8(good) + 1) % 256]), + ], + 'unsupported-version': [hashOf([0]), hashOf([2, 8, 1])], + 'truncated ': [ + hashOf(good.slice(0, good.length - 1)), + hashOf([1, 0x10]), // field 2 varint with no value + hashOf([1, 0x52, 0x7f, 1]), // field 10 claims 127 bytes + hashOf([1, 0x52, 0xff, 0xff, 0xff, 0x7f]), // absurd length + ], + 'varint-overflow': [ + hashOf([1, 0x10, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01]), + ], + 'non-canonical-varint': [hashOf([1, 0x10, 0x82, 0x00])], + 'bad-wire-type': [hashOf([1, 0x13]), hashOf([1, 0x16])], + 'bad-field-number': [hashOf([1, 0x00])], + 'wire-type-mismatch': [ + hashOf([1, 0x12, 0x01, 0x00]), + hashOf([1, 0x50, 0x01]), + ], + 'duplicate-field': [hashOf([1, 0x10, 0x02, 0x10, 0x04])], + 'duplicate-entry': [hashOf([1, 0x62, 0x02, 0x31, 0x31])], + 'out-of-range': [ + hashOf([1, ...new WireWriter().sintField(2, 900001).finish()]), + ], + 'bad-text': [ + hashOf([1, ...new WireWriter().bytesField(14, [0xc3, 0xa9]).finish()]), + ], + }; + for (const [label, inputs] of Object.entries(cases)) { + const reason = label.trim(); + for (const input of inputs) { + const result = decodeShareView(input, { nowMs: NOW_MS }); + assert.deepEqual( + result, + { ok: false, reason }, + `${reason}: ${String(input).slice(0, 40)}`, + ); + } + } +}); + +test('every truncation and single-byte corruption of real payloads is handled', () => { + const random = rng(7); + for (let i = 0; i < 60; i += 1) { + const bytes = payloadBytes(encodeShareView(randomState(random))); + for (let cut = 1; cut < bytes.length; cut += 1) { + const result = decode(hashOf(bytes.slice(0, cut))); + assert.equal(typeof result.ok, 'boolean'); + } + for (let j = 0; j < 40; j += 1) { + const corrupt = Uint8Array.from(bytes); + corrupt[1 + Math.floor(random() * (corrupt.length - 1))] = Math.floor( + random() * 256, + ); + const result = decode(hashOf(corrupt)); + assert.equal(typeof result.ok, 'boolean'); + if (result.ok) + assert.deepEqual(normalizeShareViewState(result.state), result.state); + } + } +}); + +/* ---------------- sizes ---------------- */ + +test('size: a camera-only place view', () => { + const hash = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + }); + assert.equal(hash.length, 24); + const copied = assertRoundTrip({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + sharedAtMs: NOW_MS, + }); + assert.equal(copied.length, 31); +}); + +test('size: following an aircraft', () => { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + layerState.options.flights.models3d = true; + const view = (offset) => ({ + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset, + lastKnown: { + lat: 37.6213, + lon: -122.379, + alt: 3200, + observedAtMs: NOW_MS - 4000, + }, + }); + // The app's own follow camera looks north at −35°; only the range is new. + const followed = assertRoundTrip({ + view: view({ range: 5800, heading: 0, pitch: -35 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(followed.length, 44); + // Made by the share button: the fix is as of the link, so it has no age. + const fresh = view({ range: 5800, heading: 0, pitch: -35 }); + fresh.lastKnown.observedAtMs = NOW_MS; + assert.equal( + assertRoundTrip({ view: fresh, layerState, sharedAtMs: NOW_MS }).length, + 42, + ); + // In Noir with Noir's own look, only the style is added. + assert.equal( + assertRoundTrip({ + ...SHARE_VIEW_STYLE_DEFAULTS.noir, + style: 'noir', + view: fresh, + layerState, + sharedAtMs: NOW_MS, + }).length, + 44, + ); + // After orbiting the aircraft, heading and pitch are written too. + const orbited = assertRoundTrip({ + view: view({ range: 1500, heading: 30, pitch: -20 }), + layerState, + sharedAtMs: NOW_MS, + }); + assert.equal(orbited.length, 50); + // No camera is stored: it is where following the fix puts it. + const { state } = decodeOk(orbited); + assert.deepEqual( + { + lat: state.lat, + lon: state.lon, + alt: state.alt, + heading: state.heading, + pitch: state.pitch, + }, + { lat: 37.6103, lon: -122.387, alt: 3713, heading: 30, pitch: -20 }, + ); +}); + +test('the follow offset converts to and from east/north/up metres', () => { + for (const offset of [ + { range: 5800, heading: 0, pitch: -35 }, + { range: 1500, heading: 30, pitch: -20 }, + { range: 750000, heading: 225, pitch: -60 }, + { range: 300, heading: 359, pitch: 10 }, + ]) + assert.deepEqual(followOffsetFromEnu(followOffsetToEnu(offset)), offset); + const { east, north, up } = followOffsetToEnu({ + range: 1000, + heading: 90, + pitch: -30, + }); + // Looking east and down: the camera is west of the entity and above it. + assert.ok(east < -800 && Math.abs(north) < 1e-9 && Math.abs(up - 500) < 1e-9); + assert.equal(followOffsetFromEnu({ east: 0, north: 0, up: 0 }), null); +}); + +test('the derived camera looks at the fix from the offset, even from orbit', () => { + const R = 6_371_000; + const ecef = ({ lat, lon, alt }) => { + const r = R + alt; + const c = Math.cos((lat * Math.PI) / 180); + return [ + r * c * Math.cos((lon * Math.PI) / 180), + r * c * Math.sin((lon * Math.PI) / 180), + r * Math.sin((lat * Math.PI) / 180), + ]; + }; + for (const [fix, offset] of [ + // A satellite followed from the app's own distance. + [ + { lat: 51.6, lon: -0.1, alt: 420_000 }, + { range: 750_000, heading: 225, pitch: -35 }, + ], + // Near a pole, and across the antimeridian. + [ + { lat: 89.9, lon: 179.95, alt: 10_000 }, + { range: 20_000, heading: 90, pitch: -20 }, + ], + [ + { lat: 37.6213, lon: -122.379, alt: 3200 }, + { range: 5800, heading: 0, pitch: -35 }, + ], + ]) { + const camera = followCameraFor(fix, offset); + const [a, b] = [ecef(camera), ecef(fix)]; + const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + assert.ok(Math.abs(distance - offset.range) < 1e-3 * offset.range); + assert.ok(Math.abs(camera.lon) <= 180 && Math.abs(camera.lat) <= 90); + // Short ranges look along the offset itself. + if (offset.range < 10_000) { + assert.ok(Math.abs(camera.heading - offset.heading) < 0.1); + assert.ok(Math.abs(camera.pitch - offset.pitch) < 0.1); + } + } +}); + +test('a fix without its own time is as of the link', () => { + const view = { + kind: 'thing', + entity: { kind: 'satellite', id: '25544' }, + lastKnown: { lat: 1, lon: 2, alt: 420000 }, + }; + const state = normalizeShareViewState({ view, sharedAtMs: NOW_MS }); + assert.equal(state.view.lastKnown.observedAtMs, NOW_MS); + assertRoundTrip({ view, sharedAtMs: NOW_MS }); + // With no created-at time either, the fix has no time. + assert.equal( + decodeOk(encodeShareView({ view })).state.view.lastKnown.observedAtMs, + null, + ); +}); + +test('the check byte rejects every single-character typo', () => { + const ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + const random = rng(0xc0de); + let typos = 0; + for (let i = 0; i < 40; i += 1) { + const code = encodeShareView(randomState(random)); + for (let at = 0; at < code.length; at += 1) { + for (const char of ALPHABET) { + if (char === code[at]) continue; + const typo = code.slice(0, at) + char + code.slice(at + 1); + const result = decode(typo); + assert.equal(result.ok, false, `${code} → ${typo}`); + typos += 1; + } + } + } + assert.ok(typos > 50_000); +}); + +/* ---------------- review regressions ---------------- */ + +test('unknown detection names fall back to their defaults and are reported', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + writer.textField(20, 'FUTURE').textField(22, 'GREEDY'); + const result = decodeOk(hashOf(writer.finish())); + assert.equal(result.state.detectionMode, 'DENSE'); + assert.equal(result.state.detectionAllocation, 'ELASTIC'); + assert.deepEqual(result.unknownFields, [ + { scope: 'enum:detectionMode', code: 'FUTURE' }, + { scope: 'enum:detectionAllocation', code: 'GREEDY' }, + ]); +}); + +test('extreme and negative-zero numbers normalize to values the decoder returns', () => { + assert.ok(Number.isFinite(normalizeShareViewState({ lon: 1e305 }).lon)); + assertRoundTrip({ lon: 1e305, lat: -1e305 }); + for (const state of [ + { lat: -0.00001 }, + { lon: -0.00001 }, + { alt: -0.2 }, + { pitch: -0.2 }, + ]) { + assertRoundTrip(state); + for (const value of Object.values(normalizeShareViewState(state))) + assert.equal(Object.is(value, -0), false); + } +}); + +test('future panel values and style tokens are skipped, not applied', () => { + const panels = new WireWriter(); + panels.bytes.push(1); + panels.bytesField( + 12, + new WireWriter() + .varint(3 * 16 + 3 * 4) + .varint(0 * 16 + 2 * 4 + 3) + .finish(), + ); + const panelResult = decodeOk(hashOf(panels.finish())); + const ids = panelResult.state.panelState.specs.map((spec) => spec.id); + assert.equal(ids.includes('cctv-panel'), false); + assert.equal(ids.includes('control-panel'), false); + assert.equal(panelResult.unknownFields.length, 2); + + const style = new WireWriter(); + style.bytes.push(1); + style.uintField(14, 3); // thermal + style.bytesField( + 27, + new WireWriter().varint(36).varint(5).varint(37).varint(6).finish(), + ); + const styleResult = decodeOk(hashOf(style.finish())); + // Unknown tokens change nothing: the style keeps its own parameters. + assert.deepEqual( + styleResult.state.styleParams, + SHARE_VIEW_STYLE_DEFAULTS.thermal.styleParams, + ); + assert.equal(styleResult.unknownFields.length, 2); +}); + +test('a layer option filed under a mirror layer is ignored', () => { + const writer = new WireWriter(); + writer.bytes.push(1); + const military = layerTokenNumber('m'); + writer.bytesField(10, [0, 0, 0b01000000]); // m = 22 + writer.bytesField( + 11, + new WireWriter() + .varint(military) + .varint(layerTokenNumber('m') * 2) // option `m` (models3dMode) as integer + .varint(0) + .finish(), + ); + const result = decodeOk(hashOf(writer.finish())); + assert.deepEqual(result.state.layerState.enabledLayerIds, ['military']); + assert.deepEqual(result.unknownFields, [ + { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, + ]); +}); diff --git a/src/share/wire.js b/src/share/wire.js new file mode 100644 index 000000000..f0b72f301 --- /dev/null +++ b/src/share/wire.js @@ -0,0 +1,259 @@ +/** + * Byte-level primitives for the compact share-view format: base64url text, + * protobuf-style varints, and a tag-length-value reader and writer. + * + * Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a + * {@link ShareWireError} with a stable `reason`, so the view codec can turn it + * into a rejected link instead of an exception at app start. + */ + +/** Wire types. Field numbers and their wire types are permanent. */ +export const WIRE_VARINT = 0; +export const WIRE_FIXED64 = 1; +export const WIRE_LEN = 2; +export const WIRE_FIXED32 = 5; + +/** Field numbers stay inside protobuf's range so tags fit in five bytes. */ +export const MAX_FIELD_NUMBER = 2 ** 29 - 1; +const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer. + +/** A malformed or out-of-bounds payload. `reason` is a stable code. */ +export class ShareWireError extends Error { + constructor(reason, detail = '') { + super(detail ? `${reason}: ${detail}` : reason); + this.name = 'ShareWireError'; + this.reason = reason; + } +} + +const BASE64URL = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +const BASE64URL_INDEX = new Map( + [...BASE64URL].map((char, index) => [char, index]), +); + +/** Unpadded base64url text for `bytes`. */ +export function encodeBase64Url(bytes) { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + const triple = (a << 16) | (b << 8) | c; + out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63]; + if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63]; + if (i + 2 < bytes.length) out += BASE64URL[triple & 63]; + } + return out; +} + +/** + * Bytes for unpadded base64url text. Rejects padding, foreign characters, an + * impossible length and non-zero trailing bits, so every payload has exactly + * one spelling. + */ +export function decodeBase64Url(text) { + if (typeof text !== 'string' || text.length === 0) + throw new ShareWireError('empty'); + if (text.length % 4 === 1) throw new ShareWireError('bad-base64'); + const bytes = new Uint8Array(Math.floor((text.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let out = 0; + for (const char of text) { + const value = BASE64URL_INDEX.get(char); + if (value === undefined) throw new ShareWireError('bad-base64'); + buffer = ((buffer << 6) | value) & 0xffffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + bytes[out++] = (buffer >> bits) & 0xff; + } + } + if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0) + throw new ShareWireError('bad-base64', 'non-zero trailing bits'); + return bytes; +} + +/** + * CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection; + * no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to + * 8 bits, so any one mistyped base64url character (6 bits) is caught. + */ +export function crc8(bytes) { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) + crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff; + } + return crc; +} + +/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */ +export function zigzag(value) { + return value >= 0 ? value * 2 : -value * 2 - 1; +} + +/** Inverse of {@link zigzag}. */ +export function unzigzag(value) { + return value % 2 === 0 ? value / 2 : -(value + 1) / 2; +} + +/** Largest magnitude a zigzag field can carry without losing precision. */ +export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2); + +/** Append-only byte sink with protobuf-style field helpers. */ +export class WireWriter { + constructor() { + this.bytes = []; + } + + varint(value) { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError(`varint out of range: ${value}`); + let rest = value; + while (rest >= 0x80) { + this.bytes.push((rest % 0x80) | 0x80); + rest = Math.floor(rest / 0x80); + } + this.bytes.push(rest); + return this; + } + + tag(field, wireType) { + if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER) + throw new RangeError(`field number out of range: ${field}`); + return this.varint(field * 8 + wireType); + } + + uintField(field, value) { + return this.tag(field, WIRE_VARINT).varint(value); + } + + sintField(field, value) { + if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE) + throw new RangeError(`signed value out of range: ${value}`); + return this.tag(field, WIRE_VARINT).varint(zigzag(value)); + } + + bytesField(field, bytes) { + this.tag(field, WIRE_LEN).varint(bytes.length); + for (const byte of bytes) this.bytes.push(byte); + return this; + } + + /** ASCII-only text; every share-view string is drawn from ASCII grammars. */ + textField(field, text) { + return this.bytesField(field, asciiBytes(text)); + } + + finish() { + return Uint8Array.from(this.bytes); + } +} + +/** Printable-ASCII bytes for `text`; anything else is a programming error. */ +export function asciiBytes(text) { + const bytes = []; + for (let i = 0; i < text.length; i += 1) { + const code = text.charCodeAt(i); + if (code < 0x20 || code > 0x7e) + throw new RangeError('share-view text must be printable ASCII'); + bytes.push(code); + } + return bytes; +} + +/** Printable-ASCII text from bytes; anything else is a malformed payload. */ +export function asciiText(bytes) { + let out = ''; + for (const byte of bytes) { + if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text'); + out += String.fromCharCode(byte); + } + return out; +} + +/** Bounds-checked cursor over one message (or one packed blob). */ +export class WireReader { + constructor(bytes, start = 0, end = bytes.length) { + this.bytes = bytes; + this.pos = start; + this.end = end; + } + + get done() { + return this.pos >= this.end; + } + + /** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */ + varint() { + let value = 0; + let scale = 1; + for (let count = 0; count < MAX_VARINT_BYTES; count += 1) { + if (this.pos >= this.end) throw new ShareWireError('truncated'); + const byte = this.bytes[this.pos++]; + value += (byte & 0x7f) * scale; + if ((byte & 0x80) === 0) { + // A zero final byte after a continuation is an overlong spelling. + if (count > 0 && byte === 0) + throw new ShareWireError('non-canonical-varint'); + if (!Number.isSafeInteger(value)) + throw new ShareWireError('varint-overflow'); + return value; + } + scale *= 0x80; + } + throw new ShareWireError('varint-overflow'); + } + + svarint() { + return unzigzag(this.varint()); + } + + /** The next field's number and wire type, rejecting reserved wire types. */ + tag() { + const key = this.varint(); + const field = Math.floor(key / 8); + const wireType = key % 8; + if (field < 1 || field > MAX_FIELD_NUMBER) + throw new ShareWireError('bad-field-number'); + if ( + wireType !== WIRE_VARINT && + wireType !== WIRE_FIXED64 && + wireType !== WIRE_LEN && + wireType !== WIRE_FIXED32 + ) + throw new ShareWireError('bad-wire-type', String(wireType)); + return { field, wireType }; + } + + /** A length-delimited body as a sub-reader over the same bytes. */ + lengthDelimited() { + const length = this.varint(); + if (length > this.end - this.pos) + throw new ShareWireError('truncated', 'length exceeds payload'); + const reader = new WireReader(this.bytes, this.pos, this.pos + length); + this.pos += length; + return reader; + } + + /** Remaining bytes of this reader as a fresh array. */ + rest() { + const out = this.bytes.slice(this.pos, this.end); + this.pos = this.end; + return out; + } + + /** Skip one value of `wireType`, so newer fields cost older decoders nothing. */ + skip(wireType) { + if (wireType === WIRE_VARINT) this.varint(); + else if (wireType === WIRE_LEN) this.lengthDelimited(); + else { + const size = wireType === WIRE_FIXED64 ? 8 : 4; + if (size > this.end - this.pos) throw new ShareWireError('truncated'); + this.pos += size; + } + } +} diff --git a/src/share/wire.test.mjs b/src/share/wire.test.mjs new file mode 100644 index 000000000..5383293b3 --- /dev/null +++ b/src/share/wire.test.mjs @@ -0,0 +1,188 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ShareWireError, + WIRE_FIXED32, + WIRE_FIXED64, + WIRE_LEN, + WIRE_VARINT, + WireReader, + WireWriter, + crc8, + decodeBase64Url, + encodeBase64Url, + unzigzag, + zigzag, +} from './wire.js'; + +const reasonOf = (fn) => { + try { + fn(); + } catch (error) { + assert.ok(error instanceof ShareWireError, String(error)); + return error.reason; + } + assert.fail('expected a ShareWireError'); +}; + +test('base64url round-trips every length and byte value without padding', () => { + for (let length = 0; length < 70; length += 1) { + const bytes = Uint8Array.from( + { length }, + (_, i) => (i * 97 + length) & 0xff, + ); + const text = encodeBase64Url(bytes); + assert.match(text, /^[A-Za-z0-9_-]*$/); + if (length) assert.deepEqual(decodeBase64Url(text), bytes); + } + const all = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all); + assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8'); +}); + +test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => { + for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) { + assert.ok( + ['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))), + text, + ); + } + // "AR" carries one byte (0x01) plus four trailing bits that must be zero. + assert.equal( + reasonOf(() => decodeBase64Url('AR')), + 'bad-base64', + ); + assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1)); +}); + +test('zigzag maps small magnitudes of either sign to small codes', () => { + assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]); + for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) { + assert.equal(unzigzag(zigzag(value)), value); + } +}); + +test('varints round-trip up to the largest safe integer', () => { + const values = [ + 0, + 1, + 127, + 128, + 300, + 2 ** 21, + 2 ** 31, + 2 ** 32 + 5, + Number.MAX_SAFE_INTEGER, + ]; + const writer = new WireWriter(); + for (const value of values) writer.varint(value); + const reader = new WireReader(writer.finish()); + for (const value of values) assert.equal(reader.varint(), value); + assert.ok(reader.done); + assert.deepEqual( + new WireWriter().varint(300).finish(), + Uint8Array.of(0xac, 0x02), + ); +}); + +test('the writer refuses values the format cannot carry', () => { + assert.throws(() => new WireWriter().varint(-1), RangeError); + assert.throws(() => new WireWriter().varint(1.5), RangeError); + assert.throws(() => new WireWriter().varint(2 ** 53), RangeError); + assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError); + assert.throws(() => new WireWriter().textField(1, 'é'), RangeError); +}); + +test('the reader rejects truncated, overlong and overflowing varints', () => { + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()), + 'truncated', + ); + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()), + 'non-canonical-varint', + ); + const tooLong = new Uint8Array(9).fill(0xff); + assert.equal( + reasonOf(() => new WireReader(tooLong).varint()), + 'varint-overflow', + ); + const beyondSafe = Uint8Array.of( + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0xff, + 0x7f, + ); + assert.equal( + reasonOf(() => new WireReader(beyondSafe).varint()), + 'varint-overflow', + ); +}); + +test('the reader bounds lengths and rejects group and reserved wire types', () => { + const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish(); + const reader = new WireReader(absurd); + reader.tag(); + assert.equal( + reasonOf(() => reader.lengthDelimited()), + 'truncated', + ); + for (const wireType of [3, 4, 6, 7]) { + const bytes = new WireWriter().varint(8 + wireType).finish(); + assert.equal( + reasonOf(() => new WireReader(bytes).tag()), + 'bad-wire-type', + ); + } + assert.equal( + reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()), + 'bad-field-number', + ); +}); + +test('skip steps over each wire type and stops at a truncated value', () => { + const writer = new WireWriter() + .uintField(1, 300) + .bytesField(2, [1, 2, 3]) + .tag(3, WIRE_FIXED32); + writer.bytes.push(1, 2, 3, 4); + writer.tag(4, WIRE_FIXED64); + writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8); + writer.uintField(5, 9); + const reader = new WireReader(writer.finish()); + for (let field = 1; field <= 4; field += 1) + reader.skip(reader.tag().wireType); + assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT }); + assert.equal(reader.varint(), 9); + const short = new WireWriter().tag(1, WIRE_FIXED64).finish(); + const shortReader = new WireReader(Uint8Array.of(...short, 1, 2)); + assert.equal( + reasonOf(() => shortReader.skip(shortReader.tag().wireType)), + 'truncated', + ); +}); + +test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => { + // The catalogued check value for the ASCII digits 1–9. + assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4); + assert.equal(crc8([]), 0); + const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33]; + const sum = crc8(bytes); + for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) { + for (let pattern = 1; pattern < 256; pattern += 1) { + // Flip a burst starting at `bit` (its first bit always set). + if (!(pattern & 1)) continue; + const flipped = [...bytes]; + for (let k = 0; k < 8; k += 1) { + if (!(pattern & (1 << k))) continue; + const at = bit + k; + flipped[at >> 3] ^= 0x80 >> (at & 7); + } + assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`); + } + } +}); diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index 746d5b3bc..91961e0b1 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -8,6 +8,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { createDefaultLayerState } from './data/layerState.js'; +import { parseSharePath } from './share/shareLinkPath.js'; +import { decodeShareView } from './share/viewCodec.js'; + +/** The decoded view of a copied `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; const uiSource = readShellSource(); @@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async ( assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true); - assert.equal(new URL(copied[0]).hash.includes('at=2000'), true); - assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); + assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000); + assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000); assert.equal(window.location.hash, liveHash); assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); }); @@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress manager.parseInitialHash(); assert.equal(manager._initialRestorePending, true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); - const params = new URL(copied).hash.slice(1); - assert.equal(new URLSearchParams(params).get('at'), '3000'); + assert.equal(sharedState(copied).sharedAtMs, 3_000_000); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); }); diff --git a/src/sharelink.js b/src/sharelink.js index 9668fa979..4a0291a84 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -11,12 +11,30 @@ import { encodeLayerStateParams, } from './data/layerState.js'; import { STYLE_URL_NAMES } from './view/index.js'; +import { + SHARE_VIEW_PANELS, + SHARE_VIEW_STYLE_PARAMS, + decodeShareView, + encodeShareView, +} from './share/viewCodec.js'; +import { + fromLegacyShareState, + toLegacyShareState, +} from './share/legacyShareState.js'; +import { + appRootUrl, + parseSharePath, + shareLinkUrl, +} from './share/shareLinkPath.js'; /** * Share Links — URL Hash State Management * - * Encodes camera position + style into the URL hash so links can be shared. - * Format: #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * The address bar follows the live view in the long hash form: + * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal + * + * A shared link is the short form, `/share/`, with the code from + * src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here. */ const DEBOUNCE_MS = 500; @@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui'; const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_CREATED_AT_PARAM = 'at'; -const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ - { id: 'control-panel', token: 'c', pinnable: true }, - { id: 'location-bar', token: 'l', pinnable: true }, - { id: 'data-panel', token: 'd', pinnable: false }, - { id: 'cctv-panel', token: 'v', pinnable: false }, - { id: 'recent-imagery-panel', token: 'i', pinnable: false }, - { id: 'street-level-panel', token: 't', pinnable: false }, - { id: 'radio-panel', token: 'r', pinnable: false }, - { id: 'scene-panel', token: 's', pinnable: false }, - { id: 'global-context-panel', token: 'g', pinnable: false }, - { id: 'pp-toggles', token: 'p', pinnable: false }, - { id: 'param-slider-panel', token: 'm', pinnable: false }, -]); - +// The panel and visual-preset parameter tables are the share format's own +// (src/share/viewCodec.js); the long form spells their entries by token. +const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS; const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), ); @@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries( Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]), ); -const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ - retro: Object.freeze([ - { key: 'pixelation', token: 'p', min: 1, max: 10 }, - { key: 'distortion', token: 'd', min: 0, max: 1 }, - { key: 'instability', token: 'i', min: 0, max: 1 }, - ]), - surveillance: Object.freeze([ - { key: 'gain', token: 'g', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'scanlineStr', token: 's', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - ]), - thermal: Object.freeze([ - { key: 'sensitivity', token: 's', min: 0, max: 1 }, - { key: 'bloom', token: 'b', min: 0, max: 1 }, - { key: 'mode', token: 'm', min: 0, max: 1 }, - { key: 'pixelation', token: 'p', min: 1, max: 6 }, - { key: 'palette', token: 'a', min: 0, max: 1 }, - ]), - anime: Object.freeze([ - { key: 'saturation', token: 's', min: 0, max: 2 }, - { key: 'edgeThick', token: 'e', min: 0, max: 1 }, - ]), - noir: Object.freeze([ - { key: 'contrastAmt', token: 'c', min: 0, max: 2 }, - { key: 'grainAmt', token: 'g', min: 0, max: 1 }, - { key: 'vignetteAmt', token: 'v', min: 0, max: 1 }, - ]), - snow: Object.freeze([ - { key: 'density', token: 'd', min: 0, max: 1 }, - { key: 'wind', token: 'w', min: 0, max: 1 }, - ]), -}); +const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS; export class ShareLinkManager { constructor( viewer, - { onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, + { + onRestore, + isNavigationCurrent, + cancelOwnedNavigation, + initialShareCode = null, + } = {}, ) { this.viewer = viewer; + // A host may hand over the share code to open instead of the address. + this._initialShareCode = + typeof initialShareCode === 'string' ? initialShareCode : null; + // Why the share code this page opened with was refused, if it was. + this.rejectedShareCode = null; + this._followSubjectProvider = null; this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void this._debounceTimer = null; this._currentStyle = 'normal'; @@ -155,17 +141,42 @@ export class ShareLinkManager { } /** - * Parse URL hash on page load. Returns parsed state or null. + * Parse the share this page opened with: the host's `initialShareCode`, a + * `/share/` address, or a long-form hash. Returns the parsed state, or + * null for none. A share code that cannot be opened is recorded in + * `rejectedShareCode` and restores nothing. */ parseInitialHash() { const hash = window.location.hash.slice(1); this._knownHash = hash; - const state = this._parseShareHash(hash); + const code = + this._initialShareCode ?? + parseSharePath(window.location.pathname)?.code ?? + null; + this._initialShareCode = null; + const state = + code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; this._beginIncomingRestore(state); return state; } + /** + * The restore state for a share code, in the long form's parsed shape plus + * `shareView` (what the view follows, with its offset and last-known fix), + * or null when the code is refused. + */ + _parseShareCode(code) { + const result = decodeShareView(code); + if (!result.ok) { + this.rejectedShareCode = result.reason; + return null; + } + const state = toLegacyShareState(result.state); + state.shareView = result.state.view; + return state; + } + /** Parse one hash without side effects. Returns null unless it is a share. */ _parseShareHash(hash) { if (!hash) return null; @@ -459,6 +470,16 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the source of the entity the camera follows, used for share links: + * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share + * format's terms (src/share/follow.js), with the fix as of `nowMs`. + */ + setFollowSubjectProvider(provider) { + this._followSubjectProvider = + typeof provider === 'function' ? provider : null; + } + /** Install the finalized panel-state source used by URL generation. */ setPanelStateProvider(provider) { this._panelStateProvider = typeof provider === 'function' ? provider : null; @@ -559,17 +580,41 @@ export class ShareLinkManager { this._scheduleUpdate(); } + /** + * The share link for the current state, `/share/`, stamped with + * its creation time, or null before the camera has a position. While the + * camera follows an entity, the link follows it too. + */ + buildLink({ nowMs = Date.now() } = {}) { + const params = this._buildHashParams(); + if (!params) return null; + // The long form is the one description of the live state; the code is + // that state in the share format. + const state = fromLegacyShareState(this._parseShareHash(params.toString())); + state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + let subject = null; + try { + subject = this._followSubjectProvider?.({ nowMs }) ?? null; + } catch { + subject = null; + } + if (subject) { + state.view = { + kind: 'thing', + entity: { kind: subject.kind, id: subject.id }, + offset: subject.offset ?? null, + lastKnown: subject.lastKnown ?? null, + }; + } + return shareLinkUrl(encodeShareView(state), window.location.href); + } + /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */ async copyLink({ nowMs = Date.now() } = {}) { - const params = this._buildHashParams(); - if (!params) return false; - params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000))); - const copiedUrl = new URL(window.location.href); - // ?lang is the sender's language override; the recipient keeps their own. - copiedUrl.searchParams.delete('lang'); - copiedUrl.hash = params.toString(); try { - await navigator.clipboard.writeText(copiedUrl.href); + const href = this.buildLink({ nowMs }); + if (!href) return false; + await navigator.clipboard.writeText(href); return true; } catch { return false; @@ -587,8 +632,13 @@ export class ShareLinkManager { const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the - // app may load under. - const url = new URL(`#${params.toString()}`, window.location.href); + // app may load under. A `/share/` address gives way to the app's + // own address, so a reload follows the live view; replacing it adds no + // history entry. + const url = parseSharePath(new URL(window.location.href).pathname) + ? appRootUrl(window.location.href) + : new URL(window.location.href); + url.hash = params.toString(); this._knownHash = url.hash.slice(1); history.replaceState(null, '', url.href); } @@ -649,8 +699,8 @@ export class ShareLinkManager { this._styleParamStateProvider?.(this._currentStyle), ); - // Copy-time metadata is intentionally absent here. `copyLink()` adds a - // fresh timestamp to its ephemeral URL without aging the live address. + // Creation-time metadata is intentionally absent here. `buildLink()` + // stamps its own link without aging the live address. params.delete(SHARE_CREATED_AT_PARAM); return params; } @@ -677,6 +727,7 @@ export class ShareLinkManager { this._layerStateProvider = null; this._panelStateProvider = null; this._styleParamStateProvider = null; + this._followSubjectProvider = null; this._onRestore = null; } } diff --git a/src/testSupport/shareViewFixtures.mjs b/src/testSupport/shareViewFixtures.mjs new file mode 100644 index 000000000..629132459 --- /dev/null +++ b/src/testSupport/shareViewFixtures.mjs @@ -0,0 +1,103 @@ +/** + * Shared fixtures for the share-view codec tests: a seeded generator and + * real long-form links. + */ +import { + REGISTERED_LAYER_IDS, + createDefaultLayerState, + optionSpecs, +} from '../data/layerState.js'; + +/** A long-form link copied from a real session. */ +export const HAL_EXAMPLE_HASH = + 'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0'; + +/** Deterministic generator (mulberry32) for property-style tests. */ +export function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const OPTION_CANDIDATES = [ + true, + false, + 'light', + 'strong', + 'gfs', + 'ifs', + 'none', + 'speed', + 'temperature', + 'pressure', + 'km/h', + 'm/s', + 'mph', + 'proximity', + 'all', + 'core', + 'dense', + 'off', + 'on', + 'viewshed', + 'tomtom', + 'osm', + 'hybrid', + 'clouds', + 'clouds-regional', + 'pano', + 'flat', + 'filtered', + 'full', + 0, + 1, + 2, + 37, + 50, + 100, + 365, + -12345678, + 12345678, + 8505110, + 'S30:2026-09-18', + 'L30:2024-02-29', + 'VIIRS:2025-01-02', + 'genre:jazz & blues', + 'news', + 'public-safety', + 0.35, + 0.8, + 'abc-1*def-0', + 'osm-1', + 25544, + 'a1b2c3', + '~abc123', +]; + +/** A random layer state using only values each option accepts. */ +export function randomLayerState(random) { + const state = createDefaultLayerState(); + state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3); + for (const ownerId of Object.keys(state.options)) { + for (const spec of optionSpecs(ownerId)) { + if (random() < 0.5) continue; + const accepted = OPTION_CANDIDATES.filter( + (value) => spec.normalize(value) !== null, + ); + if (accepted.length) + state.options[ownerId][spec.key] = + accepted[Math.floor(random() * accepted.length)]; + } + } + return state; +} + +/** One element of `values`, chosen by `random`. */ +export function pick(random, values) { + return values[Math.floor(random() * values.length)]; +} diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4552da8d4..e3ed24867 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -35,6 +35,10 @@ import * as Cesium from 'cesium'; import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { ShellFeedback } from './shellFeedback.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; @@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade { */ constructor( viewer, - { mapStackController = null, placeSearch, services, requestServices } = {}, + { + mapStackController = null, + placeSearch, + services, + requestServices, + initialShareCode = null, + } = {}, ) { super(); const { @@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade { this._showGlobalStatusNotice(message, options), feedback: this._feedback, updateFeedback: () => this._updateGlobalLoadingFeedback(), + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), }); this._visualSettings = new VisualSettings({ @@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade { isNavigationCurrent: (generation) => generation === this._navigationGeneration, cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), + // A host may open a share code without a `/share/` address. + initialShareCode, }); + this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) => + readShareFollowSubject({ viewer: this.viewer, nowMs }), + ); this.shareLinkManager.setPanelStateProvider(() => this._buildSharePanelState(), ); @@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade { // ── Share Button ───────────────────────────── /** - * Wires the share button click to copy the current share link to the clipboard. + * Wires the share button. By default it copies the current share link to the + * clipboard; a host may pass `services.shareLink(url)` to share it another way + * (a system share sheet, for example). It resolves to 'shared', 'copied', + * 'cancelled' (no toast) or false (failed). * @returns {void} */ _initShareButton() { this._lifetime.listen(this._shareBtn, 'click', async () => { - const success = await this.shareLinkManager.copyLink(); - if (!this._disposed) - this._showToast(success ? t('Link copied!') : t('Copy failed')); + const share = this.services.shareLink; + let result; + if (typeof share === 'function') { + // A host's hook may return a plain value or throw; both are answers. + result = await Promise.resolve() + .then(() => { + const href = this.shareLinkManager.buildLink(); + return href ? share(href) : false; + }) + .catch(() => false); + } else { + result = (await this.shareLinkManager.copyLink()) ? 'copied' : false; + } + if (this._disposed || result === 'cancelled') return; + this._showToast( + result === 'shared' + ? t('Link shared') + : result === 'copied' + ? t('Link copied!') + : t('Copy failed'), + ); }); } diff --git a/src/ui/shareButton.test.mjs b/src/ui/shareButton.test.mjs new file mode 100644 index 000000000..e5e8422c0 --- /dev/null +++ b/src/ui/shareButton.test.mjs @@ -0,0 +1,160 @@ +// The share button copies by default; a host's `services.shareLink` shares the +// link its own way and reports how it went. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StyleManager } from './applicationShell.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { parseSharePath } from '../share/shareLinkPath.js'; +import { decodeShareView } from '../share/viewCodec.js'; + +/** The decoded view of a `/share/` link. */ +const sharedState = (href) => + decodeShareView(parseSharePath(new URL(href).pathname).code).state; +const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/; + +function makeManager(href, { positioned = true } = {}) { + globalThis.window = { location: { href, hash: new URL(href).hash } }; + globalThis.history = { replaceState() {} }; + const viewer = { + camera: { + changed: { addEventListener() {} }, + positionCartographic: positioned + ? { latitude: 0, longitude: 0, height: 1000 } + : undefined, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + }, + }; + return new ShareLinkManager(viewer); +} + +function installClipboard(writeText) { + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { clipboard: { writeText } }, + }); +} + +/** Wire the real share button handler onto a minimal shell; return a click. */ +function shell({ manager, services = {} }) { + const toasts = []; + let click; + const owner = { + services, + shareLinkManager: manager, + _shareBtn: {}, + _disposed: false, + _lifetime: { listen: (_el, _type, handler) => (click = handler) }, + _showToast: (message) => toasts.push(message), + }; + StyleManager.prototype._initShareButton.call(owner); + return { toasts, click: () => click() }; +} + +test('buildLink is a /share link stamped with its creation time, without ?lang', () => { + const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20'); + const href = manager.buildLink({ nowMs: 5_000_000 }); + assert.match(href, SHARE_LINK); + const link = new URL(href); + assert.equal(link.searchParams.get('lang'), null); + assert.equal(link.hash, ''); + assert.equal(sharedState(href).sharedAtMs, 5_000_000); + // The camera is the live one, not the address bar's. + assert.equal(sharedState(href).lat, 0); + assert.equal( + makeManager('https://gev.example/', { positioned: false }).buildLink(), + null, + ); +}); + +test('copyLink copies exactly what buildLink builds', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const manager = makeManager('https://gev.example/#v=2'); + assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true); + assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 })); +}); + +test('without a host hook the button copies the link', async () => { + let copied = null; + installClipboard(async (url) => { + copied = url; + }); + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + }); + await click(); + assert.match(copied, SHARE_LINK); + assert.deepEqual(toasts, ['Link copied!']); + + installClipboard(async () => { + throw new Error('denied'); + }); + await click(); + assert.deepEqual(toasts, ['Link copied!', 'Copy failed']); +}); + +test('a host hook receives the link and its result picks the toast', async () => { + installClipboard(async () => assert.fail('the host shares instead')); + const shared = []; + const results = ['shared', 'copied', 'cancelled', false]; + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { + shareLink: async (url) => { + shared.push(url); + return results.shift(); + }, + }, + }); + for (let i = 0; i < 4; i++) await click(); + assert.equal(shared.length, 4); + assert.match(shared[0], SHARE_LINK); + // 'cancelled' (the user closed the sheet) shows nothing. + assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']); +}); + +test('a failing host hook, or no camera position, reports a failure', async () => { + const failing = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink: async () => Promise.reject(new Error('no sheet')) }, + }); + await failing.click(); + assert.deepEqual(failing.toasts, ['Copy failed']); + + let called = false; + const unpositioned = shell({ + manager: makeManager('https://gev.example/', { positioned: false }), + services: { + shareLink: async () => { + called = true; + return 'shared'; + }, + }, + }); + await unpositioned.click(); + assert.equal(called, false); + assert.deepEqual(unpositioned.toasts, ['Copy failed']); +}); + +test('a host hook that answers without a promise, or throws, still gets a toast', async () => { + for (const [shareLink, toast] of [ + [() => 'shared', 'Link shared'], + [ + () => { + throw new Error('no sheet'); + }, + 'Copy failed', + ], + ]) { + const { toasts, click } = shell({ + manager: makeManager('https://gev.example/#v=2'), + services: { shareLink }, + }); + await click(); + assert.deepEqual(toasts, [toast]); + } +}); diff --git a/src/ui/shareFollow.js b/src/ui/shareFollow.js new file mode 100644 index 000000000..ba5851da6 --- /dev/null +++ b/src/ui/shareFollow.js @@ -0,0 +1,190 @@ +import * as Cesium from 'cesium'; +import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js'; +import { + SHARE_VIEW_ENTITY_KINDS, + followOffsetFromEnu, + followOffsetToEnu, +} from '../share/follow.js'; + +/** + * The bridge between the camera following an entity and a share link's thing + * view: what the camera follows now, in the share format's terms, and the + * stored follow offset applied to an entity the camera has started to follow. + */ + +const KIND_BY_LAYER = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]), +); +const MAX_FRAMES = 120; + +/** `position`'s local east/north/up offset from `origin`, in metres. */ +function enuOffset(origin, position) { + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin); + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()), + position, + new Cesium.Cartesian3(), + ); + return { east: local.x, north: local.y, up: local.z }; +} + +/** + * The entity the camera follows as `{ kind, id, offset, lastKnown }`, with + * the camera's offset from it and its displayed position as of `nowMs`; null + * when the camera follows nothing a share link can name. A selected vessel is + * not followed (the camera stays where the user puts it), so a link made then + * keeps its own camera. + */ +export function readShareFollowSubject({ viewer, nowMs = Date.now() }) { + const cameraPosition = viewer?.camera?.positionWC; + if (!cameraPosition) return null; + const tracked = viewer.trackedEntity; + const trackedId = String(tracked?.gevTrackedId || ''); + const separator = trackedId.indexOf(':'); + if (separator <= 0) return null; + const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator)); + const id = trackedId.slice(separator + 1); + const target = kind + ? trackedDisplayPositionForCamera( + tracked, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!kind || !id || !target) return null; + const offset = followOffsetFromEnu(enuOffset(target, cameraPosition)); + const fix = Cesium.Cartographic.fromCartesian(target); + if (!offset || !fix) return null; + return { + kind, + id, + offset, + lastKnown: { + lat: Cesium.Math.toDegrees(fix.latitude), + lon: Cesium.Math.toDegrees(fix.longitude), + alt: fix.height, + observedAtMs: nowMs, + }, + }; +} + +/** Largest turn, in radians, the camera makes around the entity per frame. */ +const MAX_TURN_PER_FRAME = Math.PI / 3; + +/** + * Put the camera at a shared follow offset from `entity` once following it + * has framed the camera. Cesium keeps the offset afterwards, as it keeps one + * the user orbits to. The camera swings round the entity at most 60° a frame: + * the follow's own guard treats a jump to the far side as the camera crossing + * through the entity and puts it back. Returns a disposer. + */ +export function applyShareFollowOffset(viewer, entity, offset) { + if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0)) + return () => {}; + const local = followOffsetToEnu(offset); + let frames = 0; + let remove = null; + const stop = () => { + remove?.(); + remove = null; + }; + remove = viewer.scene.preUpdate.addEventListener(() => { + if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { + stop(); + return; + } + frames += 1; + const camera = viewer.camera; + // Let the follow frame the camera first (its first frame), then move it. + const framed = + frames > 1 && + !Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY); + const target = framed + ? trackedDisplayPositionForCamera( + entity, + viewer.clock.currentTime, + new Cesium.Cartesian3(), + ) + : null; + if (!target) { + if (frames >= MAX_FRAMES) stop(); + return; + } + // The offset is east/north/up at the entity; the follow's own frame may + // differ, so place the camera in world space and re-express it there. + const world = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const goal = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + camera.transform, + new Cesium.Matrix4(), + ), + world, + new Cesium.Cartesian3(), + ); + const from = Cesium.Cartesian3.clone( + camera.position, + new Cesium.Cartesian3(), + ); + const angle = + Cesium.Cartesian3.magnitude(from) > 0 + ? Cesium.Cartesian3.angleBetween(from, goal) + : 0; + const done = !(angle > MAX_TURN_PER_FRAME); + const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME); + camera.lookAtTransform(camera.transform, next); + viewer.scene.requestRender?.(); + if (done) stop(); + }); + return stop; +} + +/** + * `from` turned `step` radians toward `to`, with its length moved the same + * fraction of the way: one frame of the swing round the entity. + */ +function turnToward(from, to, step) { + const angle = Cesium.Cartesian3.angleBetween(from, to); + const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3()); + // The direction perpendicular to `from` in the plane toward `to` (or any + // perpendicular when they are opposite). + let toward = Cesium.Cartesian3.subtract( + Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(angle), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitude(toward) < 1e-9) + toward = Cesium.Cartesian3.cross( + unitFrom, + Math.abs(unitFrom.z) < 0.9 + ? Cesium.Cartesian3.UNIT_Z + : Cesium.Cartesian3.UNIT_X, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.normalize(toward, toward); + const length = + Cesium.Cartesian3.magnitude(from) + + (Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) * + (step / angle); + const direction = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + unitFrom, + Math.cos(step), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + toward, + Math.sin(step), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Cesium.Cartesian3.multiplyByScalar(direction, length, direction); +} diff --git a/src/ui/shareLinks.test.mjs b/src/ui/shareLinks.test.mjs new file mode 100644 index 000000000..d7b747d66 --- /dev/null +++ b/src/ui/shareLinks.test.mjs @@ -0,0 +1,554 @@ +// Short share links: `/share/` opens through the start-up restore +// pipeline, the address then follows the live view, and the share button +// makes `/share/` links (following an entity when the camera does). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { ShareLinkManager } from '../sharelink.js'; +import { ShareRestoration, sharedFixNotice } from './shareRestoration.js'; +import { + applyShareFollowOffset, + readShareFollowSubject, +} from './shareFollow.js'; +import { createDefaultLayerState } from '../data/layerState.js'; +import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js'; +import { + decodeShareView, + encodeShareView, + followCameraFor, + followOffsetToEnu, +} from '../share/viewCodec.js'; + +const NOW_MS = Date.now(); +const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 }; +const OFFSET = { range: 5800, heading: 0, pitch: -35 }; + +const PLACE_CODE = encodeShareView({ + lat: 48.8584, + lon: 2.2945, + alt: 1200, + heading: 120, + pitch: -25, + style: 'noir', + panelState: { specs: [{ id: 'data-panel', collapsed: false }] }, + sharedAtMs: NOW_MS - 60_000, +}); + +function aircraftCode() { + const layerState = createDefaultLayerState(); + layerState.enabledLayerIds = ['flights']; + return encodeShareView({ + view: { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 }, + }, + layerState, + sharedAtMs: NOW_MS - 120_000, + }); +} + +/** + * A page at `href`. `history.replaceState` rewrites the address in place and + * is counted; `pushState` (a new history entry) is counted too. + */ +function installPage(t, href) { + const prior = { window: globalThis.window, history: globalThis.history }; + const target = new EventTarget(); + const location = new URL(href); + target.location = location; + globalThis.window = target; + const writes = { replaced: 0, pushed: 0 }; + globalThis.history = { + replaceState(_state, _title, next) { + writes.replaced += 1; + location.href = new URL(next, location.href).href; + }, + pushState() { + writes.pushed += 1; + }, + }; + t.after(() => Object.assign(globalThis, prior)); + return { location, writes, target }; +} + +function installManualTimers(t) { + const prior = { + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }; + const timers = new Map(); + let timerId = 0; + globalThis.setTimeout = (fn) => { + timers.set(++timerId, fn); + return timerId; + }; + globalThis.clearTimeout = (id) => timers.delete(id); + t.after(() => Object.assign(globalThis, prior)); + return { + timers, + runAll() { + for (const [id, fn] of [...timers]) { + timers.delete(id); + fn(); + } + }, + }; +} + +/** A viewer whose camera flights land at once. */ +function fakeViewer() { + const flights = []; + return { + flights, + canvas: new EventTarget(), + camera: { + changed: { addEventListener: () => () => {} }, + positionCartographic: { latitude: 0, longitude: 0, height: 1000 }, + heading: 0, + pitch: -Math.PI / 2, + roll: 0, + flyTo(options) { + flights.push(options); + options.complete?.(); + }, + setView() {}, + }, + }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */ +function openPage(t, href, { managerOptions = {}, tracking = null } = {}) { + const page = installPage(t, href); + const clock = installManualTimers(t); + const viewer = fakeViewer(); + const restores = []; + const manager = new ShareLinkManager(viewer, { + onRestore: async (state) => restores.push(state), + isNavigationCurrent: () => true, + ...managerOptions, + }); + const notices = []; + let generation = 0; + const owner = new ShareRestoration({ + viewer, + navigation: { + _beginDeferredNavigation: () => ++generation, + _reassertNavigationHandoff: () => true, + _stampNavigation() {}, + }, + syncShareState() {}, + syncModels3d() {}, + showStatus() {}, + feedback: {}, + updateFeedback() {}, + applyFollowOffset: (entity, offset) => + applyShareFollowOffset(viewer, entity, offset), + }); + // Notices are checked by message; their start-up-cover timing is not. + owner._presentAfterStartupCover = (message) => notices.push(message); + owner.attachLinks(manager); + owner._layerStateCoordinator = { + applyShareLayerState: () => Promise.resolve([]), + getDurableState: () => null, + cancelPendingShareTracking: () => false, + restoreShareTrackingSelection: async () => tracking?.(owner) ?? null, + destroy() {}, + }; + owner.start(); + t.after(() => owner.destroy()); + return { ...page, ...clock, viewer, manager, owner, restores, notices }; +} + +/** Run the delayed start-up restore and let it settle. */ +async function restore(page) { + page.runAll(); + const result = await page.owner.initialRestorePromise; + await flush(); + return result; +} + +test('a /share/ address opens its place view through the start-up restore', async (t) => { + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight'); + assert.equal(page.manager._initialRestorePending, true); + const result = await restore(page); + assert.equal(result.status, 'settled'); + assert.equal(result.share.camera, 'applied'); + assert.equal(page.restores[0].style, 'noir'); + assert.deepEqual(page.restores[0].panelState.specs, [ + { id: 'data-panel', collapsed: false, pinned: null }, + ]); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6, + ); + assert.equal(page.manager._initialRestorePending, false); + assert.deepEqual(page.notices, []); +}); + +test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => { + for (const [href, root] of [ + [`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'], + [ + `http://localhost:4173/gev/share/${PLACE_CODE}`, + 'http://localhost:4173/gev/', + ], + ]) { + const page = openPage(t, href); + await restore(page); + page.manager._updateHash(); + assert.equal(`${page.location.origin}${page.location.pathname}`, root); + // The live view's long form, as written for any other page. + const params = new URLSearchParams(page.location.hash.slice(1)); + assert.equal(params.get('v'), '2'); + assert.ok(params.has('lat') && params.has('style')); + assert.equal(page.writes.pushed, 0); + assert.equal(page.writes.replaced, 1); + page.manager.destroy(); + } +}); + +test('a host can pass the share code instead of the address', async (t) => { + const page = openPage(t, 'http://localhost/', { + managerOptions: { initialShareCode: PLACE_CODE }, + }); + await restore(page); + assert.equal(page.restores[0].style, 'noir'); +}); + +test('a code that fails its check restores nothing and says so', async (t) => { + // One character changed: the check byte no longer matches. + const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A'; + const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`; + const page = openPage(t, `http://localhost/share/${typo}`); + assert.equal(page.manager.rejectedShareCode, 'checksum'); + assert.notEqual(page.owner._hasShareState, true); + assert.equal(page.timers.size, 0, 'nothing to restore'); + assert.equal( + (await page.owner.initialRestorePromise).status, + 'not-requested', + ); + assert.deepEqual(page.notices, ["This share link can't be opened"]); + // The default start's first address write leaves the bad link behind. + page.manager._updateHash(); + assert.equal(page.location.pathname, '/'); +}); + +test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => { + let entity = null; + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // What the coordinator does when the aircraft is in the feed. + owner.viewer.trackedEntity = entity; + owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + classification: 'followed', + }); + return { classification: 'followed' }; + }, + }); + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + Object.assign(page.viewer, { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + isDestroyed: () => false, + }); + Object.assign(page.viewer.camera, { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + // The app's own follow framing: south of the aircraft and above it. + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(offset); + this.position = Cesium.Cartesian3.clone(offset); + }, + }); + entity = { + gevTrackedId: 'flights:a1b2c3', + gevDisplayPosition: () => target, + }; + + const parsed = page.manager._parseShareCode(aircraftCode()); + assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']); + assert.equal( + parsed.layerState.options.flights.selectedFlightsTrackingId, + 'a1b2c3', + ); + await restore(page); + // The opening flight goes where following the fix puts the camera. + const camera = followCameraFor(FIX, OFFSET); + const destination = Cesium.Cartographic.fromCartesian( + page.viewer.flights[0].destination, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3, + ); + assert.ok( + Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3, + ); + // Once followed, the camera takes the shared offset after the follow's + // own first frame. + assert.equal(listeners.length, 1); + listeners[0](); + assert.equal(looks.length, 0); + listeners[0]?.(); + assert.equal(looks.length, 1); + const expected = followOffsetToEnu(OFFSET); + assert.ok(Math.abs(looks[0].x - expected.east) < 1); + assert.ok(Math.abs(looks[0].y - expected.north) < 1); + assert.ok(Math.abs(looks[0].z - expected.up) < 1); + assert.equal(listeners.length, 0, 'the offset is applied once'); + assert.deepEqual(page.notices, []); +}); + +test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => { + const page = openPage(t, `http://localhost/share/${aircraftCode()}`, { + tracking: (owner) => { + // The coordinator's verdict once the follow window has passed. + const result = { + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }; + owner._handleShareTrackingRestoreStatus(result); + return result; + }, + }); + await restore(page); + await flush(); + assert.equal(page.notices.length, 1); + assert.match( + page.notices[0], + /^A1B2C3 isn't reporting now — showing where it was at .+/, + ); + // A long link names no fix and keeps the existing notice. + page.owner._initialShareState = { shareView: null }; + page.owner._handleShareTrackingRestoreStatus({ + layerId: 'flights', + targetId: 'a1b2c3', + label: 'flight', + classification: 'expired', + }); + assert.equal(page.notices[1], 'Shared flight follow expired'); +}); + +test('the not-reporting notice names the id and the time of the fix', () => { + const at = new Date(2026, 9, 26, 14, 5).getTime(); + assert.match( + sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000), + /^A1B2C3 isn't reporting now — showing where it was at .*05/, + ); + assert.equal( + sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), + "366999712 isn't reporting now — showing where it was last seen", + ); +}); + +test('a shared vessel is selected once the vessel feed has it', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: NOW_MS }); + const page = openPage(t, 'http://localhost/'); + const selected = []; + let present = false; + page.owner._dataManager = { + layers: new Map([ + [ + 'ais-live-vessels', + { + module: { + hasContact: (id) => present && id === '366999712', + selectById: (id) => selected.push(id) > 0, + }, + }, + ], + ]), + }; + const view = { + kind: 'thing', + entity: { kind: 'vessel', id: '366999712' }, + lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 }, + }; + const found = page.owner._restoreSharedVessel(view, () => true); + await flush(); + present = true; + page.runAll(); + assert.equal(await found, true); + assert.deepEqual(selected, ['366999712']); + + present = false; + const missing = page.owner._restoreSharedVessel(view, () => true); + for (let i = 0; i < 61; i += 1) { + await flush(); + t.mock.timers.tick(2_000); + page.runAll(); + } + assert.equal(await missing, false); + assert.match(page.notices.at(-1), /^366999712 isn't reporting now/); +}); + +test('the share button links to /share/, following the followed entity', (t) => { + installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2'); + const manager = new ShareLinkManager(fakeViewer()); + // A fresh session's look, as the app reports it at start-up. + manager.onToggleChange(false, true, { + hudVisible: true, + detectionMode: 'DENSE', + detectionDensity: 75, + }); + // No entity: a place view at the live camera. + const place = manager.buildLink({ nowMs: NOW_MS }); + assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/); + assert.equal( + decodeShareView(parseSharePath(new URL(place).pathname).code).state.view + .kind, + 'place', + ); + + manager.setFollowSubjectProvider(() => ({ + kind: 'aircraft', + id: 'a1b2c3', + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: NOW_MS }, + })); + const href = manager.buildLink({ nowMs: NOW_MS }); + const code = parseSharePath(new URL(href).pathname).code; + const { state } = decodeShareView(code, { nowMs: NOW_MS }); + assert.deepEqual(state.view, { + kind: 'thing', + entity: { kind: 'aircraft', id: 'a1b2c3' }, + offset: OFFSET, + lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 }, + }); + // The followed aircraft's layer is implied; the camera is derived. + assert.ok(code.length <= 45, `${code.length} characters`); + assert.equal(href, shareLinkUrl(code, 'https://gev.example/')); + manager.destroy(); +}); + +test('the followed entity is read in the share format, and its offset round-trips', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const offset = { range: 2400, heading: 30, pitch: -20 }; + const local = followOffsetToEnu(offset); + const cameraPosition = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Cartesian3(local.east, local.north, local.up), + new Cesium.Cartesian3(), + ); + const viewer = { + camera: { positionWC: cameraPosition }, + clock: { currentTime: Cesium.JulianDate.now() }, + trackedEntity: { + gevTrackedId: 'military:ae1234', + gevDisplayPosition: () => target, + }, + }; + const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS }); + assert.equal(subject.kind, 'military_aircraft'); + assert.equal(subject.id, 'ae1234'); + assert.deepEqual(subject.offset, offset); + assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9); + assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3); + // A followed entity the share format has no kind for is not a thing view. + viewer.trackedEntity.gevTrackedId = 'local-adsb:abc'; + assert.equal(readShareFollowSubject({ viewer }), null); + // Nor is a camera that follows nothing (a selected vessel is not followed). + viewer.trackedEntity = undefined; + assert.equal(readShareFollowSubject({ viewer }), null); +}); + +test('a shared offset on the far side swings round, never jumping through the entity', () => { + const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt); + const listeners = []; + const looks = []; + const entity = { gevDisplayPosition: () => target }; + const viewer = { + clock: { currentTime: Cesium.JulianDate.now() }, + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: entity, + camera: { + transform: Cesium.Transforms.eastNorthUpToFixedFrame(target), + position: new Cesium.Cartesian3(0, -4800, 3300), + lookAtTransform(transform, offset) { + looks.push(Cesium.Cartesian3.clone(offset)); + this.position = Cesium.Cartesian3.clone(offset); + }, + }, + }; + // Looking south at the nose: the opposite side of the app's framing. + const offset = { range: 5800, heading: 180, pitch: -35 }; + applyShareFollowOffset(viewer, entity, offset); + let previous = viewer.camera.position; + for (let frame = 0; frame < 10 && listeners.length; frame += 1) { + listeners[0](); + const now = viewer.camera.position; + // The follow's guard treats a turn of 90° or more as a crossing. + assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2); + previous = now; + } + assert.equal(listeners.length, 0, 'it arrives'); + assert.ok(looks.length >= 2, `${looks.length} steps`); + const expected = followOffsetToEnu(offset); + const last = looks.at(-1); + assert.ok(Math.abs(last.x - expected.east) < 1); + assert.ok(Math.abs(last.y - expected.north) < 1); + assert.ok(Math.abs(last.z - expected.up) < 1); +}); + +test('applying a follow offset gives up when the follow ends', () => { + const listeners = []; + const viewer = { + scene: { + preUpdate: { + addEventListener(fn) { + listeners.push(fn); + return () => listeners.splice(listeners.indexOf(fn), 1); + }, + }, + }, + trackedEntity: null, + }; + applyShareFollowOffset(viewer, {}, OFFSET); + listeners[0](); + assert.equal(listeners.length, 0); +}); + +test('long links and hash navigation are unaffected by a share code start', async (t) => { + // A long link still opens from the hash. + const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg'); + await restore(long); + assert.equal(long.restores[0].style, 'surveillance'); + assert.equal(long.restores[0].shareView, undefined); + long.manager._updateHash(); + assert.equal(long.location.pathname, '/'); + + // After a /share start, navigating to a long link restores it (#1416). + const page = openPage(t, `http://localhost/share/${PLACE_CODE}`); + await restore(page); + page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal'; + page.target.dispatchEvent(new Event('hashchange')); + await flush(); + assert.equal(page.restores.length, 2); + assert.equal(page.restores[1].style, 'normal'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 7fd52d917..12f09c18b 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,9 +1,18 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; -import { t, msg } from '../i18n/index.js'; +import { t, msg, formatDate } from '../i18n/index.js'; +import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +/** How long a shared vessel may take to appear in the vessel feed. */ +export const SHARED_VESSEL_WAIT_MS = 120_000; +const SHARED_VESSEL_POLL_MS = 2_000; +const DAY_MS = 86_400_000; +const ENTITY_BY_KIND = new Map( + SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]), +); + // One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES // label) and outcome: a label dropped into a template cannot take the case or // article the sentence needs in every language. The generic template stays @@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) { : t('Shared {subject} is unavailable', { subject }); } +/** + * The notice for a shared entity that is not reporting: the view shows where + * it was when the link was made. + * @param {{kind: string, id: string}} entity + * @param {number|null} observedAtMs + * @param {number} [nowMs] + * @returns {string} + */ +export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) { + // ICAO addresses read as they are written on the aircraft: upper case. + const id = + ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24' + ? String(entity.id).toUpperCase() + : String(entity.id); + if (!Number.isFinite(observedAtMs)) + return t("{id} isn't reporting now — showing where it was last seen", { + id, + }); + const time = formatDate( + observedAtMs, + nowMs - observedAtMs < DAY_MS + ? { hour: '2-digit', minute: '2-digit' } + : { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }, + ); + return t("{id} isn't reporting now — showing where it was at {time}", { + id, + time, + }); +} + /** Own initial share restoration, durable layer state and restoration notices. */ export class ShareRestoration { constructor({ @@ -61,6 +100,7 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + applyFollowOffset = null, }) { Object.assign(this, { viewer, @@ -70,6 +110,9 @@ export class ShareRestoration { showStatus, feedback, updateFeedback, + // (entity, offset) => disposer: frame a followed entity from a shared + // follow offset (shareFollow.js). + applyFollowOffset, }); this._lifetime = new UiLifetime(); this._disposed = false; @@ -84,6 +127,10 @@ export class ShareRestoration { // settles later must neither apply follow-up work nor release URL writes. this._shareRestoreGeneration = 0; this._navigatedShareRestoreActive = false; + this._stopFollowOffset = null; + // Bumped to abandon the wait for a shared vessel. + this._sharedVesselGeneration = 0; + this._sharedVesselPending = false; } attachLinks(shareLinkManager) { this.shareLinkManager = shareLinkManager; @@ -121,11 +168,21 @@ export class ShareRestoration { }, 1500); } else { this.syncShareState(); + if (this.shareLinkManager?.rejectedShareCode) + this._presentAfterStartupCover( + t("This share link can't be opened"), + ++this._shareTrackingNoticeGeneration, + ); } // A recipient can orbit before or during the delayed share flight. That // gesture keeps ordinary layer state but revokes the passive base camera // and selected-subject Follow so delayed work cannot seize navigation. this._initialShareGestureHandler = () => { + // The recipient taking the camera also ends the wait for a vessel. + if (this._sharedVesselPending) { + this._sharedVesselGeneration += 1; + this._sharedVesselPending = false; + } if ( this._disposed || !this._hasShareState || @@ -191,6 +248,10 @@ export class ShareRestoration { }; if (!current()) return; this.shareLinkManager.completeInitialRestore(state.restoreToken); + // Selecting a vessel leaves the camera alone, so it does not depend on + // the shared flight having landed. + const vessel = this._sharedVessel(state); + if (vessel) void this._restoreSharedVessel(vessel, current); settle({ status: 'settled', share, layers, tracking }); } catch (error) { if (!current()) return; @@ -301,7 +362,79 @@ export class ShareRestoration { }); } } + /** The shared vessel a share's thing view selects, if it names one. */ + _sharedVessel(state) { + const view = state?.shareView; + return view?.kind === 'thing' && view.entity.kind === 'vessel' + ? view + : null; + } + + /** + * Select a shared vessel once the vessel feed has it. Vessels are selected, + * not followed: the camera is already at the shared view. If the vessel does + * not appear in time, the view stays where it was when the link was made. + */ + async _restoreSharedVessel(view, current) { + const generation = ++this._sharedVesselGeneration; + const live = () => current() && generation === this._sharedVesselGeneration; + const deadline = Date.now() + SHARED_VESSEL_WAIT_MS; + const { id } = view.entity; + const { layerId } = ENTITY_BY_KIND.get(view.entity.kind); + this._sharedVesselPending = true; + try { + while (live()) { + const vessels = this._dataManager?.layers?.get?.(layerId)?.module; + if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true; + if (Date.now() >= deadline) break; + await new Promise((resolve) => + this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS), + ); + } + } finally { + if (generation === this._sharedVesselGeneration) + this._sharedVesselPending = false; + } + if (!live()) return false; + this._presentAfterStartupCover( + view.lastKnown + ? sharedFixNotice(view.entity, view.lastKnown.observedAtMs) + : t('Shared vessel is unavailable'), + ++this._shareTrackingNoticeGeneration, + ); + return false; + } + + /** The current share's thing view when `result` is about its entity. */ + _sharedThingFor(result) { + const view = this._initialShareState?.shareView; + if (view?.kind !== 'thing') return null; + const entry = ENTITY_BY_KIND.get(view.entity.kind); + return entry?.tracking === result?.layerId && + String(view.entity.id) === String(result?.targetId) + ? view + : null; + } + + /** Frame a shared entity the camera now follows from the shared offset. */ + _applySharedFollowOffset(result) { + const view = this._sharedThingFor(result); + const entity = this.viewer?.trackedEntity; + // Only a link made while following carries a real offset (with the fix); + // a long link's follow keeps the app's own framing. + if ( + !view?.lastKnown || + String(entity?.gevTrackedId || '') !== + `${result.layerId}:${result.targetId}` + ) + return; + this._stopFollowOffset?.(); + this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset); + } + cancelSelection() { + // An explicit navigation also ends the wait for a shared vessel. + this._sharedVesselGeneration += 1; if ( this._hasShareState && this._resolveInitialShareRestore && @@ -347,17 +480,39 @@ export class ShareRestoration { this.updateFeedback(); } } - if ( - result.classification === 'followed' || - result.classification === 'cancelled' - ) + if (result.classification === 'followed') { + this._applySharedFollowOffset(result); return; + } + if (result.classification === 'cancelled') return; // A stale terminal result must never replace a newer target's acquisition. if (this._shareTrackingAcquiringKey) return; const noticeGeneration = ownsAcquiringNotice ? this._shareTrackingNoticeGeneration : ++this._shareTrackingNoticeGeneration; - const message = shareTrackingRestoreNotice(result); + // A share link that knows where the entity was says so; the view is + // already there. + const fix = this._sharedThingFor(result)?.lastKnown; + const message = fix + ? sharedFixNotice( + this._initialShareState.shareView.entity, + fix.observedAtMs, + ) + : shareTrackingRestoreNotice(result); + if (this._resolveInitialShareRestore) { + void this.initialRestorePromise.then(() => + this._presentAfterStartupCover(message, noticeGeneration), + ); + return; + } + this._presentAfterStartupCover(message, noticeGeneration); + } + + /** + * Show a status notice once the start-up cover is gone, unless a newer + * notice (generation) has taken its place. + */ + _presentAfterStartupCover(message, noticeGeneration) { const showAfterStartupCover = () => { this._lifetime.frame(() => { if ( @@ -399,10 +554,6 @@ export class ShareRestoration { fallbackTimer = this._lifetime.timeout(showOnce, 1000); }); }; - if (this._resolveInitialShareRestore) { - void this.initialRestorePromise.then(showAfterStartupCover); - return; - } showAfterStartupCover(); } _settleInitialShareRestore(result) { @@ -419,6 +570,9 @@ export class ShareRestoration { this._disposed = true; this._shareTrackingNoticeGeneration += 1; this._shareTrackingAcquiringKey = null; + this._sharedVesselGeneration += 1; + this._stopFollowOffset?.(); + this._stopFollowOffset = null; this._layerStateCoordinator?.destroy(); this._layerStateCoordinator = null; this._layerStateRestorePromise = null; From 3d9f153bc055acc58d18c6a4b329f044816409a7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:20:23 -0700 Subject: [PATCH 119/131] feat(share): export the share-view codec and link-path helpers (#1419) * feat(share): export the share-view codec for host servers Add `@gods-eye-view/core/share/view-codec` (src/share/viewCodec.js) as a package export with its own boundary group. The codec is pure (no DOM, no Cesium, no external dependencies), so a host's server can decode a /share/ link, for example to describe the view in link previews. * feat(share): export the share-link address helpers Add `@gods-eye-view/core/share/link-path` (src/share/shareLinkPath.js), so a host's server reads /share/ addresses the way the app does. --- changelog.d/1419-share-view-codec-export.md | 4 +++ docs/CODE-BOUNDARIES.md | 4 +++ docs/SHARE-LINK-FORMAT.md | 3 +- package.json | 2 ++ scripts/package-boundaries.json | 33 +++++++++++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1419-share-view-codec-export.md diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md new file mode 100644 index 000000000..e3ce1b105 --- /dev/null +++ b/changelog.d/1419-share-view-codec-export.md @@ -0,0 +1,4 @@ +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 9f8e0f629..efc7b86bc 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -598,6 +598,10 @@ state subscriptions retain their existing event contract. tracking handoff. It reads Cockpit admission and uses supplied tracking operations. - `ui/share-restoration` owns the initial restore transaction, layer coordinator, status notices and gesture/timer cleanup. +- `share/view-codec` is the share-link codec (`decodeShareView`, + `encodeShareView`). It is pure (no DOM, no Cesium), so a host's server can + read a `/share/` link, for example to describe it in link previews. + `share/link-path` reads and builds the `/share/` address itself. - `ui/visual-settings` owns style preferences, detection overrides, display inputs and IR cleanup. Its engine services and panel operations are explicit. - `ui/panel-chrome` owns disclosure, docking, collapse preferences and temporary diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index f590e02bc..cfb6d50fb 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -9,7 +9,8 @@ style, data layers and panel layout. There are two forms. - **Short form** — `/share/`, where `` is wherever the app is served (an origin's root, or a base path) and `` is encoded and decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and - needs no server. The share button makes this form. The same code opens on + needs no server. Hosts import it as `@gods-eye-view/core/share/view-codec` + (a server can read a code to describe the view in a link preview). The share button makes this form. The same code opens on any copy of the app. `src/share/shareLinkPath.js` reads and builds the address, and `src/share/legacyShareState.js` converts between the long form's parsed state and the code's state. diff --git a/package.json b/package.json index cb06324ee..75a1f994c 100644 --- a/package.json +++ b/package.json @@ -223,6 +223,8 @@ "./data/lifecycle": "./src/data/lifecycle.js", "./ui/navigation": "./src/ui/navigationController.js", "./ui/share-restoration": "./src/ui/shareRestoration.js", + "./share/view-codec": "./src/share/viewCodec.js", + "./share/link-path": "./src/share/shareLinkPath.js", "./ui/visual-settings": "./src/ui/visualSettings.js", "./ui/panel-chrome": "./src/ui/panelChrome.js", "./layers/flights/records": "./src/layers/flights/records.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index dedf4c272..d32997d5c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3887,6 +3887,39 @@ ], "external": [] }, + "share-view-codec": { + "exports": ["./share/view-codec"], + "modules": [ + "src/share/viewCodec.js", + "src/share/follow.js", + "src/share/wire.js", + "src/ui/visualPresets.js", + "src/bloom.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/view/index.js", + "src/data/layerState.js", + "src/data/layerStateTokenReservations.json", + "src/data/idSwitches.js", + "src/data/detectionPolicy.js", + "src/scopeMaskPolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/zh-CN.json" + ], + "external": [] + }, + "share-link-path": { + "exports": ["./share/link-path"], + "modules": ["src/share/shareLinkPath.js"], + "external": [] + }, "ghostmaps": { "exports": ["./layers/ghostmaps"], "modules": [ From 6152f9f3110a91d2198d2d3fd642b51855b91561 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:44:34 -0700 Subject: [PATCH 120/131] fix(search): keep a missed location query in the field so it can be corrected (#1420) A location search that found nothing or failed now keeps its query, the open field and focus (text selected), and shows a persistent role=alert message under the search row instead of a toast. Typing, a new search or closing the emptied field clears it. HAL-727. --- .../1420-keep-missed-location-query.md | 4 + docs/CURRENT-STATE.md | 8 ++ src/cameraHandoff.test.mjs | 2 +- src/i18n/locales/es.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/ui/applicationShell.js | 1 + src/ui/locationControls.js | 6 + src/ui/locationControls.test.mjs | 11 ++ src/ui/locationNavigation.js | 34 +++++- src/ui/locationSearchRecovery.test.mjs | 110 ++++++++++++++++++ src/ui/navigationController.js | 11 +- src/ui/shellElements.js | 1 + src/ui/shellFacade.js | 2 +- src/ui/styles/location.css | 17 +++ src/ui/styles/scenes.css | 3 +- src/ui/templates/command-dock.html | 1 + 17 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 changelog.d/1420-keep-missed-location-query.md create mode 100644 src/ui/locationSearchRecovery.test.mjs diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md new file mode 100644 index 000000000..233abf495 --- /dev/null +++ b/changelog.d/1420-keep-missed-location-query.md @@ -0,0 +1,4 @@ +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be5972034..0cb46940c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5268,6 +5268,14 @@ cancels the previous lookup; disposing its controls cancels the active lookup. P candidates, and an unbiased retry for name mismatches. Invalid or wrapped bounds returned by providers are omitted rather than framing the wrong part of the globe. +A location search that finds nothing, or fails, leaves the field open with the +query still in it, focused and selected, so it can be corrected and resubmitted +without reopening search. The miss is shown under the field as a persistent +`role="alert"` message ("Location not found." or "Search failed." with what to +do next) referenced by the input's `aria-describedby`, with `aria-invalid` set; +it is not a toast. Typing, a new search, or closing the emptied field clears it. +A search that arrives still clears and closes the field. + For request viewport bias, Photon unwraps antimeridian-crossing longitude bounds before computing their midpoint, then wraps that midpoint back into ±180 degrees. Malformed viewport bounds add no proximity bias. Nominatim omits a crossing diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 1fe30ff06..15a316366 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -261,7 +261,7 @@ test('deferred search releases only after its final authority check', () => { assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation\)/); + assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); }); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2cfa47a69..0285917b0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -310,6 +310,8 @@ "Copy failed": "No se pudo copiar", "Location not found": "Ubicación no encontrada", "Search failed": "No se pudo completar la búsqueda", + "Check the spelling and press Enter to search again.": "Revisa la ortografía y pulsa Intro para buscar de nuevo.", + "Press Enter to try again.": "Pulsa Intro para intentarlo de nuevo.", "Fly to a POI first": "Ve primero a un punto de interés", "Clearing selected data layers": "Limpiando las capas de datos seleccionadas", "Selected data layers could not be cleared": "No se pudieron limpiar las capas de datos seleccionadas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b3bdd165f..b1c1be229 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -530,6 +530,8 @@ "Resetting cockpit to full globe view": "Сброс кабины к виду всего глобуса", "Location not found": "Место не найдено", "Search failed": "Не удалось выполнить поиск", + "Check the spelling and press Enter to search again.": "Проверьте написание и нажмите Enter, чтобы повторить поиск.", + "Press Enter to try again.": "Нажмите Enter, чтобы попробовать снова.", "MAP": "КАРТА", "panel": "панель", "Expand {name}": "Развернуть {name}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a44973167..9bf76700c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1819,6 +1819,8 @@ "ORBIT": "环绕", "Location not found": "未找到位置", "Search failed": "搜索失败", + "Check the spelling and press Enter to search again.": "请检查拼写,然后按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重试。", "Detailed outline unavailable": "无法获取详细轮廓", "Fly to a POI first": "请先飞往一个 POI", "Reset to full globe view": "重置为完整地球视图", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index e3ed24867..4b999a75f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -348,6 +348,7 @@ export class StyleManager extends ShellFacade { _poiRow: this._poiRow, _locationBarDivider: this._locationBarDivider, _locationSearch: this._locationSearch, + _locationSearchError: this._locationSearchError, _searchToggle: this._searchToggle, _resetGlobeBtn: this._resetGlobeBtn, _cockpitResetGlobeBtn: this._cockpitResetGlobeBtn, diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index f2b67c7db..27363c1ac 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -22,6 +22,7 @@ export class LocationControls { onCity, onPoi, onSearch, + onSearchEdit, onReset, doc = document, requestFrame = (callback) => requestAnimationFrame(callback), @@ -74,6 +75,11 @@ export class LocationControls { this.bind(elements.search, 'keydown', (event) => { if (event.key === 'Enter') void onSearch(elements.search.value); }); + // Typing corrects a failed query; closing the field (which empties it) drops its error. + this.bind(elements.search, 'input', () => onSearchEdit?.()); + this.bind(elements.search, 'blur', () => { + if (!elements.search.value) onSearchEdit?.(); + }); for (const button of elements.resetButtons) this.bind(button, 'click', onReset); } diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index 928e48554..33f5eaa7f 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -86,6 +86,7 @@ function fixture() { onCity: (id) => calls.push(['city', id]), onPoi: (id, index) => calls.push(['poi', id, index]), onSearch: (query) => calls.push(['search', query]), + onSearchEdit: () => calls.push(['edit']), onReset: () => calls.push(['reset']), doc, requestFrame: (fn) => { @@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () => f.elements.resetButtons[1].fire('click'); assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); +test('typing clears a failed search error and closing the emptied field drops it too', () => { + const f = fixture(); + f.elements.search.value = 'Pariss'; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error'); + f.elements.search.fire('input'); + f.elements.search.value = ''; + f.elements.search.fire('blur'); + assert.deepEqual(f.calls, [['edit'], ['edit']]); +}); diff --git a/src/ui/locationNavigation.js b/src/ui/locationNavigation.js index af8fb95b8..d0664509f 100644 --- a/src/ui/locationNavigation.js +++ b/src/ui/locationNavigation.js @@ -80,19 +80,27 @@ export class LocationNavigation { _handleLocationSearchState(state, change) { if (this._disposed || !change) return; - if (change.type === 'started') + if (change.type === 'started') { this._activeLocationSearchGeneration = change.generation; - else if (change.type === 'found') { + this._setLocationSearchError(null); + } else if (change.type === 'found') { this._searchedLocationLabel = state.destination.label || state.query; this._setActiveLocation(null); this._currentPoi = null; this._collapsePOIRow(); this._updateLocationMiniStatus(); } else if (change.type === 'missing') - this._showToast(t('Location not found')); - else if (change.type === 'failed') this._showToast(t('Search failed')); + this._setLocationSearchError( + `${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`, + ); + else if (change.type === 'failed') + this._setLocationSearchError( + `${t('Search failed')}. ${t('Press Enter to try again.')}`, + ); else if (change.type === 'settled') - this._settleLocationSearchUi(change.generation); + this._settleLocationSearchUi(change.generation, { + keepQuery: state.status === 'missing' || state.status === 'failed', + }); else if ( change.type === 'reset' && this._activeLocationSearchGeneration !== null @@ -101,6 +109,21 @@ export class LocationNavigation { } } + /** Show, or with null clear, the announced error under the search field. */ + _setLocationSearchError(message) { + const input = this._locationSearch; + const error = this._locationSearchError; + if (error) error.textContent = message || ''; + if (!input) return; + if (message) { + input.setAttribute('aria-invalid', 'true'); + if (error?.id) input.setAttribute('aria-describedby', error.id); + } else { + input.removeAttribute('aria-invalid'); + input.removeAttribute('aria-describedby'); + } + } + _initLocationBar() { const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services; this._locationControls?.destroy(); @@ -144,6 +167,7 @@ export class LocationNavigation { onCity: (id) => this._onCityPillClick(id), onPoi: (id, index) => this._onPoiClick(id, index), onSearch: (query) => this._locationLookup.run(query), + onSearchEdit: () => this._setLocationSearchError(null), onReset: () => this.resetToGlobeView(), }); } diff --git a/src/ui/locationSearchRecovery.test.mjs b/src/ui/locationSearchRecovery.test.mjs new file mode 100644 index 000000000..19a310203 --- /dev/null +++ b/src/ui/locationSearchRecovery.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { NavigationController } from './navigationController.js'; +import { LocationNavigation } from './locationNavigation.js'; + +function input() { + const classes = new Set(['expanded', 'searching']); + const attributes = new Map(); + return { + id: 'location-search', + value: 'Pariss', + focused: false, + selected: false, + classList: { + remove: (...names) => names.forEach((name) => classes.delete(name)), + contains: (name) => classes.has(name), + }, + focus() { + this.focused = true; + }, + blur() { + this.focused = false; + }, + select() { + this.selected = true; + }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: (name) => attributes.delete(name), + getAttribute: (name) => attributes.get(name) ?? null, + }; +} + +function settle(searchInput, options) { + const owner = { _activeLocationSearchGeneration: 4, searchInput }; + NavigationController.prototype._settleLocationSearchUi.call( + owner, + 4, + options, + ); + return owner; +} + +test('a search that found nothing keeps its query, the open field and focus', () => { + const field = input(); + const owner = settle(field, { keepQuery: true }); + assert.equal(owner._activeLocationSearchGeneration, null); + assert.equal(field.value, 'Pariss'); + assert.equal(field.classList.contains('expanded'), true); + assert.equal(field.classList.contains('searching'), false); + assert.equal(field.focused, true); + assert.equal(field.selected, true); +}); + +test('a search that arrived still clears and closes the field', () => { + const field = input(); + field.focused = true; + settle(field); + assert.equal(field.value, ''); + assert.equal(field.classList.contains('expanded'), false); + assert.equal(field.focused, false); +}); + +function navigation() { + const field = input(); + const error = { id: 'location-search-error', textContent: '' }; + const settled = []; + const toasts = []; + const owner = { + _disposed: false, + _locationSearch: field, + _locationSearchError: error, + _activeLocationSearchGeneration: null, + _settleLocationSearchUi: (generation, options) => + settled.push([generation, options]), + _showToast: (message) => toasts.push(message), + _setLocationSearchError: + LocationNavigation.prototype._setLocationSearchError, + }; + const handle = (status, type) => + LocationNavigation.prototype._handleLocationSearchState.call( + owner, + { status, query: field.value }, + { type, generation: 7 }, + ); + return { owner, field, error, settled, toasts, handle }; +} + +for (const [status, text] of [ + ['missing', 'Location not found'], + ['failed', 'Search failed'], +]) { + test(`a ${status} search announces a lasting error on the field instead of a toast`, () => { + const n = navigation(); + n.handle('searching', 'started'); + n.handle(status, status); + n.handle(status, 'settled'); + assert.match(n.error.textContent, new RegExp(`^${text}\\. `)); + assert.equal(n.field.getAttribute('aria-invalid'), 'true'); + assert.equal( + n.field.getAttribute('aria-describedby'), + 'location-search-error', + ); + assert.deepEqual(n.settled, [[7, { keepQuery: true }]]); + assert.deepEqual(n.toasts, []); + n.handle('searching', 'started'); + assert.equal(n.error.textContent, ''); + assert.equal(n.field.getAttribute('aria-invalid'), null); + assert.equal(n.field.getAttribute('aria-describedby'), null); + }); +} diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index e2a135142..3c0bc7684 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -119,10 +119,17 @@ export class NavigationController { return this._navigationGeneration; } - _settleLocationSearchUi(generation) { + /** A search that found nothing keeps its query and focus so it can be corrected. */ + _settleLocationSearchUi(generation, { keepQuery = false } = {}) { if (this._activeLocationSearchGeneration !== generation) return; this._activeLocationSearchGeneration = null; - this.searchInput?.classList.remove('searching', 'expanded'); + this.searchInput?.classList.remove('searching'); + if (keepQuery) { + this.searchInput?.focus(); + this.searchInput?.select?.(); + return; + } + this.searchInput?.classList.remove('expanded'); if (this.searchInput) this.searchInput.value = ''; this.searchInput?.blur(); } diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 9134b9045..df7fcbc16 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) { _cctvSyncProgress: document.getElementById('cctv-sync-progress'), _toast: document.getElementById('toast'), _locationSearch: document.getElementById('location-search'), + _locationSearchError: document.getElementById('location-search-error'), _searchToggle: document.getElementById('search-toggle'), _locationPills: document.getElementById('location-pills'), _poiRow: document.getElementById('poi-row'), diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 2c1861b31..01962f8f9 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -550,7 +550,7 @@ export class ShellFacade { } /** Settle only the search generation that still owns the shared input UI. */ - _settleLocationSearchUi(generation) { + _settleLocationSearchUi(generation, options) { return this._navigation._settleLocationSearchUi(...arguments); } diff --git a/src/ui/styles/location.css b/src/ui/styles/location.css index 281e4b08f..5c10f20b5 100644 --- a/src/ui/styles/location.css +++ b/src/ui/styles/location.css @@ -298,3 +298,20 @@ #location-search.searching { opacity: 0.5; } + +.location-search-error { + /* Its own line under the search row, which clips overflow in the dock. */ + margin: 6px 0 0; + padding: 6px 10px; + border: 1px solid rgba(255, 99, 99, 0.45); + border-radius: 6px; + background: rgba(30, 8, 10, 0.92); + color: #ffb3b3; + font-family: var(--font-sans); + font-size: 12px; + line-height: 1.35; +} + +.location-search-error:empty { + display: none; +} diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index b048c5bd2..29250a636 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -363,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #param-slider-panel.collapsed #param-sliders, #location-bar.collapsed .poi-row-container, #location-bar.collapsed .location-bar-divider, -#location-bar.collapsed .location-city-row { +#location-bar.collapsed .location-city-row, +#location-bar.collapsed .location-search-error { display: none !important; } diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index e6e2735ea..3dac977ae 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -91,6 +91,7 @@
+ From 15de9d738c4a85be1a96726c6114181caef87eeb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 26 Oct 2026 16:54:01 -0700 Subject: [PATCH 121/131] fix(i18n): mark style labels without loading the catalogs (#1421) The style shaders and visualPresets imported msg() from src/i18n/index.js, which loads every locale catalog. The share-view codec reads the styles' defaults, so a server bundling the codec carried about 0.9 MB of translations it never uses. They now import the catalog-free marker, as other portable modules do; the codec's bundle drops from about 1 MB to 130 KB. --- changelog.d/1421-style-modules-skip-catalogs.md | 3 +++ scripts/package-boundaries.json | 13 +++---------- src/styles/anime.js | 2 +- src/styles/noir.js | 2 +- src/styles/retro.js | 2 +- src/styles/snow.js | 2 +- src/styles/surveillance.js | 2 +- src/styles/thermal.js | 2 +- src/ui/visualPresets.js | 2 +- 9 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 changelog.d/1421-style-modules-skip-catalogs.md diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md new file mode 100644 index 000000000..bc79c5cd6 --- /dev/null +++ b/changelog.d/1421-style-modules-skip-catalogs.md @@ -0,0 +1,3 @@ +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d32997d5c..b42d90fdc 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -463,11 +463,7 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": ["cesium"] }, @@ -3330,6 +3326,7 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js", "src/ui/segmentedControl.js", + "src/i18n/marker.js", "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", @@ -3907,11 +3904,7 @@ "src/data/idSwitches.js", "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", - "src/i18n/index.js", - "src/i18n/locale.js", - "src/i18n/locales/es.json", - "src/i18n/locales/ru.json", - "src/i18n/locales/zh-CN.json" + "src/i18n/marker.js" ], "external": [] }, diff --git a/src/styles/anime.js b/src/styles/anime.js index deb79c355..4097ecc48 100644 --- a/src/styles/anime.js +++ b/src/styles/anime.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Anime / Studio Ghibli Style — Cel-shading + Saturation boost diff --git a/src/styles/noir.js b/src/styles/noir.js index 5ea458859..472dde990 100644 --- a/src/styles/noir.js +++ b/src/styles/noir.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Noir Style — Desaturation + High Contrast + Vignette diff --git a/src/styles/retro.js b/src/styles/retro.js index 396f95853..f4e2d8f62 100644 --- a/src/styles/retro.js +++ b/src/styles/retro.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * CRT Terminal — Dynamic CRT Monitor Aesthetic diff --git a/src/styles/snow.js b/src/styles/snow.js index 569f6cbbc..a4db0a029 100644 --- a/src/styles/snow.js +++ b/src/styles/snow.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Snow Style — White overlay + Desaturation + Falling particles diff --git a/src/styles/surveillance.js b/src/styles/surveillance.js index 810ac7e4f..5eae7fdd3 100644 --- a/src/styles/surveillance.js +++ b/src/styles/surveillance.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * Night Vision / NVG — PVS-14 Image Intensifier Emulation diff --git a/src/styles/thermal.js b/src/styles/thermal.js index 90aff5312..f62327507 100644 --- a/src/styles/thermal.js +++ b/src/styles/thermal.js @@ -1,4 +1,4 @@ -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** * FLIR — Forward Looking Infrared diff --git a/src/ui/visualPresets.js b/src/ui/visualPresets.js index 48a758a33..e3830601a 100644 --- a/src/ui/visualPresets.js +++ b/src/ui/visualPresets.js @@ -5,7 +5,7 @@ import { snowShader } from '../styles/snow.js'; import { nightVisionShader } from '../styles/surveillance.js'; import { thermalShader } from '../styles/thermal.js'; import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js'; -import { msg } from '../i18n/index.js'; +import { msg } from '../i18n/marker.js'; /** Duration (ms) for shader intensity crossfade between style presets. */ export const TRANSITION_DURATION_MS = 500; From 8f16cf263bf1f98f5012d72c202f3a69450fd9ca Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 10:20:54 +0400 Subject: [PATCH 122/131] i18n: use zh-Hant for Chinese (Traditional) --- changelog.d/1336-traditional-chinese.md | 2 +- docs/CURRENT-STATE.md | 4 +- docs/TRANSLATORS.md | 4 +- docs/translations/zh-Hant.md | 36 ++++++++ docs/translations/zh-TW.md | 36 -------- scripts/package-boundaries.json | 86 +++++++++---------- src/i18n/firms-live-zh.test.mjs | 2 +- src/i18n/i18n.test.mjs | 12 +-- src/i18n/index.js | 4 +- src/i18n/locale.js | 11 +-- src/i18n/locales/{zh-TW.json => zh-Hant.json} | 0 src/i18n/same-as-english.json | 2 +- ...play.test.mjs => zh-Hant-display.test.mjs} | 30 +++++-- src/ui/localePicker.js | 12 ++- src/ui/localePicker.test.mjs | 8 +- 15 files changed, 139 insertions(+), 110 deletions(-) create mode 100644 docs/translations/zh-Hant.md delete mode 100644 docs/translations/zh-TW.md rename src/i18n/locales/{zh-TW.json => zh-Hant.json} (100%) rename src/i18n/{zh-TW-display.test.mjs => zh-Hant-display.test.mjs} (72%) diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1336-traditional-chinese.md index 386fd8ed4..c3969d44e 100644 --- a/changelog.d/1336-traditional-chinese.md +++ b/changelog.d/1336-traditional-chinese.md @@ -1,4 +1,4 @@ -- Add Traditional Chinese (zh-TW) with Taiwan terminology, language selection, +- Add Traditional Chinese (zh-Hant) with language selection, and localized Chinese readouts. Based on translations by @caxer (#724), @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) (German, @gekh, #1336). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 69139e6bb..c3cbacba0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3583,7 +3583,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-TW**. The code lives in +Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: [`docs/TRANSLATORS.md`](./TRANSLATORS.md). @@ -3595,7 +3595,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z `src/i18n/locales/.json`, and a missing entry falls back to English. - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → `navigator.languages` → English. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` - select `zh-TW`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop + select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?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 5122afa9f..830ca155e 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`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-TW`). +Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`). ## 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), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-TW.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), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one. diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md new file mode 100644 index 000000000..9e3bbfeba --- /dev/null +++ b/docs/translations/zh-Hant.md @@ -0,0 +1,36 @@ +# Traditional Chinese (`zh-Hant`) + +Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). + +Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for consistency. The catalog retains terminology from these Taiwan-oriented sources; `zh-Hant` identifies the writing system and does not promise separate regional vocabulary. + +## Style + +- Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. +- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. +- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. +- The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. + +## Plurals + +`Intl.PluralRules('zh-Hant')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. + +## Catalog terminology + +| English | Traditional Chinese | +| --- | --- | +| data / information | 資料 / 資訊 | +| file / folder | 檔案 / 資料夾 | +| settings / save | 設定 / 儲存 | +| API key / token | API 金鑰 / 權杖 | +| live / load | 即時 / 載入 | +| camera / video | 攝影機 / 影片 | +| network / link | 網路 / 連結 | +| image / imagery | 圖像 / 影像 | +| click / double-click | 點選 / 按兩下 | +| response / timeout | 回應 / 逾時 | +| layer / data layer | 圖層 / 資料圖層 | +| cockpit / context | 駕駛艙 / 態勢 | +| air quality | 空氣品質 | +| transit / bikeshare | 大眾運輸 / 公共自行車 | diff --git a/docs/translations/zh-TW.md b/docs/translations/zh-TW.md deleted file mode 100644 index 8e25c8540..000000000 --- a/docs/translations/zh-TW.md +++ /dev/null @@ -1,36 +0,0 @@ -# Traditional Chinese (`zh-TW`) - -Catalog: `src/i18n/locales/zh-TW.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). - -Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for Taiwan usage. - -## Style - -- Use Traditional characters and Taiwan terminology. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. -- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. -- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. -- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. -- Browser tags with Hant or TW/HK/MO select this Taiwan catalog. Explicit Hans selects Simplified Chinese, including Hans-TW. This is not a separate Hong Kong localization. - -## Plurals - -`Intl.PluralRules('zh-TW')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. - -## Taiwan terminology - -| English | Taiwan | Mainland | -| -------------------- | --------------------- | ------------------- | -| data / information | 資料 / 資訊 | 数据 / 信息 | -| file / folder | 檔案 / 資料夾 | 文件 / 文件夹 | -| settings / save | 設定 / 儲存 | 设置 / 保存 | -| API key / token | API 金鑰 / 權杖 | API 密钥 / 令牌 | -| live / load | 即時 / 載入 | 实时 / 加载 | -| camera / video | 攝影機 / 影片 | 摄像头 / 视频 | -| network / link | 網路 / 連結 | 网络 / 链接 | -| image / imagery | 圖像 / 影像 | 图像 / 影像 | -| click / double-click | 點選 / 按兩下 | 点击 / 双击 | -| response / timeout | 回應 / 逾時 | 响应 / 超时 | -| layer / data layer | 圖層 / 資料圖層 | 图层 / 数据图层 | -| cockpit / context | 駕駛艙 / 態勢 | 座舱 / 态势 | -| air quality | 空氣品質 | 空气质量 | -| transit / bikeshare | 大眾運輸 / 公共自行車 | 公共交通 / 共享单车 | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4f88e858e..ae30a8c49 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -442,7 +442,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -469,7 +469,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -487,7 +487,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -517,7 +517,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/statusText.js", @@ -539,7 +539,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -556,7 +556,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -576,7 +576,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/statusBadge.js" ], @@ -603,7 +603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/layerNames.js" ], "external": [] @@ -638,7 +638,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -655,7 +655,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": [] @@ -811,7 +811,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/ui/keyGuidance.js", "src/ui/layerNames.js", @@ -932,7 +932,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -999,7 +999,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1047,7 +1047,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1109,7 +1109,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1139,7 +1139,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js" ], @@ -1170,7 +1170,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1202,7 +1202,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium", "satellite.js"] @@ -1236,7 +1236,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1279,7 +1279,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/layers/installations/classLabel.js" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] @@ -1315,7 +1315,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1366,7 +1366,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, @@ -1396,7 +1396,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1445,7 +1445,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["hls.js", "cesium"] }, @@ -1509,7 +1509,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/layers/streetLevel/providers/mapillary/policy.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] @@ -1539,7 +1539,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -1580,7 +1580,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1603,7 +1603,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1660,7 +1660,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -1717,7 +1717,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCommands.js", "src/voice/providerCapabilities.js", @@ -2582,7 +2582,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/ui/keyGuidance.js", @@ -3159,7 +3159,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js", "src/layers/recentImagery/display.js", "src/layers/installations/classLabel.js", @@ -3267,7 +3267,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/i18n/index.js" ], "external": ["cesium"] @@ -3290,7 +3290,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3326,7 +3326,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3351,7 +3351,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": [] }, @@ -3443,7 +3443,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3499,7 +3499,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/voice/providerCapabilities.js" ], @@ -3754,7 +3754,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3798,7 +3798,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium", "pbf"] }, @@ -3852,7 +3852,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json" + "src/i18n/locales/zh-Hant.json" ], "external": ["cesium"] }, @@ -3869,7 +3869,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], @@ -4063,7 +4063,7 @@ "src/i18n/locales/es.json", "src/i18n/locales/ru.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-TW.json", + "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", "src/ui/layerNames.js" ], diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 9698d5dd6..11878e790 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -36,7 +36,7 @@ for (const [age, zh, en, tw] of cases) { assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); - setLocale('zh-TW'); + setLocale('zh-Hant'); assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); } finally { setLocale(previousLocale); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 87eeed24a..031d4f85e 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -39,12 +39,14 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => 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'), 'zh-TW'); - assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant'); + assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); + assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); - assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-TW'); - assert.equal(normalizeLocale('zh_HK'), 'zh-TW'); - assert.equal(normalizeLocale('zh-MO'), 'zh-TW'); + assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); + assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); + assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); assert.equal(normalizeLocale(undefined), null); }); diff --git a/src/i18n/index.js b/src/i18n/index.js index 84776e7dd..6fc4becc9 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -19,9 +19,9 @@ import { 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' }; -import zhTW from './locales/zh-TW.json' with { type: 'json' }; +import zhHant from './locales/zh-Hant.json' with { type: 'json' }; -const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW }); +const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); let currentLocale = DEFAULT_LOCALE; diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 4efd4f589..55ed71163 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,15 +8,15 @@ 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', 'zh-CN', 'zh-TW']); +export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']); /** Each locale named in its own language, for the selector. */ export const LOCALE_NAMES = Object.freeze({ en: 'English', es: 'Español', ru: 'Русский', - 'zh-CN': '简体中文', - 'zh-TW': '繁體中文', + 'zh-CN': '中文(简体)', + 'zh-Hant': '中文(繁體)', }); /** Right-to-left locales. */ @@ -24,7 +24,8 @@ const RTL_LOCALES = Object.freeze([]); /** * Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', - * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'. + * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'; + * 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'. * @returns {string|null} */ export function normalizeLocale(candidate) { @@ -33,7 +34,7 @@ export function normalizeLocale(candidate) { if (primary === 'zh') { if (rest.includes('hans')) return 'zh-CN'; return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) - ? 'zh-TW' + ? 'zh-Hant' : 'zh-CN'; } return LOCALES.includes(primary) ? primary : null; diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-Hant.json similarity index 100% rename from src/i18n/locales/zh-TW.json rename to src/i18n/locales/zh-Hant.json diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 48688a0c7..d96319535 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -193,7 +193,7 @@ "ΔN", "▲ {frp} MW" ], - "zh-TW": [ + "zh-Hant": [ "1090 MHz", "978 MHz UAT", "AIS: --", diff --git a/src/i18n/zh-TW-display.test.mjs b/src/i18n/zh-Hant-display.test.mjs similarity index 72% rename from src/i18n/zh-TW-display.test.mjs rename to src/i18n/zh-Hant-display.test.mjs index 74b2f714a..187977cb3 100644 --- a/src/i18n/zh-TW-display.test.mjs +++ b/src/i18n/zh-Hant-display.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, t, tn, getLocale } from './index.js'; -import { resolveLocale, LOCALE_STORAGE_KEY } from './locale.js'; +import { + resolveLocale, + LOCALE_STORAGE_KEY, + writeStoredLocale, + applyDocumentLanguage, +} from './locale.js'; import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; import { READOUT_WORDS } from '../layers/recentImagery/display.js'; @@ -14,7 +19,7 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua storage: { getItem: () => 'en' }, languages: ['en'], }), - 'zh-TW', + 'zh-Hant', ); assert.equal( resolveLocale({ @@ -24,7 +29,7 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua }, languages: ['en'], }), - 'zh-TW', + 'zh-Hant', ); assert.equal( resolveLocale({ @@ -32,14 +37,29 @@ test('Traditional Chinese resolves from URL, saved preference and browser langua storage: { getItem: () => null }, languages: ['zh-Hant', 'en'], }), - 'zh-TW', + 'zh-Hant', ); }); +test('Traditional Chinese persists and exposes the script locale for legacy choices', () => { + const stored = new Map(); + assert.equal( + writeStoredLocale('zh-TW', { + setItem: (key, value) => stored.set(key, value), + }), + true, + ); + assert.equal(stored.get(LOCALE_STORAGE_KEY), 'zh-Hant'); + const doc = { documentElement: {} }; + assert.equal(applyDocumentLanguage(doc, 'zh-TW'), true); + assert.equal(doc.documentElement.lang, 'zh-Hant'); + assert.equal(doc.documentElement.dir, 'ltr'); +}); + test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => { const previous = getLocale(); try { - setLocale('zh-TW'); + setLocale('zh-Hant'); assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km'); assert.equal(formatCockpitDistance(14000), '14\u00a0km'); assert.equal( diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index d60b4e91a..41875042d 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8; /** Type-ahead keystrokes closer together than this extend one prefix. */ export const TYPEAHEAD_RESET_MS = 500; +const CHINESE_LABELS = { + 'zh-CN': 'Chinese (Simplified)', + 'zh-Hant': 'Chinese (Traditional)', +}; + /** * Text folded for search: no diacritics, lower case. 'Español' → 'espanol', * 'Ёжик' → 'ежик'. @@ -464,12 +469,13 @@ export function mountLocalePicker({ item.dataset.locale = option.code; const name = element(documentRef, 'span', 'locale-picker-native'); name.textContent = option.nativeName; - // Every row reads the same: the language named in itself, then its code. - // The interface-language name only helps the search find it. + // Chinese variants are distinguished by script rather than region. const meta = element(documentRef, 'span', 'locale-picker-meta'); const codeLabel = element(documentRef, 'span', 'locale-picker-option-code'); codeLabel.setAttribute('translate', 'no'); - codeLabel.textContent = option.code.toUpperCase(); + codeLabel.textContent = + CHINESE_LABELS[option.code] || option.code.toUpperCase(); + if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en'; meta.append(codeLabel); const text = element(documentRef, 'span', 'locale-picker-text'); text.append(name, meta); diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 5151ad4fc..057642827 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', 'zh-CN', 'zh-TW'], + ['ru', 'en', 'es', 'zh-CN', 'zh-Hant'], ); 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', 'zh-CN', 'zh-TW'], + ['es', 'en', 'ru', 'zh-CN', 'zh-Hant'], ); 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', 'zh-CN', 'zh-TW'], + ['en', 'es', 'ru', 'zh-CN', 'zh-Hant'], ); 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, 'zh-TW'); + assert.equal(model.activeOption.code, 'zh-Hant'); model.keydown({ key: 'Home' }); assert.equal(model.activeOption.code, 'ru'); model.keydown({ key: 'ArrowUp' }); From 6d6b2e8fe9f9ff4a2279148aa8461476ae5a07fb Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:52:27 +0530 Subject: [PATCH 123/131] =?UTF-8?q?fix(street-level):=20show=20a=20359.6?= =?UTF-8?q?=C2=B0=20bearing=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Street Level viewer caption rounded the photo's bearing without wrapping, so a view facing 359.5° or more read "360°". The rounded bearing now wraps to 0°. --- docs/CURRENT-STATE.md | 2 ++ src/ui/streetLevelPresentation.js | 3 ++- src/ui/streetLevelPresentation.test.mjs | 8 ++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..838911b0c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3906,6 +3906,8 @@ reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage line and the layer row's tooltip say to light it. The viewer sits under the header with EXPAND, FIT/FILL and close above the image. SINCE is a stepped slider whose readout names the cut-off date. +The viewer caption's bearing wraps after rounding to whole degrees, so a view +facing 359.5° or more reads `0°` rather than `360°`. MapillaryJS loads on the first photo. Opening one frames it with a single camera flight through the application's deferred navigation (`begin` / diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index c491ed9bf..2366371f7 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -168,8 +168,9 @@ function presentViewer(state) { const { street } = state; const right = []; if (street.isPano) right.push('360°'); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing)}°`); + right.push(`${Math.round(street.bearing) % 360}°`); if (street.capturedAt) right.push(formatDate(street.capturedAt, street.capturedAtPrecision)); const left = []; diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index b81e203cb..b72e1a5d3 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -273,6 +273,14 @@ test('viewer caption reads "Image by" left, date right, and links to Mapillary', assert.equal(view.viewer.open, true); }); +test('a bearing that rounds to north reads 0°, not 360°', () => { + const caption = (bearing) => + presentStreetLevelPanel(snapshot({ street: { open: true, bearing } })) + .viewer.captionRight; + assert.equal(caption(359.4), '359°'); + assert.equal(caption(359.6), '0°'); +}); + test('an image without a creator name leaves the left caption empty', () => { const view = presentStreetLevelPanel( snapshot({ From 5654be5074a60f82f4de7177284fa4a82d0f7635 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:36:35 +0400 Subject: [PATCH 124/131] docs: point Traditional Chinese changelog to replacement PR --- ...{1336-traditional-chinese.md => 1430-traditional-chinese.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{1336-traditional-chinese.md => 1430-traditional-chinese.md} (88%) diff --git a/changelog.d/1336-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md similarity index 88% rename from changelog.d/1336-traditional-chinese.md rename to changelog.d/1430-traditional-chinese.md index c3969d44e..3d34d6536 100644 --- a/changelog.d/1336-traditional-chinese.md +++ b/changelog.d/1430-traditional-chinese.md @@ -1,4 +1,4 @@ - Add Traditional Chinese (zh-Hant) with language selection, and localized Chinese readouts. Based on translations by @caxer (#724), @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) - (German, @gekh, #1336). + (German, @gekh, #1430). From 8269facb5af8db92ecf0a875d8894c5438e35eb7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:41:07 +0400 Subject: [PATCH 125/131] i18n: address Traditional Chinese translation review --- docs/translations/zh-Hant.md | 11 ++ src/i18n/locales/zh-Hant.json | 314 +++++++++++++++++----------------- 2 files changed, 168 insertions(+), 157 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 9e3bbfeba..f53a8cd97 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -9,6 +9,7 @@ Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abi - Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. - Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. - Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. - Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. - The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. @@ -34,3 +35,13 @@ Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abi | cockpit / context | 駕駛艙 / 態勢 | | air quality | 空氣品質 | | transit / bikeshare | 大眾運輸 / 公共自行車 | + +## Context-sensitive terms + +- Weather `CLEAR` is 晴; `action|CLEAR` is 清除. +- `LOCAL INFO` describes 在地資訊; 本機 refers to the local device. +- Observation windows describe 範圍, not a UI 視窗. The scope control is 視域 and its feather slider is 視域邊緣羽化. +- Roundabouts are 圓環; telemetry is 回報; weather advisories are 發布. +- Intercardinal compass points: NNE 北北東, ENE 東北東, ESE 東南東, SSE 南南東, SSW 南南西, WSW 西南西, WNW 西北西, NNW 北北西. +- The Celestial control and its help text use 天球環. An uncalibrated camera's `RAW PRIOR` is 原始推估. +- `Wind unavailable` is shared by cyclone wind-speed cards and the wind-field layer; keep the wording broad enough for both (風況資料不可用). diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 6ef369fae..e86b7d9ab 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -53,7 +53,7 @@ "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": "近距離崩塌觀察點", + "Immediate collapse viewpoint": "初始崩塌觀察點", "Collapse viewpoint": "崩塌觀察點", "Debris-dammed lake": "碎屑堰塞湖", "Dammed lake": "堰塞湖", @@ -140,7 +140,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.": "雲層按原始影像原樣保留。", "Rasuwagadhi witness": "Rasuwagadhi 目擊者", "Open original source for {source}; capture time unverified": "開啟 {source} 的原始來源;拍攝時間未核實", "Historical reference and post-event image divider": "歷史參考與災後影像的分割線", @@ -263,11 +263,11 @@ "STATION": "太空站", "Crewed stations and their visiting vehicles": "載人太空站與來訪載具", "NAV": "導航", - "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星 — GPS、GLONASS、Galileo", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星——GPS、GLONASS、Galileo", "GEO": "GEO", - "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶 — 通訊與氣象衛星,固定於赤道上空", + "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶——通訊與氣象衛星,固定於赤道上空", "VISUAL": "目視可見", - "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體 — CelesTrak 目視群組", + "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體——CelesTrak 目視群組", "COMMS": "通訊", "Broadband constellation shell — shown only in DENSE mode": "寬頻星座殼層——僅在「密集」模式下顯示", "FISHING": "漁船", @@ -322,7 +322,7 @@ "NORTHERN OCEANIC GRID": "北方海洋網格", "SOUTHERN OCEANIC GRID": "南方海洋網格", "LOADING": "載入中", - "DEGRADED": "效能受限", + "DEGRADED": "資料品質下降", "STALE": "資料過期", "PARTIAL": "部分可用", "FALLBACK": "備用模式", @@ -369,9 +369,9 @@ "{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: 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 登入狀態,請重試。", @@ -381,13 +381,13 @@ "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": "計費 — 需啟用帳單的帳戶", + "Metered — a billing-enabled account": "計費——需啟用帳單的帳戶", "Optional paid token — public access works without one": "可選的付費權杖——無需權杖也可公開訪問", - "Free key — register, paste, done": "免費金鑰 — 註冊、貼上、完成", + "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": "由你的環境、鑰匙串或啟動器提供——請在原設定處修改", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙圈或啟動器提供——請在原設定處修改", "MANAGE ↗": "管理 ↗", "GET KEY ↗": "取得金鑰 ↗", "{envVar} saved — paste to replace": "{envVar} 已儲存——貼上可替換", @@ -415,20 +415,20 @@ "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 地球、地點搜尋 + 街景", - "Places context + Street View fallback; optional separate key": "地點資訊 + 街景備援;可選用獨立金鑰", + "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)": "真實即時路況(無金鑰時為模擬)", + "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", + "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} 個站點上報資料" + "other": "{count} 個站點回報資料" }, "Highest air-quality readings in view": "視野內空氣品質讀數最高的站點", "Low": "低", @@ -440,7 +440,7 @@ "Very High": "極高", "Very High health risk": "極高健康風險", "Air quality": "空氣品質", - "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 站網", + "Canada only · ECCC AQHI network": "僅限加拿大 · ECCC AQHI 監測網", "ECCC request timed out": "ECCC 請求逾時", "Malformed AQHI station response": "AQHI 站點回應格式錯誤", "No AQHI stations available": "沒有可用的 AQHI 站點", @@ -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} 個" @@ -478,13 +478,13 @@ "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", "Global Context navigation": "全域態勢導航", - "Previous — prior visited contact in the 250 km window": "上一個 — 250 公里範圍內先前造訪的目標", + "Previous — prior visited contact in the 250 km window": "上一個——250 公里範圍內先前造訪的目標", "PREVIOUS": "上一個", "FOCUS": "聚焦", - "Next — nearest unvisited contact in the 250 km window": "下一個 — 250 公里範圍內最近的未造訪目標", + "Next — nearest unvisited contact in the 250 km window": "下一個——250 公里範圍內最近的未造訪目標", "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 普查", @@ -499,7 +499,7 @@ "Flights": "航班", "Military flights": "軍機", "AIS vessels": "AIS 船舶", - "Mapped installations": "已標記軍事設施", + "Mapped installations": "地圖標註設施", "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", "scanning nearby systems...": "正在掃描附近系統…", "GBFS fetch error": "GBFS 取得錯誤", @@ -545,7 +545,7 @@ }, "Position as of {time}": "位置(截至 {time})", "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 釋出於 {time}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", "No active NHC/CPHC systems": "目前沒有活躍的 NHC/CPHC 氣旋", "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", @@ -553,7 +553,7 @@ "{storms} · {name} selected": "{storms} · 已選擇 {name}", "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", - "Wind unavailable": "風場資料不可用", + "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 公告。該區域表示氣旋中心路徑預報的不確定範圍,不代表氣旋大小或全部災害影響範圍。預報點標註的是資料來源的預報時效(小時),不是按公告發布時刻推算的時間。幾何圖形貼地顯示;顯示高度並非氣象高度。請以官方公告為準。", @@ -632,16 +632,16 @@ "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": "駛出環島", + "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}", @@ -683,7 +683,7 @@ "Routing timed out": "路線規劃逾時", "Routing unavailable — no response from the routing service": "路線規劃不可用——路線服務無回應", "Earthquake source unavailable": "地震資料來源不可用", - "Focus fire detection {title}, {details}": "聚焦火點探測 {title},{details}", + "Focus fire detection {title}, {details}": "聚焦火點偵測 {title},{details}", "live feed unavailable": "即時資料來源不可用", "{value} conf": "置信度 {value}", "{value} ago": "{value}前", @@ -721,12 +721,12 @@ "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 分鐘內已訪問檢視中的網格", + "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 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 完好性快照缺少觀測時間", @@ -799,7 +799,7 @@ "FINAL POSITION": "最終位置", "REPLAY SPEED": "回放速度", "Replay speed multiplier": "回放速度倍率", - "REPLAY ASCENT": "重播升空", + "REPLAY ASCENT": "回放升空", "Previous mission": "上一個任務", "PREV": "上一個", "Next mission": "下一個任務", @@ -810,7 +810,7 @@ "PLANNED · ": "計畫 · ", "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", "RECONSTRUCTED ESTIMATE": "重建估算", - "Replay the estimated ascent with a following camera": "以跟隨鏡頭重播推估的升空過程", + "Replay the estimated ascent with a following camera": "以跟隨鏡頭回放推估的升空過程", "Resume replay": "繼續回放", "T minus {seconds}": "T−{seconds}", "Preparing launch site": "正在準備發射場", @@ -878,7 +878,7 @@ "part of {complex}": "屬於 {complex} 複合火場", "InciWeb ↗ · click card to open": "InciWeb ↗ · 點選卡片開啟", "FIRE · {name}": "火場 · {name}", - "Unnamed incident": "未命名火情", + "Unnamed incident": "未命名事件", "Open {title} on InciWeb": "在 InciWeb 上開啟 {title}", "Perimeter source unavailable": "周界資料來源不可用", "Not contained or unknown": "未控制或未知", @@ -890,7 +890,7 @@ "radio tag|All": "全部", "News": "新聞", "Talk": "談話", - "Weather / Emergency": "氣象/緊急", + "Weather / Emergency": "氣象 / 緊急", "Public Safety": "公共安全", "Aviation / Marine": "航空/海事", "Traffic / Transit": "交通/運輸", @@ -950,19 +950,19 @@ "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 天", + "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)", + "Sentinel-2 via HLS": "Sentinel-2(透過 HLS)", "Landsat 8/9": "Landsat 8/9", - "Landsat 8/9 via HLS": "Landsat 8/9(經 HLS)", + "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 寬頻星座(額外數千個點)", + "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": "載入失敗", @@ -973,7 +973,7 @@ "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)中選擇", + "{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...": "正在載入覆蓋範圍…", @@ -1001,7 +1001,7 @@ "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 照片透過影像篩選", + "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 返回的回應無法讀取", @@ -1009,7 +1009,7 @@ "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——請在服務供應商設定中更換", + "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 繁忙——覆蓋範圍稍後會自動恢復", @@ -1059,7 +1059,7 @@ "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 金鑰以取得即時路況", + "SIMULATED — add TomTom key for live": "模擬——加入 TomTom 金鑰以取得即時路況", "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片逾時", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", @@ -1077,14 +1077,14 @@ "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}", + "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} 分鐘前上報", + "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} 分鐘(受限)", @@ -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": "幀不可用;保留上一幀觀測", @@ -1219,7 +1219,7 @@ "Reduced motion": "減弱動效", "Resume": "繼續", "Pause": "暫停", - "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時間不會隨動畫推進", "None": "無", "Speed": "風速", "Pressure": "氣壓", @@ -1227,14 +1227,14 @@ "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": "地面以上兩米的氣溫", + "Air temperature two meters above the surface": "地面以上 2 公尺的氣溫", "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}", "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。", @@ -1243,18 +1243,18 @@ "UNITS": "單位", "MOTION": "運動", "Forecast": "預報", - "NNE": "北東北", + "NNE": "北北東", "NE": "東北", - "ENE": "東東北", - "ESE": "東東南", + "ENE": "東北東", + "ESE": "東南東", "SE": "東南", - "SSE": "南東南", - "SSW": "南西南", + "SSE": "南南東", + "SSW": "南南西", "SW": "西南", - "WSW": "西西南", - "WNW": "西西北", + "WSW": "西南西", + "WNW": "西北西", "NW": "西北", - "NNW": "北西北", + "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.": "位置讀數需要已載入的預報,且地圖中心對準地球。", @@ -1382,14 +1382,14 @@ }, "{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.": "使用了已註冊內容或連結媒體。這些外部檔案不包含在場景資源包中。其原始宣告仍然適用。", + "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.": "確認匯入此檔案前,不會載入或更改任何內容。", "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.": "未執行匯入。目前專案可能已發生變化。", + "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.": "相機經緯度以度為單位,高度以米為單位(相對橢球面)。草稿透過校驗後才會替換已儲存的場景。", @@ -1402,7 +1402,7 @@ "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", "Use ordinary flight": "使用普通飛行", "Invalid shot JSON": "鏡頭 JSON 無效", - "Apply details": "應用詳情", + "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 保留創作設定與署名資訊。它不包含資源檔案。資源包僅包含你在此選擇的包檔案或先前匯入的資源包檔案。", @@ -1432,9 +1432,9 @@ "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": "搜臺已停止", + "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 訊號", @@ -1467,8 +1467,8 @@ "Switching language reloads the page. This will be lost: {items}. Switch anyway?": "切換語言會重新載入頁面。以下內容將會丟失:{items}。仍要切換嗎?", "VISUAL PRESETS": "視覺預設", "LOCATION": "位置", - "CONTACTS — nearest planes · vessels · sites": "目標 — 最近的飛機 · 船舶 · 設施", - "SPACE MISSIONS — launches & orbital assets": "太空任務 — 發射與軌道資產", + "CONTACTS — nearest planes · vessels · sites": "目標——最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務——發射與軌道資產", "LIVE SIGNALS": "即時訊號", "CCTV layer unavailable": "CCTV 圖層不可用", "Link copied!": "連結已複製!", @@ -1489,7 +1489,7 @@ "ΔE": "ΔE", "{value}m": "{value} m", "CCTV calibration reset": "CCTV 校準已重置", - "ADJUST ON": "調整開", + "ADJUST ON": "調整 開", "ADJUST": "調整", "SNAPSHOT": "快照", "LIVE": "即時", @@ -1511,7 +1511,7 @@ "No source configured": "未設定來源", "Configured source": "已配置來源", "Configured Source": "已配置資料來源", - "Synthetic fallback": "合成備用", + "Synthetic fallback": "合成備援", "Upstream HTTP {status}": "上游 HTTP {status}", "Unexpected media type {type}": "意外的媒體型別 {type}", "SOURCE · UNKNOWN": "來源 · 未知", @@ -1519,10 +1519,10 @@ "FRAME · UNAVAILABLE": "畫面 · 無法使用", "CALIBRATED": "已校準", "CURATED": "精選", - "RAW PRIOR": "原始預設", - "CCTV ON": "監視器 開", - "CCTV OFF": "監視器 關", - "VIEWSHED ON": "可視域開", + "RAW PRIOR": "原始推估", + "CCTV ON": "CCTV 開", + "CCTV OFF": "CCTV 關", + "VIEWSHED ON": "可視域 開", "COVERAGE ON": "覆蓋範圍 開", "COVERAGE OFF": "覆蓋範圍 關", "AUTO HOP ON": "自動輪播 開", @@ -1533,22 +1533,22 @@ "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 以啟用" + "other": "已載入 {count} 個攝影機 · 開啟 CCTV 即可使用" }, "Enable CCTV to load camera intersections": "啟用監視器以載入路口攝影機", "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", "No summary available.": "尚無摘要。", - "loading frames": "載入畫面", + "loading frames": "畫面載入中", "camera grid ready": "攝影機網格就緒", "Clearing selected data layers": "正在清除已選資料圖層", "Clear selected data layers": "清除已選資料圖層", "CONDITIONS UNKNOWN": "天氣狀況未知", - "CLEAR": "清除", + "CLEAR": "晴", "PARTLY CLOUDY": "晴間多雲", "OVERCAST": "陰", "FOG": "霧", @@ -1560,10 +1560,10 @@ "MIXED CONDITIONS": "混合天氣", "CYCLE ON": "輪播 開", "CYCLE OFF": "輪播 關", - "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": "地區新聞不可用", + "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": "區域新聞不可用", "RESOLVING REGION": "解析區域中", "REGION UNAVAILABLE": "無法取得區域", "NO RECENT LOCATION MATCHES": "暫無近期位置匹配結果", @@ -1572,8 +1572,8 @@ "CLOUD {pct}%": "雲量 {pct}%", "CLOUD UNKNOWN": "雲量未知", "{source} · LOCATION QUERY": "{source} · 位置查詢", - "REGIONAL NEWS": "地區新聞", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視視窗", + "REGIONAL NEWS": "區域新聞", + "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空 / 海監視範圍", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全域態勢以接收鄰近目標提醒", @@ -1596,7 +1596,7 @@ "ADS-B EMERGENCY · {status}": "ADS-B 緊急 · {status}", "AIRCRAFT": "飛機", "ACQUIRING SURFACE": "正在取得地面資料", - "SURFACE FALLBACK": "地面資料備用", + "SURFACE FALLBACK": "地面資料備援", "STALE FEED": "資料來源過期", "LIVE TRACK": "即時航跡", "COMMERCIAL": "商用", @@ -1628,7 +1628,7 @@ "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", - "LOCAL INFO": "本機資訊", + "LOCAL INFO": "在地資訊", "PLACE / CONDITIONS / POSITION": "地點/天候/位置", "TIME UNKNOWN": "時間未知", "{count}M AGO": "{count} 分鐘前", @@ -1699,9 +1699,9 @@ "{requirement}, or {offer}": "{requirement},或{offer}", "Live Flights": "即時航班", "Military Flights": "軍機", - "Earthquakes (24h)": "全球地震 (24小時)", + "Earthquakes (24h)": "全球地震(24 小時)", "Satellites": "衛星", - "Space Missions (30d)": "太空發射任務 (30天)", + "Space Missions (30d)": "太空發射任務(30 天)", "Street Traffic": "道路交通", "CCTV": "CCTV", "Radio": "廣播電台", @@ -1731,7 +1731,7 @@ "Active Fires": "野火熱點", "Military aircraft": "軍用飛機", "Vessels": "船舶", - "CCTV + Street View fallback": "CCTV + 街景備用", + "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 · 觀測", @@ -1811,10 +1811,10 @@ "Search failed": "搜尋失敗", "Detailed outline unavailable": "無法取得詳細輪廓", "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": "正在將駕駛艙重置為完整地球檢視", + "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}", @@ -1823,7 +1823,7 @@ "Collapse Radio": "收起電台", "Expand Radio section": "展開電台區域", "Collapse Radio section": "收起電台區域", - "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的預設值", + "Panel layout updated — positions reset to new defaults": "面板版面已更新——位置已重設為新的預設值", "Austin": "奧斯汀", "San Francisco": "舊金山", "New York": "紐約", @@ -1836,7 +1836,7 @@ "Los Angeles": "洛杉磯", "Collapse Cockpit display options": "收起駕駛艙顯示選項", "Expand Cockpit display options": "展開駕駛艙顯示選項", - "Collapse Cockpit Radio controls": "收起駕駛艙電台控制元件", + "Collapse Cockpit Radio controls": "收起駕駛艙廣播控制", "Expand Cockpit Radio controls": "展開駕駛艙廣播控制", "CH {index} / {total}": "頻道 {index} / {total}", "NO STATIONS": "無電台", @@ -1956,7 +1956,7 @@ "USE VIEW": "使用視野", "Use the current view as the box": "以目前視野作為框選", "Forget the box and its images": "清除框選及其影像", - "START HERE": "從此開始", + "START HERE": "從這裡開始", "Checking": "檢查中", "PREVIEW": "預覽", "Newest day with imagery for this box": "此框選有影像的最新一天", @@ -1984,7 +1984,7 @@ "Box {width} × {height} km": "框選 {width} × {height} km", "Acquired {time}": "取得於 {time}", "Coverage {coverage}": "覆蓋 {coverage}", - "START HERE · {reason}": "從此開始 · {reason}", + "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": "詳情", @@ -2052,9 +2052,9 @@ "OVERPASS TIMED OUT": "Overpass 逾時", "OVERPASS COULD NOT COMPLETE THE QUERY": "Overpass 無法完成查詢", "MAP TILES TEMPORARILY UNAVAILABLE": "地圖瓦片暫時不可用", - "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地圖標註名稱暫時不可用", "TURNING OFF LIVE DATA": "正在關閉即時資料", - "REFRESHING LIVE DATA": "更新即時資料", + "REFRESHING LIVE DATA": "正在重新整理即時資料", "syncing road network": "同步道路網路", "retry pending": "等待重試", "retrying in {seconds}s": "{seconds} 秒後重試", @@ -2131,7 +2131,7 @@ "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", "Loading this sequence…": "正在載入此序列…", "{n} images in this sequence · Esc clears": { - "other": "此序列共 {n} 張圖像 · Esc 清除" + "other": "此序列共 {n} 張圖像 · 按 Esc 清除" }, "{n} streets in view · click a line for its nearest photo": { "other": "視野內共 {n} 條街道 · 點選線段檢視距其最近的照片" @@ -2155,15 +2155,15 @@ "min": "分鐘", "s": "秒", "dB": "dB", - "Celestial ring — reveal the full globe": "天體環 — 顯示完整地球", - "Celestial ring — available in Normal style": "天球環——僅在常規樣式下可用", - "Detection overlay: {mode}": "探測疊加層:{mode}", - "Detection overlay: off": "探測疊加層:關閉", + "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} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "Forecast · valid {valid}": "預報 · 有效時間 {valid}", "voice|OFF": "關", @@ -2172,15 +2172,15 @@ "voice|EXECUTING": "執行中", "voice|ERROR": "錯誤", "AI AGENT": "AI 助理", - "Voice model tier — applies next session": "語音模型等級 — 於下次工作階段生效", + "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": "語音控制", "Hold Space to speak · tap Space to activate focused controls": "按住空白鍵說話 · 輕按空白鍵啟用聚焦的控制項", - "Voice assistant": "語音助手", + "Voice assistant": "語音助理", "VOICE": "語音", "Dismiss voice card": "關閉語音卡片", "YOU": "你", @@ -2211,7 +2211,7 @@ "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}", @@ -2272,7 +2272,7 @@ "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} (Contacts window)": "{place} {radius} 範圍內(目標範圍)", "within {radius} of {place}": "{place} {radius} 範圍內", "within {radius}": "{radius} 範圍內", "over {region}": "{region} 範圍內", @@ -2302,7 +2302,7 @@ "unavailable": "不可用", "Not answered: {layers}": "未回應:{layers}", "Toggle voice control": "切換語音控制", - "Activate to toggle voice": "點選切換語音", + "Activate to toggle voice": "啟用以切換語音", "Voice active": "語音已啟用", "Voice could not be started.": "無法啟動語音。", "the active voice session": "目前語音工作階段", @@ -2390,7 +2390,7 @@ "Change visual style": "切換視覺樣式", "Adjust HUD": "調整 HUD", "Adjust cyber sonar": "調整賽博聲納", - "Adjust detection overlay": "調整探測疊加層", + "Adjust detection overlay": "調整偵測疊加層", "Change map stack": "切換地圖組合", "Adjust post-processing": "調整後期處理", "Control scene": "控制場景", @@ -2449,8 +2449,8 @@ "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 cockpit vision style: NORMAL. Activate for next style.": "目前駕駛艙視覺樣式:一般。啟用以切換下一個樣式。", + "Current style: NORMAL — click for next": "目前風格:一般——點選切換", "CURRENT": "目前", "Next cockpit vision style": "下一個駕駛艙視覺風格", "Next vision style": "下一個視覺風格", @@ -2475,10 +2475,10 @@ "Estimated flight plan": "推估飛行計畫", "ESTIMATED FLIGHT PLAN": "推估飛行計畫", "FROM": "出發", - "TO": "目的", + "TO": "目的地", "Cockpit briefing controls": "駕駛艙簡報控制", - "Previous briefing page": "上一頁簡報", - "Next briefing page": "下一頁簡報", + "Previous briefing page": "簡報上一頁", + "Next briefing page": "簡報下一頁", "Live signals": "即時訊號", "Latest regional news": "最新區域新聞", "Location-based information": "在地資訊", @@ -2508,7 +2508,7 @@ "ESC EXIT": "ESC 離開", "C TOGGLE": "C 切換", "View switcher": "視角切換", - "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視角", + "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視野", "RESET": "重設", "Exit cockpit view": "離開駕駛艙視角", "EXIT COCKPIT": "離開駕駛艙", @@ -2521,7 +2521,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": "地圖來源", @@ -2546,7 +2546,7 @@ "Expand panel": "展開面板", "Context mode": "態勢模式", "CONTACTS": "目標", - "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標 — 飛機、船舶、設施。衛星獨立追蹤。", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "輪流切換所選類型的最近目標——飛機、船舶、設施。衛星獨立追蹤。", "SPACE MISSIONS": "太空任務", "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標情境操作", @@ -2566,7 +2566,7 @@ "All": "全部", "Enable Radio, then choose a globe marker or use next.": "啟用廣播,再選擇地球上的標記或按「下一個」。", "DRAG TO TUNE": "拖曳調頻", - "Tune available internet radio stations": "調整可用網路電台", + "Tune available internet radio stations": "調諧可用網路電台", "ALL · DRAG THE NEEDLE": "全部 · 拖曳指針", "SNAPS TO AVAILABLE STATIONS": "自動對齊可用電台", "Radio playback": "廣播播放", @@ -2595,11 +2595,11 @@ "FM MHZ": "FM MHz", "TUNE": "調諧", "Seek broadcast FM stations": "搜尋廣播 FM 電台", - "◀ SEEK": "◀ 搜臺", - "SEEK ▶": "搜臺 ▶", + "◀ SEEK": "◀ 搜台", + "SEEK ▶": "搜台 ▶", "SDR VOL": "SDR 音量", "Local SDR volume": "本機 SDR 音量", - "Intelligence HUD (H)": "情報抬頭顯示 (H)", + "Intelligence HUD (H)": "情報抬頭顯示(H)", "Layout": "版面", "HUD layout": "HUD 版面", "Tactical": "戰術", @@ -2619,7 +2619,7 @@ "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "兩次聲納掃描之間地圖點、圖示、模型、括號標記與標籤的最低不透明度", "Sector": "扇區", "Sonar sector width": "聲納扇區寬度", - "Detection Overlay (D)": "偵測疊加 (D)", + "Detection Overlay (D)": "偵測疊加(D)", "Detection overlay": "偵測疊加", "Detection label density": "偵測標籤密度", "Allocation": "分配", @@ -2628,27 +2628,27 @@ "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": "瞄準孔外的偵測不透明度", "World-overlay label and card opacity beyond the fade distance": "超過淡出距離後世界疊加標籤與卡片的不透明度", "PARAMETERS": "參數", - "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機 — 拉遠為平面圖示,拉近為 3D 模型", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠為平面圖示,拉近為 3D 模型", "Models": "模型", "3D model coverage": "3D 模型範圍", "Proximity": "鄰近", - "Scope — the circular viewport mask": "視域 — 圓形檢視範圍遮罩", + "Scope — the circular viewport mask": "視域——圓形檢視範圍遮罩", "Scope": "視域", "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 取消", + "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 (optional)": "標籤(選填)", "Label for the drawn shape": "繪製形狀的標籤", "Colour of the drawn shape": "繪製形狀的顏色", "Primary": "主色", @@ -2662,7 +2662,7 @@ "Import": "匯入", "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 檔案", "Imported files": "已匯入的檔案", - "Celestial": "天體環", + "Celestial": "天球環", "Hide UI chrome": "隱藏介面框架", "Clean UI": "簡潔介面", "Bloom / Glow": "光暈/輝光", @@ -2679,14 +2679,14 @@ "CCTV camera": "監視器攝影機", "CALIBRATION": "校正", "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", - "Camera pose — click a value to type": "攝影機姿態 — 點選數值即可輸入", - "Heading (compass °) — click to type": "方位角(羅盤 °)— 點選輸入", - "Pitch (° up/down) — click to type": "俯仰角(° 上/下)— 點選輸入", - "Horizontal FOV (°) — click to type": "水平視角(°)— 點選輸入", - "Range / monitor-plane distance (m) — click to type": "距離/投影平面距離(公尺)— 點選輸入", - "Mount height above ground (m) — click to type": "架設離地高度(公尺)— 點選輸入", - "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)— 點選輸入", - "East offset from catalog position (m) — click 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": "水平視角(°)——點選輸入", + "Range / monitor-plane distance (m) — click to type": "距離 / 螢幕平面距離(公尺)——點選輸入", + "Mount height above ground (m) — click to type": "架設離地高度(公尺)——點選輸入", + "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)——點選輸入", + "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)——點選輸入", "SAVE CAL": "儲存校正", "RESET CAL": "重設校正", "SCENE SUMMARY": "場景摘要", @@ -2694,9 +2694,9 @@ "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": "填充:圖像鋪滿畫框並裁剪照片", + "Fill: fill the frame edge to edge, cropping the photo": "填滿:圖像鋪滿畫框並裁剪照片", "Close the image and deselect it on the map": "關閉圖像並取消其在地圖上的選中狀態", "Close the street-level image": "關閉街景圖像", "Street-level viewer": "街景檢視器", @@ -2722,10 +2722,10 @@ "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 在瀏覽器中執行,務必在供應商端設定使用限制。在其他地方設定的金鑰只會顯示,不會被修改。", + "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 金鑰帶來擬真地球——其他功能都疊加在它之上。", "NO PLACE LEFT BEHIND": "無處遁形", "ACTIVE STYLE": "目前風格", "Globe actions": "地球操作", @@ -2734,7 +2734,7 @@ "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": "重設鏡頭並回到完整地球視角", + "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視野", "MISSION CONTROL · FIRST LAUNCH": "任務控制 · 首次啟動", "Choose your first view": "選擇你的第一個視角", "LIVE CONTACTS": "即時目標", @@ -2759,7 +2759,7 @@ "No coverage in view": "視野內無覆蓋範圍", "Voice provider": "語音服務供應商", "Changing provider reconnects voice if it is on.": "如語音已開啟,切換服務供應商會重新連線語音。", - "Gemini returned an oversized audio chunk": "Gemini 返回的音訊塊過大", + "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 音訊不完整", @@ -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 語音啟動過於頻繁。請稍後重試。", @@ -2852,7 +2852,7 @@ "GhostMaps catalog unavailable": "無法取得 GhostMaps 目錄", "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", "{count} source links ↗ · click card to open the first": { - "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個" + "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個連結" }, "S2 · {name}": "S2 · {name}", "Report": "報告", From 09cef198292ac5701d7ec07ce40f11dc8343b2d9 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:56:52 +0530 Subject: [PATCH 126/131] docs(changelog): add #1431 fragment --- changelog.d/1431-street-level-bearing-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1431-street-level-bearing-north.md diff --git a/changelog.d/1431-street-level-bearing-north.md b/changelog.d/1431-street-level-bearing-north.md new file mode 100644 index 000000000..f30084b71 --- /dev/null +++ b/changelog.d/1431-street-level-bearing-north.md @@ -0,0 +1,3 @@ +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). From 51ee9ae9142f8b53a828b6e2645b6354811f9aa9 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 23:57:25 +0400 Subject: [PATCH 127/131] i18n: write zh-Hant as zh-CN in Traditional characters Rebuild the zh-Hant catalog from zh-CN with OpenCC s2tw glyph conversion, so wording and terminology match zh-CN and only the script differs. Drop Taiwan-specific vocabulary and wording notes; zh-TW/HK/MO browser tags still select zh-Hant. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1430-traditional-chinese.md | 8 +- docs/CURRENT-STATE.md | 2 +- docs/translations/zh-Hant.md | 40 +- src/i18n/firms-live-zh.test.mjs | 4 +- src/i18n/i18n.test.mjs | 2 +- src/i18n/locales/zh-Hant.json | 2344 +++++++++++------------ src/i18n/same-as-english.json | 2 +- src/i18n/zh-Hant-display.test.mjs | 4 +- 8 files changed, 1187 insertions(+), 1219 deletions(-) diff --git a/changelog.d/1430-traditional-chinese.md b/changelog.d/1430-traditional-chinese.md index 3d34d6536..e644829fc 100644 --- a/changelog.d/1430-traditional-chinese.md +++ b/changelog.d/1430-traditional-chinese.md @@ -1,4 +1,4 @@ -- Add Traditional Chinese (zh-Hant) with language selection, - and localized Chinese readouts. Based on translations by @caxer (#724), - @tbdavid2019 (#652), @abiyeenzo (#914), and @EDIT-Aeterna (#979) - (German, @gekh, #1430). +- Add Traditional Chinese (zh-Hant) with language selection and localized + Chinese readouts: the Simplified Chinese catalog written in Traditional + characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna + (#979) (German, @gekh, #1430). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 570b50371..829fb1ceb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3744,7 +3744,7 @@ Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **z 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. Chinese tags default to `zh-CN`; `Hant`, `TW`, `HK` and `MO` + `navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?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 diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index f53a8cd97..7354e6b1b 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -2,46 +2,14 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md). -Sources: caxer (#724), tbdavid2019 (#652), and the zh-CN catalog credited to abiyeenzo (#914) and EDIT-Aeterna (#979). The remaining entries were seeded with OpenCC s2twp and edited for consistency. The catalog retains terminology from these Taiwan-oriented sources; `zh-Hant` identifies the writing system and does not promise separate regional vocabulary. +`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. ## Style -- Use Traditional characters and consistent terminology from the glossary below. Prefer short, neutral instructions: 「點選地圖」、「儲存金鑰」. -- Use full-width Chinese punctuation and 「」 for quotations. Keep `·`, `…`, provider names, codes and placeholders unchanged. -- Separate Chinese from Latin letters and numbers with a space: 「AIS 船舶」、「24 小時」. Keep unit symbols unchanged: km, m, km/h, m/s, kn, ft, MW, °, %. +- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. - Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前. -- Chinese labels have no uppercase variant. Keep button labels short and avoid splitting short words across lines. -- The picker displays 中文(繁體) / Chinese (Traditional). Browser tags with Hant or TW/HK/MO select the shared `zh-Hant` catalog. Existing `zh-TW` links and saved choices remain supported. Explicit Hans selects Simplified Chinese, including Hans-TW. +- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese. ## Plurals -`Intl.PluralRules('zh-Hant')` uses only `other`. Keep suitable measure words: 「{count} 架飛機」、「{count} 艘船」、「{count} 個金鑰」. - -## Catalog terminology - -| English | Traditional Chinese | -| --- | --- | -| data / information | 資料 / 資訊 | -| file / folder | 檔案 / 資料夾 | -| settings / save | 設定 / 儲存 | -| API key / token | API 金鑰 / 權杖 | -| live / load | 即時 / 載入 | -| camera / video | 攝影機 / 影片 | -| network / link | 網路 / 連結 | -| image / imagery | 圖像 / 影像 | -| click / double-click | 點選 / 按兩下 | -| response / timeout | 回應 / 逾時 | -| layer / data layer | 圖層 / 資料圖層 | -| cockpit / context | 駕駛艙 / 態勢 | -| air quality | 空氣品質 | -| transit / bikeshare | 大眾運輸 / 公共自行車 | - -## Context-sensitive terms - -- Weather `CLEAR` is 晴; `action|CLEAR` is 清除. -- `LOCAL INFO` describes 在地資訊; 本機 refers to the local device. -- Observation windows describe 範圍, not a UI 視窗. The scope control is 視域 and its feather slider is 視域邊緣羽化. -- Roundabouts are 圓環; telemetry is 回報; weather advisories are 發布. -- Intercardinal compass points: NNE 北北東, ENE 東北東, ESE 東南東, SSE 南南東, SSW 南南西, WSW 西南西, WNW 西北西, NNW 北北西. -- The Celestial control and its help text use 天球環. An uncalibrated camera's `RAW PRIOR` is 原始推估. -- `Wind unavailable` is shared by cyclone wind-speed cards and the wind-field layer; keep the wording broad enough for both (風況資料不可用). +`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」. diff --git a/src/i18n/firms-live-zh.test.mjs b/src/i18n/firms-live-zh.test.mjs index 11878e790..03b3af6c8 100644 --- a/src/i18n/firms-live-zh.test.mjs +++ b/src/i18n/firms-live-zh.test.mjs @@ -10,7 +10,7 @@ const cases = [ [7200000, '2 小时前', '2h ago', '2 小時前'], ]; -for (const [age, zh, en, tw] of cases) { +for (const [age, zh, en, hant] of cases) { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { const now = 1800000000000; context.mock.method(Date, 'now', () => now); @@ -37,7 +37,7 @@ for (const [age, zh, en, tw] of cases) { setLocale('zh-CN'); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); setLocale('zh-Hant'); - assert.equal(methods.getStats().loadingLabel, `即時 · 更新於 ${tw}`); + assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`); } finally { setLocale(previousLocale); } diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 031d4f85e..1b421f2ff 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -43,7 +43,7 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () => assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant'); assert.equal(normalizeLocale('zh-TW'), 'zh-Hant'); assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant'); - assert.equal(normalizeLocale('zh-Hans-TW'), 'zh-CN'); + assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant'); assert.equal(normalizeLocale('zh_HK'), 'zh-Hant'); assert.equal(normalizeLocale('zh-MO'), 'zh-Hant'); diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e86b7d9ab..414624f2d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,41 +1,41 @@ { - "Pick a shape, then click the map.": "選擇形狀,然後點選地圖。", + "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", - "Click where the pin goes.": "點選圖釘的放置位置。", + "Click where the pin goes.": "點擊圖釘的放置位置。", "Click {count} more points.": { - "other": "再點選 {count} 個點。" + "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 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}", + "{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 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...": "正在設定檢視器…", - "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...": "系統初始化中…", - "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放資料。退出該模式後才能啟用「{layer}」。", - "Data attribution": "資料來源", - "Data provided by:": "資料提供:", - "Close data attribution": "關閉資料來源", + "{owner} is using the pointer — close it first.": "{owner} 正在使用指針——請先關閉它。", + "{label} · Detailed outline unavailable": "{label} · 無法獲取詳細輪廓", + "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...": "正在初始化系統…", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "太空任務模式會隔離回放數據。退出該模式後才能啟用“{layer}”。", + "Data attribution": "數據來源說明", + "Data provided by:": "數據提供方:", + "Close data attribution": "關閉數據來源說明", "Light aircraft": "輕型飛機", "Glider": "滑翔機", "Turboprop": "渦槳飛機", "Airliner": "民航客機", "Widebody": "寬體機", - "Four-engine jet": "四發噴射機", + "Four-engine jet": "四發噴氣機", "Helicopter": "直升機", - "Fast jet": "噴射戰機", + "Fast jet": "噴氣戰機", "Business jet": "公務機", "Drone": "無人機", "General emergency": "一般緊急情況", @@ -44,16 +44,16 @@ "Downed aircraft": "飛機墜毀", "Minimum fuel": "最低油量", "Lifeguard (medical priority)": "救護飛行(醫療優先)", - "Rasuwagadhi witness video": "Rasuwagadhi 目擊影片", + "Rasuwagadhi witness video": "Rasuwagadhi 目擊視頻", "Bhote Koshi valley footage": "Bhote Koshi 河谷畫面", "Flood footage": "洪水畫面", - "Compilation and casualty claims": "影片合集與傷亡說法", + "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": "初始崩塌觀察點", + "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": "堰塞湖", @@ -69,43 +69,43 @@ "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 逾時。", + "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": "逾時", + "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} 請開啟原始來源。", + "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 · PROVIDER CONTROLS": "FACEBOOK · 平臺控件", "Facebook embed unavailable": "Facebook 嵌入內容不可用。", - "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平台控制元件", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "自動播放被攔截 · 請使用平臺控件", "Embedded {provider} media for {source}": "{source} 的嵌入 {provider} 媒體", "witness source": "目擊來源", "Witness source": "目擊來源", "{provider} · EMBEDDED SOURCE": "{provider} · 嵌入來源", - "OPEN ORIGINAL ↗": "開啟原始來源 ↗", + "OPEN ORIGINAL ↗": "打開原始來源 ↗", "{provider} media: {source}": "{provider} 媒體:{source}", - "{provider} · PROVIDER CONTROLS": "{provider} · 平台控制元件", - "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 開啟原始來源", + "{provider} · PROVIDER CONTROLS": "{provider} · 平臺控件", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · 打開原始來源", "INITIATION": "起始", "DAMMING": "堰塞", "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵達邊境", "PROPAGATION": "演進", - "DOWNSTREAM PASSAGE": "向下游推進", + "DOWNSTREAM PASSAGE": "向下遊推進", "CONSEQUENCE": "後果", "DOWNSTREAM CONSEQUENCE": "下游後果", "EVIDENCE": "證據", - "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", + "{year} HISTORICAL REFERENCE": "{year} 年曆史參考", "{year} POST-EVENT": "{year} 年災後", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "other": "{count} 個地理定位來源點位" @@ -113,15 +113,15 @@ "NO PREVIEW": "無預覽", "YouTube source": "YouTube 來源", "Facebook source": "Facebook 來源", - "SOURCE LINK": "來源連結", - "{publisher} / OPEN": "{publisher} / 開啟", + "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 年災後", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年曆史參考 / 2026 年災後", "Historical reference and post-event image split": "歷史參考與災後影像的分割", "RECONSTRUCTION CLOCK": "重構時鐘", "Schematic downstream progression": "下游演進示意", @@ -135,14 +135,14 @@ "▶ PLAY SCENE": "▶ 播放場景", "◉ CINEMATIC": "◉ 運鏡", "↺ FULL STORY": "↺ 完整故事", - "↗ OPEN ORIGINAL": "↗ 開啟原始來源", + "↗ OPEN ORIGINAL": "↗ 打開原始來源", "⌖ LOWER GORGE": "⌖ 下游峽谷", "FIELD REPORTS · {count}": "現場報道 · {count}", "GEOLOCATIONS · {credit}": "地理定位 · {credit}", - "↗ OPEN PUBLIC MAP": "↗ 開啟公共地圖", - " Clouds are preserved from the source imagery.": "雲層按原始影像原樣保留。", + "↗ OPEN PUBLIC MAP": "↗ 打開公共地圖", + " Clouds are preserved from the source imagery.": "雲層按源影像原樣保留。", "Rasuwagadhi witness": "Rasuwagadhi 目擊者", - "Open original source for {source}; capture time unverified": "開啟 {source} 的原始來源;拍攝時間未核實", + "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}%", "■ RELEASE CAMERA": "■ 釋放相機", @@ -155,7 +155,7 @@ "↺ REPLAY": "↺ 重播", "… PLAYING SCENE": "… 場景播放中", "Continue {scene} from next shot": "從下一鏡頭繼續播放 {scene}", - "current scene": "目前場景", + "current scene": "當前場景", "Replay the full reconstruction": "重播完整重構", "SCENE SHOT": "場景鏡頭", "TIME UNVERIFIED": "拍攝時間未核實", @@ -166,10 +166,10 @@ "{scene} / AUTHORED SHOT": "{scene} / 編排鏡頭", "NEPAL FLOOD INCIDENT": "尼泊爾洪水事件", "Play {shot}": "播放 {shot}", - "current scene 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": "事件重構", @@ -179,8 +179,8 @@ "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 日尼泊爾滑坡的初始報告", + "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": "城市", @@ -194,56 +194,56 @@ "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 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 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 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 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 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 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.": "該檔案的點樣式無效。", + "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 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": "已載入地圖標註設施", + "Zoom in to search mapped installations": "請放大地圖以搜索地圖標註的設施", + "Showing cached mapped sites": "正在顯示緩存的地圖標註設施", + "Mapped sites not loaded": "地圖標註設施未加載", + "Mapped sites loaded": "已加載地圖標註設施", "Military airfield": "軍用機場", "Naval base": "海軍基地", "Firing range": "靶場", @@ -253,23 +253,23 @@ "flight": "航班", "military flight": "軍用航班", "satellite": "衛星", - "feed unavailable": "資料來源不可用", - "feed stale": "資料來源過期", + "feed unavailable": "數據源不可用", + "feed stale": "數據源過期", "observed or mapped nearby context": "附近的觀測或地圖標註態勢", - "no observed or mapped objects in current feeds": "目前資料來源中沒有觀測或地圖標註的物件", + "no observed or mapped objects in current feeds": "當前數據源中沒有觀測或地圖標註的對象", "Draw": "繪製", - "Directions": "路線規劃", + "Directions": "路線導航", "Recent imagery": "近期影像", - "STATION": "太空站", - "Crewed stations and their visiting vehicles": "載人太空站與來訪載具", + "STATION": "空間站", + "Crewed stations and their visiting vehicles": "載人空間站及其來訪航天器", "NAV": "導航", - "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航衛星——GPS、GLONASS、Galileo", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS 導航:GPS、GLONASS、Galileo", "GEO": "GEO", - "Geostationary belt — comms and weather, fixed over the equator": "地球同步軌道帶——通訊與氣象衛星,固定於赤道上空", + "Geostationary belt — comms and weather, fixed over the equator": "地球靜止軌道帶:通信和氣象,固定在赤道上空", "VISUAL": "目視可見", - "Brightest naked-eye objects — CelesTrak visual group": "肉眼最亮天體——CelesTrak 目視群組", - "COMMS": "通訊", - "Broadband constellation shell — shown only in DENSE mode": "寬頻星座殼層——僅在「密集」模式下顯示", + "Brightest naked-eye objects — CelesTrak visual group": "最亮的肉眼可見天體:CelesTrak visual 組", + "COMMS": "通信", + "Broadband constellation shell — shown only in DENSE mode": "寬帶星座殼層——僅在“密集”模式下顯示", "FISHING": "漁船", "TOWING": "拖帶", "DREDGER": "挖泥船", @@ -290,21 +290,21 @@ "TANKER": "油輪", "OTHER": "其他", "Included in bundle": "已包含在資源包中", - "Missing bundle file — reimport its bundle": "缺少資源包檔案——請重新匯入其資源包", - "Source configured; file checked when loaded": "資料來源已配置;檔案在載入時校驗", - "Source unavailable": "資料來源不可用", + "Missing bundle file — reimport its bundle": "缺少資源包文件——請重新導入其資源包", + "Source configured; file checked when loaded": "數據源已配置;文件在加載時校驗", + "Source unavailable": "數據源不可用", "ENVIRONMENTAL": "環境", "EARTH WATCH": "地球監測", - "ACTIVE EVENTS": "進行中事件", - "Starting live contacts…": "正在接入即時目標…", - "Opening space missions…": "正在開啟太空任務…", + "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.": "像是闖進了禁區駕駛艙——接著你才發現,所有來源都是公開的,資料也都是真的。", + "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": "一般", + "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", @@ -321,11 +321,11 @@ "OCEANIA": "大洋洲", "NORTHERN OCEANIC GRID": "北方海洋網格", "SOUTHERN OCEANIC GRID": "南方海洋網格", - "LOADING": "載入中", - "DEGRADED": "資料品質下降", - "STALE": "資料過期", - "PARTIAL": "部分可用", - "FALLBACK": "備用模式", + "LOADING": "加載中", + "DEGRADED": "降級", + "STALE": "過期", + "PARTIAL": "部分", + "FALLBACK": "備用", "{state} {layers}": "{state} {layers}", "view band|STREET": "街道", "view band|CITY": "城市", @@ -333,11 +333,11 @@ "view band|REGIONAL": "區域", "view band|GLOBAL": "全球", "SUMMARY": "摘要", - "Awaiting telemetry...": "等待遙測資料…", + "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}", + "{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": "北", @@ -345,92 +345,92 @@ "E": "東", "W": "西", "POWER UP · {count} KEYS WAITING": { - "other": "增強 · {count} 個金鑰待配置" + "other": "增強 · {count} 個密鑰待配置" }, - "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", + "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} 必須是字串", + "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})。", + "{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 登入狀態,請重試。", + "Could not check ChatGPT sign-in. Try again.": "無法檢查 ChatGPT 登錄狀態,請重試。", "VOICE AUTH · CHATGPT OAUTH": "語音認證 · CHATGPT OAUTH", - "VOICE AUTH · API KEY": "語音認證 · API 金鑰", - "USE API KEY": "使用 API 金鑰", + "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": "計費——需啟用帳單的帳戶", - "Optional paid token — public access works without one": "可選的付費權杖——無需權杖也可公開訪問", - "Free key — register, paste, done": "免費金鑰——註冊、貼上、完成", + "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)", - "configured externally": "已在外部設定", - "Supplied by your environment, Keychain, or launcher — change it where it was set": "由你的環境、鑰匙圈或啟動器提供——請在原設定處修改", + "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}", + "GET KEY ↗": "獲取密鑰 ↗", + "{envVar} saved — paste to replace": "{envVar} 已保存——粘貼可替換", + "paste {envVar}": "粘貼 {envVar}", "REMOVE": "移除", - "Remove {title} from this app's saved keys": "從本應用已儲存的金鑰中移除 {title}", - "unsaved provider keys": "未儲存的服務供應商金鑰", - "Project keys: {status}": "專案金鑰:{status}", + "Remove {title} from this app's saved keys": "從本應用已保存的密鑰中移除 {title}", + "unsaved provider keys": "未保存的服務商密鑰", + "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} 中移除。正在重啟——本頁面將自動重新載入。", - "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": "即時野火偵測", - "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", - "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務資料無需金鑰。常規使用請留空;付費權杖只會提高請求上限。", - "Air quality request failed": "空氣品質請求失敗", + "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} 中移除。正在重啟——本頁面將自動重新加載。", + "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": "實時活躍火點探測", + "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", + "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} 個站點回報資料" + "other": "{count} 個站點上報數據" }, - "Highest air-quality readings in view": "視野內空氣品質讀數最高的站點", + "Highest air-quality readings in view": "視野內空氣質量讀數最高的站點", "Low": "低", "Low health risk": "低健康風險", "Moderate": "中等", @@ -439,70 +439,70 @@ "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 站點回應格式錯誤", + "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": "地圖標註的攝影機位置", + "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.": "選中後青色攝影機變為珊瑚色。扇形僅示意已標繪方向,並非實測覆蓋範圍。附近的攝影機可能不在螢幕內。", + "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} 個" + "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": "放大以載入地圖標註的攝影機", - "Showing cached locations": "目前顯示的是快取位置", + "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": "車牌辨識攝影機", - "Camera coverage unavailable": "攝影機覆蓋範圍不可用", - "OpenStreetMap · community mapped": "OpenStreetMap · 社群標註", - "Overpass returned an incomplete camera response": "Overpass 返回的攝影機資料不完整", + "No ALPR data for this area": "此區域沒有 ALPR 數據", + "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": "全域態勢已關閉", + "GLOBAL CONTEXT OFF": "全局態勢已關閉", "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "選擇航班、船舶或地圖標註設施", - "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以載入觀測 / 地圖標註的鄰近資訊", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "啟用以加載觀測 / 地圖標註的鄰近信息", "Unavailable": "不可用", "Focus {label}": "聚焦 {label}", - "Global Context navigation": "全域態勢導航", - "Previous — prior visited contact in the 250 km window": "上一個——250 公里範圍內先前造訪的目標", + "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 公里範圍內最近的未造訪目標", + "Next — nearest unvisited contact in the 250 km window": "下一個:250 km 範圍內最近的未訪問目標", "NEXT": "下一個", "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 內的地圖標註匹配", + "{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 普查", + "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": "僅限目前視口", + "CURRENT VIEWPORT ONLY": "僅限當前視口", "WITHIN {km} KM": "{km} km 以內", "Flights": "航班", - "Military flights": "軍機", + "Military flights": "軍用航班", "AIS vessels": "AIS 船舶", "Mapped installations": "地圖標註設施", - "syncing {count} city feeds...": "正在同步 {count} 個城市資料來源…", + "syncing {count} city feeds...": "正在同步 {count} 個城市數據源…", "scanning nearby systems...": "正在掃描附近系統…", - "GBFS fetch error": "GBFS 取得錯誤", + "GBFS fetch error": "GBFS 獲取錯誤", "Station {id}": "站點 {id}", "Station": "站點", "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 可用 {bikes} · 車樁 {docks} · 容量 {capacity}", @@ -510,14 +510,14 @@ "⚠️ 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.": "目錄中沒有可用的攝影機。", + "{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} 個攝影機", + "OVERLAP {count} cams": "重疊 {count} 個攝像頭", "ISOLATED VIEW": "獨立視角", "PROJ MONITOR": "投影:監視器", "PROJ OFF": "投影:關", @@ -528,7 +528,7 @@ "{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": "氣旋資料不可用", + "Cyclone data unavailable": "氣旋數據不可用", "Potential tropical cyclone": "潛在熱帶氣旋", "Hurricane": "颶風", "Tropical storm": "熱帶風暴", @@ -538,25 +538,25 @@ "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", "Track/cone unavailable": "路徑/預報不確定範圍不可用", - "Cached advisory · stale source": "快取公告 · 資料來源已過期", - "Loading advisories…": "正在載入公告…", + "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 氣旋", + "{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": "大西洋 · 東/中太平洋", "{storms} · {name} selected": "{storms} · 已選擇 {name}", "Official advisory ↗": "官方公告 ↗", "Active NHC and CPHC cyclone advisories": "活躍的 NHC 與 CPHC 氣旋公告", - "Wind unavailable": "風況資料不可用", + "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": "步行", @@ -632,62 +632,62 @@ "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": "駛出圓環", + "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(再次點選取消)", - "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", + "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", "action|CLEAR": "清除", - "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}", + "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": "逐步導航", - "Another map tool is using clicks — close it, then SET A again": "另一個地圖工具正在攔截點選——請先關閉它,再重新設定 A", + "Turn-by-turn directions": "分步導航", + "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,然後點選地球", + "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": "路線規劃不可用——路線服務無回應", - "Earthquake source unavailable": "地震資料來源不可用", - "Focus fire detection {title}, {details}": "聚焦火點偵測 {title},{details}", - "live feed unavailable": "即時資料來源不可用", + "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", + "sensor n/a": "傳感器 n/a", "FIRE · {frp} MW": "火點 · {frp} MW", "NIGHT": "夜間", "▲ {frp} MW": "▲ {frp} MW", @@ -710,24 +710,24 @@ "age|{count}h": "{count} 小時", "age|{count}d": "{count} 天", "Fire · FRP {frp} MW": "火點 · FRP {frp} MW", - "STALE · cached {age}": "過期 · {age}前快取", - "refreshing...": "重新整理中…", - "loading...": "載入中…", - "KEY REQUIRED": "需要金鑰", - "LIVE · updated {age}": "即時 · 更新於 {age}", + "STALE · cached {age}": "過期 · {age}前緩存", + "refreshing...": "刷新中…", + "loading...": "加載中…", + "KEY REQUIRED": "需要密鑰", + "LIVE · updated {age}": "實時 · 更新於 {age}", "Source snapshot time unavailable": "來源快照時間不可用", - "Live data 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 分鐘內已瀏覽視野中的網格", + "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 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於取樣覆蓋範圍之外,或觀測資料不足。", + "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)", + "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": "機場", @@ -747,9 +747,9 @@ "{count} mapped sites in view": { "other": "視野內有 {count} 個地圖標註設施" }, - "loading mapped installation context": "正在載入地圖標註設施資訊", + "loading mapped installation context": "正在加載地圖標註設施信息", "Too many mapped sites in view to list them all": "視野內地圖標註設施過多,無法全部列出", - "Installation context unavailable": "設施資訊不可用", + "Installation context unavailable": "設施信息不可用", "MAPPED INSTALLATION": "地圖標註設施", "Unknown": "未知", "LAUNCH SITE · {site}": "發射場 · {site}", @@ -764,16 +764,16 @@ "PAUSED · {title}": "已暫停 · {title}", "UNSPECIFIED": "未指明", "+{count} additional payload records": "另有 {count} 條有效載荷記錄", - "PAYLOAD DATA UNAVAILABLE": "有效載荷資料不可用", + "PAYLOAD DATA UNAVAILABLE": "有效載荷數據不可用", "{value} KM DOWNRANGE": "落點距離 {value} km", - "POSITION UNAVAILABLE": "無法取得位置", + "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 天視窗內沒有可用任務", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "當前 30 天窗口內沒有可用任務", "DATE UNAVAILABLE": "日期不可用", - "UNSPECIFIED OPERATOR": "營運方未指明", + "UNSPECIFIED OPERATOR": "運營方未指明", "Select {mission}": "選擇 {mission}", "{value} KM": "{value} km", "{value} KM/S": "{value} km/s", @@ -787,11 +787,11 @@ "LAUNCH TIME · ": "發射時間 · ", "ORBIT · ": "軌道 · ", "ASCENT PATH · ": "上升軌跡 · ", - "CURRENT DISTANCE FROM EARTH · ": "目前與地球的距離 · ", + "CURRENT DISTANCE FROM EARTH · ": "當前與地球的距離 · ", "SATELLITE SPEED · ": "衛星速度 · ", "PAYLOAD": "有效載荷", "NAME": "名稱", - "TYPE": "型別", + "TYPE": "類型", "DESTINATION": "目的地", "STAGE / RE-ENTRY / RECOVERY": "子級 / 再入 / 回收", "STAGE": "子級", @@ -799,7 +799,7 @@ "FINAL POSITION": "最終位置", "REPLAY SPEED": "回放速度", "Replay speed multiplier": "回放速度倍率", - "REPLAY ASCENT": "回放升空", + "REPLAY ASCENT": "回放上升過程", "Previous mission": "上一個任務", "PREV": "上一個", "Next mission": "下一個任務", @@ -807,10 +807,10 @@ "Pause replay": "暫停回放", "Cancel replay": "取消回放", "EST. DOWNRANGE": "預估落點距離", - "PLANNED · ": "計畫 · ", + "PLANNED · ": "計劃 · ", "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", "RECONSTRUCTED ESTIMATE": "重建估算", - "Replay the estimated ascent with a following camera": "以跟隨鏡頭回放推估的升空過程", + "Replay the estimated ascent with a following camera": "以跟隨相機回放估算的上升過程", "Resume replay": "繼續回放", "T minus {seconds}": "T−{seconds}", "Preparing launch site": "正在準備發射場", @@ -823,9 +823,9 @@ "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": "你的解碼器資料來源", + "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} kn · 航跡角 {track}", @@ -834,21 +834,21 @@ "POSITION {age}": "位置 {age}前", "POSITION {seconds} S AGO": "位置 {seconds} 秒前", "{count} MSGS": { - "other": "{count} 條訊息" + "other": "{count} 條消息" }, - "Decoder feeds": "解碼器資料來源", - "WebUSB + decoder feeds": "WebUSB + 解碼器資料來源", - "opening receiver": "正在開啟接收機", - "checking decoder feeds": "正在檢查解碼器資料來源", + "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": "在電台面板連線接收機", + "connect a receiver in Radio": "在電臺面板連接接收機", "receiver is in FM mode": "接收機處於 FM 模式", "listening": "監聽中", "USB error": "USB 錯誤", "Alt unknown": "高度未知", - "Type unknown": "型別未知", + "Type unknown": "類型未知", "Reg unknown": "註冊號未知", - "Operator unknown": "營運方未知", + "Operator unknown": "運營方未知", "${amount}K": "{amount}K 美元", "${amount}M": "{amount}M 美元", "${amount}B": "{amount}B 美元", @@ -876,11 +876,11 @@ "discovered {age} ago": "發現於 {age}前", "updated {age} ago": "更新於 {age}前", "part of {complex}": "屬於 {complex} 複合火場", - "InciWeb ↗ · click card to open": "InciWeb ↗ · 點選卡片開啟", + "InciWeb ↗ · click card to open": "InciWeb ↗ · 點擊卡片打開", "FIRE · {name}": "火場 · {name}", - "Unnamed incident": "未命名事件", - "Open {title} on InciWeb": "在 InciWeb 上開啟 {title}", - "Perimeter source unavailable": "周界資料來源不可用", + "Unnamed incident": "未命名火情", + "Open {title} on InciWeb": "在 InciWeb 上打開 {title}", + "Perimeter source unavailable": "周界數據源不可用", "Not contained or unknown": "未控制或未知", "Under 50% contained": "控制低於 50%", "50–99% contained": "控制 50–99%", @@ -889,28 +889,28 @@ "Malformed perimeter snapshot": "周界快照格式錯誤", "radio tag|All": "全部", "News": "新聞", - "Talk": "談話", - "Weather / Emergency": "氣象 / 緊急", + "Talk": "脫口秀", + "Weather / Emergency": "天氣 / 應急", "Public Safety": "公共安全", - "Aviation / Marine": "航空/海事", - "Traffic / Transit": "交通/運輸", + "Aviation / Marine": "航空 / 海事", + "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.": "電台目錄暫時無法使用。", + "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|TALK": "脫口秀", "radio|WEATHER": "天氣", "radio|SAFETY": "安全", "radio|AIR / SEA": "航空 / 海事", - "radio|TRANSIT": "大眾運輸", + "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.": "無法啟動廣播串流。", + "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": "藍調", @@ -928,7 +928,7 @@ "Metal": "金屬樂", "Oldies": "懷舊金曲", "Pop": "流行", - "Punk": "龐克", + "Punk": "朋克", "R&B": "R&B", "Reggae": "雷鬼", "Rock": "搖滾", @@ -950,118 +950,118 @@ "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 天", + "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)", + "Sentinel-2 via HLS": "Sentinel-2(經 HLS)", "Landsat 8/9": "Landsat 8/9", - "Landsat 8/9 via HLS": "Landsat 8/9(透過 HLS)", + "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}——點選重試", - "load failed": "載入失敗", - "Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點選僅顯示核心目錄", + "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": "密集", - "CelesTrak unreachable": "CelesTrak 無法連線", + "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...": "正在載入覆蓋範圍…", + "{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...": "正在加載覆蓋範圍…", "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": "覆蓋瓦片載入失敗", - "The photo could not be loaded": "照片無法載入", + "Coverage tile failed": "覆蓋瓦片加載失敗", + "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 無法載入", + "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": "點選街道以開啟街景", + "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 覆蓋範圍", + "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 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 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——請在服務供應商設定中更換該權杖", + "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 照片無法載入", + "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 返回的資料過多", + "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": "未配置街景影像服務供應商", + "Panoramax sent an unreadable answer": "Panoramax 返回的響應無法讀取", + "No street-level imagery providers configured": "未配置街景影像服務商", "(by upload date)": "(按上傳日期)", - "Open the Street Level panel to view imagery": "開啟街景面板以檢視影像", - "Image could not be opened": "圖像無法開啟", - "TeleGeography load failed": "TeleGeography 載入失敗", + "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 need a TomTom key": "TomTom 道路需要 TomTom 金鑰", - "Hybrid needs a TomTom key": "混合模式需要 TomTom 金鑰", - "Local surface still loading": "本機路面仍在載入", + "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": "詳細覆蓋範圍受限", - "TomTom roads only, each with its live flow": "僅 TomTom 道路,各自帶即時路況", - "TomTom roads only, each with its live flow (needs a TomTom key)": "僅 TomTom 道路,各自帶即時路況(需要 TomTom 金鑰)", + "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 key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", - "TomTom upstream unreachable": "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}%", + "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}%", + "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 金鑰以取得即時路況", + "SIMULATED — traffic service unreachable": "模擬——交通服務無法連接", + "SIMULATED — add TomTom key for live": "模擬——添加 TomTom 密鑰以獲取實時數據", "OpenFreeMap tiles rate-limited": "OpenFreeMap 瓦片已被限流", - "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片逾時", + "OpenFreeMap tiles timed out": "OpenFreeMap 瓦片超時", "OpenFreeMap tiles unavailable": "OpenFreeMap 瓦片不可用", "occupancy|empty": "空載", "occupancy|many seats available": "空座較多", @@ -1077,56 +1077,56 @@ "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} 分鐘前回報", + "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}(資料來源時間)", + "{reported} (feed time)": "{reported}(數據源時間)", "Available trail: {minutes} min (limited)": "可用軌跡:{minutes} 分鐘(受限)", "Available trail: {minutes} min": "可用軌跡:{minutes} 分鐘", - "Bus": "公車", - "Tram": "路面電車", + "Bus": "公交車", + "Tram": "有軌電車", "Subway": "地鐵", "Train": "火車", "Ferry": "渡輪", - "Transit vehicle": "大眾運輸車輛", + "Transit vehicle": "公共交通車輛", "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", - "POS: LIVE": "POS:即時", + "POS: LIVE": "POS:實時", "POS: {time}Z": "POS:{time}Z", - "AIS live load failed": "AIS 即時資料載入失敗", + "AIS live load failed": "AIS 實時數據加載失敗", "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 資料", - "feed down — retrying slowly (attempt {attempt})": "資料來源中斷——正在低頻重試(第 {attempt} 次)", - "reconnecting to feed… (attempt {attempt})": "正在重新連線資料來源…(第 {attempt} 次)", - "feed down — retrying slowly": "資料來源中斷——正在低頻重試", - "reconnecting to feed…": "正在重新連線資料來源…", + "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 數據", + "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": "已被目前地圖來源隱藏 · 請選擇地球地圖", + "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": "天氣資料不可用", + "Weather source unavailable; previous observation retained": "天氣數據源不可用;保留上一幀觀測", + "Weather unavailable": "天氣數據不可用", "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} 分鐘前", @@ -1135,7 +1135,7 @@ "Observed": "觀測", "History": "歷史", "Observation unavailable": "觀測不可用", - "Waiting for observation": "等待觀測資料", + "Waiting for observation": "等待觀測數據", "{detail} · synced": "{detail} · 已同步", "{detail} · nearest": "{detail} · 最接近", "RADAR REFLECTIVITY · dBZ": "雷達反射率 · dBZ", @@ -1145,9 +1145,9 @@ "{state}: {time}": "{state}:{time}", "Latest observation": "最新觀測", "frame {index}/{total}": "第 {index}/{total} 幀", - "loading": "載入中", + "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 小時", @@ -1160,9 +1160,9 @@ "Americas + Pacific": "美洲與太平洋", "Global · 60°S–60°N": "全球 · 60°S–60°N", "North America": "北美洲", - "Source observations delayed": "資料來源觀測延遲", - "Stale source": "資料來源已過期", - "Loading next frame…": "正在載入下一幀…", + "Source observations delayed": "數據源觀測延遲", + "Stale source": "數據源已過期", + "Loading next frame…": "正在加載下一幀…", "Map center outside coverage": "地圖中心在覆蓋範圍之外", "strikes/km²/min ×10³": "閃擊次數/km²/分鐘 ×10³", "N. America": "北美", @@ -1170,22 +1170,22 @@ "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%", + "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": "檢視覆蓋範圍", + "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 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 全球長波鑲嵌圖。「僅雲區」會調暗除明亮冷雲頂之外的一切;這是亮度濾鏡,不是雲掩膜。覆蓋範圍與新鮮度因區域而異。", + "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。各幀使用數據源公佈的精確觀測時間。", + "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": "濾鏡", @@ -1196,13 +1196,13 @@ "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", "Some weather tiles unavailable": "部分天氣瓦片不可用", "Malformed weather manifest": "天氣清單格式錯誤", - "Weather request timed out": "天氣請求逾時", + "Weather request timed out": "天氣請求超時", "Temperature field unavailable": "溫度場不可用", "Mean sea level pressure field unavailable": "平均海平面氣壓場不可用", - "Wind upstream unavailable": "風場上游資料來源不可用", + "Wind upstream unavailable": "風場上游數據源不可用", "{source} HTTP {status}": "{source} HTTP {status}", "Wind": "風", - "Wind source unavailable": "風場資料來源不可用", + "Wind source unavailable": "風場數據源不可用", "Forecast · does not follow history": "預報 · 不隨觀測歷史變化", "Wind motion": "風場運動", "Wind speed": "風速", @@ -1210,16 +1210,16 @@ "Air temperature · 2 m": "氣溫 · 2 m", "Global · 1° grid": "全球 · 1° 網格", "{model} forecast · {time}": "{model} 預報 · {time}", - "Loading forecast": "正在載入預報", + "Loading forecast": "正在加載預報", "Preparing flow": "正在準備流場", "Selected field unavailable": "所選氣象場不可用", - "Cached forecast · stale": "快取預報 · 已過期", + "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": "暫停流動動畫;預報時間不會隨動畫推進", + "Pause visual flow; forecast time does not advance with animation": "暫停流動動畫;預報時次不會隨動畫推進", "None": "無", "Speed": "風速", "Pressure": "氣壓", @@ -1227,37 +1227,37 @@ "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": "地面以上 2 公尺的氣溫", + "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": "讀取地圖中心的預報值,不改變目前選擇", + "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": "單位", "MOTION": "運動", "Forecast": "預報", - "NNE": "北北東", + "NNE": "北東北", "NE": "東北", - "ENE": "東北東", - "ESE": "東南東", + "ENE": "東東北", + "ESE": "東東南", "SE": "東南", - "SSE": "南南東", - "SSW": "南南西", + "SSE": "南東南", + "SSW": "南西南", "SW": "西南", - "WSW": "西南西", - "WNW": "西北西", + "WSW": "西西南", + "WNW": "西西北", "NW": "西北", - "NNW": "北北西", + "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.": "位置讀數需要已載入的預報,且地圖中心對準地球。", + "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° 網格插值的模式預報。反映大範圍天氣形勢,不是街道級實測。", " · calm": " · 靜風", " from {direction}": ",{direction}風", @@ -1268,87 +1268,87 @@ "Globe field image unavailable": "地球氣象場圖像不可用", "Unknown wind model": "未知風場模式", "Unknown weather overlay": "未知天氣疊加層", - "Wind request timed out": "風場請求逾時", + "Wind request timed out": "風場請求超時", "Malformed wind manifest": "風場清單格式錯誤", "Malformed wind grid": "風場網格格式錯誤", "Error: {message}": "錯誤:{message}", - "Needs {envVars} — add it in Provider Settings": "需要 {envVars},請在服務供應商設定中新增", + "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 限制、配額或網路", - "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": "可見地圖目標", + "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.": "已儲存的專案無法讀取;儲存已保留。請匯入有效檔案以恢復儲存。", + "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}", + "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}:鏡頭繫結不完整", + "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}", + "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 檔案)", + "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": "場景媒體播放逾時", + "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": "場景執行完成", + "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": "軌道監看", + "Orbital Watch": "軌道監視", "Thermal Threat Board": "熱源威脅看板", "City Overload": "城市過載", "Omniscience Pullback": "全知拉遠", - "Global Incident Context": "全球事件情境", + "Global Incident Context": "全球事件背景", "Nepal-Focused Globe Rotation": "聚焦尼泊爾的地球旋轉", "Bhote Koshi Regional Approach": "Bhote Koshi 區域推近", "Bhote Koshi Nearby Cities": "Bhote Koshi 周邊城市", @@ -1362,15 +1362,15 @@ "Timure Evidence Cluster": "Timure 證據群", "Syabru Besi Passage": "Syabru Besi 通道", "Bidur / Trishuli Consequence": "Bidur / Trishuli 後果", - "Final view": "最終視角", + "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.": "無法完成此操作。請檢查檔案、引用和所選資源。", + "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} 個場景" }, @@ -1378,75 +1378,75 @@ "other": "{count} 個鏡頭" }, "{count} data packs": { - "other": "{count} 個資料包" + "other": "{count} 個數據包" }, - "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}:{status}。檔案:{path}。{attribution} · {license}", + "{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.": "確認匯入此檔案前,不會載入或更改任何內容。", - "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.": "匯入未套用。目前專案可能已發生變化。", + "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.": "確認導入此文件前,不會加載或更改任何內容。", + "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": "鏡頭相機、時序、資料包與動作", + "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": "將運鏡起點設為目前相機位置", + "Set move start to current camera": "將運鏡起點設為當前相機位置", "Move added to draft; destination remains the shot camera": "運鏡已加入草稿;終點仍為該鏡頭的相機位置", "Use ordinary flight": "使用普通飛行", "Invalid shot JSON": "鏡頭 JSON 無效", - "Apply details": "套用詳細資料", + "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 保留創作設定與署名資訊。它不包含資源檔案。資源包僅包含你在此選擇的包檔案或先前匯入的資源包檔案。", + "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": "或選擇資料包資料夾", + "Choose data-pack files": "選擇數據包文件", + "Or choose a data-pack folder": "或選擇數據包文件夾", "Download asset bundle": "下載資源包", "Asset bundle downloaded": "資源包已下載", - "No RTL-SDR device selected": "未選擇 RTL-SDR 裝置", + "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 連線失敗", + "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", + "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 worker failed": "SDR 解碼器工作線程失敗", "SDR decoder did not start; receiver stopped": "SDR 解碼器未能啟動;接收機已停止", - "SDR decoder stopped responding; 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 取樣流已停止", + "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 以恢復", + "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": "搜台已停止", + "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 訊號", + "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": "放大以檢視向量瓦片覆蓋", + "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": "對比度", @@ -1457,96 +1457,96 @@ "Instability": "抖動", "Density": "密度", "Gain": "增益", - "Bloom": "光暈", + "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}。仍要切換嗎?", + "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": "目標——最近的飛機 · 船舶 · 設施", - "SPACE MISSIONS — launches & orbital assets": "太空任務——發射與軌道資產", - "LIVE SIGNALS": "即時訊號", + "CONTACTS — nearest planes · vessels · sites": "目標:最近的飛機 · 船舶 · 設施", + "SPACE MISSIONS — launches & orbital assets": "太空任務:發射與在軌資產", + "LIVE SIGNALS": "實時信號", "CCTV layer unavailable": "CCTV 圖層不可用", - "Link copied!": "連結已複製!", + "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} 度", + "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 校準已儲存", + "CCTV calibration saved": "CCTV 校準已保存", "HDG": "HDG", "PITCH": "俯仰", "FOV": "FOV", - "RANGE": "距離", + "RANGE": "範圍", "HGT": "高度", "ΔN": "ΔN", "ΔE": "ΔE", "{value}m": "{value} m", "CCTV calibration reset": "CCTV 校準已重置", - "ADJUST ON": "調整 開", + "ADJUST ON": "調整開", "ADJUST": "調整", "SNAPSHOT": "快照", - "LIVE": "即時", + "LIVE": "實時", "UPSTREAM": "上游", "SYNTHETIC": "合成", "CONFIGURED": "已配置", "SEED": "預置", "UNKNOWN": "未知", "ERROR": "錯誤", - "Live HLS connected": "即時 HLS 已連線", - "Live HLS unavailable": "即時 HLS 不可用", - "No stream URL configured": "未設定串流網址", - "Live stream connected": "即時串流已連線", - "Snapshot feed connected": "快照來源已連線", - "Media fetch failed": "媒體取得失敗", - "Upstream snapshot active": "上游快照運作中", - "Fallback Street View frame": "備援街景畫面", - "Upstream unavailable": "上游無法使用", - "No source configured": "未設定來源", + "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": "合成備援", + "Configured Source": "已配置數據源", + "Synthetic fallback": "合成備用", "Upstream HTTP {status}": "上游 HTTP {status}", - "Unexpected media type {type}": "意外的媒體型別 {type}", + "Unexpected media type {type}": "意外的媒體類型 {type}", "SOURCE · UNKNOWN": "來源 · 未知", - "FRAME · LOADING": "畫面 · 載入中", - "FRAME · UNAVAILABLE": "畫面 · 無法使用", + "FRAME · LOADING": "畫面 · 加載中", + "FRAME · UNAVAILABLE": "畫面 · 不可用", "CALIBRATED": "已校準", "CURATED": "精選", - "RAW PRIOR": "原始推估", + "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 · 已編輯(未儲存)", + "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": "螢幕", + "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} 個攝影機 · 點選攝影機以啟用" + "other": "已加載 {count} 個攝像頭 · 點擊攝像頭以激活" }, "{count} cameras loaded · enable CCTV to activate": { - "other": "已載入 {count} 個攝影機 · 開啟 CCTV 即可使用" + "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" }, - "Enable CCTV to load camera intersections": "啟用監視器以載入路口攝影機", - "Enable CCTV to start camera-linked intelligence summaries.": "啟用監視器以開始產生攝影機情資摘要。", - "No summary available.": "尚無摘要。", - "loading frames": "畫面載入中", - "camera grid ready": "攝影機網格就緒", - "Clearing selected data layers": "正在清除已選資料圖層", - "Clear selected data layers": "清除已選資料圖層", + "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": "晴間多雲", @@ -1558,32 +1558,32 @@ "SNOW SHOWERS": "陣雪", "THUNDERSTORM": "雷暴", "MIXED CONDITIONS": "混合天氣", - "CYCLE ON": "輪播 開", - "CYCLE OFF": "輪播 關", - "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": "區域新聞不可用", - "RESOLVING REGION": "解析區域中", - "REGION UNAVAILABLE": "無法取得區域", + "CYCLE ON": "輪換開", + "CYCLE OFF": "輪換關", + "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": "地區新聞不可用", + "RESOLVING REGION": "正在解析區域", + "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": "區域新聞", - "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空 / 海監視範圍", + "REGIONAL NEWS": "地區新聞", + "{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": "目標已脫離其資料來源 · 讀數保持最後已知值", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "目標已脫離其數據源 · 讀數保持最後已知值", "{cohort} · {contact}": "{cohort} · {contact}", - "NO AVAILABLE EXAMPLE": "沒有可用範例", + "NO AVAILABLE EXAMPLE": "無可用示例", "{cohort}, Unavailable": "{cohort},不可用", - "AHEAD": "前方", + "AHEAD": "正前方", "{side} {angle}": "{side} {angle}", "L": "左", "R": "右", @@ -1592,51 +1592,51 @@ }, "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": "正在取得地面資料", - "SURFACE FALLBACK": "地面資料備援", - "STALE FEED": "資料來源過期", - "LIVE TRACK": "即時航跡", + "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": "無航線資料", + "ROUTE DATA UNAVAILABLE": "航線數據不可用", "DEST {bearing}": "目的地 {bearing}", - "Disable cockpit weather effects": "停用駕駛艙天氣效果", - "Enable cockpit weather effects": "啟用駕駛艙天氣效果", + "Disable cockpit weather effects": "停用座艙天氣效果", + "Enable cockpit weather effects": "啟用座艙天氣效果", "ON": "開", - "Normal": "一般", + "Normal": "常規", "Night vision": "夜視", "Thermal": "熱成像", - "Anime": "動畫", - "Noir": "黑色電影", + "Anime": "動漫", + "Noir": "黑白", "Snow": "雪景", - "Current cockpit vision style: {style}. Activate for next style.": "目前駕駛艙視覺樣式:{style}。點選可切換到下一個樣式。", - "Current style: {style} — click for next": "目前樣式:{style}——點選切換到下一個", - "Collapse Contact panel": "收合目標面板", + "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": "收合目標面板", + "Collapse contact panel": "收起目標面板", "Expand contact panel": "展開目標面板", - "Collapse cockpit briefing panel": "收合駕駛艙簡報面板", - "Expand cockpit briefing panel": "展開駕駛艙簡報面板", - "Collapse briefing panel": "收合簡報面板", + "Collapse cockpit briefing panel": "收起座艙簡報面板", + "Expand cockpit briefing panel": "展開座艙簡報面板", + "Collapse briefing panel": "收起簡報面板", "Expand briefing panel": "展開簡報面板", - "OBSERVED / MAPPED PINGS": "觀測/標記訊號", - "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "有來源佐證的事件 · 無合成新聞", - "LATEST LOCATION-MATCHED REPORTING": "最新在地相關報導", + "OBSERVED / MAPPED PINGS": "觀測 / 地圖標註目標", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "來源可查的事件 · 無合成新聞", + "LATEST LOCATION-MATCHED REPORTING": "當前地區最新報道", "{source} · LOCATION QUERY · RECENT": "{source} · 位置查詢 · 近期", - "LOCAL INFO": "在地資訊", - "PLACE / CONDITIONS / POSITION": "地點/天候/位置", + "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} · 目前", + "{aircraftClass} · CURRENT": "{aircraftClass} · 當前", "MILITARY FLIGHT": "軍用航班", "COMMERCIAL FLIGHT": "商用航班", "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", @@ -1644,113 +1644,113 @@ "{count} INPUTS UNKNOWN": { "other": "{count} 項輸入未知" }, - "SOURCE STATUS UNAVAILABLE": "資料來源狀態不可用", + "SOURCE STATUS UNAVAILABLE": "數據源狀態不可用", "Restore real aircraft": "恢復真實飛機", - "Reclassify as TR-3B": "重新分類為 TR-3B", - "TRACK ACQUIRED": "已取得航跡", + "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": "沒有已選資料圖層", + "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { - "other": "{count} 個資料圖層無法清除" + "other": "{count} 個數據圖層無法清除" }, "Cleared {count} data layers": { - "other": "已清除 {count} 個資料圖層" + "other": "已清除 {count} 個數據圖層" }, - "Selected data layers could not be cleared": "無法清除已選資料圖層", + "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": "無法重新整理附近設施,請重試", + "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": "態勢", - "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.": "無法重新整理地面高度。請在地圖載入完成後重新嘗試該檔案。", + "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} 個地圖物件" + "other": "{count} 個地圖對象" }, ", drawn with its own styles": ",使用其自帶樣式繪製", "Hide {name}": "隱藏 {name}", "Show {name}": "顯示 {name}", "Fly to {name}": "飛往 {name}", "Remove {name}": "移除 {name}", - "At most {count} files at once — remove one first.": "一次最多 {count} 個檔案——請先移除一個。", + "At most {count} files at once — remove one first.": "一次最多 {count} 個文件——請先移除一個。", "Reading {name}…": "正在讀取 {name}…", - "That file could not be read.": "無法讀取該檔案。", + "That file could not be read.": "無法讀取該文件。", "Added {count} files.": { - "other": "已新增 {count} 個檔案。" + "other": "已添加 {count} 個文件。" }, "{count} files could not be added ({name}: {error})": { - "other": "{count} 個檔案無法新增({name}:{error})" + "other": "{count} 個文件無法添加({name}:{error})" }, - "Some KML content was left out.": "部分 KML 內容未被匯入。", + "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} to use your {name} key": "開啟 {source} 以使用你的 {name} 密鑰", "light {source}": "開啟 {source}", ", or ": ",或", "{requirement}, or {offer}": "{requirement},或{offer}", - "Live Flights": "即時航班", - "Military Flights": "軍機", - "Earthquakes (24h)": "全球地震(24 小時)", + "Live Flights": "實時航班", + "Military Flights": "軍用航班", + "Earthquakes (24h)": "地震(24 小時)", "Satellites": "衛星", - "Space Missions (30d)": "太空發射任務(30 天)", - "Street Traffic": "道路交通", + "Space Missions (30d)": "太空任務(30 天)", + "Street Traffic": "街道交通", "CCTV": "CCTV", - "Radio": "廣播電台", - "Bikeshare": "公共自行車", - "Live AIS Vessels": "即時 AIS 船舶", - "Mapped Installations": "已標記軍事設施", - "Global Context": "全域態勢", - "Datacenters": "資料中心", + "Radio": "電臺", + "Bikeshare": "共享單車", + "Live AIS Vessels": "實時 AIS 船舶", + "Mapped Installations": "地圖標註設施", + "Global Context": "全局態勢", + "Datacenters": "數據中心", "Dams": "水壩", "Submarine Cables": "海底電纜", - "FIRMS Active Fires": "FIRMS 野火熱點", - "Local ADS-B": "本機 ADS-B", - "Transit": "大眾運輸", + "FIRMS Active Fires": "FIRMS 活躍火點", + "Local ADS-B": "本地 ADS-B", + "Transit": "公共交通", "Recent Imagery": "近期影像", "Fire Perimeters": "火場周界", "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷達", "Lightning density": "閃電密度", "Cyclone advisories": "氣旋預警", - "ALPR Cameras": "ALPR 攝影機", - "Live Vessels": "即時船舶", - "Bike Share": "公共自行車", - "Cameras": "攝影機", + "ALPR Cameras": "ALPR 攝像頭", + "Live Vessels": "實時船舶", + "Bike Share": "共享單車", + "Cameras": "攝像頭", "Street Level": "街景", - "Mapped ALPR Cameras": "已標記車牌辨識攝影機", - "Data Centers": "資料中心", - "Active Fires": "野火熱點", + "Mapped ALPR Cameras": "地圖標註 ALPR 攝像頭", + "Data Centers": "數據中心", + "Active Fires": "活躍火點", "Military aircraft": "軍用飛機", "Vessels": "船舶", - "CCTV + Street View fallback": "CCTV + 街景備援", + "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 · 即時", + "NASA FIRMS · LIVE": "NASA FIRMS · 實時", "OSM routing": "OSM 路線規劃", - "data source|Local": "本機", - "ENABLING": "啟動中", - "DISABLING": "關閉中", - "Movement": "移動目標", + "data source|Local": "本地", + "ENABLING": "啟用中", + "DISABLING": "停用中", + "Movement": "動態目標", "Infrastructure": "基礎設施", "Events": "事件", "Weather": "天氣", - "Utilities": "公用設施", + "Utilities": "工具", "Other layers": "其他圖層", "{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": "從未", + "never": "未更新", "{source} · {loadingLabel}": "{source} · {loadingLabel}", "{accepted} of {raw} records accepted": "已接受 {accepted} / {raw} 條記錄", "incomplete snapshot": "快照不完整", @@ -1758,112 +1758,112 @@ "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · {seconds} 秒後重試", "{state} · {source} · {ago}": "{state} · {source} · {ago}", "{source} · {ago}": "{source} · {ago}", - "UNCERTAIN": "狀態未知", + "UNCERTAIN": "不確定", "status chip|UNAVAILABLE": "不可用", - "Data source unavailable": "資料來源不可用", + "Data source unavailable": "數據源不可用", "{name}: {state}": "{name}:{state}", "{count}s ago": "{count} 秒前", - "Local FM stopped: internet radio started": "本機 FM 已停止:網路電台已開始播放", + "Local FM stopped: internet radio started": "本地 FM 已停止:網絡電臺已開始播放", "IDLE": "空閒", - "CONNECTING": "連線中", + "CONNECTING": "連接中", "TUNING": "調諧中", "STREAMING": "接收中", - "UNSUPPORTED": "不支援", + "UNSUPPORTED": "不支持", "idle": "空閒", - "running": "執行中", + "running": "運行中", "suspended": "已掛起", "closed": "已關閉", - "feed status|live": "即時", + "feed status|live": "實時", "feed status|stale": "過期", - "feed status|unreachable": "無法連線", + "feed status|unreachable": "無法連接", "feed status|invalid": "無效", - "waiting for IQ": "等待 IQ 資料", + "waiting for IQ": "等待 IQ 數據", "{count} messages": { - "other": "{count} 條訊息" + "other": "{count} 條消息" }, "DSP {count} blocks": { - "other": "DSP {count} 個資料塊" + "other": "DSP {count} 個數據塊" }, "DSP waiting": "DSP 等待中", - "{level} dBFS audio": "音訊 {level} dBFS", - "audio signal --": "音訊訊號 --", - "audio {state}": "音訊 {state}", + "{level} dBFS audio": "音頻 {level} dBFS", + "audio signal --": "音頻信號 --", + "audio {state}": "音頻 {state}", "{count} HEARD": { - "other": "已接收 {count} 架飛機的訊號" + "other": "已接收 {count} 架飛機的信號" }, - "DISCONNECT": "斷開連線", - "CONNECT": "連線", + "DISCONNECT": "斷開連接", + "CONNECT": "連接", "LOCATING…": "定位中…", "LOCATED": "已定位", "LOCATE": "定位", - " · read while Local ADS-B is on": " · 僅在本機 ADS-B 開啟時讀取", - "Decoder feeds: {feeds}": "解碼器資料來源:{feeds}", + " · read while Local ADS-B is on": " · 僅在本地 ADS-B 開啟時讀取", + "Decoder feeds: {feeds}": "解碼器數據源:{feeds}", "Language: {language}": "語言:{language}", - "Change the interface language": "更改介面語言", - "Interface language": "介面語言", - "Search languages": "搜尋語言", + "Change the interface language": "更改界面語言", + "Interface language": "界面語言", + "Search languages": "搜索語言", "No matching languages": "沒有匹配的語言", "📍 Location: --": "📍 位置:--", "Landmark: --": "地標:--", - "Searched location": "已搜尋的位置", - "ORBIT": "軌道", + "Searched location": "已搜索的位置", + "ORBIT": "環繞", "Location not found": "未找到位置", - "Search failed": "搜尋失敗", - "Detailed outline unavailable": "無法取得詳細輪廓", + "Search failed": "搜索失敗", + "Detailed outline unavailable": "無法獲取詳細輪廓", "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": "正在將駕駛艙重設為完整地球視野", + "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": "面板版面已更新——位置已重設為新的預設值", + "Expand Radio": "展開電臺", + "Collapse Radio": "收起電臺", + "Expand Radio section": "展開電臺區域", + "Collapse Radio section": "收起電臺區域", + "Panel layout updated — positions reset to new defaults": "面板佈局已更新——位置已重置為新的默認值", "Austin": "奧斯汀", "San Francisco": "舊金山", "New York": "紐約", "Tokyo": "東京", "London": "倫敦", "Paris": "巴黎", - "Dubai": "杜拜", + "Dubai": "迪拜", "Washington DC": "華盛頓特區", "Tallinn": "塔林", "Los Angeles": "洛杉磯", - "Collapse Cockpit display options": "收起駕駛艙顯示選項", - "Expand Cockpit display options": "展開駕駛艙顯示選項", - "Collapse Cockpit Radio controls": "收起駕駛艙廣播控制", - "Expand Cockpit Radio controls": "展開駕駛艙廣播控制", + "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": "開啟精簡廣播控制", + "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": "同步", "RECONCILE": "核對", "DISABLE": "停用", "ENABLE": "啟用", - "Reconcile Radio — lifecycle uncertain": "核對電台——生命週期狀態不確定", - "Disable Radio": "停用廣播", - "Enable Radio": "啟用廣播", + "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.": "選擇地球上的標記或按「下一個」。", + "NO STATION SELECTED": "未選擇電臺", + "Directory metadata only": "僅目錄元數據", + "Loading station directory…": "正在加載電臺目錄…", + "Choose a globe marker or use next.": "請選擇地球上的標註或使用“下一個”。", "TAGS · {tags}": "標籤 · {tags}", "Play": "播放", "PAUSE": "暫停", @@ -1871,32 +1871,32 @@ "PLAY": "播放", "selected": "所選", "nearest": "最近", - "{action} {target} radio station": "{action}{target}電台", - "RADIO STATE UNCERTAIN": "電台狀態不確定", + "{action} {target} radio station": "{action}{target}電臺", + "RADIO STATE UNCERTAIN": "電臺狀態不確定", "SYNCING DIRECTORY": "正在同步目錄", - "RADIO READY": "廣播就緒", + "RADIO READY": "電臺就緒", "SYNCING": "同步中", "READY": "就緒", - "station": "電台", + "station": "電臺", " · stale/degraded directory": " · 目錄過期/降級", " · degraded directory": " · 目錄降級", " · stale directory": " · 目錄過期", - " · outside current filter": " · 不在目前篩選範圍內", + " · outside current filter": " · 不在當前篩選範圍內", "Ready — playback starts only from your action": "就緒——手動操作後開始播放", - "Radio off": "廣播關閉", - "Connecting directly to broadcaster…": "正在直接連線廣播方…", - "Buffering broadcaster stream…": "正在緩衝廣播串流…", + "Radio off": "電臺已關閉", + "Connecting directly to broadcaster…": "正在直接連接廣播方…", + "Buffering broadcaster stream…": "正在緩衝廣播流…", "Playing {station}": "正在播放 {station}", "Paused {station}": "已暫停 {station}", - "Broadcaster stream unavailable": "廣播串流不可用", - " · muted during voice interaction": " · 語音互動期間已靜音", + "Broadcaster stream unavailable": "廣播流不可用", + " · muted during voice interaction": " · 語音交互期間已靜音", " · restoring volume after voice": " · 語音結束後正在恢復音量", - " · static indicates no broadcaster audio": " · 雜音表示廣播方沒有音訊", + " · 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": "電台生命週期狀態不確定——請使用「啟用」或「停用」進行核對", + "Station unavailable after directory refresh — choose another channel": "目錄刷新後電臺不可用——請選擇其他頻道", + "Radio is enabling…": "電臺正在啟用…", + "Radio is disabling…": "電臺正在停用…", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", "Clear reading": "清除讀數", "Earlier observation": "較早觀測", "Observed history": "觀測歷史", @@ -1905,10 +1905,10 @@ "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": "將相機對準地面才能使用目前視野", + "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} 正在使用指標——請先將其關閉", + "{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": "對比底圖", @@ -1922,7 +1922,7 @@ "newest day (partial coverage)": "最新一天(部分覆蓋)", "newest overview": "最新概覽", "No imagery": "無影像", - "Loading": "載入中", + "Loading": "加載中", "coverage|full": "完整", "coverage|partial": "部分", "unknown": "未知", @@ -1930,19 +1930,19 @@ "{percent}% cloud": "{percent}% 雲量", "{min}–{max}% cloud": "{min}–{max}% 雲量", "NO BOX": "無框選", - "SEARCHING": "搜尋中", + "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 天", + "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 固定目前所選日期", + "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 · 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 固定", @@ -1954,9 +1954,9 @@ "SELECT BOX": "框選區域", "Drag a box on the map (Esc cancels)": "在地圖上拖出框選(Esc 取消)", "USE VIEW": "使用視野", - "Use the current view as the box": "以目前視野作為框選", + "Use the current view as the box": "以當前視野作為框選", "Forget the box and its images": "清除框選及其影像", - "START HERE": "從這裡開始", + "START HERE": "從此開始", "Checking": "檢查中", "PREVIEW": "預覽", "Newest day with imagery for this box": "此框選有影像的最新一天", @@ -1968,27 +1968,27 @@ "preview": "預覽", "start here": "從此開始", "No box": "無框選", - "Searching": "搜尋中", + "Searching": "搜索中", "Every day is empty here": "此處每天都為空", - "Sources off": "資料來源已關閉", + "Sources off": "數據源已關閉", "VS": "對比", - "Not set": "未設定", + "Not set": "未設置", "Basemap": "底圖", - "Source off": "資料來源已關閉", + "Source off": "數據源已關閉", "Unpin {slot}": "取消固定 {slot}", "Unpin the image": "取消固定圖像", - "EXPORT {slot}": "匯出 {slot}", - "EXPORT": "匯出", + "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}", + "Acquired {time}": "獲取於 {time}", "Coverage {coverage}": "覆蓋 {coverage}", - "START HERE · {reason}": "從這裡開始 · {reason}", + "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": "時間、覆蓋與資料來源", + "Times, coverage, sources": "時間、覆蓋與數據源", "HIDE EMPTY DAYS": "隱藏空白日", "SHOW EMPTY DAYS · {count}": "顯示空白日 · {count}", "Days the probe found empty in this box": "探測發現此框選內為空的日期", @@ -1999,69 +1999,69 @@ "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}", + "Export failed · {error}": "導出失敗 · {error}", "Scene action failed": "場景操作失敗", "New scene name": "新場景名稱", - "Delete scene \"{scene}\" and all shots?": "刪除場景「{scene}」及其全部鏡頭?", - "Delete shot \"{shot}\"?": "刪除鏡頭「{shot}」?", + "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": "按兩下以重新命名", + "BALANCED": "均衡", + "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": "選擇檔案", + "Choose files": "選擇文件", "{count} files selected": { - "other": "已選擇 {count} 個檔案" + "other": "已選擇 {count} 個文件" }, - "No file chosen": "未選擇檔案", + "No file chosen": "未選擇文件", "Cancel": "取消", - "EDIT DETAILS": "編輯詳細資料", + "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 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} could not be restored — feed unavailable": "無法恢復分享的 {subject}——數據源不可用", "Shared {subject} is unavailable": "分享的 {subject} 不可用", - "ACQUIRING": "取得中", + "ACQUIRING": "獲取中", "SHARED {subject}": "分享的 {subject}", "subject": "目標", - "LOADING LIVE DATA": "載入即時資料", - "LOAD COMPLETE": "載入完成", - "LOAD CANCELLED": "載入已取消", - "LOAD FAILED": "載入失敗", - "LIVE DATA OFF": "即時資料已關閉", - "MAPPED SITES LOADED": "已載入地圖標註設施", + "LOADING LIVE DATA": "正在加載實時數據", + "LOAD COMPLETE": "加載完成", + "LOAD CANCELLED": "加載已取消", + "LOAD FAILED": "加載失敗", + "LIVE DATA OFF": "實時數據已關閉", + "MAPPED SITES LOADED": "已加載地圖標註設施", "RETRYING MAPPED SITES": "正在重試地圖標註設施", - "FETCHING MAPPED SITES": "正在取得地圖標註設施", - "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝影機", - "FETCHING ALPR CAMERAS": "正在取得 ALPR 攝影機", + "FETCHING MAPPED SITES": "正在獲取地圖標註設施", + "RETRYING ALPR CAMERAS": "正在重試 ALPR 攝像頭", + "FETCHING ALPR CAMERAS": "正在獲取 ALPR 攝像頭", "OVERPASS TEMPORARILY UNAVAILABLE": "Overpass 暫時不可用", "OVERPASS RATE-LIMITED": "Overpass 已被限流", - "OVERPASS TIMED OUT": "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": "同步道路網路", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "地點名稱暫時不可用", + "TURNING OFF LIVE DATA": "正在關閉實時數據", + "REFRESHING LIVE DATA": "正在刷新實時數據", + "syncing road network": "正在同步路網", "retry pending": "等待重試", "retrying in {seconds}s": "{seconds} 秒後重試", - "ALPR cameras": "ALPR 攝影機", + "ALPR cameras": "ALPR 攝像頭", "OpenSky OAuth client missing/invalid": "OpenSky OAuth 客戶端缺失/無效", "OpenSky OAuth rejected credentials": "OpenSky OAuth 拒絕了憑據", - "OpenSky username/password rejected": "OpenSky 使用者名稱/密碼被拒絕", + "OpenSky username/password rejected": "OpenSky 用戶名/密碼被拒絕", "OpenSky auth missing": "OpenSky 缺少認證", "OpenSky auth required": "OpenSky 需要認證", "OpenSky auth invalid": "OpenSky 認證無效", @@ -2069,36 +2069,36 @@ "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})", + "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} 分鐘前", + "{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} 回應格式錯誤", + "{source} network error": "{source} 網絡錯誤", + "Malformed {source} aircraft rows": "{source} 飛機數據行格式錯誤", + "Malformed {source} response": "{source} 響應格式錯誤", "entry {n}": "條目 {n}", "feeds {names} stale": { - "other": "資料來源 {names} 過期" + "other": "數據源 {names} 過期" }, "feeds {names} unreachable": { - "other": "資料來源 {names} 無法連線" + "other": "數據源 {names} 無法連接" }, "feeds {names} invalid": { - "other": "資料來源 {names} 無效" + "other": "數據源 {names} 無效" }, - "Loading {feeds}": "正在載入 {feeds}", + "Loading {feeds}": "正在加載 {feeds}", "{count} heard": { - "other": "已接收 {count} 架飛機的訊號" + "other": "已接收 {count} 架飛機的信號" }, "USB {rate} msg/s": "USB {rate} msg/s", "{rate} msg/s": "{rate} msg/s", "{count} feeds live": { - "other": "即時資料來源 {count} 個" + "other": "實時數據源 {count} 個" }, "{feed} {count} ({seconds}s old)": "{feed} {count}({seconds} 秒前)", "Street Level could not start": "街景未能啟動", @@ -2126,21 +2126,21 @@ "{name} imagery off": "{name} 影像已關閉", "Image by {creator}": "圖像作者:{creator}", " or ": " 或 ", - "OPEN {labels}": "開啟 {labels}", - "Open {names} at the centre of the view": "在視野中心開啟 {names}", - "Zoom in to a street to open {names} here": "放大到街道以在此處開啟 {names}", - "Loading this sequence…": "正在載入此序列…", + "OPEN {labels}": "打開 {labels}", + "Open {names} at the centre of the view": "在視野中心打開 {names}", + "Zoom in to a street to open {names} here": "放大到街道以在此處打開 {names}", + "Loading this sequence…": "正在加載此序列…", "{n} images in this sequence · Esc clears": { - "other": "此序列共 {n} 張圖像 · 按 Esc 清除" + "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} 條序列 · 點選線段檢視其照片" + "other": "視野內共 {n} 條序列 · 點擊線段查看其照片" }, "km/h": "km/h", "m/s": "m/s", @@ -2156,67 +2156,67 @@ "s": "秒", "dB": "dB", "Celestial ring — reveal the full globe": "天球環——顯示完整地球", - "Celestial ring — available in Normal style": "天球環——僅在一般樣式下可用", - "Detection overlay: {mode}": "偵測疊加層:{mode}", - "Detection overlay: off": "偵測疊加層:關閉", - "DETECT": "偵測", + "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} · issued {issued}": "預報 · 有效時間 {valid} · 發佈時間 {issued}", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "Forecast · valid {valid}": "預報 · 有效時間 {valid}", "voice|OFF": "關", - "voice|CONNECTING": "連線中", + "voice|CONNECTING": "連接中", "voice|LISTENING": "聆聽中", "voice|EXECUTING": "執行中", "voice|ERROR": "錯誤", - "AI AGENT": "AI 助理", - "Voice model tier — applies next session": "語音模型等級——於下次工作階段生效", + "AI AGENT": "AI 智能體", + "Voice model tier — applies next session": "語音模型檔位——下次會話生效", "STD": "標準", - "Estimated session cost": "預估工作階段費用", - "Voice control — activate to toggle voice; hold Space to speak": "語音控制——啟用以切換語音;按住空白鍵說話", + "Estimated session cost": "預估會話費用", + "Voice control — activate to toggle voice; hold Space to speak": "語音控制——點擊可切換語音;按住空格鍵說話", "ON/OFF": "開/關", - "VOICE STANDBY": "語音待命", + "VOICE STANDBY": "語音待機", "VOICE CONTROL": "語音控制", - "Hold Space to speak · tap Space to activate focused controls": "按住空白鍵說話 · 輕按空白鍵啟用聚焦的控制項", - "Voice assistant": "語音助理", + "Hold Space to speak · tap Space to activate focused controls": "按住空格鍵說話 · 輕按空格鍵激活當前聚焦的控件", + "Voice assistant": "語音助手", "VOICE": "語音", "Dismiss voice card": "關閉語音卡片", "YOU": "你", "THIS": "所指", - "Plan": "計畫", + "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 使用麥克風", + "Check microphone permission and network access, then try again.": "請檢查麥克風權限和網絡連接,然後重試。", + "Stopped by voice": "已通過語音停止", + "WebRTC microphone support unavailable": "當前瀏覽器不支持通過 WebRTC 使用麥克風", "Requesting microphone": "正在請求麥克風", - "Release Space to send": "鬆開空白鍵傳送", - "Hold Space to talk": "按住空白鍵說話", + "Release Space to send": "鬆開空格鍵發送", + "Hold Space to talk": "按住空格鍵說話", "Ask or command": "提問或下達指令", "Voice off": "語音已關閉", "VOICE UNAVAILABLE": "語音不可用", "VOICE ACTIVE": "語音已啟用", - "Voice session could not be started.": "無法啟動語音工作階段。", + "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};下次工作階段生效", + "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} 次字幕轉寫。", + "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.": "估算不完整——工作階段結束時仍有回應在處理中,因此其用量從未回報。", + "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.": "估算不完整——會話結束時仍有響應在處理中,因此其用量從未上報。", "STANDARD": "標準", - "{tier} applies next session": "{tier} 將在下次工作階段生效", - "Session ended — cost cap {cost}": "工作階段已結束——已達費用上限 {cost}", + "{tier} applies next session": "{tier} 將在下次會話生效", + "Session ended — cost cap {cost}": "會話已結束——已達費用上限 {cost}", "MIC": "麥克風", - "Radio did not start": "電台未能啟動", + "Radio did not start": "電臺未能啟動", "Running command": "正在執行指令", "{n} aircraft": { "other": "{n} 架飛機" @@ -2246,33 +2246,33 @@ "other": "{n} 個氣旋" }, "{n} buses": { - "other": "{n} 輛公車" + "other": "{n} 輛公交車" }, "{n} bike share": { - "other": "{n} 個公共自行車站點" + "other": "{n} 個共享單車站點" }, "{n} flock cameras": { - "other": "{n} 個 Flock 攝影機" + "other": "{n} 個 Flock 攝像頭" }, "{n} military bases": { "other": "{n} 處軍事基地" }, "{n} datacenters": { - "other": "{n} 個資料中心" + "other": "{n} 個數據中心" }, "{n} dams": { "other": "{n} 座水壩" }, "{n} air quality": { - "other": "{n} 個空氣品質站點" + "other": "{n} 個空氣質量站點" }, "{n} results": { "other": "{n} 條結果" }, - "anywhere in the loaded data": "已載入資料中的任意位置", + "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} (Contacts window)": "{place} {radius} 範圍內(目標窗口)", "within {radius} of {place}": "{place} {radius} 範圍內", "within {radius}": "{radius} 範圍內", "over {region}": "{region} 範圍內", @@ -2280,57 +2280,57 @@ "{items} {scope}": "{items} {scope}", "last 24 h": "過去 24 小時", "past 30 d and upcoming": "過去 30 天及未來", - "the flights layer loads by viewport": "航班圖層按視口載入", + "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} loading": "{label} 加載中", "{label} error": "{label} 出錯", - "counted the first {returned} loaded records": "已統計最先載入的 {returned} 條記錄", - "counted {returned} of {total} loaded records": "已統計 {total} 條已載入記錄中的 {returned} 條", - "{layers} just turned on — still loading, counts will rise": "{layers} 剛開啟——仍在載入,計數還會增加", + "counted the first {returned} loaded records": "已統計最先加載的 {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": "目標面板快照——卡片、語音播報和後續追問記憶均來自同一組保留目標", "Fly below {km} km to a covered region": "請降至 {km} km 以下並飛往有覆蓋的區域", "No feed here yet · {n} regions available": { - "other": "此處暫無資料來源 · 有 {n} 個覆蓋區域" + "other": "此處暫無數據源 · 有 {n} 個覆蓋區域" }, "degraded": "降級", "stale": "過期", "partial": "部分可用", "fallback": "備用", "unavailable": "不可用", - "Not answered: {layers}": "未回應:{layers}", + "Not answered: {layers}": "未響應:{layers}", "Toggle voice control": "切換語音控制", - "Activate to toggle voice": "啟用以切換語音", + "Activate to toggle voice": "點擊切換語音", "Voice active": "語音已啟用", "Voice could not be started.": "無法啟動語音。", - "the active voice session": "目前語音工作階段", + "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 船舶圖層未開啟", + "The ais-live-vessels layer is not enabled": "實時 AIS 船舶圖層未開啟", "Could not enable the requested layer": "無法開啟所請求的圖層", "Could not disable the requested layer": "無法關閉所請求的圖層", - "Radio layer unavailable": "電台圖層不可用", - "Camera navigation is unavailable in the current view": "目前檢視下無法進行相機導航", + "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 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} 已開啟,但目的地重新整理未完成", + "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} 已不在載入資料中", + "{name} is no longer loaded": "{name} 已不在加載數據中", "Unknown target layer: {target}": "未知的目標圖層:{target}", "nominal": "正常", "{label} on": "{label} 已開啟", @@ -2362,9 +2362,9 @@ "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 個目標", + "Framing uses the nearest 80 loaded contacts": "調整視野時僅包含已加載的最近 80 個目標", "{distance} from {location}": "距 {location} {distance}", "Altitude {altitude}": "高度 {altitude}", "{origin} to {destination}": "{origin} 至 {destination}", @@ -2382,22 +2382,22 @@ "Frame military": "將軍用航班納入視野", "Frame vessels": "將船舶納入視野", "Frame {target}": "將 {target} 納入視野", - "Zoom out to globe": "縮小至地球檢視", - "Open data layers": "開啟資料圖層", - "Open or close a panel": "開啟或關閉面板", + "Zoom out to globe": "縮小至地球視圖", + "Open data layers": "打開數據圖層", + "Open or close a panel": "打開或關閉面板", "Change context mode": "切換態勢模式", - "Control cockpit": "控制駕駛艙", + "Control cockpit": "控制座艙", "Change visual style": "切換視覺樣式", "Adjust HUD": "調整 HUD", - "Adjust cyber sonar": "調整賽博聲納", - "Adjust detection overlay": "調整偵測疊加層", + "Adjust cyber sonar": "調整賽博聲吶", + "Adjust detection overlay": "調整探測疊加層", "Change map stack": "切換地圖組合", "Adjust post-processing": "調整後期處理", "Control scene": "控制場景", "Control CCTV": "控制 CCTV", - "Control radio": "控制電台", - "Track contact": "追蹤目標", - "Stop tracking": "停止追蹤", + "Control radio": "控制電臺", + "Track contact": "跟蹤目標", + "Stop tracking": "停止跟蹤", "Clear marks": "清除標註", "Move camera": "移動相機", "Fly route": "沿路線飛行", @@ -2415,15 +2415,15 @@ "Layer off": "圖層已關閉", "Zoom out": "縮小", "Zoom in": "放大", - "Read the view": "播報目前視野", + "Read the view": "播報當前視野", "Check view state": "檢查視野狀態", "Action": "操作", - "Finding places": "正在查詢地點", + "Finding places": "正在查找地點", "Tracing outline": "正在繪製輪廓", "Looking up place": "正在查詢地點", "Flying": "飛行中", "Turning on layer": "正在開啟圖層", - "Loading aircraft": "正在載入飛機", + "Loading aircraft": "正在加載飛機", "Picking nearest": "正在選取最近目標", "Working": "處理中", "in progress": "進行中", @@ -2436,273 +2436,273 @@ "WORKING": "處理中", "DONE": "完成", "HERE": "此處", - "Aircraft cockpit view": "飛機駕駛艙視角", + "Aircraft cockpit view": "飛機座艙視圖", "LEVEL": "水平", - "Estimated destination direction": "推估目的地方向", + "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", "OPTICAL PLANE · 01": "光學平面 · 01", - "VISOR LOCK · ACTIVE": "護目鏡鎖定 · 啟用", - "GROUND SPEED · KTS": "地速 · 節", - "ALTITUDE · FT": "高度 · 英尺", + "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", + "GROUND SPEED · KTS": "地速 · kn", + "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": "目前飛機航向", + "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": "地速", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", - "Contact cockpit summary": "目標駕駛艙摘要", + "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", - "CONTACTS · 250 KM": "目標 · 250 公里", - "Contact navigation": "目標導覽", - "CONTEXT ONLY": "僅供態勢參考", - "Nearby cohort counts": "周邊目標數量", + "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": "僅依現有資料 · 不代表安全無虞", + "NEAREST OBSERVED / MAPPED": "最近的觀測 / 地圖標註目標", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "僅限可用輸入 · 不代表安全無虞", "WX": "天氣", - "Cockpit briefing carousel": "駕駛艙簡報輪播", - "Estimated flight plan": "推估飛行計畫", - "ESTIMATED FLIGHT PLAN": "推估飛行計畫", + "Cockpit briefing carousel": "座艙簡報輪播", + "Estimated flight plan": "預估飛行計劃", + "ESTIMATED FLIGHT PLAN": "預估飛行計劃", "FROM": "出發", - "TO": "目的地", - "Cockpit briefing controls": "駕駛艙簡報控制", + "TO": "到達", + "Cockpit briefing controls": "座艙簡報控件", "Previous briefing page": "簡報上一頁", "Next briefing page": "簡報下一頁", - "Live signals": "即時訊號", - "Latest regional news": "最新區域新聞", - "Location-based information": "在地資訊", - "TEMP": "溫度", + "Live signals": "實時信號", + "Latest regional news": "最新地區新聞", + "Location-based information": "基於位置的信息", + "TEMP": "氣溫", "WIND": "風", "SKY": "天空", "PRECIP": "降水", "MM": "mm", - "Weather data by Open-Meteo.com": "天氣資料來自 Open-Meteo.com", - "Cockpit briefing pages": "駕駛艙簡報頁", - "SIG": "訊號", - "Show Live Signals": "顯示即時訊號", + "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": "駕駛艙顯示與廣播控制", + "Show Regional News": "顯示地區新聞", + "LOCAL": "本地", + "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": "下一個篩選電台", + "Cockpit display options": "座艙顯示選項", + "RADIO": "電臺", + "Cockpit compact Radio controls": "座艙精簡電臺控件", + "Previous filtered radio station": "上一個已篩選電臺", + "Play selected radio station": "播放所選電臺", + "Next filtered radio station": "下一個已篩選電臺", "VOLUME": "音量", - "Cockpit Radio volume": "駕駛艙廣播音量", - "ESC EXIT": "ESC 離開", + "Cockpit Radio volume": "座艙電臺音量", + "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切換", - "View switcher": "視角切換", - "Exit cockpit and return to full globe view": "離開駕駛艙並回到完整地球視野", - "RESET": "重設", - "Exit cockpit view": "離開駕駛艙視角", - "EXIT COCKPIT": "離開駕駛艙", - "Navigation, voice, and visual preset controls": "導航、語音與視覺預設控制", + "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": "地圖來源", + "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...": "搜尋任何地點…", + "Expand LOCATION": "展開位置", + "Collapse panel": "收起面板", + "Pin location tray": "固定位置托盤", + "Keep location tray open": "保持位置托盤展開", + "Search any location": "搜索任意位置", + "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": "開啟詳細廣播控制", - "Previous station": "上一個電台", - "Next station": "下一個電台", - "Compact Radio volume": "精簡廣播音量", + "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.": "輪流切換所選類型的最近目標——飛機、船舶、設施。衛星獨立追蹤。", + "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", + "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": "選擇「目標」以載入觀測/標記的鄰近資訊", + "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": "依電台標籤篩選", + "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": "拖曳調頻", - "Tune available internet radio stations": "調諧可用網路電台", - "ALL · DRAG THE NEEDLE": "全部 · 拖曳指針", - "SNAPS TO AVAILABLE STATIONS": "自動對齊可用電台", - "Radio playback": "廣播播放", - "Previous filtered station": "上一個篩選電台", - "Play selected station": "播放所選電台", - "Next filtered station": "下一個篩選電台", + "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": "電臺播放", + "Previous filtered station": "上一個已篩選電臺", + "Play selected station": "播放所選電臺", + "Next filtered station": "下一個已篩選電臺", "STOP": "停止", - "Stop radio playback": "停止廣播播放", - "Radio volume": "廣播音量", - "STATION SITE": "電台位置", + "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 圖層共享。", + "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": "更換裝置", + "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 調諧器增益", + "RTL-SDR tuner gain for the current mode": "當前模式下的 RTL-SDR 調諧器增益", "AUTO": "自動", "MSG/S": "msg/s", - "HEARD": "已接收訊號", + "HEARD": "已接收信號", "POSITIONED": "已定位", "FM MHZ": "FM MHz", "TUNE": "調諧", - "Seek broadcast FM stations": "搜尋廣播 FM 電台", - "◀ SEEK": "◀ 搜台", - "SEEK ▶": "搜台 ▶", + "Seek broadcast FM stations": "搜索廣播 FM 電臺", + "◀ SEEK": "◀ 搜臺", + "SEEK ▶": "搜臺 ▶", "SDR VOL": "SDR 音量", - "Local SDR volume": "本機 SDR 音量", - "Intelligence HUD (H)": "情報抬頭顯示(H)", - "Layout": "版面", - "HUD layout": "HUD 版面", + "Local SDR volume": "本地 SDR 音量", + "Intelligence HUD (H)": "情報 HUD(H)", + "Layout": "佈局", + "HUD layout": "HUD 佈局", "Tactical": "戰術", "Operator": "操作員", - "Minimal": "極簡", + "Minimal": "精簡", "Cyber": "賽博", - "Sonar": "聲納", - "Cyber sonar contact scanner": "賽博聲納目標掃描器", + "Sonar": "聲吶", + "Cyber sonar contact scanner": "賽博聲吶目標掃描器", "Rings": "圈數", - "Sonar ring count": "聲納圈數", + "Sonar ring count": "聲吶圈數", "Range": "範圍", - "Sonar ring range": "聲納圈範圍", + "Sonar ring range": "聲吶圈範圍", "Power": "強度", - "Sonar intensity": "聲納強度", + "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)", - "Detection overlay": "偵測疊加", - "Detection label density": "偵測標籤密度", + "Sonar sector width": "聲吶扇區寬度", + "Detection Overlay (D)": "探測疊加層(D)", + "Detection overlay": "探測疊加層", + "Detection label density": "探測標籤密度", "Allocation": "分配", - "Detection label 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": "超過淡出距離後世界疊加標籤與卡片的不透明度", + "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 模型", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D 飛機——拉遠顯示平面圖標,拉近顯示 3D 模型", "Models": "模型", - "3D model coverage": "3D 模型範圍", - "Proximity": "鄰近", - "Scope — the circular viewport mask": "視域——圓形檢視範圍遮罩", + "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": "視域邊緣羽化(瞄準孔半徑百分比)", - "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "在世界上繪圖——點選頂點,按兩下或 Enter 完成,Esc 取消", + "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": "區域", + "Area": "面", "Line": "線", "Pin": "圖釘", - "Label (optional)": "標籤(選填)", + "Label (optional)": "標籤(可選)", "Label for the drawn shape": "繪製形狀的標籤", "Colour of the drawn shape": "繪製形狀的顏色", - "Primary": "主色", - "Amber": "琥珀", - "Cyan": "青", - "Green": "綠", - "Red": "紅", + "Primary": "主題色", + "Amber": "琥珀色", + "Cyan": "青色", + "Green": "綠色", + "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 檔案(也可直接拖放到地球上)。檔案內容僅保留在本裝置上;疊加層可能出現在隨語音分享的地圖截圖中。", - "Import": "匯入", - "Choose GeoJSON, KML, KMZ or GPX files": "選擇 GeoJSON、KML、KMZ 或 GPX 檔案", - "Imported files": "已匯入的檔案", - "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": "資料圖層", - "ACQUIRING FRAME": "擷取畫面中", + "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": "純淨界面", + "Bloom / Glow": "泛光 / 輝光", + "Bloom intensity": "泛光強度", + "Sharpening": "銳化", + "Sharpen": "銳化", + "Sharpen intensity": "銳化強度", + "EXIT CLEAN VIEW": "退出純淨界面", + "Return UI controls": "恢復界面控件", + "Initializing photorealistic world...": "正在初始化照片級逼真世界…", + "DATA LAYERS": "數據圖層", + "ACQUIRING FRAME": "正在獲取畫面", "NEAREST": "最近", - "CCTV camera": "監視器攝影機", - "CALIBRATION": "校正", - "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "在世界中拖曳攝影機:圓環旋轉、箭頭移動、把手調整距離/視角", - "Camera pose — click a value to type": "攝影機姿態——點選數值即可輸入", - "Heading (compass °) — click to type": "方位角(羅盤 °)——點選輸入", - "Pitch (° up/down) — click to type": "俯仰角(° 上/下)——點選輸入", - "Horizontal FOV (°) — click to type": "水平視角(°)——點選輸入", - "Range / monitor-plane distance (m) — click to type": "距離 / 螢幕平面距離(公尺)——點選輸入", - "Mount height above ground (m) — click to type": "架設離地高度(公尺)——點選輸入", - "North offset from catalog position (m) — click to type": "相對目錄位置的北向偏移(公尺)——點選輸入", - "East offset from catalog position (m) — click to type": "相對目錄位置的東向偏移(公尺)——點選輸入", - "SAVE CAL": "儲存校正", - "RESET CAL": "重設校正", + "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": "場景摘要", "STREET LEVEL": "街景", "Turn Street Level on or off": "開啟或關閉街景", - "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": "填滿:圖像鋪滿畫框並裁剪照片", + "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": "填充:圖像鋪滿畫框並裁剪照片", "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": "服務供應商", + "Street-level viewer": "街景查看器", + "Open this image on the provider's site": "在服務商網站上打開此圖像", + "Imagery providers": "影像服務商", + "PROVIDERS": "服務商", "Imagery filters": "影像篩選", "IMAGERY": "影像", "Panorama filter": "全景篩選", @@ -2710,108 +2710,108 @@ "FLAT": "平面", "SINCE": "起始時間", "SCENES": "場景", - "Scene recipe": "場景配方", - "NEW": "新增", - "CAPTURE SHOT": "擷取鏡頭", + "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": "目前風格", + "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 密鑰提供照片級逼真 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 bearing to north": "重設地圖方位為正北", - "Reset camera and return to full globe view": "重設鏡頭並回到完整地球視野", + "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": "選擇你的第一個視角", - "LIVE CONTACTS": "即時目標", - "Aircraft, vessels and nearby intelligence": "飛機、船舶與周邊情資", - "Launches, spacecraft and orbital context": "發射、太空載具與軌道資訊", - "EXPLORE MANUALLY": "自由探索", - "Begin with a clean globe": "從乾淨的地球開始", + "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 麥克風按鈕,就能用語音操控地圖。", + "ESC to dismiss": "按 ESC 關閉", + "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", "Gemini Live voice control": "Gemini Live 語音控制", - "Zoom in to see street-level coverage": "放大地圖以檢視街景覆蓋範圍", - "Point the camera at the ground for street-level coverage": "將相機朝向地面以檢視街景覆蓋範圍", + "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.": "所有服務供應商均已關閉:點亮一個標籤即可繪製其覆蓋範圍。", + "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 金鑰", + "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 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 權杖請求", + "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 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.": "請重新啟動語音以申請新的工作階段憑據。", + "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 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 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 語音未連線", + "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 縮小)", + "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 縮小)", "OK": "正常", "{category} ({count})": "{category}({count})", "Layers": "圖層", - "Search": "搜尋", + "Search": "搜索", "More": "更多", "Panels": "面板", "Post-tropical cyclone": "後熱帶氣旋", @@ -2819,49 +2819,49 @@ "Take the ramp {direction}": "{direction}駛入匝道", "Take the exit {direction} onto {road}": "從出口{direction}駛出,進入 {road}", "Take the exit {direction}": "從出口{direction}駛出", - "Link shared": "連結已分享", - "This share link can't be opened": "無法開啟此分享連結", - "{id} isn't reporting now — showing where it was at {time}": "{id} 目前未傳送訊號——顯示其在 {time} 的位置", - "{id} isn't reporting now — showing where it was last seen": "{id} 目前未傳送訊號——顯示其最後出現的位置", - "Shared vessel is unavailable": "無法取得分享的船舶", - "Check the spelling and press Enter to search again.": "請檢查拼字,然後按 Enter 重新搜尋。", - "Press Enter to try again.": "按 Enter 再試一次。", + "Link shared": "鏈接已分享", + "This share link can't be opened": "無法打開此分享鏈接", + "{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置", + "{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置", + "Shared vessel is unavailable": "分享的船隻不可用", + "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", + "Press Enter to try again.": "按 Enter 重試。", "Recent speech": "最近語音", - "Voice Settings": "語音設定", + "Voice Settings": "語音設置", "Preview": "預覽", - "Settings": "設定", + "Settings": "設置", "Close voice panel": "關閉語音面板", "Show Voice Preview": "顯示語音預覽", - "Voice inactivity timeout": "語音閒置逾時", - "Custom": "自訂", + "Voice inactivity timeout": "語音空閒超時", + "Custom": "自定義", "Minutes": "分鐘", - "These preferences stay in this browser.": "這些偏好設定僅儲存在此瀏覽器中。", - "Your choice applies now, but could not be saved.": "選擇已生效,但無法儲存。", - "Voice replies appear here.": "語音回覆會顯示在此處。", + "These preferences stay in this browser.": "這些偏好設置僅保存在此瀏覽器中。", + "Your choice applies now, but could not be saved.": "選擇已生效,但無法保存。", + "Voice replies appear here.": "語音回覆顯示在此處。", "Preview is off.": "預覽已關閉。", - "Automatic stop is unavailable for this voice connection.": "此語音連線不支援自動停止。", + "Automatic stop is unavailable for this voice connection.": "此語音連接不支持自動停止。", "S2 GhostMaps": "S2 GhostMaps", "Intel Picture": "情報態勢圖", "Kinetic 30d": "近 30 天軍事行動", "Border Crisis": "邊境危機", "Iran Picture": "伊朗態勢圖", "Iran Kinetic": "伊朗軍事行動", - "Yemen FLOT": "葉門前線", + "Yemen FLOT": "也門前線", "Latest Drop": "最新發布", - "GhostMaps file could not be read": "無法讀取 GhostMaps 檔案", - "GhostMaps catalog unavailable": "無法取得 GhostMaps 目錄", + "GhostMaps file could not be read": "無法讀取 GhostMaps 文件", + "GhostMaps catalog unavailable": "GhostMaps 目錄不可用", "Malformed GhostMaps catalog": "GhostMaps 目錄格式無效", "{count} source links ↗ · click card to open the first": { - "other": "{count} 個來源連結 ↗ · 點選卡片開啟第一個連結" + "other": "{count} 個來源鏈接 ↗ · 點擊卡片打開第一個" }, "S2 · {name}": "S2 · {name}", "Report": "報告", - "Open the source for {title}": "開啟 {title} 的來源", - "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "即時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", - "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連線以取得即時更新", - "Connecting to the S2 GhostMaps repository…": "正在連線至 S2 GhostMaps 儲存庫…", + "Open the source for {title}": "打開 {title} 的來源", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "實時 · GitHub {commit} · {message} · 每 {seconds} 秒檢查一次", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · 正在重新連接以獲取實時更新", + "Connecting to the S2 GhostMaps repository…": "正在連接 S2 GhostMaps 倉庫…", "{count} placemarks": { "other": "{count} 個地標" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被偵測到,並自動取代舊版本。點選標記查看報告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發佈這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 37e26399f..83c8897d8 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -225,9 +225,9 @@ "MM", "NVG", "REC", + "SRC {value}", "S2 GhostMaps", "S2 · {name}", - "SRC {value}", "Sentinel-2", "Techno", "Trance", diff --git a/src/i18n/zh-Hant-display.test.mjs b/src/i18n/zh-Hant-display.test.mjs index 187977cb3..c802c61c0 100644 --- a/src/i18n/zh-Hant-display.test.mjs +++ b/src/i18n/zh-Hant-display.test.mjs @@ -69,10 +69,10 @@ test('Traditional Chinese readouts preserve unit case, coordinates and natural r assert.equal(READOUT_WORDS.daysAgo(9), '9 天前'); assert.equal(READOUT_WORDS.daysAgo(0), '今天'); assert.equal(cctvSourceStatusLabel('ok'), '正常'); - assert.equal(t('SAVE KEYS'), '儲存金鑰'); + assert.equal(t('SAVE KEYS'), '保存密鑰'); assert.equal( tn(2, 'Click {count} more point.', 'Click {count} more points.'), - '再點選 2 個點。', + '再點擊 2 個點。', ); } finally { setLocale(previous); From 237684ba8636151d8148f72495fe30e7339ab6e0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 27 Oct 2026 22:08:29 -0700 Subject: [PATCH 128/131] chore(release): prepare v0.3.0 --- CHANGELOG.md | 740 +++++++++++++++++- changelog.d/1056-area-tool-breakdowns.md | 12 - changelog.d/1057-windows-npm-dep0190.md | 4 - changelog.d/1066-keyless-google-token.md | 3 - changelog.d/1071-geo-uri-search.md | 3 - .../1094-dependency-security-patches.md | 4 - changelog.d/1096-military-hold-track-speed.md | 3 - .../1097-overpass-remark-failure-phrases.md | 5 - changelog.d/1101-tle-catalog-number-match.md | 3 - changelog.d/1111-map-fallback-retry.md | 5 - .../1115-aircraft-altitude-band-unknown.md | 2 - changelog.d/1117-google-viewer-lifecycle.md | 3 - .../1118-cctv-snapshot-fallback-provenance.md | 3 - .../1135-director-save-status-run-results.md | 5 - .../1142-antimeridian-fire-perimeters.md | 6 - changelog.d/1171-director-goto-shot.md | 3 - changelog.d/1172-cctv-missing-numbers.md | 13 - changelog.d/1173-antimeridian-ground-floor.md | 10 - .../1186-launch-unknown-coordinates.md | 4 - changelog.d/1190-bikeshare-spain-cities.md | 6 - .../1197-launch-library-provider-guidance.md | 5 - changelog.d/1199-launch-cache-provenance.md | 3 - changelog.d/1200-cctv-live-icon-color.md | 5 - changelog.d/1201-cctv-stills-aspect.md | 4 - changelog.d/1205-cctv-tool-routing.md | 3 - .../1207-voice-entity-labels-english.md | 3 - changelog.d/1220-openai-typed-send.md | 3 - changelog.d/1222-pinokio-open-in-browser.md | 3 - changelog.d/1225-globe-rotation.md | 2 - changelog.d/1226-first-run-touch-dismiss.md | 4 - changelog.d/1228-cctv-ontario-511-key.md | 4 - changelog.d/1229-opensky-oauth-only.md | 8 - changelog.d/1232-gbfs-invalid-coordinates.md | 3 - .../1233-tarktee-datex-catalog-hardening.md | 4 - .../1234-cctv-catalog-background-refresh.md | 5 - changelog.d/1235-location-resolution.md | 3 - changelog.d/1237-taiwan-freeway-cctv.md | 4 - changelog.d/1239-traffic-stale-flow.md | 3 - changelog.d/1243-starfield-sky.md | 5 - .../1244-terrain-test-cache-isolation.md | 4 - .../1264-overpass-request-admission.md | 9 - changelog.d/1265-docker-trusted-peers.md | 3 - changelog.d/1268-radio-issue-fix.md | 2 - changelog.d/1269-aircraft-issue-fix.md | 1 - changelog.d/1270-space-issue-fix.md | 1 - changelog.d/1271-share-issue-fix.md | 1 - changelog.d/1272-search-issue-fix.md | 1 - changelog.d/1273-history-issue-fix.md | 1 - changelog.d/1274-import-issue-fix.md | 1 - changelog.d/1275-forecast-issue-fix.md | 3 - changelog.d/1276-terrain-issue-fix.md | 1 - changelog.d/1278-overpass-issue-fix.md | 1 - changelog.d/1279-launch-issue-fix.md | 1 - changelog.d/1281-traffic-issue-fix.md | 8 - changelog.d/1282-polling-issue-fix.md | 7 - .../1283-satellite-cache-provenance.md | 3 - .../1284-route-plausibility-segment.md | 3 - changelog.d/1286-since-note-inline.md | 4 - changelog.d/1287-shared-sliders.md | 3 - changelog.d/1288-zoomfor-legacy-shape.md | 4 - changelog.d/1297-s2-ghostmaps-layer.md | 14 - changelog.d/1298-simplified-chinese.md | 4 - changelog.d/1299-voice-panel-settings.md | 5 - .../1301-shared-segmented-selectors.md | 5 - changelog.d/1302-shared-action-buttons.md | 3 - changelog.d/1303-wind-stale.md | 2 - changelog.d/1304-antimeridian-distances.md | 2 - changelog.d/1305-shared-native-form-fields.md | 3 - changelog.d/1306-shared-status-badges.md | 4 - .../1309-shared-field-css-ownership.md | 3 - .../1317-provider-response-hardening.md | 1 - changelog.d/1321-dead-code-cleanup.md | 6 - .../1324-street-level-provider-exports.md | 8 - changelog.d/1325-phone-layout.md | 4 - changelog.d/1326-terrain-cache-eviction.md | 3 - changelog.d/1338-mobile-contact-context.md | 4 - changelog.d/1339-hud-dms-carry.md | 3 - .../1340-search-separated-hemisphere.md | 3 - .../1341-kartaview-impossible-timestamps.md | 3 - .../1342-firms-acquisition-calendar.md | 3 - changelog.d/1343-route-ramp-lane-wording.md | 4 - changelog.d/1345-weather-staging-wake.md | 3 - .../1349-cyclone-classification-codes.md | 3 - changelog.d/1350-cyclone-movement-knots.md | 3 - changelog.d/1351-ais-static-keeps-motion.md | 6 - .../1352-cockpit-destination-heading.md | 3 - changelog.d/1353-transit-ninety-second-age.md | 3 - changelog.d/1354-firms-frp-ten-megawatts.md | 3 - ...1366-aircraft-models-no-environment-map.md | 5 - changelog.d/1367-turned-off-layer-memory.md | 4 - .../1378-touch-zoom-aircraft-updates.md | 5 - changelog.d/1379-aircraft-label-candidates.md | 4 - changelog.d/1384-hud-summary-moving.md | 4 - changelog.d/1385-route-duration-minute.md | 3 - changelog.d/1386-route-distance-kilometre.md | 3 - .../1387-awareness-distance-kilometre.md | 3 - changelog.d/1388-draw-line-kilometre.md | 3 - changelog.d/1389-cockpit-wind-north.md | 3 - changelog.d/1390-awareness-bearing-north.md | 2 - changelog.d/1391-docker-selinux.md | 4 - changelog.d/1394-docker-healthcheck.md | 2 - changelog.d/1395-fires-tool-acquired-time.md | 6 - changelog.d/1396-local-adsb-track-north.md | 3 - changelog.d/1398-sharpen-zero-off.md | 2 - changelog.d/1399-ground-snap-clock-step.md | 3 - changelog.d/1403-remove-unused-modules.md | 5 - .../1410-scope-controls-hud-readability.md | 8 - changelog.d/1416-share-hash-navigation.md | 4 - changelog.d/1417-clear-all-saved-layers.md | 3 - changelog.d/1418-short-share-links.md | 5 - changelog.d/1419-share-view-codec-export.md | 4 - .../1420-keep-missed-location-query.md | 4 - .../1421-style-modules-skip-catalogs.md | 3 - .../1431-street-level-bearing-north.md | 3 - .../287-shared-great-circle-distance.md | 5 - changelog.d/605-fl511-florida-cameras.md | 9 - changelog.d/686-terrain-heights-bounds.md | 9 - changelog.d/761-dev-unwatched-runtime-dirs.md | 5 - changelog.d/767-alpha5-catalog-numbers.md | 11 - changelog.d/787-changelog-fragments.md | 7 - changelog.d/816-radio-owner-restoration.md | 7 - changelog.d/872-gemini-live-voice.md | 4 - changelog.d/880-world-packet-boundary.md | 6 - .../945-cctv-vegvesen-kartverket-heights.md | 17 - changelog.d/948-vite-fs-deny.md | 4 - .../970-recent-imagery-details-scroll.md | 3 - changelog.d/973-tomtom-cross-site-gate.md | 4 - changelog.d/974-street-level-slim.md | 6 - changelog.d/975-terrain-cross-site-gate.md | 8 - changelog.d/990-hong-kong-cctv.md | 7 - package-lock.json | 4 +- package.json | 2 +- 132 files changed, 714 insertions(+), 586 deletions(-) delete mode 100644 changelog.d/1056-area-tool-breakdowns.md delete mode 100644 changelog.d/1057-windows-npm-dep0190.md delete mode 100644 changelog.d/1066-keyless-google-token.md delete mode 100644 changelog.d/1071-geo-uri-search.md delete mode 100644 changelog.d/1094-dependency-security-patches.md delete mode 100644 changelog.d/1096-military-hold-track-speed.md delete mode 100644 changelog.d/1097-overpass-remark-failure-phrases.md delete mode 100644 changelog.d/1101-tle-catalog-number-match.md delete mode 100644 changelog.d/1111-map-fallback-retry.md delete mode 100644 changelog.d/1115-aircraft-altitude-band-unknown.md delete mode 100644 changelog.d/1117-google-viewer-lifecycle.md delete mode 100644 changelog.d/1118-cctv-snapshot-fallback-provenance.md delete mode 100644 changelog.d/1135-director-save-status-run-results.md delete mode 100644 changelog.d/1142-antimeridian-fire-perimeters.md delete mode 100644 changelog.d/1171-director-goto-shot.md delete mode 100644 changelog.d/1172-cctv-missing-numbers.md delete mode 100644 changelog.d/1173-antimeridian-ground-floor.md delete mode 100644 changelog.d/1186-launch-unknown-coordinates.md delete mode 100644 changelog.d/1190-bikeshare-spain-cities.md delete mode 100644 changelog.d/1197-launch-library-provider-guidance.md delete mode 100644 changelog.d/1199-launch-cache-provenance.md delete mode 100644 changelog.d/1200-cctv-live-icon-color.md delete mode 100644 changelog.d/1201-cctv-stills-aspect.md delete mode 100644 changelog.d/1205-cctv-tool-routing.md delete mode 100644 changelog.d/1207-voice-entity-labels-english.md delete mode 100644 changelog.d/1220-openai-typed-send.md delete mode 100644 changelog.d/1222-pinokio-open-in-browser.md delete mode 100644 changelog.d/1225-globe-rotation.md delete mode 100644 changelog.d/1226-first-run-touch-dismiss.md delete mode 100644 changelog.d/1228-cctv-ontario-511-key.md delete mode 100644 changelog.d/1229-opensky-oauth-only.md delete mode 100644 changelog.d/1232-gbfs-invalid-coordinates.md delete mode 100644 changelog.d/1233-tarktee-datex-catalog-hardening.md delete mode 100644 changelog.d/1234-cctv-catalog-background-refresh.md delete mode 100644 changelog.d/1235-location-resolution.md delete mode 100644 changelog.d/1237-taiwan-freeway-cctv.md delete mode 100644 changelog.d/1239-traffic-stale-flow.md delete mode 100644 changelog.d/1243-starfield-sky.md delete mode 100644 changelog.d/1244-terrain-test-cache-isolation.md delete mode 100644 changelog.d/1264-overpass-request-admission.md delete mode 100644 changelog.d/1265-docker-trusted-peers.md delete mode 100644 changelog.d/1268-radio-issue-fix.md delete mode 100644 changelog.d/1269-aircraft-issue-fix.md delete mode 100644 changelog.d/1270-space-issue-fix.md delete mode 100644 changelog.d/1271-share-issue-fix.md delete mode 100644 changelog.d/1272-search-issue-fix.md delete mode 100644 changelog.d/1273-history-issue-fix.md delete mode 100644 changelog.d/1274-import-issue-fix.md delete mode 100644 changelog.d/1275-forecast-issue-fix.md delete mode 100644 changelog.d/1276-terrain-issue-fix.md delete mode 100644 changelog.d/1278-overpass-issue-fix.md delete mode 100644 changelog.d/1279-launch-issue-fix.md delete mode 100644 changelog.d/1281-traffic-issue-fix.md delete mode 100644 changelog.d/1282-polling-issue-fix.md delete mode 100644 changelog.d/1283-satellite-cache-provenance.md delete mode 100644 changelog.d/1284-route-plausibility-segment.md delete mode 100644 changelog.d/1286-since-note-inline.md delete mode 100644 changelog.d/1287-shared-sliders.md delete mode 100644 changelog.d/1288-zoomfor-legacy-shape.md delete mode 100644 changelog.d/1297-s2-ghostmaps-layer.md delete mode 100644 changelog.d/1298-simplified-chinese.md delete mode 100644 changelog.d/1299-voice-panel-settings.md delete mode 100644 changelog.d/1301-shared-segmented-selectors.md delete mode 100644 changelog.d/1302-shared-action-buttons.md delete mode 100644 changelog.d/1303-wind-stale.md delete mode 100644 changelog.d/1304-antimeridian-distances.md delete mode 100644 changelog.d/1305-shared-native-form-fields.md delete mode 100644 changelog.d/1306-shared-status-badges.md delete mode 100644 changelog.d/1309-shared-field-css-ownership.md delete mode 100644 changelog.d/1317-provider-response-hardening.md delete mode 100644 changelog.d/1321-dead-code-cleanup.md delete mode 100644 changelog.d/1324-street-level-provider-exports.md delete mode 100644 changelog.d/1325-phone-layout.md delete mode 100644 changelog.d/1326-terrain-cache-eviction.md delete mode 100644 changelog.d/1338-mobile-contact-context.md delete mode 100644 changelog.d/1339-hud-dms-carry.md delete mode 100644 changelog.d/1340-search-separated-hemisphere.md delete mode 100644 changelog.d/1341-kartaview-impossible-timestamps.md delete mode 100644 changelog.d/1342-firms-acquisition-calendar.md delete mode 100644 changelog.d/1343-route-ramp-lane-wording.md delete mode 100644 changelog.d/1345-weather-staging-wake.md delete mode 100644 changelog.d/1349-cyclone-classification-codes.md delete mode 100644 changelog.d/1350-cyclone-movement-knots.md delete mode 100644 changelog.d/1351-ais-static-keeps-motion.md delete mode 100644 changelog.d/1352-cockpit-destination-heading.md delete mode 100644 changelog.d/1353-transit-ninety-second-age.md delete mode 100644 changelog.d/1354-firms-frp-ten-megawatts.md delete mode 100644 changelog.d/1366-aircraft-models-no-environment-map.md delete mode 100644 changelog.d/1367-turned-off-layer-memory.md delete mode 100644 changelog.d/1378-touch-zoom-aircraft-updates.md delete mode 100644 changelog.d/1379-aircraft-label-candidates.md delete mode 100644 changelog.d/1384-hud-summary-moving.md delete mode 100644 changelog.d/1385-route-duration-minute.md delete mode 100644 changelog.d/1386-route-distance-kilometre.md delete mode 100644 changelog.d/1387-awareness-distance-kilometre.md delete mode 100644 changelog.d/1388-draw-line-kilometre.md delete mode 100644 changelog.d/1389-cockpit-wind-north.md delete mode 100644 changelog.d/1390-awareness-bearing-north.md delete mode 100644 changelog.d/1391-docker-selinux.md delete mode 100644 changelog.d/1394-docker-healthcheck.md delete mode 100644 changelog.d/1395-fires-tool-acquired-time.md delete mode 100644 changelog.d/1396-local-adsb-track-north.md delete mode 100644 changelog.d/1398-sharpen-zero-off.md delete mode 100644 changelog.d/1399-ground-snap-clock-step.md delete mode 100644 changelog.d/1403-remove-unused-modules.md delete mode 100644 changelog.d/1410-scope-controls-hud-readability.md delete mode 100644 changelog.d/1416-share-hash-navigation.md delete mode 100644 changelog.d/1417-clear-all-saved-layers.md delete mode 100644 changelog.d/1418-short-share-links.md delete mode 100644 changelog.d/1419-share-view-codec-export.md delete mode 100644 changelog.d/1420-keep-missed-location-query.md delete mode 100644 changelog.d/1421-style-modules-skip-catalogs.md delete mode 100644 changelog.d/1431-street-level-bearing-north.md delete mode 100644 changelog.d/287-shared-great-circle-distance.md delete mode 100644 changelog.d/605-fl511-florida-cameras.md delete mode 100644 changelog.d/686-terrain-heights-bounds.md delete mode 100644 changelog.d/761-dev-unwatched-runtime-dirs.md delete mode 100644 changelog.d/767-alpha5-catalog-numbers.md delete mode 100644 changelog.d/787-changelog-fragments.md delete mode 100644 changelog.d/816-radio-owner-restoration.md delete mode 100644 changelog.d/872-gemini-live-voice.md delete mode 100644 changelog.d/880-world-packet-boundary.md delete mode 100644 changelog.d/945-cctv-vegvesen-kartverket-heights.md delete mode 100644 changelog.d/948-vite-fs-deny.md delete mode 100644 changelog.d/970-recent-imagery-details-scroll.md delete mode 100644 changelog.d/973-tomtom-cross-site-gate.md delete mode 100644 changelog.d/974-street-level-slim.md delete mode 100644 changelog.d/975-terrain-cross-site-gate.md delete mode 100644 changelog.d/990-hong-kong-cctv.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d3a4842..356289841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,692 @@ # Changelog +## [Unreleased] + +## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language + +- Show a Street Level view facing 359.5° or more as 0°. The viewer caption + rounded the bearing without wrapping, so a view facing 359.6° read "360°" + (Aditya, @Rohilalala, #1431). + +- The visual styles and their presets mark their labels with the catalog-free + `msg()`, so the share-link codec, and any server that loads it, no longer + bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). + +- Keep a location search that found nothing, or failed, in the field so it can + be corrected: the query stays, focused and selected, and a persistent + announced message under the search row says what to do next, instead of a + toast followed by a cleared and closed field (samehkhamis, #1420). + +- Hosts can read share links on their server: the share-link codec and the + `/share/` address helpers are now package exports, + `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` + (samehkhamis, #1419). + +- Share links are now short: the share button makes `/share/` links + that open on any copy of the app, follow the aircraft, military aircraft or + satellite you were following, and fall back to its last-known position with + a notice if it isn't reporting. A mistyped link is refused instead of + opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). + +- Make Clear All clear the saved and shared layer selection. Layers that a + context mode had already switched off no longer stay in the share link and + come back on reload (samehkhamis, #1417). + +- Restore a share link opened in an already-open tab. Pasting a link into the + address bar, following one, or using Back/Forward now moves the camera and + restores the style, map, panels and layers instead of changing only the URL + (samehkhamis, #1416). + +- 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. + +- 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). + +- 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). + +- The Sharpen slider at 0 now turns the sharpen pass off instead of still + sharpening at a low amount (quanticsoul4772, #1398). + +- 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). + +- 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`. Missing or invalid dates and times remain + unknown rather than rolling into another calendar day + (Rudy Celekli, @rudycelekli, #1395). + +- Report Docker container health with a local HTTP check using Node, with the + same port parsing as the launcher (John Rogers, @14traits, #1394). + +- 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). + +- Render awareness-overlay bearings and courses that round through north as + `000°`, rather than `360°` (Rudy Celekli, #1390). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- Reduce repeated work in aircraft detection candidates by caching unchanged + callsign/registration text and reusing resolved fleet models. Metadata updates, + live positions and tracked-aircraft/model handoffs remain immediate + (samehkhamis, #1379). + +- Increase touch-first globe zoom sensitivity to three times the default while + retaining Cesium collision and distance limits. +- Reuse one delayed display timestamp per civil-aircraft fleet update and cache + extrapolation coordinate frames until their fix coordinates change. Preserve + contact counts, movement cadence, tracked motion and ground corrections. + +- 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). + +- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's + dynamic environment map, a sky render and GPU readback per model. With + ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at + rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison + showed no visible change (quanticsoul4772, #1366). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- 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). + +- Read a hemisphere letter that is separated from its number, so a search for + `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders + (Rudy Celekli, @rudycelekli, #1340). + +- 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). + +- 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). + +- Evict terrain-height cache entries in fetch-time order without repeatedly + sorting the full cache, while keeping refreshed points newest and ordering + restored entries by their fetch time (daikaginza, #1326). + +- 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). + +- 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). + +- 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 + (samehkhamis, #1321). + +- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). + +- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, + while Radio and Cyber supply explicit field palettes without changing the + current appearance or native control behavior (mazy1998, #1309). + +- Share read-only status badge presentation across CCTV source/frame labels, + calibration provenance and Weather coverage, preserving status meanings, + palettes, translations and announcements while preparing the view contract + for a future Vue migration (mazy1998, #1306). + +- Share native dropdown and numeric-field styling across Scene, CCTV, Display + and Radio controls, with a shared CCTV number-input builder that preserves + existing precision, validation and commit behavior (mazy1998, #1305). + +- Point distances wrap longitude differences across the antimeridian, in voice + proximity checks and `places_nearby` (quanticsoul4772, #1304). + +- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a + cached model run (quanticsoul4772, #1303). + +- Give ordinary Scene and CCTV actions shared native buttons with consistent + default, destructive and compact styling while preserving their existing + behavior and specialized controls (mazy1998, #1302). + +- Unify segmented selectors for model coverage, detection allocation, drawing + shape, Street View and imagery comparison while preserving their existing + behavior. Keep compact labels such as FILL and FLAT on one line, and let + collapsed Weather release space to the open rail panel. Reserve the full mode + button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). + +- Add a shared Voice Preview and Settings panel with browser-local preferences, + inactivity controls and accessible floating provider controls on compact layouts; + recover inactivity after confirmed audio-less replies, release retired audio + observers and keep cost warnings visible outside Settings + (manjunath22466, #1299). + +- 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). + +- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products + (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran + and Yemen products and the newest incident drop) from + github.com/s2underground/GhostMaps, with one chip per product and report + cards on click. `/api/ghostmaps` watches the repository's branch head every + 15 s and pushes each new commit to open pages, which reload only the + products that changed (Black Pine Defense). +- Clear a temporary GhostMaps upstream error and notify open pages when the + repository becomes reachable again, including when no new edition was published. +- Release packed-image resources when a load is cancelled or fails, respect + injected or omitted GhostMaps sources, and wait for background cache warm-up + before removing provider test fixtures. +- Include Simplified Chinese translations and keep the combined voice prompt + within its existing size limit while retaining all spoken aliases. + +- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the + shape before the core went slim, draws its lines again instead of a silent + blank; an overview level or any answer that is not a whole zoom shows the + zoom-in hint (asturksever, #1288). + +- Give ordinary sliders a shared native control and consistent track, thumb, + value, focus and disabled styling while preserving feature behavior and + specialized controls (mazy1998, #1287). + +- The Street Level SINCE note sits on the value's line, in its type and a + size smaller: with KartaView lit it reads ANY DATE (by upload date). It + had lost its size variable in the Street Level slim-down and rendered + larger than the value, on a line of its own (asturksever, #1286). + +- Keep aircraft route labels on the scheduled airport leg instead of accepting + its distant great-circle extensions, preserving generous endpoint tolerance + across the signed-angle branch cut (rudycelekli, #1284). + +- Report cached CelesTrak satellite records as stale in the Data Layers row, + preserving separate core and dense catalog provenance through refresh, + removal and lifecycle cleanup (rudycelekli, #1283). + +- 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). +- 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). + +- 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). +- 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). + +- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). + +- Reserve singleflight before disk cache reads (asturksever, #1278). + +- Bound each height request to one upstream chunk (asturksever, #1276). + +- 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). + +- Scan the kml root delimiter in bounded time (asturksever, #1274). + +- Retire unavailable history and keep local trails (asturksever, #1273). + +- Normalize geocoder bias across the antimeridian (asturksever, #1272). + +- Reject inherited style keys during restore (asturksever, #1271). + +- Respect provider cooldowns and bound tle groups (asturksever, #1270). + +- Coalesce refreshes and cool down opensky failures (asturksever, #1269). + +- 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). + +- 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). + +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). + +- 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). + +- Replace the blocky JPEG sky around the globe with the 9,096 stars of the + Yale Bright Star Catalogue, drawn as sharp points where they are at the + scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun + and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, + #1243). + +- `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). + +- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: + ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, + with on-demand snapshots and attribution. No API key is required; set + `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). + +- Require a geocoded containment anchor for ambiguous analyst region names, + preserve boundary provenance and cancellation safety, and retain exact-name + precedence for natural outlines (manjunath22466, #1235). + +- Serve the camera catalog while it refreshes. An expired catalog made every + camera request wait for the slowest pack to reload; it is now answered at + once while one background refresh runs, so only the first load waits. Estonia's + slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out + on about a third of loads (quanticsoul4772, #1234). + +- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, + cancel both response streams when either response fails or overflows the 4 MB + byte cap, and deduplicate camera IDs before per-pack prioritization + (yaka0007, #1233). + +- 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, #1232). + +- 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. + +- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a + free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is + otherwise skipped with one log line saying where to get a key + (quanticsoul4772, #1228). + +- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is + now a button that closes the card the same way ESC does; touch screens read + DISMISS, and phones no longer hide it, so choosing a mission is not the only + way out (KnottyDyes, #1226). + +- Add continuous globe rotation with direction and speed controls and a globe-fit + check before starting (manjunath22466, #1225). + +- 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). + +- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI + command, while preserving an earlier accepted deferred input + (Manjunath, #1220). + +- 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). + +- Report actual outcomes when voice enables or disables CCTV through the shared + layer lifecycle, including failed and cancelled changes (manjunath22466, + #1205). + +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). + +- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or + WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen + video camera could only be found by opening it. Stills stay cyan and the + active camera stays amber, and focus de-emphasis fades the violet the same + way (Danielslay86, #1200). + +- Keep Space Missions marked STALE when Launch Library 2 falls back to cached + launch data, and return to NOMINAL after a fresh response without discarding + retained missions (rudycelekli, #1199). + +- Share Launch Library request cache keys across installations refreshing in the + same UTC minute, identify the app to the provider, and explain that standard + space-missions access needs no key. Optional higher-rate LL2 tokens are paid + upgrades; existing local cache and server-only token handling are unchanged + (rudycelekli, #1197). + +- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD + (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI + (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and + BicinRivas, about 1,840 stations. Station names read underscores as word + breaks, and station capacity counts docks rather than PBSC's geofenced area + (ulihhhh, #1190). + +- Keep missing launch-pad coordinates unknown so launches use the location + fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage + flight numbers no longer show "FLIGHT 0" + (Rudy Celekli, @rudycelekli, #1186). + +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view (Aditya, @Rohilalala, #1173). + +- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` + turns null, blank strings and booleans into 0 or 1, and the camera source + list sends an unknown number as JSON null, so a source-pack camera that left + out its pose reached the browser as zeros clamped to each field's floor — + facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — + instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the + city's ground); a null or blank position placed the camera at 0,0. The + server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, + blank and boolean values as missing. The same conversion also stops a + Tallinn row with a null heading reading as a confident north-facing camera, + a Caltrans camera with a blank elevation losing its 150 m ground prior, and + a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 + or on the zero meridian (omerbberk, #1172). + +- Director shot actions land on the requested shot. A seek to a shot's start + could round a hair below it and select the end of the previous shot for some + shot counts and durations (Aditya, @Rohilalala, #1171). + +- Fire-perimeter queries handle outlines and holes crossing the antimeridian: + areas enclosed near ±180° are included, areas at the opposite meridian are + excluded, and returned perimeter coordinates stay near the dateline. Incident + cards use the same circular longitude mean and compare multipart outline + areas along short longitude edges, keeping the card on the largest polygon + (Arianna Method, @ariannamethod, #1142). + +- Scenes keep the "Scene not saved" status when a capture, update, append or + import cannot be saved, and an unreadable saved project no longer overwrites + the recovery checkpoint. `startScene()` resolves to its documented result, + `afterShotId` works without an explicit scene, and `scenesRun` counts the + distinct scenes queued (djabhi31, #1135). + +- Label camera-tool Street View fallbacks as historical Google imagery rather + than the current camera view; keep unknown frame provenance explicit + (rudycelekli, #1118). + +- Release superseded Google Street View open waits and suppress metadata + requests whose viewer was unmounted while the service loaded (rudycelekli, + #1117). + +- Exclude aircraft without a reported altitude from requested altitude bands; + unfiltered aircraft queries still retain them (rudycelekli, #1115). + +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry (Aditya, @Rohilalala, #1111). + +- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a + spliced catalog cannot pair one satellite's elements with another's; the next + valid set still parses (VG31OP, #1101). + +- Overpass data whose element tags contain a failure phrase ("timed out", + "runtime error", "too many requests" and similar, e.g. a place named "Timed + Out Escape Rooms") is returned as data instead of being refused and putting + the endpoint on cooldown; upstream failures are read from the remark (Aditya, + @Rohilalala, #1097). + +- Military aircraft keep their last heading and speed when an adsb.lol row omits + `track` or `gs`, instead of turning north and stopping; a grounded row without + a speed still reads 0 (Aditya, @Rohilalala, #1096). + +- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for + GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and + GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, + #1094). + +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). + +- 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) + +- On Windows, `npm run doctor` and the Pinokio install no longer print Node's + DEP0190 warning ("Passing args to a child process with shell option true can + lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of + the shell command string instead of an argument array (quanticsoul4772, #1057). + +- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an + MCP client can ask which airlines, countries, aircraft types or ship types + dominate an area, or the average altitude or speed, over every match instead + of estimating from the returned rows. Airlines come from the ICAO designator + in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds + that report no operator; operator and type are counted only where the feed + reports them. Ship types count by family, so AIS codes 70 to 79 are all + cargo; type 0 ("not available") and the reserved codes 100 to 255 count as + unknown. Altitude and speed stats cover airborne aircraft only, so parked + aircraft do not pull the means down. Every vessel answer, `find_vessel` + included, now reads AIS ship-type codes as their family and a destination + with no letters as unknown (quanticsoul4772, #1056). + +- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV + source pack: the public DATA.GOV.HK English locations XML, frames pinned to + `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By + default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, + prioritized across Hong Kong Island, Kowloon and the New Territories. Headings + use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets + the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). + +- Cross-site pages can no longer fill the terrain-height cache or spend the + visitor's own `/api/terrain/heights` rate-limit quota through the visitor's + browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or + opaque `Origin` before rate limiting, cache access or upstream work. Unlike + the cost-bearing routes it still answers same-origin requests that arrive + through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or + Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, + #975). + +- Street Level is rebuilt around one small provider registry: Mapillary, + KartaView, Panoramax and Google Street View share one coverage drawer and + one photo viewer, one source lit at a time. Floating and docked panels, + FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are + gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be + deleted (gekh, #974). + +- Cross-site browser requests can no longer spend the TomTom traffic-tile + budget through `/api/tomtom/*`. The shared same-site gate runs before status, + cache, and upstream work; same-origin app requests and non-browser local + callers remain supported (Sunil56224972, #973). + +- Recent Imagery: opening DETAILS scrolls its card into view when it opens below + the fold. The panel used to restore the previous scroll position right after + the reveal, so the card stayed out of sight (Ian Blenke, #970). + +- 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). + +- Give each Statens vegvesen camera its own ground height from Kartverket's + keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so + mountain-pass cameras no longer sit hundreds of metres below the terrain + where neither the point-height prior nor the ground snap resolves. About 850 + of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 + points, the most the API answers from its 1 m terrain model, eight at a + time; a cold lookup takes about 6–13 s. Cameras over water use the water + surface rather than the sea-floor depth the API returns there; for bridge + cameras that is still below the deck. Heights are cached for good and misses + for 6 h, so a refresh only asks about new or moved cameras. After a + failing request (a 408, 429 or 5xx, a network error, a request over 10 s or + an unreadable body) the lookup stops and backs off (Retry-After, else + 30 min, longer than the refresh). The lane waits at most 12 s from its + start, and only on a cold cache; a slower first lookup leaves the cameras it + had not reached on the flat prior until the next 15-minute refresh, and + keeps filling the cache meanwhile. + `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). + +- Extract a reusable model-free World Packet from the existing voice + `get_current_view_state` owner without restoring disabled layers to the + model context. Keep the enabled-only payload, disabled-layer count, source + provenance, action result and view-state contract; read the layer roster once + per packet; voice retains the speech/card envelope at its own boundary. + Credits: original GEV voice/layer contributors and #880 implementation. + +- Add optional Gemini Live voice to the standalone app, with a provider chooser, + server-held API key, microphone streaming, shared map actions and captions. + Switching providers ends the old conversation and preserves the input mode + (Manjunath, #872). + +- Close the compact Radio disclosure when an adaptive tactical right-rail + restore directly expands Context, before Space Missions can be covered. + A repeated Context-open request also clears stale disclosure state. + Real PanelChrome constructor wiring and the rail reflow path are covered + by focused regressions. Credits: kvnloo (#816), raysung-2026's original + #804 report, asturksever's review, and quanticsoul4772's independent + Windows diagnosis. + +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). + +- Read satellite catalog numbers written in Alpha-5, the TLE spelling of + numbers above 99999: a letter for the first two digits, skipping I and O + (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a + field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all + but the first were dropped as duplicates; the satellite tools read the same + field as no number, so such a satellite could not be found by it. The layer + and the tools now decode the field in `src/sources/tle.js`, and reject a + malformed one (a sign, decimal point, hex, embedded space or stray letter) + instead of reading part of it as a number. This is Alpha-5 TLE + compatibility: CelesTrak's TLE feed carries no satellite above 99999, which + needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). + +- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write + cache files and logs there while the app runs, and none of it is source, so + the watcher now skips it; this guards against the DNS-lookup delays heavy + cache writes have caused, though no difference was measured on current main + (monwiss, #761). + +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate. The point cache now holds at most 50,000 entries + and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). + +- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: + the statewide ArcGIS catalog, read a page at a time under a byte cap with + redirects refused, and frames pinned to FL511's two image hosts over HTTPS. + FL511's terms limit its content to individual, personal use, and any other + use (a public deployment included) needs FDOT's express written consent, so + the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the + catalog's direction codes, and camera ids come from the image channel, since + the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` + sets the cap (Danielslay86, #605). + +- The client's great-circle distances now come from one shared + `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` + points). The Earth radius is standardized on 6371.0088 km, so distances shift + by about 1.4 ppm and region areas by about 2.8 ppm, and missing or + out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, @@ -291,8 +978,6 @@ point. A slow render gets one bounded second frame request, while hidden, stale, moved-camera, invalid and oversized captures still fail closed. -## [Unreleased] - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without @@ -507,6 +1192,28 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Voice can switch on every shipped data layer (the two scene-driven + Bhote Koshi layers stay scene-only), including transit, Recent + Imagery, wind, the weather layers, cyclones and mapped installations, and + understands their spoken names ("buses", "hurricanes", "military bases"). + One voice manifest (`src/voice/layerManifest.js`) generates the tool enums + and aliases, and a unit test fails when a new layer has no entry. +- `analyst_query` counts up to 250,000 loaded records per layer instead of the + first 2,000, and says when a layer went past that (`complete: false`). It + also answers over bikeshare stations, transit vehicles, rocket launches, + cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire + records carry their sensor (VIIRS or MODIS). +- Unknown fields, operators, units, value types, scope kinds and invalid + centres are refused with the allowed values instead of answering zero or widening to anywhere. A + layer that is off, still loading or unavailable is reported as such, not + as a count of zero; a partly answerable query is marked `partial`. + Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` + converts, and large rankings no longer sort the whole set. +- Results carry `lat`/`lon` for each item and a `display` object with the + scope, the feed window (earthquakes and fires cover the last 24 h) and any + caveat for the screen. The model is asked to answer in one short phrase. +- The weather and Recent Imagery panels can be opened by voice. + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, @@ -789,32 +1496,6 @@ (`src/ui/imagerySplit.js`, `src/maps/imageryComparison.js`), and `MapSourceController.subscribe()` reports every settled map switch. -## Unreleased — voice reaches every layer; honest analyst answers - -- Voice can switch on every shipped data layer (the two scene-driven - Bhote Koshi layers stay scene-only), including transit, Recent - Imagery, wind, the weather layers, cyclones and mapped installations, and - understands their spoken names ("buses", "hurricanes", "military bases"). - One voice manifest (`src/voice/layerManifest.js`) generates the tool enums - and aliases, and a unit test fails when a new layer has no entry. -- `analyst_query` counts up to 250,000 loaded records per layer instead of the - first 2,000, and says when a layer went past that (`complete: false`). It - also answers over bikeshare stations, transit vehicles, rocket launches, - cyclones, ALPR cameras, mapped installations, local ADS-B aircraft and fire perimeters; fire - records carry their sensor (VIIRS or MODIS). -- Unknown fields, operators, units, value types, scope kinds and invalid - centres are refused with the allowed values instead of answering zero or widening to anywhere. A - layer that is off, still loading or unavailable is reported as such, not - as a count of zero; a partly answerable query is marked `partial`. - Distance sorts honour `sortDir`, a written unit such as `altitudeM(ft)` - converts, and large rankings no longer sort the whole set. -- Results carry `lat`/`lon` for each item and a `display` object with the - scope, the feed window (earthquakes and fires cover the last 24 h) and any - caveat for the screen. The model is asked to answer in one short phrase. -- The weather and Recent Imagery panels can be opened by voice. - -## Unreleased — local receiver feeds - ### Local receiver feeds - The Local ADS-B layer also reads local 1090 MHz and 978 MHz UAT decoder @@ -957,7 +1638,6 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). - - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public @@ -1234,6 +1914,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ### Other changes + - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. - Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. @@ -2042,3 +2723,4 @@ represent previously published GitHub Releases. ## [0.1.0] — 2026-02-09 - Initial project version. + diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md deleted file mode 100644 index 3ca857a45..000000000 --- a/changelog.d/1056-area-tool-breakdowns.md +++ /dev/null @@ -1,12 +0,0 @@ -- `aircraft_in_area` and `vessels_in_area` take `group_by` and `stats`, so an - MCP client can ask which airlines, countries, aircraft types or ship types - dominate an area, or the average altitude or speed, over every match instead - of estimating from the returned rows. Airlines come from the ICAO designator - in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds - that report no operator; operator and type are counted only where the feed - reports them. Ship types count by family, so AIS codes 70 to 79 are all - cargo; type 0 ("not available") and the reserved codes 100 to 255 count as - unknown. Altitude and speed stats cover airborne aircraft only, so parked - aircraft do not pull the means down. Every vessel answer, `find_vessel` - included, now reads AIS ship-type codes as their family and a destination - with no letters as unknown (quanticsoul4772, #1056). diff --git a/changelog.d/1057-windows-npm-dep0190.md b/changelog.d/1057-windows-npm-dep0190.md deleted file mode 100644 index 1e8e1da55..000000000 --- a/changelog.d/1057-windows-npm-dep0190.md +++ /dev/null @@ -1,4 +0,0 @@ -- On Windows, `npm run doctor` and the Pinokio install no longer print Node's - DEP0190 warning ("Passing args to a child process with shell option true can - lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of - the shell command string instead of an argument array (quanticsoul4772, #1057). diff --git a/changelog.d/1066-keyless-google-token.md b/changelog.d/1066-keyless-google-token.md deleted file mode 100644 index c07cbade8..000000000 --- a/changelog.d/1066-keyless-google-token.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1071-geo-uri-search.md b/changelog.d/1071-geo-uri-search.md deleted file mode 100644 index eba251948..000000000 --- a/changelog.d/1071-geo-uri-search.md +++ /dev/null @@ -1,3 +0,0 @@ -- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with - the existing coordinate framing and label. Unsupported URI fields are passed - to the remaining search providers unchanged (cj-vana, #1071). diff --git a/changelog.d/1094-dependency-security-patches.md b/changelog.d/1094-dependency-security-patches.md deleted file mode 100644 index 058359efc..000000000 --- a/changelog.d/1094-dependency-security-patches.md +++ /dev/null @@ -1,4 +0,0 @@ -- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for - GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and - GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK, - #1094). diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md deleted file mode 100644 index adc1793c4..000000000 --- a/changelog.d/1096-military-hold-track-speed.md +++ /dev/null @@ -1,3 +0,0 @@ -- Military aircraft keep their last heading and speed when an adsb.lol row omits - `track` or `gs`, instead of turning north and stopping; a grounded row without - a speed still reads 0 (Aditya, @Rohilalala, #1096). diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md deleted file mode 100644 index 4543710da..000000000 --- a/changelog.d/1097-overpass-remark-failure-phrases.md +++ /dev/null @@ -1,5 +0,0 @@ -- Overpass data whose element tags contain a failure phrase ("timed out", - "runtime error", "too many requests" and similar, e.g. a place named "Timed - Out Escape Rooms") is returned as data instead of being refused and putting - the endpoint on cooldown; upstream failures are read from the remark (Aditya, - @Rohilalala, #1097). diff --git a/changelog.d/1101-tle-catalog-number-match.md b/changelog.d/1101-tle-catalog-number-match.md deleted file mode 100644 index d1e2aa407..000000000 --- a/changelog.d/1101-tle-catalog-number-match.md +++ /dev/null @@ -1,3 +0,0 @@ -- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a - spliced catalog cannot pair one satellite's elements with another's; the next - valid set still parses (VG31OP, #1101). diff --git a/changelog.d/1111-map-fallback-retry.md b/changelog.d/1111-map-fallback-retry.md deleted file mode 100644 index 45086eab1..000000000 --- a/changelog.d/1111-map-fallback-retry.md +++ /dev/null @@ -1,5 +0,0 @@ -- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain - at once, but no longer lasts the whole session: while the fallback is on - screen, the real provider is retried in the background (30 seconds, doubling - to a 10-minute cap) and replaces it live once it loads. Map switches never - wait for a retry (Aditya, @Rohilalala, #1111). diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md deleted file mode 100644 index a6b69472d..000000000 --- a/changelog.d/1115-aircraft-altitude-band-unknown.md +++ /dev/null @@ -1,2 +0,0 @@ -- Exclude aircraft without a reported altitude from requested altitude bands; - unfiltered aircraft queries still retain them (rudycelekli, #1115). diff --git a/changelog.d/1117-google-viewer-lifecycle.md b/changelog.d/1117-google-viewer-lifecycle.md deleted file mode 100644 index 66d73d4a2..000000000 --- a/changelog.d/1117-google-viewer-lifecycle.md +++ /dev/null @@ -1,3 +0,0 @@ -- Release superseded Google Street View open waits and suppress metadata - requests whose viewer was unmounted while the service loaded (rudycelekli, - #1117). diff --git a/changelog.d/1118-cctv-snapshot-fallback-provenance.md b/changelog.d/1118-cctv-snapshot-fallback-provenance.md deleted file mode 100644 index 648891785..000000000 --- a/changelog.d/1118-cctv-snapshot-fallback-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Label camera-tool Street View fallbacks as historical Google imagery rather - than the current camera view; keep unknown frame provenance explicit - (rudycelekli, #1118). diff --git a/changelog.d/1135-director-save-status-run-results.md b/changelog.d/1135-director-save-status-run-results.md deleted file mode 100644 index aa3c6f0e6..000000000 --- a/changelog.d/1135-director-save-status-run-results.md +++ /dev/null @@ -1,5 +0,0 @@ -- Scenes keep the "Scene not saved" status when a capture, update, append or - import cannot be saved, and an unreadable saved project no longer overwrites - the recovery checkpoint. `startScene()` resolves to its documented result, - `afterShotId` works without an explicit scene, and `scenesRun` counts the - distinct scenes queued (djabhi31, #1135). diff --git a/changelog.d/1142-antimeridian-fire-perimeters.md b/changelog.d/1142-antimeridian-fire-perimeters.md deleted file mode 100644 index 8b847163e..000000000 --- a/changelog.d/1142-antimeridian-fire-perimeters.md +++ /dev/null @@ -1,6 +0,0 @@ -- Fire-perimeter queries handle outlines and holes crossing the antimeridian: - areas enclosed near ±180° are included, areas at the opposite meridian are - excluded, and returned perimeter coordinates stay near the dateline. Incident - cards use the same circular longitude mean and compare multipart outline - areas along short longitude edges, keeping the card on the largest polygon - (Arianna Method, @ariannamethod, #1142). diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md deleted file mode 100644 index 4c08351ef..000000000 --- a/changelog.d/1171-director-goto-shot.md +++ /dev/null @@ -1,3 +0,0 @@ -- Director shot actions land on the requested shot. A seek to a shot's start - could round a hair below it and select the end of the previous shot for some - shot counts and durations (Aditya, @Rohilalala, #1171). diff --git a/changelog.d/1172-cctv-missing-numbers.md b/changelog.d/1172-cctv-missing-numbers.md deleted file mode 100644 index d401021e9..000000000 --- a/changelog.d/1172-cctv-missing-numbers.md +++ /dev/null @@ -1,13 +0,0 @@ -- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` - turns null, blank strings and booleans into 0 or 1, and the camera source - list sends an unknown number as JSON null, so a source-pack camera that left - out its pose reached the browser as zeros clamped to each field's floor — - facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — - instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the - city's ground); a null or blank position placed the camera at 0,0. The - server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, - blank and boolean values as missing. The same conversion also stops a - Tallinn row with a null heading reading as a confident north-facing camera, - a Caltrans camera with a blank elevation losing its 150 m ground prior, and - a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 - or on the zero meridian (omerbberk, #1172). diff --git a/changelog.d/1173-antimeridian-ground-floor.md b/changelog.d/1173-antimeridian-ground-floor.md deleted file mode 100644 index 939559bb5..000000000 --- a/changelog.d/1173-antimeridian-ground-floor.md +++ /dev/null @@ -1,10 +0,0 @@ -- Flights' ground floor works across the antimeridian. The viewer-distance - check behind the 150 km floor radius and its copy in the photoreal mesh-floor - sampler subtracted longitudes without wrapping, so two points 21 km apart - across 180° measured about 38,000 km and low or grounded aircraft just across - the line from the viewer were never floored. A contact's display corridor - walked a segment across 180° the long way round, spending its cell budget on - ground behind the contact, and a contact on the seam lost its cell hysteresis. - SEARCH NEARBY SITES on a view across 180° searched Google Places around - longitude 0, on the far side of the globe, with a radius sized from the view's - height alone; it now searches the view (Aditya, @Rohilalala, #1173). diff --git a/changelog.d/1186-launch-unknown-coordinates.md b/changelog.d/1186-launch-unknown-coordinates.md deleted file mode 100644 index d03c2d902..000000000 --- a/changelog.d/1186-launch-unknown-coordinates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep missing launch-pad coordinates unknown so launches use the location - fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage - flight numbers no longer show "FLIGHT 0" - (Rudy Celekli, @rudycelekli, #1186). diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md deleted file mode 100644 index f07faa813..000000000 --- a/changelog.d/1190-bikeshare-spain-cities.md +++ /dev/null @@ -1,6 +0,0 @@ -- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD - (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI - (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and - BicinRivas, about 1,840 stations. Station names read underscores as word - breaks, and station capacity counts docks rather than PBSC's geofenced area - (ulihhhh, #1190). diff --git a/changelog.d/1197-launch-library-provider-guidance.md b/changelog.d/1197-launch-library-provider-guidance.md deleted file mode 100644 index 34cbe299e..000000000 --- a/changelog.d/1197-launch-library-provider-guidance.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share Launch Library request cache keys across installations refreshing in the - same UTC minute, identify the app to the provider, and explain that standard - space-missions access needs no key. Optional higher-rate LL2 tokens are paid - upgrades; existing local cache and server-only token handling are unchanged - (rudycelekli, #1197). diff --git a/changelog.d/1199-launch-cache-provenance.md b/changelog.d/1199-launch-cache-provenance.md deleted file mode 100644 index 04f33cb7f..000000000 --- a/changelog.d/1199-launch-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep Space Missions marked STALE when Launch Library 2 falls back to cached - launch data, and return to NOMINAL after a fresh response without discarding - retained missions (rudycelekli, #1199). diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md deleted file mode 100644 index 30d67e1d4..000000000 --- a/changelog.d/1200-cctv-live-icon-color.md +++ /dev/null @@ -1,5 +0,0 @@ -- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or - WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen - video camera could only be found by opening it. Stills stay cyan and the - active camera stays amber, and focus de-emphasis fades the violet the same - way (Danielslay86, #1200). diff --git a/changelog.d/1201-cctv-stills-aspect.md b/changelog.d/1201-cctv-stills-aspect.md deleted file mode 100644 index fdb560064..000000000 --- a/changelog.d/1201-cctv-stills-aspect.md +++ /dev/null @@ -1,4 +0,0 @@ -- Draw CCTV stills at their source aspect on the monitor plane instead of - stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so - every still was widened; they are now centered and pillarboxed on the dark - the placeholder already ends on (Danielslay86, #1201). diff --git a/changelog.d/1205-cctv-tool-routing.md b/changelog.d/1205-cctv-tool-routing.md deleted file mode 100644 index ed7552591..000000000 --- a/changelog.d/1205-cctv-tool-routing.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report actual outcomes when voice enables or disables CCTV through the shared - layer lifecycle, including failed and cancelled changes (manjunath22466, - #1205). diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md deleted file mode 100644 index b69cdc7ed..000000000 --- a/changelog.d/1207-voice-entity-labels-english.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1220-openai-typed-send.md b/changelog.d/1220-openai-typed-send.md deleted file mode 100644 index cd1b5b470..000000000 --- a/changelog.d/1220-openai-typed-send.md +++ /dev/null @@ -1,3 +0,0 @@ -- Avoid starting or queuing a reply when the local channel rejects a typed OpenAI - command, while preserving an earlier accepted deferred input - (Manjunath, #1220). diff --git a/changelog.d/1222-pinokio-open-in-browser.md b/changelog.d/1222-pinokio-open-in-browser.md deleted file mode 100644 index bc6b62402..000000000 --- a/changelog.d/1222-pinokio-open-in-browser.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1225-globe-rotation.md b/changelog.d/1225-globe-rotation.md deleted file mode 100644 index 844966030..000000000 --- a/changelog.d/1225-globe-rotation.md +++ /dev/null @@ -1,2 +0,0 @@ -- Add continuous globe rotation with direction and speed controls and a globe-fit - check before starting (manjunath22466, #1225). diff --git a/changelog.d/1226-first-run-touch-dismiss.md b/changelog.d/1226-first-run-touch-dismiss.md deleted file mode 100644 index 3eb802dac..000000000 --- a/changelog.d/1226-first-run-touch-dismiss.md +++ /dev/null @@ -1,4 +0,0 @@ -- The first-run card can be closed on a phone or tablet. "ESC to dismiss" is - now a button that closes the card the same way ESC does; touch screens read - DISMISS, and phones no longer hide it, so choosing a mission is not the only - way out (KnottyDyes, #1226). diff --git a/changelog.d/1228-cctv-ontario-511-key.md b/changelog.d/1228-cctv-ontario-511-key.md deleted file mode 100644 index d9dc7986f..000000000 --- a/changelog.d/1228-cctv-ontario-511-key.md +++ /dev/null @@ -1,4 +0,0 @@ -- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a - free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is - otherwise skipped with one log line saying where to get a key - (quanticsoul4772, #1228). diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md deleted file mode 100644 index 25159830b..000000000 --- a/changelog.d/1229-opensky-oauth-only.md +++ /dev/null @@ -1,8 +0,0 @@ -- 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/changelog.d/1232-gbfs-invalid-coordinates.md b/changelog.d/1232-gbfs-invalid-coordinates.md deleted file mode 100644 index 4f3da2a97..000000000 --- a/changelog.d/1232-gbfs-invalid-coordinates.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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, #1232). diff --git a/changelog.d/1233-tarktee-datex-catalog-hardening.md b/changelog.d/1233-tarktee-datex-catalog-hardening.md deleted file mode 100644 index 39f184526..000000000 --- a/changelog.d/1233-tarktee-datex-catalog-hardening.md +++ /dev/null @@ -1,4 +0,0 @@ -- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects, - cancel both response streams when either response fails or overflows the 4 MB - byte cap, and deduplicate camera IDs before per-pack prioritization - (yaka0007, #1233). diff --git a/changelog.d/1234-cctv-catalog-background-refresh.md b/changelog.d/1234-cctv-catalog-background-refresh.md deleted file mode 100644 index 2d3706b14..000000000 --- a/changelog.d/1234-cctv-catalog-background-refresh.md +++ /dev/null @@ -1,5 +0,0 @@ -- Serve the camera catalog while it refreshes. An expired catalog made every - camera request wait for the slowest pack to reload; it is now answered at - once while one background refresh runs, so only the first load waits. Estonia's - slow Tarktee feeds get 30 s instead of 15 s, so the pack no longer drops out - on about a third of loads (quanticsoul4772, #1234). diff --git a/changelog.d/1235-location-resolution.md b/changelog.d/1235-location-resolution.md deleted file mode 100644 index 160688005..000000000 --- a/changelog.d/1235-location-resolution.md +++ /dev/null @@ -1,3 +0,0 @@ -- Require a geocoded containment anchor for ambiguous analyst region names, - preserve boundary provenance and cancellation safety, and retain exact-name - precedence for natural outlines (manjunath22466, #1235). diff --git a/changelog.d/1237-taiwan-freeway-cctv.md b/changelog.d/1237-taiwan-freeway-cctv.md deleted file mode 100644 index 1578971d4..000000000 --- a/changelog.d/1237-taiwan-freeway-cctv.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add Taiwan's national freeway cameras from the Freeway Bureau, MOTC to CCTV: - ~1,800 cameras read at runtime from the official MOTC 2.0 open-data table, - with on-demand snapshots and attribution. No API key is required; set - `CCTV_TAIWAN_ENABLED=0` to skip it (419vive, #1237). diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md deleted file mode 100644 index 5338e8e60..000000000 --- a/changelog.d/1239-traffic-stale-flow.md +++ /dev/null @@ -1,3 +0,0 @@ -- `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/changelog.d/1243-starfield-sky.md b/changelog.d/1243-starfield-sky.md deleted file mode 100644 index 04d0cfbf8..000000000 --- a/changelog.d/1243-starfield-sky.md +++ /dev/null @@ -1,5 +0,0 @@ -- Replace the blocky JPEG sky around the globe with the 9,096 stars of the - Yale Bright Star Catalogue, drawn as sharp points where they are at the - scene time, over a soft Milky Way from ESO/S. Brunier (CC BY 4.0). The Sun - and Moon stay; the anime style hides the Milky Way. Assets: 296 kB (gekh, - #1243). diff --git a/changelog.d/1244-terrain-test-cache-isolation.md b/changelog.d/1244-terrain-test-cache-isolation.md deleted file mode 100644 index 5efacd3bb..000000000 --- a/changelog.d/1244-terrain-test-cache-isolation.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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). diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md deleted file mode 100644 index c5e763e30..000000000 --- a/changelog.d/1264-overpass-request-admission.md +++ /dev/null @@ -1,9 +0,0 @@ -- Prevent malformed Overpass filters from blocking the local server, and reject - cross-site or excessive query requests before reading their bodies - (asturksever, #1264; fixes #1248). -- Release unread responses from the optional Google-token probe so keyless - browser validation can settle; the Overpass gate recognizes only that probe's - expected 404 as an unavailable capability (asturksever, #1264). -- Make tracking arrival checks respect the aircraft update cadence while checking - orientation on the first reveal, avoiding false failures at faster render - rates (asturksever, #1264). diff --git a/changelog.d/1265-docker-trusted-peers.md b/changelog.d/1265-docker-trusted-peers.md deleted file mode 100644 index f86fe3732..000000000 --- a/changelog.d/1265-docker-trusted-peers.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1268-radio-issue-fix.md b/changelog.d/1268-radio-issue-fix.md deleted file mode 100644 index ab68243d6..000000000 --- a/changelog.d/1268-radio-issue-fix.md +++ /dev/null @@ -1,2 +0,0 @@ -- 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/changelog.d/1269-aircraft-issue-fix.md b/changelog.d/1269-aircraft-issue-fix.md deleted file mode 100644 index 8984a9141..000000000 --- a/changelog.d/1269-aircraft-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Coalesce refreshes and cool down opensky failures (asturksever, #1269). diff --git a/changelog.d/1270-space-issue-fix.md b/changelog.d/1270-space-issue-fix.md deleted file mode 100644 index b483c4c01..000000000 --- a/changelog.d/1270-space-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Respect provider cooldowns and bound tle groups (asturksever, #1270). diff --git a/changelog.d/1271-share-issue-fix.md b/changelog.d/1271-share-issue-fix.md deleted file mode 100644 index 7a01330ba..000000000 --- a/changelog.d/1271-share-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reject inherited style keys during restore (asturksever, #1271). diff --git a/changelog.d/1272-search-issue-fix.md b/changelog.d/1272-search-issue-fix.md deleted file mode 100644 index 48b803534..000000000 --- a/changelog.d/1272-search-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Normalize geocoder bias across the antimeridian (asturksever, #1272). diff --git a/changelog.d/1273-history-issue-fix.md b/changelog.d/1273-history-issue-fix.md deleted file mode 100644 index f672a5c22..000000000 --- a/changelog.d/1273-history-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Retire unavailable history and keep local trails (asturksever, #1273). diff --git a/changelog.d/1274-import-issue-fix.md b/changelog.d/1274-import-issue-fix.md deleted file mode 100644 index 99bbc1429..000000000 --- a/changelog.d/1274-import-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Scan the kml root delimiter in bounded time (asturksever, #1274). diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md deleted file mode 100644 index 2013bc4f2..000000000 --- a/changelog.d/1275-forecast-issue-fix.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1276-terrain-issue-fix.md b/changelog.d/1276-terrain-issue-fix.md deleted file mode 100644 index 17e8f4b50..000000000 --- a/changelog.d/1276-terrain-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Bound each height request to one upstream chunk (asturksever, #1276). diff --git a/changelog.d/1278-overpass-issue-fix.md b/changelog.d/1278-overpass-issue-fix.md deleted file mode 100644 index 4b028f046..000000000 --- a/changelog.d/1278-overpass-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Reserve singleflight before disk cache reads (asturksever, #1278). diff --git a/changelog.d/1279-launch-issue-fix.md b/changelog.d/1279-launch-issue-fix.md deleted file mode 100644 index ec451e7ae..000000000 --- a/changelog.d/1279-launch-issue-fix.md +++ /dev/null @@ -1 +0,0 @@ -- Throttle mission hover picks with a trailing check for the last pointer position, and clear stale cursors (asturksever, #1279). diff --git a/changelog.d/1281-traffic-issue-fix.md b/changelog.d/1281-traffic-issue-fix.md deleted file mode 100644 index 3ce673038..000000000 --- a/changelog.d/1281-traffic-issue-fix.md +++ /dev/null @@ -1,8 +0,0 @@ -- 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). -- 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/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md deleted file mode 100644 index 98885fc20..000000000 --- a/changelog.d/1282-polling-issue-fix.md +++ /dev/null @@ -1,7 +0,0 @@ -- 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). -- 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/changelog.d/1283-satellite-cache-provenance.md b/changelog.d/1283-satellite-cache-provenance.md deleted file mode 100644 index c9b0dd7dc..000000000 --- a/changelog.d/1283-satellite-cache-provenance.md +++ /dev/null @@ -1,3 +0,0 @@ -- Report cached CelesTrak satellite records as stale in the Data Layers row, - preserving separate core and dense catalog provenance through refresh, - removal and lifecycle cleanup (rudycelekli, #1283). diff --git a/changelog.d/1284-route-plausibility-segment.md b/changelog.d/1284-route-plausibility-segment.md deleted file mode 100644 index 4ab211957..000000000 --- a/changelog.d/1284-route-plausibility-segment.md +++ /dev/null @@ -1,3 +0,0 @@ -- Keep aircraft route labels on the scheduled airport leg instead of accepting - its distant great-circle extensions, preserving generous endpoint tolerance - across the signed-angle branch cut (rudycelekli, #1284). diff --git a/changelog.d/1286-since-note-inline.md b/changelog.d/1286-since-note-inline.md deleted file mode 100644 index f66467d67..000000000 --- a/changelog.d/1286-since-note-inline.md +++ /dev/null @@ -1,4 +0,0 @@ -- The Street Level SINCE note sits on the value's line, in its type and a - size smaller: with KartaView lit it reads ANY DATE (by upload date). It - had lost its size variable in the Street Level slim-down and rendered - larger than the value, on a line of its own (asturksever, #1286). diff --git a/changelog.d/1287-shared-sliders.md b/changelog.d/1287-shared-sliders.md deleted file mode 100644 index b5e5fb215..000000000 --- a/changelog.d/1287-shared-sliders.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary sliders a shared native control and consistent track, thumb, - value, focus and disabled styling while preserving feature behavior and - specialized controls (mazy1998, #1287). diff --git a/changelog.d/1288-zoomfor-legacy-shape.md b/changelog.d/1288-zoomfor-legacy-shape.md deleted file mode 100644 index e23b524e8..000000000 --- a/changelog.d/1288-zoomfor-legacy-shape.md +++ /dev/null @@ -1,4 +0,0 @@ -- A Street Level provider whose `zoomFor` still answers `{zoom, kind}`, the - shape before the core went slim, draws its lines again instead of a silent - blank; an overview level or any answer that is not a whole zoom shows the - zoom-in hint (asturksever, #1288). diff --git a/changelog.d/1297-s2-ghostmaps-layer.md b/changelog.d/1297-s2-ghostmaps-layer.md deleted file mode 100644 index 7dd52862d..000000000 --- a/changelog.d/1297-s2-ghostmaps-layer.md +++ /dev/null @@ -1,14 +0,0 @@ -- Add the S2 GhostMaps layer: S2 Underground's public KMZ intel products - (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran - and Yemen products and the newest incident drop) from - github.com/s2underground/GhostMaps, with one chip per product and report - cards on click. `/api/ghostmaps` watches the repository's branch head every - 15 s and pushes each new commit to open pages, which reload only the - products that changed (Black Pine Defense). -- Clear a temporary GhostMaps upstream error and notify open pages when the - repository becomes reachable again, including when no new edition was published. -- Release packed-image resources when a load is cancelled or fails, respect - injected or omitted GhostMaps sources, and wait for background cache warm-up - before removing provider test fixtures. -- Include Simplified Chinese translations and keep the combined voice prompt - within its existing size limit while retaining all spoken aliases. diff --git a/changelog.d/1298-simplified-chinese.md b/changelog.d/1298-simplified-chinese.md deleted file mode 100644 index d35d08cf1..000000000 --- a/changelog.d/1298-simplified-chinese.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1299-voice-panel-settings.md b/changelog.d/1299-voice-panel-settings.md deleted file mode 100644 index 1a95a6ed4..000000000 --- a/changelog.d/1299-voice-panel-settings.md +++ /dev/null @@ -1,5 +0,0 @@ -- Add a shared Voice Preview and Settings panel with browser-local preferences, - inactivity controls and accessible floating provider controls on compact layouts; - recover inactivity after confirmed audio-less replies, release retired audio - observers and keep cost warnings visible outside Settings - (manjunath22466, #1299). diff --git a/changelog.d/1301-shared-segmented-selectors.md b/changelog.d/1301-shared-segmented-selectors.md deleted file mode 100644 index 379b8c716..000000000 --- a/changelog.d/1301-shared-segmented-selectors.md +++ /dev/null @@ -1,5 +0,0 @@ -- Unify segmented selectors for model coverage, detection allocation, drawing - shape, Street View and imagery comparison while preserving their existing - behavior. Keep compact labels such as FILL and FLAT on one line, and let - collapsed Weather release space to the open rail panel. Reserve the full mode - button height in Recent Imagery so keyboard focus is not clipped (mazy1998, #1301). diff --git a/changelog.d/1302-shared-action-buttons.md b/changelog.d/1302-shared-action-buttons.md deleted file mode 100644 index 3c8318488..000000000 --- a/changelog.d/1302-shared-action-buttons.md +++ /dev/null @@ -1,3 +0,0 @@ -- Give ordinary Scene and CCTV actions shared native buttons with consistent - default, destructive and compact styling while preserving their existing - behavior and specialized controls (mazy1998, #1302). diff --git a/changelog.d/1303-wind-stale.md b/changelog.d/1303-wind-stale.md deleted file mode 100644 index cb54224a5..000000000 --- a/changelog.d/1303-wind-stale.md +++ /dev/null @@ -1,2 +0,0 @@ -- `get_wind` flags stale forecasts (`stale`) when the wind proxy serves a - cached model run (quanticsoul4772, #1303). diff --git a/changelog.d/1304-antimeridian-distances.md b/changelog.d/1304-antimeridian-distances.md deleted file mode 100644 index fc7744f86..000000000 --- a/changelog.d/1304-antimeridian-distances.md +++ /dev/null @@ -1,2 +0,0 @@ -- Point distances wrap longitude differences across the antimeridian, in voice - proximity checks and `places_nearby` (quanticsoul4772, #1304). diff --git a/changelog.d/1305-shared-native-form-fields.md b/changelog.d/1305-shared-native-form-fields.md deleted file mode 100644 index 129d81bcf..000000000 --- a/changelog.d/1305-shared-native-form-fields.md +++ /dev/null @@ -1,3 +0,0 @@ -- Share native dropdown and numeric-field styling across Scene, CCTV, Display - and Radio controls, with a shared CCTV number-input builder that preserves - existing precision, validation and commit behavior (mazy1998, #1305). diff --git a/changelog.d/1306-shared-status-badges.md b/changelog.d/1306-shared-status-badges.md deleted file mode 100644 index 007a028d1..000000000 --- a/changelog.d/1306-shared-status-badges.md +++ /dev/null @@ -1,4 +0,0 @@ -- Share read-only status badge presentation across CCTV source/frame labels, - calibration provenance and Weather coverage, preserving status meanings, - palettes, translations and announcements while preparing the view contract - for a future Vue migration (mazy1998, #1306). diff --git a/changelog.d/1309-shared-field-css-ownership.md b/changelog.d/1309-shared-field-css-ownership.md deleted file mode 100644 index 1b8817bac..000000000 --- a/changelog.d/1309-shared-field-css-ownership.md +++ /dev/null @@ -1,3 +0,0 @@ -- Clarify shared field CSS ownership: CCTV owns calibration editor sizing, - while Radio and Cyber supply explicit field palettes without changing the - current appearance or native control behavior (mazy1998, #1309). diff --git a/changelog.d/1317-provider-response-hardening.md b/changelog.d/1317-provider-response-hardening.md deleted file mode 100644 index 398688fc0..000000000 --- a/changelog.d/1317-provider-response-hardening.md +++ /dev/null @@ -1 +0,0 @@ -- Validate provider response formats, strengthen camera source checks, and apply consistent API response protections (samehkhamis, #1317). diff --git a/changelog.d/1321-dead-code-cleanup.md b/changelog.d/1321-dead-code-cleanup.md deleted file mode 100644 index e8cad2a23..000000000 --- a/changelog.d/1321-dead-code-cleanup.md +++ /dev/null @@ -1,6 +0,0 @@ -- 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 - (samehkhamis, #1321). diff --git a/changelog.d/1324-street-level-provider-exports.md b/changelog.d/1324-street-level-provider-exports.md deleted file mode 100644 index a3dc4cb6b..000000000 --- a/changelog.d/1324-street-level-provider-exports.md +++ /dev/null @@ -1,8 +0,0 @@ -- 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/changelog.d/1325-phone-layout.md b/changelog.d/1325-phone-layout.md deleted file mode 100644 index c1653df62..000000000 --- a/changelog.d/1325-phone-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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). diff --git a/changelog.d/1326-terrain-cache-eviction.md b/changelog.d/1326-terrain-cache-eviction.md deleted file mode 100644 index ccb374ccd..000000000 --- a/changelog.d/1326-terrain-cache-eviction.md +++ /dev/null @@ -1,3 +0,0 @@ -- Evict terrain-height cache entries in fetch-time order without repeatedly - sorting the full cache, while keeping refreshed points newest and ordering - restored entries by their fetch time (daikaginza, #1326). diff --git a/changelog.d/1338-mobile-contact-context.md b/changelog.d/1338-mobile-contact-context.md deleted file mode 100644 index e240dc82a..000000000 --- a/changelog.d/1338-mobile-contact-context.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md deleted file mode 100644 index 588668afe..000000000 --- a/changelog.d/1339-hud-dms-carry.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1340-search-separated-hemisphere.md b/changelog.d/1340-search-separated-hemisphere.md deleted file mode 100644 index 5b89020e4..000000000 --- a/changelog.d/1340-search-separated-hemisphere.md +++ /dev/null @@ -1,3 +0,0 @@ -- Read a hemisphere letter that is separated from its number, so a search for - `40.7128 N 74.0060 W` flies there instead of falling through to the geocoders - (Rudy Celekli, @rudycelekli, #1340). diff --git a/changelog.d/1341-kartaview-impossible-timestamps.md b/changelog.d/1341-kartaview-impossible-timestamps.md deleted file mode 100644 index 302ff38f4..000000000 --- a/changelog.d/1341-kartaview-impossible-timestamps.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1342-firms-acquisition-calendar.md b/changelog.d/1342-firms-acquisition-calendar.md deleted file mode 100644 index e0003636d..000000000 --- a/changelog.d/1342-firms-acquisition-calendar.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1343-route-ramp-lane-wording.md b/changelog.d/1343-route-ramp-lane-wording.md deleted file mode 100644 index 4f6817e9c..000000000 --- a/changelog.d/1343-route-ramp-lane-wording.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1345-weather-staging-wake.md b/changelog.d/1345-weather-staging-wake.md deleted file mode 100644 index fec4d54ec..000000000 --- a/changelog.d/1345-weather-staging-wake.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1349-cyclone-classification-codes.md b/changelog.d/1349-cyclone-classification-codes.md deleted file mode 100644 index c22debfa1..000000000 --- a/changelog.d/1349-cyclone-classification-codes.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1350-cyclone-movement-knots.md b/changelog.d/1350-cyclone-movement-knots.md deleted file mode 100644 index 3fc8c8f9f..000000000 --- a/changelog.d/1350-cyclone-movement-knots.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md deleted file mode 100644 index 52dd9bede..000000000 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ /dev/null @@ -1,6 +0,0 @@ -- 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/changelog.d/1352-cockpit-destination-heading.md b/changelog.d/1352-cockpit-destination-heading.md deleted file mode 100644 index c7a5ea9b5..000000000 --- a/changelog.d/1352-cockpit-destination-heading.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1353-transit-ninety-second-age.md b/changelog.d/1353-transit-ninety-second-age.md deleted file mode 100644 index 50611060d..000000000 --- a/changelog.d/1353-transit-ninety-second-age.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1354-firms-frp-ten-megawatts.md b/changelog.d/1354-firms-frp-ten-megawatts.md deleted file mode 100644 index 394eba1df..000000000 --- a/changelog.d/1354-firms-frp-ten-megawatts.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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). diff --git a/changelog.d/1366-aircraft-models-no-environment-map.md b/changelog.d/1366-aircraft-models-no-environment-map.md deleted file mode 100644 index 8c5ad49c3..000000000 --- a/changelog.d/1366-aircraft-models-no-environment-map.md +++ /dev/null @@ -1,5 +0,0 @@ -- Aircraft 3D models (Flights, Military Flights, Local ADS-B) skip Cesium's - dynamic environment map, a sky render and GPU readback per model. With - ~150 models in the scene at Atlanta on Google 3D, GPU time per frame at - rest falls from 14.3-14.8 ms to 12.1-12.5 ms; a same-model comparison - showed no visible change (quanticsoul4772, #1366). diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md deleted file mode 100644 index 08bbbad81..000000000 --- a/changelog.d/1367-turned-off-layer-memory.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1378-touch-zoom-aircraft-updates.md b/changelog.d/1378-touch-zoom-aircraft-updates.md deleted file mode 100644 index 81acefb00..000000000 --- a/changelog.d/1378-touch-zoom-aircraft-updates.md +++ /dev/null @@ -1,5 +0,0 @@ -- Increase touch-first globe zoom sensitivity to three times the default while - retaining Cesium collision and distance limits. -- Reuse one delayed display timestamp per civil-aircraft fleet update and cache - extrapolation coordinate frames until their fix coordinates change. Preserve - contact counts, movement cadence, tracked motion and ground corrections. diff --git a/changelog.d/1379-aircraft-label-candidates.md b/changelog.d/1379-aircraft-label-candidates.md deleted file mode 100644 index f51ff101d..000000000 --- a/changelog.d/1379-aircraft-label-candidates.md +++ /dev/null @@ -1,4 +0,0 @@ -- Reduce repeated work in aircraft detection candidates by caching unchanged - callsign/registration text and reusing resolved fleet models. Metadata updates, - live positions and tracked-aircraft/model handoffs remain immediate - (samehkhamis, #1379). diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md deleted file mode 100644 index 21965af96..000000000 --- a/changelog.d/1384-hud-summary-moving.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md deleted file mode 100644 index fa354766b..000000000 --- a/changelog.d/1385-route-duration-minute.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md deleted file mode 100644 index 0a60fcc62..000000000 --- a/changelog.d/1386-route-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md deleted file mode 100644 index 4414b7c5f..000000000 --- a/changelog.d/1387-awareness-distance-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md deleted file mode 100644 index 137075707..000000000 --- a/changelog.d/1388-draw-line-kilometre.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md deleted file mode 100644 index cc81c362e..000000000 --- a/changelog.d/1389-cockpit-wind-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1390-awareness-bearing-north.md b/changelog.d/1390-awareness-bearing-north.md deleted file mode 100644 index af7fb4da3..000000000 --- a/changelog.d/1390-awareness-bearing-north.md +++ /dev/null @@ -1,2 +0,0 @@ -- Render awareness-overlay bearings and courses that round through north as - `000°`, rather than `360°` (Rudy Celekli, #1390). diff --git a/changelog.d/1391-docker-selinux.md b/changelog.d/1391-docker-selinux.md deleted file mode 100644 index 186147a2e..000000000 --- a/changelog.d/1391-docker-selinux.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/1394-docker-healthcheck.md b/changelog.d/1394-docker-healthcheck.md deleted file mode 100644 index 75ead9667..000000000 --- a/changelog.d/1394-docker-healthcheck.md +++ /dev/null @@ -1,2 +0,0 @@ -- 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/changelog.d/1395-fires-tool-acquired-time.md b/changelog.d/1395-fires-tool-acquired-time.md deleted file mode 100644 index 17d74e481..000000000 --- a/changelog.d/1395-fires-tool-acquired-time.md +++ /dev/null @@ -1,6 +0,0 @@ -- 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`. Missing or invalid dates and times remain - unknown rather than rolling into another calendar day - (Rudy Celekli, @rudycelekli, #1395). diff --git a/changelog.d/1396-local-adsb-track-north.md b/changelog.d/1396-local-adsb-track-north.md deleted file mode 100644 index 9235a19a3..000000000 --- a/changelog.d/1396-local-adsb-track-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1398-sharpen-zero-off.md b/changelog.d/1398-sharpen-zero-off.md deleted file mode 100644 index ab05aa6f2..000000000 --- a/changelog.d/1398-sharpen-zero-off.md +++ /dev/null @@ -1,2 +0,0 @@ -- The Sharpen slider at 0 now turns the sharpen pass off instead of still - sharpening at a low amount (quanticsoul4772, #1398). diff --git a/changelog.d/1399-ground-snap-clock-step.md b/changelog.d/1399-ground-snap-clock-step.md deleted file mode 100644 index 73196986a..000000000 --- a/changelog.d/1399-ground-snap-clock-step.md +++ /dev/null @@ -1,3 +0,0 @@ -- 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/changelog.d/1403-remove-unused-modules.md b/changelog.d/1403-remove-unused-modules.md deleted file mode 100644 index da95c7dcd..000000000 --- a/changelog.d/1403-remove-unused-modules.md +++ /dev/null @@ -1,5 +0,0 @@ -- 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/changelog.d/1410-scope-controls-hud-readability.md b/changelog.d/1410-scope-controls-hud-readability.md deleted file mode 100644 index 38cfdf48e..000000000 --- a/changelog.d/1410-scope-controls-hud-readability.md +++ /dev/null @@ -1,8 +0,0 @@ -- 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/changelog.d/1416-share-hash-navigation.md b/changelog.d/1416-share-hash-navigation.md deleted file mode 100644 index 0effac1cb..000000000 --- a/changelog.d/1416-share-hash-navigation.md +++ /dev/null @@ -1,4 +0,0 @@ -- Restore a share link opened in an already-open tab. Pasting a link into the - address bar, following one, or using Back/Forward now moves the camera and - restores the style, map, panels and layers instead of changing only the URL - (samehkhamis, #1416). diff --git a/changelog.d/1417-clear-all-saved-layers.md b/changelog.d/1417-clear-all-saved-layers.md deleted file mode 100644 index 8cffbd366..000000000 --- a/changelog.d/1417-clear-all-saved-layers.md +++ /dev/null @@ -1,3 +0,0 @@ -- Make Clear All clear the saved and shared layer selection. Layers that a - context mode had already switched off no longer stay in the share link and - come back on reload (samehkhamis, #1417). diff --git a/changelog.d/1418-short-share-links.md b/changelog.d/1418-short-share-links.md deleted file mode 100644 index 8dce5761f..000000000 --- a/changelog.d/1418-short-share-links.md +++ /dev/null @@ -1,5 +0,0 @@ -- Share links are now short: the share button makes `/share/` links - that open on any copy of the app, follow the aircraft, military aircraft or - satellite you were following, and fall back to its last-known position with - a notice if it isn't reporting. A mistyped link is refused instead of - opening a different view. Long `#v=2` links keep working (samehkhamis, #1418). diff --git a/changelog.d/1419-share-view-codec-export.md b/changelog.d/1419-share-view-codec-export.md deleted file mode 100644 index e3ce1b105..000000000 --- a/changelog.d/1419-share-view-codec-export.md +++ /dev/null @@ -1,4 +0,0 @@ -- Hosts can read share links on their server: the share-link codec and the - `/share/` address helpers are now package exports, - `@gods-eye-view/core/share/view-codec` and `@gods-eye-view/core/share/link-path` - (samehkhamis, #1419). diff --git a/changelog.d/1420-keep-missed-location-query.md b/changelog.d/1420-keep-missed-location-query.md deleted file mode 100644 index 233abf495..000000000 --- a/changelog.d/1420-keep-missed-location-query.md +++ /dev/null @@ -1,4 +0,0 @@ -- Keep a location search that found nothing, or failed, in the field so it can - be corrected: the query stays, focused and selected, and a persistent - announced message under the search row says what to do next, instead of a - toast followed by a cleared and closed field (samehkhamis, #1420). diff --git a/changelog.d/1421-style-modules-skip-catalogs.md b/changelog.d/1421-style-modules-skip-catalogs.md deleted file mode 100644 index bc79c5cd6..000000000 --- a/changelog.d/1421-style-modules-skip-catalogs.md +++ /dev/null @@ -1,3 +0,0 @@ -- The visual styles and their presets mark their labels with the catalog-free - `msg()`, so the share-link codec, and any server that loads it, no longer - bundles every translation catalog (about 0.9 MB) (samehkhamis, #1421). diff --git a/changelog.d/1431-street-level-bearing-north.md b/changelog.d/1431-street-level-bearing-north.md deleted file mode 100644 index f30084b71..000000000 --- a/changelog.d/1431-street-level-bearing-north.md +++ /dev/null @@ -1,3 +0,0 @@ -- Show a Street Level view facing 359.5° or more as 0°. The viewer caption - rounded the bearing without wrapping, so a view facing 359.6° read "360°" - (Aditya, @Rohilalala, #1431). diff --git a/changelog.d/287-shared-great-circle-distance.md b/changelog.d/287-shared-great-circle-distance.md deleted file mode 100644 index c2db9d54d..000000000 --- a/changelog.d/287-shared-great-circle-distance.md +++ /dev/null @@ -1,5 +0,0 @@ -- The client's great-circle distances now come from one shared - `src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}` - points). The Earth radius is standardized on 6371.0088 km, so distances shift - by about 1.4 ppm and region areas by about 2.8 ppm, and missing or - out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749). diff --git a/changelog.d/605-fl511-florida-cameras.md b/changelog.d/605-fl511-florida-cameras.md deleted file mode 100644 index 76f840198..000000000 --- a/changelog.d/605-fl511-florida-cameras.md +++ /dev/null @@ -1,9 +0,0 @@ -- Add FL511 (Florida DOT) traffic cameras as an opt-in CCTV source pack: - the statewide ArcGIS catalog, read a page at a time under a byte cap with - redirects refused, and frames pinned to FL511's two image hosts over HTTPS. - FL511's terms limit its content to individual, personal use, and any other - use (a public deployment included) needs FDOT's express written consent, so - the pack stays off unless `CCTV_FLORIDA_ENABLED=1`. Headings come from the - catalog's direction codes, and camera ids come from the image channel, since - the catalog's ID column repeats across regions. `CCTV_FLORIDA_MAX_SOURCES` - sets the cap (Danielslay86, #605). diff --git a/changelog.d/686-terrain-heights-bounds.md b/changelog.d/686-terrain-heights-bounds.md deleted file mode 100644 index 7e0004069..000000000 --- a/changelog.d/686-terrain-heights-bounds.md +++ /dev/null @@ -1,9 +0,0 @@ -- Bound what a caller can grow through `/api/terrain/heights`. Every distinct - 5-decimal point was a permanent in-memory entry, a permanent line in an - uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with - nothing limiting the rate. The point cache now holds at most 50,000 entries - and evicts oldest-first, a cache file above - 24 MB is not read back, and the route is limited to 90 requests/minute per - client (270 global). Evicting a point costs one refetch, never a wrong - answer, since terrain does not move. Ordinary use is unaffected: a full - `test:track` run drives 10 requests, peaking at 8/min (daikaginza, #686). diff --git a/changelog.d/761-dev-unwatched-runtime-dirs.md b/changelog.d/761-dev-unwatched-runtime-dirs.md deleted file mode 100644 index d3aaf0a91..000000000 --- a/changelog.d/761-dev-unwatched-runtime-dirs.md +++ /dev/null @@ -1,5 +0,0 @@ -- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write - cache files and logs there while the app runs, and none of it is source, so - the watcher now skips it; this guards against the DNS-lookup delays heavy - cache writes have caused, though no difference was measured on current main - (monwiss, #761). diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md deleted file mode 100644 index 8a96698a1..000000000 --- a/changelog.d/767-alpha5-catalog-numbers.md +++ /dev/null @@ -1,11 +0,0 @@ -- Read satellite catalog numbers written in Alpha-5, the TLE spelling of - numbers above 99999: a letter for the first two digits, skipping I and O - (`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a - field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all - but the first were dropped as duplicates; the satellite tools read the same - field as no number, so such a satellite could not be found by it. The layer - and the tools now decode the field in `src/sources/tle.js`, and reject a - malformed one (a sign, decimal point, hex, embedded space or stray letter) - instead of reading part of it as a number. This is Alpha-5 TLE - compatibility: CelesTrak's TLE feed carries no satellite above 99999, which - needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751). diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md deleted file mode 100644 index 41509c046..000000000 --- a/changelog.d/787-changelog-fragments.md +++ /dev/null @@ -1,7 +0,0 @@ -- Changelog entries are now one file per pull request. A change adds - `changelog.d/-.md` instead of editing the top of - `CHANGELOG.md`, which two pull requests in flight could not share — it was the - most frequent merge conflict in the open queue, and for six of them the only - one. `npm run changelog` splices the directory into `CHANGELOG.md` at release - time and empties it; `npm run changelog:check` reports the batch and changes - nothing. Fragment text is copied through unchanged (daikaginza, #787). diff --git a/changelog.d/816-radio-owner-restoration.md b/changelog.d/816-radio-owner-restoration.md deleted file mode 100644 index df38617ee..000000000 --- a/changelog.d/816-radio-owner-restoration.md +++ /dev/null @@ -1,7 +0,0 @@ -- Close the compact Radio disclosure when an adaptive tactical right-rail - restore directly expands Context, before Space Missions can be covered. - A repeated Context-open request also clears stale disclosure state. - Real PanelChrome constructor wiring and the rail reflow path are covered - by focused regressions. Credits: kvnloo (#816), raysung-2026's original - #804 report, asturksever's review, and quanticsoul4772's independent - Windows diagnosis. diff --git a/changelog.d/872-gemini-live-voice.md b/changelog.d/872-gemini-live-voice.md deleted file mode 100644 index 519e4d37b..000000000 --- a/changelog.d/872-gemini-live-voice.md +++ /dev/null @@ -1,4 +0,0 @@ -- Add optional Gemini Live voice to the standalone app, with a provider chooser, - server-held API key, microphone streaming, shared map actions and captions. - Switching providers ends the old conversation and preserves the input mode - (Manjunath, #872). diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md deleted file mode 100644 index 7ee574e35..000000000 --- a/changelog.d/880-world-packet-boundary.md +++ /dev/null @@ -1,6 +0,0 @@ -- Extract a reusable model-free World Packet from the existing voice - `get_current_view_state` owner without restoring disabled layers to the - model context. Keep the enabled-only payload, disabled-layer count, source - provenance, action result and view-state contract; read the layer roster once - per packet; voice retains the speech/card envelope at its own boundary. - Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/changelog.d/945-cctv-vegvesen-kartverket-heights.md b/changelog.d/945-cctv-vegvesen-kartverket-heights.md deleted file mode 100644 index 1572c0316..000000000 --- a/changelog.d/945-cctv-vegvesen-kartverket-heights.md +++ /dev/null @@ -1,17 +0,0 @@ -- Give each Statens vegvesen camera its own ground height from Kartverket's - keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so - mountain-pass cameras no longer sit hundreds of metres below the terrain - where neither the point-height prior nor the ground snap resolves. About 850 - of ~855 cameras get a height; the rest keep the old prior. Requests carry 20 - points, the most the API answers from its 1 m terrain model, eight at a - time; a cold lookup takes about 6–13 s. Cameras over water use the water - surface rather than the sea-floor depth the API returns there; for bridge - cameras that is still below the deck. Heights are cached for good and misses - for 6 h, so a refresh only asks about new or moved cameras. After a - failing request (a 408, 429 or 5xx, a network error, a request over 10 s or - an unreadable body) the lookup stops and backs off (Retry-After, else - 30 min, longer than the refresh). The lane waits at most 12 s from its - start, and only on a cold cache; a slower first lookup leaves the cameras it - had not reached on the flat prior until the next 15-minute refresh, and - keeps filling the cache meanwhile. - `CCTV_VEGVESEN_HEIGHTS=0` turns it off. Follow-up to #929 (trygvehhh, #945). diff --git a/changelog.d/948-vite-fs-deny.md b/changelog.d/948-vite-fs-deny.md deleted file mode 100644 index 642bafb1f..000000000 --- a/changelog.d/948-vite-fs-deny.md +++ /dev/null @@ -1,4 +0,0 @@ -- 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/changelog.d/970-recent-imagery-details-scroll.md b/changelog.d/970-recent-imagery-details-scroll.md deleted file mode 100644 index 761e265c0..000000000 --- a/changelog.d/970-recent-imagery-details-scroll.md +++ /dev/null @@ -1,3 +0,0 @@ -- Recent Imagery: opening DETAILS scrolls its card into view when it opens below - the fold. The panel used to restore the previous scroll position right after - the reveal, so the card stayed out of sight (Ian Blenke, #970). diff --git a/changelog.d/973-tomtom-cross-site-gate.md b/changelog.d/973-tomtom-cross-site-gate.md deleted file mode 100644 index e4a029406..000000000 --- a/changelog.d/973-tomtom-cross-site-gate.md +++ /dev/null @@ -1,4 +0,0 @@ -- Cross-site browser requests can no longer spend the TomTom traffic-tile - budget through `/api/tomtom/*`. The shared same-site gate runs before status, - cache, and upstream work; same-origin app requests and non-browser local - callers remain supported (Sunil56224972, #973). diff --git a/changelog.d/974-street-level-slim.md b/changelog.d/974-street-level-slim.md deleted file mode 100644 index 8bc2b5346..000000000 --- a/changelog.d/974-street-level-slim.md +++ /dev/null @@ -1,6 +0,0 @@ -- Street Level is rebuilt around one small provider registry: Mapillary, - KartaView, Panoramax and Google Street View share one coverage drawer and - one photo viewer, one source lit at a time. Floating and docked panels, - FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are - gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be - deleted (gekh, #974). diff --git a/changelog.d/975-terrain-cross-site-gate.md b/changelog.d/975-terrain-cross-site-gate.md deleted file mode 100644 index 6c0e42156..000000000 --- a/changelog.d/975-terrain-cross-site-gate.md +++ /dev/null @@ -1,8 +0,0 @@ -- Cross-site pages can no longer fill the terrain-height cache or spend the - visitor's own `/api/terrain/heights` rate-limit quota through the visitor's - browser. The route refuses a cross-site `Sec-Fetch-Site` or a foreign or - opaque `Origin` before rate limiting, cache access or upstream work. Unlike - the cost-bearing routes it still answers same-origin requests that arrive - through a reverse proxy, so terrain keeps rendering behind nginx, Caddy or - Cloudflare instead of falling back to the geoid (Sunil56224972, daikaginza, - #975). diff --git a/changelog.d/990-hong-kong-cctv.md b/changelog.d/990-hong-kong-cctv.md deleted file mode 100644 index a150b66ae..000000000 --- a/changelog.d/990-hong-kong-cctv.md +++ /dev/null @@ -1,7 +0,0 @@ -- Add Hong Kong Transport Department traffic snapshot cameras as a keyless CCTV - source pack: the public DATA.GOV.HK English locations XML, frames pinned to - `tdcctv.data.one.gov.hk`, with DATA.GOV.HK Terms of Use attribution. By - default nearly the full TD catalog (~1,013 cameras; cap 1,100) loads, - prioritized across Hong Kong Island, Kowloon and the New Territories. Headings - use the shared id-hash fallback at low confidence. `CCTV_HK_MAX_SOURCES` sets - the cap (8–1,200) and `CCTV_HK_ENABLED=0` turns the pack off (JCY2515, #990). diff --git a/package-lock.json b/package-lock.json index 7b6d3476f..9d4714ce2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gods-eye-view", - "version": "0.2.1", + "version": "0.3.0", "license": "MIT", "dependencies": { "@jtarrio/signals": "^0.10.0", diff --git a/package.json b/package.json index 75a1f994c..aa42dffe1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gods-eye-view", "private": true, - "version": "0.2.1", + "version": "0.3.0", "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "type": "module", "license": "MIT", From 018b6d3f1bd5f4e8a2a3f9dcfb45a9b6eb7b0dbe Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 10:37:21 +0400 Subject: [PATCH 129/131] i18n: fix zh-Hant characters OpenCC converts wrongly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道 and 發佈 → 發布, and list these fixes for future reconversions from zh-CN. Co-Authored-By: Claude Opus 5.5 --- docs/translations/zh-Hant.md | 2 ++ src/i18n/locales/zh-Hant.json | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 7354e6b1b..9a36f877c 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,6 +4,8 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布. + ## Style - Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters. diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 414624f2d..ec908665f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -52,7 +52,7 @@ "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 洪水。下游演進為示意,並非模擬的到達時間;也不是官方災害模型。", + "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": "碎屑堰塞湖", @@ -101,11 +101,11 @@ "SECONDARY FAILURE": "第二次滑坡", "BORDER ARRIVAL": "抵達邊境", "PROPAGATION": "演進", - "DOWNSTREAM PASSAGE": "向下遊推進", + "DOWNSTREAM PASSAGE": "向下游推進", "CONSEQUENCE": "後果", "DOWNSTREAM CONSEQUENCE": "下游後果", "EVIDENCE": "證據", - "{year} HISTORICAL REFERENCE": "{year} 年曆史參考", + "{year} HISTORICAL REFERENCE": "{year} 年歷史參考", "{year} POST-EVENT": "{year} 年災後", "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { "other": "{count} 個地理定位來源點位" @@ -121,7 +121,7 @@ "Close event layer": "關閉事件圖層", "OBSERVED IMAGERY": "觀測影像", "SCHEMATIC CORRIDOR": "示意走廊", - "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年曆史參考 / 2026 年災後", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "2021 年歷史參考 / 2026 年災後", "Historical reference and post-event image split": "歷史參考與災後影像的分割", "RECONSTRUCTION CLOCK": "重構時鐘", "Schematic downstream progression": "下游演進示意", @@ -545,7 +545,7 @@ }, "Position as of {time}": "位置(截至 {time})", "Maximum sustained wind: {wind} · Pressure: {pressure}": "最大持續風速:{wind} · 氣壓:{pressure}", - "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發佈於 {time}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · 第 {number} 號公告 · 發布於 {time}", "No active NHC/CPHC systems": "當前沒有活躍的 NHC/CPHC 氣旋", "Advisories unavailable": "公告不可用", "Cyclones · NHC / CPHC": "氣旋 · NHC / CPHC", @@ -620,10 +620,10 @@ "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 {direction} onto {road}": "{direction}併道,進入 {road}", + "Merge {direction}": "{direction}併道", "Merge onto {road}": "併入 {road}", - "maneuver|Merge": "並道", + "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}", @@ -1234,7 +1234,7 @@ "{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。", @@ -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": "關", @@ -2863,5 +2863,5 @@ "{count} placemarks": { "other": "{count} 個地標" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發佈這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 發布這些 KMZ 產品。新上傳的內容會在數秒內被檢測到,並自動替換舊版本。點擊標記查看報告。" } From 982edfd56d4d7477a95648343d69dc6eabb5d95c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 11:26:52 +0400 Subject: [PATCH 130/131] =?UTF-8?q?i18n:=20use=20=E5=85=AC=E5=B8=83=20in?= =?UTF-8?q?=20zh-Hant=20to=20match=20=E7=99=BC=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/translations/zh-Hant.md | 2 +- src/i18n/locales/zh-Hant.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index 9a36f877c..ac355516b 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,7 +4,7 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. -After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布. ## Style diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index ec908665f..d95a18e39 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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": "圖像", From 811eb7835d2fccb1a30f2fefa333d1f2d25a298b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 14:08:49 +0000 Subject: [PATCH 131/131] test(director): cover observer failures in session activate and clear --- .../interactions/interactions.test.mjs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index 496616516..6c13cecef 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -233,6 +233,37 @@ test('a throwing changed observer does not break session dispatch, activation, o } }); +test('an observer that always throws cannot stop activate or clear', () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => true, + changed: () => { + throw new Error('observer failed'); + }, + }); + session.activate([{ id: 'a' }, { id: 'b' }]); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: null, + count: 2, + }); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + assert.equal(warnings.length, 3); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] };