From 681ec84748f055051cb899bc876fe9dd6c520dfd Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sat, 17 Oct 2026 07:32:38 +0700 Subject: [PATCH 01/66] 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 02/66] fix(voice): keep FIRMS and ALPR entity labels English in voice data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fire and camera labels in the context store, getStrongestFire() and getSelectedInfo() were translated at the source, so the voice model got "Пожар · FRP 12 МВт" or "Cámara ALPR" in track_entity, get_entity_context and pointer_context while every other entity name in those payloads is English. Keep them English, as before the interface was translated; the voice card translates them where it shows them. --- src/data/alprCameras.test.mjs | 20 ++++++++++++++++++++ src/data/firmsInteraction.test.mjs | 18 ++++++++++++++++++ src/layers/alpr/presentation.js | 4 ++-- src/layers/firms/queries.js | 10 ++++------ src/layers/firms/selection.js | 5 ++--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/data/alprCameras.test.mjs b/src/data/alprCameras.test.mjs index 5a50220d5..71ee6d675 100644 --- a/src/data/alprCameras.test.mjs +++ b/src/data/alprCameras.test.mjs @@ -9,6 +9,7 @@ import alprCamerasLayer, { } from './alprCameras.js'; import { createAlprTileSource } from '../layers/alpr/source.js'; import { DATA_CREDITS } from './dataCredits.js'; +import { setLocale } from '../i18n/index.js'; import { clearSelectedEntityContextForLayer, getContextStore, @@ -473,6 +474,25 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a } }); +// The voice model reads the label as data; the voice card translates it. +test('the camera label stays English data in a translated interface', async () => { + const h = cameraHarness(); + setLocale('ru'); + try { + h.setFetch(async () => + cameraResponse([ + cameraNode(42, { tags: { 'surveillance:type': 'ALPR' } }), + ]), + ); + await alprCamerasLayer.update(); + h.click(h.source.entities.values[0].id); + assert.equal(getSelectedEntityContext().label, 'ALPR camera'); + } finally { + setLocale('en'); + h.restore(); + } +}); + test('invalid OSM ids and out-of-range coordinates never become camera entities', () => { for (const id of [undefined, null, 0, -1, 1.5, '42', NaN]) assert.equal(normalizeAlprNode(cameraNode(id, { id })), null); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 442af60b2..cde867f87 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -8,6 +8,8 @@ import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js'; import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; +import { getSelectedEntityContext } from './contextStore.js'; +import { setLocale } from '../i18n/index.js'; import { WORLD_FOCUS_REQUEST_EVENT } from '../worldFocus.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import path from 'node:path'; @@ -234,6 +236,22 @@ test('clicking a fire CARD behaves exactly like clicking its sprite', () => { } }); +// The voice model reads these labels as data (track_entity, get_entity_context, +// pointer_context); the voice card translates them where it shows them. +test('fire labels stay English data in a translated interface', () => { + setLocale('ru'); + const h = harness({ fires: [makeFire()], picked: { id: 'firms-7' } }); + try { + h.click(); + assert.equal(getSelectedEntityContext().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getSelectedInfo().label, 'Fire · FRP 1520 MW'); + assert.equal(h.layer.getStrongestFire().label, 'Fire · FRP 1520 MW'); + } finally { + h.cleanup(); + setLocale('en'); + } +}); + test('a click on neither sprite nor card clears without moving the camera', () => { const h = harness({ picked: { primitive: {}, content: {}, featureId: 0 } }); try { diff --git a/src/layers/alpr/presentation.js b/src/layers/alpr/presentation.js index 866f15a6d..c7343d573 100644 --- a/src/layers/alpr/presentation.js +++ b/src/layers/alpr/presentation.js @@ -13,7 +13,7 @@ import { MARKER_SCALE_BY_DISTANCE, } from './policy.js'; import { boxContains } from './model.js'; -import { t } from '../../i18n/index.js'; +import { msg } from '../../i18n/index.js'; import { createAlprFloorResolver } from './floors.js'; import { createAlprOverlay } from './overlay.js'; import { @@ -452,7 +452,7 @@ export function createAlprPresentation({ state, services, source }) { layerName: 'ALPR Cameras', source: source.attribution?.description || source.label || 'Camera source', - label: t('ALPR camera'), + label: msg('ALPR camera'), latitude: record.latitude, longitude: record.longitude, properties: { diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..16ca01166 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -18,9 +18,8 @@ export function createQueries({ if (!fire) return null; return { id: fireDetectionKey(fire), - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, frp: fire.frp, @@ -117,9 +116,8 @@ export function createQueries({ latitude: strongest.lat, longitude: strongest.lon, frp: strongest.frp, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(strongest.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(strongest.frp)} MW`, }; }, diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index fc05eea2a..d5cfc4d26 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -193,9 +193,8 @@ export function createSelection({ layerName: name, source: 'NASA FIRMS', dataSource: layerState._dataSource, - label: t('Fire · FRP {frp} MW', { - frp: components.model.formatFrp(fire.frp), - }), + // i18n-ignore: English entity label; the voice card translates it (shownEntityLabel) + label: `Fire · FRP ${components.model.formatFrp(fire.frp)} MW`, latitude: fire.lat, longitude: fire.lon, properties: { From 9011918731b4a3d2444c7c11e1a472195dce1035 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 14:35:07 +0700 Subject: [PATCH 03/66] docs(changelog): add the #1207 entry --- changelog.d/1207-voice-entity-labels-english.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1207-voice-entity-labels-english.md diff --git a/changelog.d/1207-voice-entity-labels-english.md b/changelog.d/1207-voice-entity-labels-english.md new file mode 100644 index 000000000..b69cdc7ed --- /dev/null +++ b/changelog.d/1207-voice-entity-labels-english.md @@ -0,0 +1,3 @@ +- Keep NASA FIRMS fire and ALPR camera labels English in voice tool results + (`track_entity`, `get_entity_context`, pointer context); the voice card still + shows them in the interface language (daikaginza, #1207). From 843c8b1a6bba03073b58e9a00e60467891827a4b Mon Sep 17 00:00:00 2001 From: daikaginza Date: Sun, 18 Oct 2026 15:22:31 +0700 Subject: [PATCH 04/66] 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 657fd589d7c30c41b111b1e46351b20c92a50c49 Mon Sep 17 00:00:00 2001 From: knotty Date: Mon, 19 Oct 2026 10:30:17 -0600 Subject: [PATCH 05/66] 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 06/66] 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 07/66] 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 08/66] 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 09/66] 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 10/66] 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 11/66] 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 12/66] 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 13/66] test: restore minimized Transit windows before screenshots --- changelog.d/1282-polling-issue-fix.md | 1 + scripts/qa-chrome.mjs | 27 +++++++++++++ scripts/qa-transit-recovery.mjs | 4 +- scripts/qa-transit-scenes.mjs | 13 +++--- scripts/qa-transit.mjs | 14 ++++--- src/qaChrome.test.mjs | 57 ++++++++++++++++++++++++++- 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/changelog.d/1282-polling-issue-fix.md b/changelog.d/1282-polling-issue-fix.md index 4af89eeee..9931385bf 100644 --- a/changelog.d/1282-polling-issue-fix.md +++ b/changelog.d/1282-polling-issue-fix.md @@ -4,3 +4,4 @@ - Bound the Transit card-anchor probe by observed poll coverage instead of a fixed 20-second window (asturksever, #1282). - Keep visible-page Transit probes focused through the browser testing API (asturksever, #1282). - Add a real-browser regression gate for hidden-tab periodic work, explicit refresh, resume and teardown (asturksever, #1282). +- Restore minimized test windows before screenshots so native compositor pauses cannot stall browser acceptance (asturksever, #1282). diff --git a/scripts/qa-chrome.mjs b/scripts/qa-chrome.mjs index 05c4dbff0..2416a04e3 100644 --- a/scripts/qa-chrome.mjs +++ b/scripts/qa-chrome.mjs @@ -18,3 +18,30 @@ export function chromeExecutablePath({ if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH; return exists(MACOS_CHROME) ? MACOS_CHROME : undefined; } + +/** Restore an owned test window before capture: focus emulation alone does not + * unminimize the native compositor, so CDP screenshots can otherwise stall. */ +export async function captureQaScreenshot( + page, + options, + { headful = true } = {}, +) { + if (headful) { + const session = await page.createCDPSession(); + try { + const { windowId, bounds } = await session.send( + 'Browser.getWindowForTarget', + ); + if (bounds.windowState === 'minimized') { + await session.send('Browser.setWindowBounds', { + windowId, + bounds: { windowState: 'normal' }, + }); + } + await page.bringToFront(); + } finally { + await session.detach(); + } + } + return page.screenshot(options); +} diff --git a/scripts/qa-transit-recovery.mjs b/scripts/qa-transit-recovery.mjs index 5f6404b41..a54e81239 100644 --- a/scripts/qa-transit-recovery.mjs +++ b/scripts/qa-transit-recovery.mjs @@ -1,6 +1,6 @@ /** Camera-jump regression against an existing server; never starts a server. */ import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js'; @@ -307,7 +307,7 @@ try { result.pixelPass = pixels.pass; result.pixelDetails = pixels; result.errors = errors; - await page.screenshot({ path: `${shots}/recovery.png` }); + await captureQaScreenshot(page, { path: `${shots}/recovery.png` }); await writeFile(`${shots}/recovery.json`, JSON.stringify(result, null, 2)); for (const key of ['on', 'off', 'onAgain', 'offAgain']) delete result[key]; const pass = diff --git a/scripts/qa-transit-scenes.mjs b/scripts/qa-transit-scenes.mjs index 1ceb45b22..f325cb9f7 100644 --- a/scripts/qa-transit-scenes.mjs +++ b/scripts/qa-transit-scenes.mjs @@ -1,3 +1,4 @@ +import { captureQaScreenshot } from './qa-chrome.mjs'; import { getRegisteredTransitFeed } from '../src/data/transitFeeds.js'; // Not registered or active: camera discovery and live polls never own this feed. @@ -219,7 +220,7 @@ export async function runFleetBudgets({ allocations.layerFrameBytes / allocationTrace.rows.length <= 8192, `${allocations.layerFrameBytes / allocationTrace.rows.length} transit-authored bytes/frame; whole frame ${allocations.frameBytes / allocationTrace.rows.length}; top three ${JSON.stringify(details.topAllocatorsPerFrame)}; collected objects included. Full profile saved.`, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-fleet-${count}.jpg`, type: 'jpeg', quality: 85, @@ -392,7 +393,7 @@ export async function runBostonMatrix({ JSON.stringify({ pixels, contrast }), { unexercised: pixels.length < 6 }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-fleet.jpg`, type: 'jpeg', quality: 90, @@ -407,7 +408,7 @@ export async function runBostonMatrix({ target.key, ); await wait(500); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-${target.route}-selected.jpg`, type: 'jpeg', quality: 90, @@ -431,7 +432,7 @@ export async function runBostonMatrix({ ); } await wait(300); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-matrix-${background.name}-${look.name}-restored.jpg`, type: 'jpeg', quality: 90, @@ -579,7 +580,7 @@ export async function runBostonLive({ JSON.stringify(selection), { unexercised: !target }, ); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-boston-live-${view.name}.jpg`, type: 'jpeg', quality: 90, @@ -1010,7 +1011,7 @@ export async function runTrailVisibility({ stable: pixels.stable[i], eligible: pixels.eligible[i], })); - await page.screenshot({ + await captureQaScreenshot(page, { path: `${shots}/${tag}-${altitude}m-trail-visible.jpg`, type: 'jpeg', quality: 95, diff --git a/scripts/qa-transit.mjs b/scripts/qa-transit.mjs index 47eafc94d..943e39df5 100644 --- a/scripts/qa-transit.mjs +++ b/scripts/qa-transit.mjs @@ -36,7 +36,7 @@ import { */ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import puppeteer from 'puppeteer'; -import { chromeExecutablePath } from './qa-chrome.mjs'; +import { chromeExecutablePath, captureQaScreenshot } from './qa-chrome.mjs'; import { createTransitHistory } from '../server/providers/transitHistory.js'; import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js'; import { transitModeTier } from '../src/data/transitPresetStyle.js'; @@ -294,11 +294,13 @@ page.on('request', (request) => { }); const shot = (name, caption) => - page - .screenshot({ path: `${SHOTS}/${name}`, type: 'jpeg', quality: 82 }) - .then(() => { - console.log(` shot ${name} — ${caption}`); - }); + captureQaScreenshot(page, { + path: `${SHOTS}/${name}`, + type: 'jpeg', + quality: 82, + }).then(() => { + console.log(` shot ${name} — ${caption}`); + }); const view = (city) => `${BASE}/?welcome=0#lat=${city.lat}&lon=${city.lon}&alt=600&heading=${city.heading}&pitch=-45`; diff --git a/src/qaChrome.test.mjs b/src/qaChrome.test.mjs index 4414f3c93..398d23ab3 100644 --- a/src/qaChrome.test.mjs +++ b/src/qaChrome.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { MACOS_CHROME, chromeExecutablePath } from '../scripts/qa-chrome.mjs'; +import { + MACOS_CHROME, + chromeExecutablePath, + captureQaScreenshot, +} from '../scripts/qa-chrome.mjs'; test('gates keep macOS Chrome where it is installed', () => { assert.equal( @@ -25,3 +29,54 @@ test('an explicit executable path wins', () => { 'C:\\Chrome\\chrome.exe', ); }); + +test('screenshots restore minimized test windows before capture and detach their session', async () => { + const calls = []; + const session = { + send: async (method, params) => { + calls.push([method, params]); + return { windowId: 7, bounds: { windowState: 'minimized' } }; + }, + detach: async () => calls.push(['detach']), + }; + const page = { + createCDPSession: async () => session, + bringToFront: async () => calls.push(['front']), + screenshot: async (options) => { + calls.push(['capture', options]); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, { path: 'proof.png' }), + 'pixels', + ); + assert.deepEqual( + calls.map((c) => c[0]), + [ + 'Browser.getWindowForTarget', + 'Browser.setWindowBounds', + 'front', + 'detach', + 'capture', + ], + ); + assert.deepEqual(calls[1][1], { + windowId: 7, + bounds: { windowState: 'normal' }, + }); +}); + +test('headless capture does not manipulate a native window', async () => { + const options = { path: 'proof.png' }; + const page = { + screenshot: async (supplied) => { + assert.equal(supplied, options); + return 'pixels'; + }, + }; + assert.equal( + await captureQaScreenshot(page, options, { headful: false }), + 'pixels', + ); +}); From fb88dd6e1b1889fea7be58ee6d7ebe883f9c9fb1 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:51:44 -0700 Subject: [PATCH 14/66] 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 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 15/66] 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 16/66] 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 626852401a98929db4f0362420a3407253ad3aa1 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Fri, 23 Oct 2026 11:57:29 +0700 Subject: [PATCH 17/66] 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 18/66] 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 19/66] 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 0309a58643d52d90f1d251c8d50f14e0fcd90429 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 24 Oct 2026 00:10:14 -0400 Subject: [PATCH 20/66] 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 21/66] 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 22/66] 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 23/66] 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 24/66] 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 25/66] 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 26/66] 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 27/66] 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 28/66] 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 29/66] 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 30/66] 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 31/66] 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 cf4e8e1d87fe1c57315f1107aae996122086e669 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 24 Oct 2026 21:19:07 -0700 Subject: [PATCH 32/66] docs(cyclones): record upstream movement speed units --- docs/CURRENT-STATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..3db6d3fb9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -423,6 +423,8 @@ than relabeling older geometry. The cone represents forecast center uncertainty, not storm size or the full hazard area. Successful empty and unavailable states are distinct. One owned native Cesium data source preserves polygon holes and geographic seams and releases on disable; forecast animation is not implied. +NHC movement speed is converted from miles per hour to rounded knots by the +proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. Consult the linked official advisory for safety decisions. From 77b485d619e79fa59994a07909c78de4ff023590 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 24 Oct 2026 21:10:12 -0700 Subject: [PATCH 33/66] fix(ais): refresh changed static metadata without advancing position --- changelog.d/1351-ais-static-keeps-motion.md | 3 + docs/CURRENT-STATE.md | 8 + server/providers/vessels/ais-store.js | 60 ++++++-- src/data/aisStreamSentinels.test.mjs | 159 ++++++++++++++++++++ 4 files changed, 220 insertions(+), 10 deletions(-) diff --git a/changelog.d/1351-ais-static-keeps-motion.md b/changelog.d/1351-ais-static-keeps-motion.md index 78b0804f7..52dd9bede 100644 --- a/changelog.d/1351-ais-static-keeps-motion.md +++ b/changelog.d/1351-ais-static-keeps-motion.md @@ -1,3 +1,6 @@ - Keep a tracked vessel's speed, course, heading and fix time when AISStream static data arrives; the static message only merges name, type, destination and IMO (rudycelekli, #1351). +- Apply changed static metadata immediately, retaining known fields when a + later static report omits them, without refreshing the vessel's position + (samehkhamis, #1351). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ede889fdc..282181df3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -459,6 +459,14 @@ than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the highest speed and course the fields can encode. +For an already tracked vessel, AISStream static messages update name, type, +destination and IMO without changing its position, motion, fix timestamps, +position freshness or trail. Non-empty static values replace earlier values; +omitted, empty or unavailable fields retain known metadata across Class B's +separate reports and in the cache used by the next position update. Position +reports still prefer their own metadata name. Invalid Class B parts are ignored; +absent fields prefer the latest live value over the static cache. + Native ` Don't show this again - ESC to dismiss + + From 6bbf9b94c4f2045865a75d7fd4147491c970a63b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:26:26 -0700 Subject: [PATCH 51/66] fix(traffic): flag stale cached TomTom tiles in get_traffic_flow (#1239) Report stale traffic tiles and their fetch time, and release pending freshness metadata when tile requests fail or are cancelled. Contribution by @quanticsoul4772. --- changelog.d/1239-traffic-stale-flow.md | 3 + docs/CURRENT-STATE.md | 8 ++ server/providers/traffic.js | 15 ++- src/layers/traffic/flowSource.js | 77 +++++++++-- src/layers/traffic/source.test.mjs | 153 ++++++++++++++++++++++ src/tooling/environmentProviders.test.mjs | 9 +- src/tools/queries/mobility.js | 11 ++ src/tools/queries/mobility.test.mjs | 41 ++++++ 8 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 changelog.d/1239-traffic-stale-flow.md diff --git a/changelog.d/1239-traffic-stale-flow.md b/changelog.d/1239-traffic-stale-flow.md new file mode 100644 index 000000000..5338e8e60 --- /dev/null +++ b/changelog.d/1239-traffic-stale-flow.md @@ -0,0 +1,3 @@ +- `get_traffic_flow` flags stale cached TomTom tiles and reports the oldest + tile fetch time (`stale`, `oldest_tile_at`), with pending freshness metadata + released on failed or cancelled tile reads (quanticsoul4772, #1239). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 332b99283..be09e5664 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1701,6 +1701,14 @@ The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before status, cache or upstream work, refusing cross-site browser requests while preserving same-origin app requests and non-browser local callers. The browser layers and their rendering remain in their existing modules. +Past the daily tile budget, or while TomTom fails, the proxy answers with its +last copy of a tile (`x-tomtom-cache: STALE-BUDGET` / `STALE-ERROR`) however old +it is, and every tile carries `x-tomtom-fetched-at`, the proxy fetch time +(or the persisted cache file time after restart). The flow source keeps that mark with each decoded tile, so +`get_traffic_flow` returns `stale` and `oldest_tile_at` and says when traffic +may be out of date instead of presenting cached tiles as live. Pending freshness +metadata is released when body reads fail, are cancelled, or exceed their size +limit, as well as when a tile decodes or the source resets. GBFS null or empty availability counts retain an unknown value through station parsing and rendering; they use the neutral station color rather than reporting an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. diff --git a/server/providers/traffic.js b/server/providers/traffic.js index da8ae73de..2069a0892 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -182,14 +182,17 @@ export function tomtomProxy() { }); res.end(JSON.stringify(obj)); }; - const sendTile = (buf, cacheStatus) => { + // x-tomtom-fetched-at is when the proxy fetched the tile (ms since the + // epoch), so a client handed a STALE-* copy can say how old it is. + const sendTile = (entry, cacheStatus) => { if (res.headersSent) return; res.writeHead(200, { 'Content-Type': 'application/x-protobuf', 'Cache-Control': 'no-store', 'x-tomtom-cache': cacheStatus, + 'x-tomtom-fetched-at': String(Math.round(entry.at)), }); - res.end(buf); + res.end(entry.buf); }; try { @@ -235,14 +238,14 @@ export function tomtomProxy() { } // Fresh cache hit — never counts against the budget. if (entry && now - entry.at < TILE_TTL_MS) { - sendTile(entry.buf, 'HIT'); + sendTile(entry, 'HIT'); return; } // Budget governor: over the soft cap, last-good data beats a dead layer. if (isTomTomOverBudget(currentBudget(), dailyBudgetLimit())) { if (entry) { - sendTile(entry.buf, 'STALE-BUDGET'); + sendTile(entry, 'STALE-BUDGET'); } else { sendJson(429, { error: 'budget' }); } @@ -271,9 +274,9 @@ export function tomtomProxy() { } const fresh = await inflight.get(key); if (fresh) { - sendTile(fresh.buf, 'MISS'); + sendTile(fresh, 'MISS'); } else if (entry) { - sendTile(entry.buf, 'STALE-ERROR'); // upstream down — stale beats empty + sendTile(entry, 'STALE-ERROR'); // upstream down — stale beats empty } else { sendJson(502, { error: 'upstream' }); } diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 49567a9ee..f517c99bc 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -4,30 +4,79 @@ import { clipTileLine } from '../../sources/openFreeMap.js'; import { tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; +const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; + +/** + * Whether the proxy answered a tile from its cache past the refresh window + * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom + * fails), and when the proxy fetched it. + */ +function tileFreshness(response) { + const status = response.headers?.get?.('x-tomtom-cache') || ''; + const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); + return { + stale: status.startsWith('STALE'), + fetchedAt: fetchedAt > 0 ? fetchedAt : null, + }; +} + /** Own one bounded decoded flow cache; every request still passes the server tile budget. */ export function createFlowTileSource({ fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { + // Freshness travels in response headers, but the tile cache keeps only + // decoded segments: hold each response's freshness until its tile decodes, + // then key it by the decoded tile, which the cache hands back on a hit. + const pending = new Map(); + const freshness = new WeakMap(); const tiles = createVectorTileSource({ - template: '/api/tomtom/flow/{z}/{x}/{y}.pbf', + template: FLOW_TILE_PATH('{z}', '{x}', '{y}'), allowedOrigin: 'http://localhost', - decode: (bytes, z, x, y) => - decodeFlowTile(bytes, z, x, y, { strict: true }) + decode: (bytes, z, x, y) => { + // Take the mark before decoding, so a tile that fails to decode + // leaves nothing behind. + const path = FLOW_TILE_PATH(z, x, y); + const entry = pending.get(path); + const mark = entry?.mark; + entry?.release(); + const segments = decodeFlowTile(bytes, z, x, y, { strict: true }) .filter(isDrivableFlowSegment) .flatMap((segment) => clipTileLine(segment.coords, tileToBBox(z, x, y)).map((coords) => ({ ...segment, coords, })), - ), - fetchImpl, + ); + if (mark) freshness.set(segments, mark); + return segments; + }, + fetchImpl: async (url, init) => { + const response = await fetchImpl(url, init); + if (response.ok && !init.signal.aborted) { + const path = String(url); + const entry = { + mark: tileFreshness(response), + release() { + if (pending.get(path) === entry) pending.delete(path); + init.signal.removeEventListener('abort', entry.release); + }, + }; + pending.get(path)?.release(); + pending.set(path, entry); + // The vector source aborts this signal for failed/cancelled body reads, + // including size-limit failures that never reach decode. + init.signal.addEventListener('abort', entry.release, { once: true }); + } + return response; + }, ttlMs: 120_000, maxTiles: 16, }); let partial = false; /** * Flow segments for bounds, with whether any covering tile failed to load - * for this request. + * for this request, whether any came from the proxy's stale cache, and when + * the oldest tile was fetched. */ async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) { const result = await tiles.fetchBounds(bounds, { @@ -44,7 +93,16 @@ export function createFlowTileSource({ coords, })), ); - return { segments, partial: result.partial === true }; + const marks = result.tiles.map((tile) => freshness.get(tile) || {}); + const times = marks + .map((mark) => mark.fetchedAt) + .filter((at) => Number.isFinite(at)); + return { + segments, + partial: result.partial === true, + stale: marks.some((mark) => mark.stale === true), + oldestTileAt: times.length ? Math.min(...times) : null, + }; } return { fetchFlowDetail, @@ -52,6 +110,9 @@ export function createFlowTileSource({ return (await fetchFlowDetail(bounds, options)).segments; }, getFlowSessionStats: () => ({ ...tiles.getStats(), partial }), - resetFlowTileCache: () => tiles.clear(), + resetFlowTileCache: () => { + for (const entry of pending.values()) entry.release(); + tiles.clear(); + }, }; } diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index affaff56d..927dc416e 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import { createFlowTileSource } from './flowSource.js'; import { tilesForBounds, tileToBBox } from '../../data/tomtomTiles.js'; import { decodeFlowTile } from './flowDecode.js'; import { decodeOpenFreeMapTile } from '../../sources/openFreeMap.js'; @@ -867,3 +869,154 @@ test('traffic separates API and public tile transports, including the default ti assert.ok(publicUrls.length >= 4); assert.ok(apiUrls.length >= 4); }); + +test('flow detail reports stale proxy tiles and the oldest tile time, also from its cache', async () => { + const fetchedAt = Date.UTC(2026, 9, 16, 8); + const reply = (status, at) => async () => + new Response(fixture, { + headers: { 'x-tomtom-cache': status, 'x-tomtom-fetched-at': String(at) }, + }); + const stale = createTrafficSource({ + fetchImpl: reply('STALE-BUDGET', fetchedAt), + }); + const first = await stale.fetchFlowDetail(bounds); + assert.ok(first.segments.length > 0); + assert.equal(first.stale, true); + assert.equal(first.oldestTileAt, fetchedAt); + // The decoded tile is served from cache now, and keeps its mark. + const again = await stale.fetchFlowDetail(bounds); + assert.equal(stale.getFlowSessionStats().tilesFetched, 1); + assert.equal(again.stale, true); + assert.equal(again.oldestTileAt, fetchedAt); + + const fresh = createTrafficSource({ fetchImpl: reply('MISS', fetchedAt) }); + const live = await fresh.fetchFlowDetail(bounds); + assert.equal(live.stale, false); + assert.equal(live.oldestTileAt, fetchedAt); + + const unmarked = createTrafficSource({ + fetchImpl: async () => new Response(fixture), + }); + const plain = await unmarked.fetchFlowDetail(bounds); + assert.equal(plain.stale, false); + assert.equal(plain.oldestTileAt, null); +}); + +// Capture constructor-owned Maps only while constructing the source, so tests +// can assert retained metadata without adding a diagnostic API to production. +function flowSourceWithMaps(fetchImpl) { + const maps = []; + const NativeMap = globalThis.Map; + let source; + try { + globalThis.Map = class extends NativeMap { + constructor(...args) { + super(...args); + maps.push(this); + } + }; + source = createFlowTileSource({ fetchImpl }); + } finally { + globalThis.Map = NativeMap; + } + return { source, pending: maps[0] }; +} + +test('flow freshness is released after body failures, size rejection, decode and reset', async () => { + for (const failure of ['body', 'size', 'decode']) { + let requestSignal; + let fail = true; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ + 'x-tomtom-cache': fail ? 'STALE-ERROR' : 'MISS', + 'x-tomtom-fetched-at': fail ? '1000' : '2000', + ...(fail && failure === 'size' + ? { 'content-length': '99999999' } + : {}), + }), + async arrayBuffer() { + if (fail && failure === 'body') throw new Error('body disconnected'); + return fail && failure === 'decode' ? new Uint8Array([255]) : fixture; + }, + }; + }); + await assert.rejects(source.fetchFlowDetail(bounds)); + assert.equal(pending.size, 0, `${failure} leaves no pending metadata`); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); + fail = false; + const fresh = await source.fetchFlowDetail(bounds); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal(pending.size, 0, 'successful decode releases metadata'); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + source.resetFlowTileCache(); + assert.equal(pending.size, 0); + } +}); + +test('flow reset and late cancelled headers cannot retain or replace newer freshness', async () => { + let resolveOld; + let oldSignal; + let calls = 0; + const { source, pending } = flowSourceWithMaps((url, { signal }) => { + if (++calls === 1) { + oldSignal = signal; + return new Promise((resolve) => { + resolveOld = resolve; + }); + } + return Promise.resolve( + new Response(fixture, { + headers: { 'x-tomtom-cache': 'MISS', 'x-tomtom-fetched-at': '2000' }, + }), + ); + }); + const old = source.fetchFlowDetail(bounds); + const rejected = assert.rejects(old, { name: 'AbortError' }); + while (!resolveOld) await new Promise((resolve) => setImmediate(resolve)); + source.resetFlowTileCache(); + const fresh = await source.fetchFlowDetail(bounds); + resolveOld( + new Response(fixture, { + headers: { + 'x-tomtom-cache': 'STALE-ERROR', + 'x-tomtom-fetched-at': '1000', + }, + }), + ); + await rejected; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(oldSignal, 'abort').length, 0); + assert.equal(fresh.stale, false); + assert.equal(fresh.oldestTileAt, 2000); + assert.equal((await source.fetchFlowDetail(bounds)).oldestTileAt, 2000); +}); + +test('cancelling a flow body releases its freshness before a new request', async () => { + const controller = new AbortController(); + let requestSignal; + const { source, pending } = flowSourceWithMaps(async (url, { signal }) => { + requestSignal = signal; + return { + ok: true, + headers: new Headers({ 'x-tomtom-cache': 'STALE-ERROR' }), + async arrayBuffer() { + assert.equal(pending.size, 1); + controller.abort(); + return fixture; + }, + }; + }); + await assert.rejects( + source.fetchFlowDetail(bounds, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(pending.size, 0); + assert.equal(getEventListeners(requestSignal, 'abort').length, 0); + assert.equal(source.getFlowSessionStats().cacheEntries, 0); +}); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index bbb0e9d6e..5e786e408 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -158,6 +158,7 @@ test('terrain middleware migrates valid legacy disk points without fabricating o test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => { isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' }); let now = Date.UTC(2026, 8, 12, 12); + const fetchedAt = String(now); t.mock.method(Date, 'now', () => now); let calls = 0; t.mock.method(globalThis, 'fetch', async (raw) => { @@ -182,10 +183,10 @@ test('traffic middleware preserves keyless mode, caching, stale budget fallback assert.equal(json(await request('/status')).dailyCount, 1); assert.equal(calls, 1); now += 120001; - assert.equal( - (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], - 'STALE-BUDGET', - ); + const staleTile = await request('/flow/8/1/1.pbf'); + assert.equal(staleTile.headers['x-tomtom-cache'], 'STALE-BUDGET'); + // The stale copy still says when TomTom produced it. + assert.equal(staleTile.headers['x-tomtom-fetched-at'], fetchedAt); assert.equal((await request('/flow/8/2/1.pbf')).status, 429); assert.equal(calls, 1); now += 86400000; diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 743fa22a2..0a57ae3ae 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -473,11 +473,22 @@ export const getTrafficFlow = defineTool({ : `No live traffic is reported in ${area.label}.`) + (flow.partial ? ' Some map tiles did not load, so these figures are partial.' + : '') + + // Past the daily TomTom budget, or while TomTom fails, the proxy + // serves its last copy of a tile however old it is. + (flow.stale + ? ' Some tiles are cached copies' + + (isoTime(flow.oldestTileAt) + ? `, the oldest from ${isoTime(flow.oldestTileAt)}` + : '') + + ', so traffic may be out of date.' : ''), data: { view: suggestView(services, { area, layers: ['traffic'] }), area: area.label, partial: flow.partial, + stale: flow.stale === true, + oldest_tile_at: isoTime(flow.oldestTileAt), speed_pct_of_free_flow: speedPct, measured_km: round(measuredKm, 1), congested_km: round(congestedKm, 1), diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 011e431fd..676351b6c 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -555,6 +555,47 @@ test('traffic counts only roads inside a radius area and reports missing tiles', assert.match(partial.summary, /Some map tiles did not load/); }); +test('traffic says when the proxy served cached tiles, and how old the oldest is', async () => { + const road = [ + { + coords: [ + [-0.2, 0], + [0.2, 0], + ], + trafficLevel: 0.9, + }, + ]; + const call = (detail) => + composeCatalog({ + tools: coreTools, + services: { + traffic: { + getStatus: async () => ({ hasKey: true }), + fetchFlowDetail: async () => ({ + segments: road, + partial: false, + ...detail, + }), + }, + }, + }).call('get_traffic_flow', { area: { lat: 0, lon: 0, radius_km: 10 } }); + const oldest = Date.UTC(2026, 9, 16, 8); + const stale = await call({ stale: true, oldestTileAt: oldest }); + assert.equal(stale.data.stale, true); + assert.equal(stale.data.oldest_tile_at, '2026-10-16T08:00:00.000Z'); + assert.match( + stale.summary, + /cached copies, the oldest from 2026-10-16T08:00:00\.000Z, so traffic may be out of date\./, + ); + const live = await call({ stale: false, oldestTileAt: oldest }); + assert.equal(live.data.stale, false); + assert.doesNotMatch(live.summary, /out of date/); + // A source that can't tell (no freshness fields) isn't reported as stale. + const unknown = await call({}); + assert.equal(unknown.data.stale, false); + assert.equal(unknown.data.oldest_tile_at, null); +}); + test('a whole-world bike-share search names every system it skipped', async () => { const empty = { data: { stations: [] } }; const result = await composeCatalog({ From eb8b63fc62b04ac505d72cd2c2d8e8cdb2427240 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:12 -0400 Subject: [PATCH 52/66] fix(directions): show a 59.6-second leg as 1 min, not 60 s routeDurationText checked the raw seconds against 60 but printed them rounded, so a leg of 59.5 s or more read "60 s". Compare the rounded seconds so it moves to minutes. --- changelog.d/1385-route-duration-minute.md | 3 +++ src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1385-route-duration-minute.md diff --git a/changelog.d/1385-route-duration-minute.md b/changelog.d/1385-route-duration-minute.md new file mode 100644 index 000000000..fa354766b --- /dev/null +++ b/changelog.d/1385-route-duration-minute.md @@ -0,0 +1,3 @@ +- Show a route leg of 59.5 seconds or more as 1 min. The route summary and step + cards kept seconds below 60, then rounded 59.6 s up to "60 s" + (Rudy Celekli, @rudycelekli, #1385). diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 4c9b54bd8..9426450a5 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', ( assert.equal(routeDistanceText(1500), '1.5\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); + assert.equal(routeDurationText(59.4), '59\u00a0s'); + assert.equal(routeDurationText(59.6), '1\u00a0min'); assert.equal(routeDurationText(3900), '1\u00a0h\u00a05\u00a0min'); assert.equal(routeDurationText(7200), '2\u00a0h'); assert.equal(routeDistanceText(-1), ''); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..8fec18f65 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -130,7 +130,8 @@ export function routeDistanceText(meters) { */ export function routeDurationText(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return ''; - if (seconds < 60) return formatQuantity(Math.round(seconds), 's'); + const wholeSeconds = Math.round(seconds); + if (wholeSeconds < 60) return formatQuantity(wholeSeconds, 's'); const minutes = Math.round(seconds / 60); if (minutes < 60) return formatQuantity(minutes, 'min'); const hours = formatQuantity(Math.floor(minutes / 60), 'h'); From b7e00f18a4b1ef2f92fdd17fd4418b72f250aae0 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:15 -0400 Subject: [PATCH 53/66] fix(directions): show a 996 m leg as 1.0 km, not 1000 m routeDistanceText checked the raw meters against 1000 but printed them rounded to tens, so 995 m or more read "1000 m". Compare the rounded value so it moves to kilometres. --- changelog.d/1386-route-distance-kilometre.md | 3 +++ src/data/directions.test.mjs | 2 ++ src/layers/directions/index.js | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1386-route-distance-kilometre.md diff --git a/changelog.d/1386-route-distance-kilometre.md b/changelog.d/1386-route-distance-kilometre.md new file mode 100644 index 000000000..0a60fcc62 --- /dev/null +++ b/changelog.d/1386-route-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a route leg of 995 m or more as 1.0 km. The route summary and step + cards kept meters below 1000, then rounded 996 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1386). diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 4c9b54bd8..187f1a8d0 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -846,6 +846,8 @@ test('route numbers follow the page language and never break from their unit', ( t.after(() => setLocale('en')); assert.equal(routeDistanceText(8.4), '8\u00a0m'); assert.equal(routeDistanceText(236), '240\u00a0m'); + assert.equal(routeDistanceText(994), '990\u00a0m'); + assert.equal(routeDistanceText(996), '1.0\u00a0km'); assert.equal(routeDistanceText(1500), '1.5\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 2b8f8875e..95e4cf258 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -112,7 +112,8 @@ export const FLIGHT_PROGRESS_MS = 250; export function routeDistanceText(meters) { if (!Number.isFinite(meters) || meters < 0) return ''; if (meters < 100) return formatQuantity(Math.round(meters), 'm'); - if (meters < 1000) return formatQuantity(Math.round(meters / 10) * 10, 'm'); + const tens = Math.round(meters / 10) * 10; + if (tens < 1000) return formatQuantity(tens, 'm'); const km = meters / 1000; return km < 10 ? formatQuantity(km, 'km', { From 6c225605c40aeb37964a6cfd489d3c0b83dc3c2b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 11:32:19 -0400 Subject: [PATCH 54/66] fix(awareness): show a 999.6 m contact as 1.0 km, not 1000 m The Contacts panel checked the raw meters against 1000 but printed them rounded, so 999.5 m or more read "1000 m". Compare the rounded meters so the distance moves to kilometres. --- changelog.d/1387-awareness-distance-kilometre.md | 3 +++ src/data/militaryAwareness.test.mjs | 11 ++++++++++- src/layers/awareness/panel.js | 5 +++-- 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1387-awareness-distance-kilometre.md diff --git a/changelog.d/1387-awareness-distance-kilometre.md b/changelog.d/1387-awareness-distance-kilometre.md new file mode 100644 index 000000000..4414b7c5f --- /dev/null +++ b/changelog.d/1387-awareness-distance-kilometre.md @@ -0,0 +1,3 @@ +- Show a Contacts entry 999.5 m or more away as 1.0 km. The panel kept meters + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1387). diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 64104953e..ccb5739f2 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -42,7 +42,10 @@ import { formatAwarenessLabel, getAwarenessNavigationTargets, } from './militaryAwarenessEngine.js'; -import { formatAwarenessCount } from '../layers/awareness/panel.js'; +import { + formatAwarenessCount, + presentAwarenessDistance, +} from '../layers/awareness/panel.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; const militaryAwarenessSource = readLayerSource( @@ -69,6 +72,12 @@ test('Contacts labels a capped cohort as a lower bound', () => { assert.equal(formatAwarenessCount({ count: null }), '?'); }); +test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => { + assert.equal(presentAwarenessDistance(999.4), '999\u00a0m'); + assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km'); + assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km'); +}); + function installAwarenessRuntime({ isEnabled = () => true, isEffectivelyEnabled = isEnabled, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 5fe2a0ac8..120e91b2c 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -25,9 +25,10 @@ function presentAwarenessCount(summary = {}) { } /** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */ -function presentAwarenessDistance(meters) { +export function presentAwarenessDistance(meters) { if (!Number.isFinite(meters) || meters < 0) return '—'; - if (meters < 1000) return formatQuantity(Math.round(meters), 'm'); + const wholeMeters = Math.round(meters); + if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm'); const digits = meters < 10000 ? 1 : 0; return formatQuantity(meters / 1000, 'km', { minimumFractionDigits: digits, From be869f916061017eadb251328829579e055c3a9a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:22 -0700 Subject: [PATCH 55/66] docs: document PR #1385 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..848c6714f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4133,6 +4133,9 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. +Route duration labels select their unit after rounding seconds: a leg of +59.5 seconds or more displays as `1 min`, rather than `60 s`. + A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. What a card needs is a resolved ground anchor: a step whose ground cell has not From d6ae581652effa0c207992ceaf57c68bf673b6e2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:30 -0700 Subject: [PATCH 56/66] docs: document PR #1386 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..7543cb4f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4081,6 +4081,9 @@ routing it shows `Routing…`, and a router miss reads The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. +Route distance labels round to the nearest ten metres before selecting the +unit, so 995 metres and above display as `1.0 km` rather than `1000 m`. + **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From c30d2d34734b5256c7ec21a2ac65d40a22bc10c1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:37 -0700 Subject: [PATCH 57/66] docs: document PR #1387 rounding behavior --- docs/CURRENT-STATE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be09e5664..dd9b15cda 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4363,6 +4363,9 @@ silent for five minutes are removed. Credits appear when the feed is used. coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. +Contacts distance labels select metres or kilometres after rounding to the +nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`. + At global scale, ambient Radio cluster badges are hard-opacity shared-host entries: count/category updates and identity replacement do not run keyhole or enter/exit ramps. Shared collision, viewport rejection, allocation, and horizon From 4d64ac48bc20509075e31519019349386e275f8f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:48:58 -0700 Subject: [PATCH 58/66] docs: clarify unit transition examples --- docs/CURRENT-STATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 848c6714f..af916fcaf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4133,8 +4133,8 @@ and as the last line of the list, because the arrival step is among the ones dropped. A route of exactly 200 is complete, and a roundabout exit after the 200th keeps folding into it. -Route duration labels select their unit after rounding seconds: a leg of -59.5 seconds or more displays as `1 min`, rather than `60 s`. +Route duration labels select their unit after rounding seconds, switching to +minutes at 59.5 seconds; a 59.6-second leg displays as `1 min`, rather than `60 s`. A step's card opens from its dot on the globe or from its line in the list, and the list covers every step including departure and arrival, which have no dot. From 455dbf2b5f56932782bdb2b07ec435fabec9b559 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:49:00 -0700 Subject: [PATCH 59/66] docs: clarify unit transition examples --- docs/CURRENT-STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7543cb4f0..f171806e0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4082,7 +4082,8 @@ The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. Route distance labels round to the nearest ten metres before selecting the -unit, so 995 metres and above display as `1.0 km` rather than `1000 m`. +unit, switching to kilometres at 995 metres; a 996-metre leg displays as +`1.0 km` rather than `1000 m`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From 48e649a95e57a9ae6d4f02dd89b16c0237b2e0e6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 08:50:11 -0700 Subject: [PATCH 60/66] docs(directions): scope the distance rounding threshold precisely --- docs/CURRENT-STATE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f171806e0..87fadd8a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4081,9 +4081,9 @@ routing it shows `Routing…`, and a router miss reads The layer holds continuous render only while a route is drawn (the dashes animate). Disable clears all state. -Route distance labels round to the nearest ten metres before selecting the -unit, switching to kilometres at 995 metres; a 996-metre leg displays as -`1.0 km` rather than `1000 m`. +At the metre/kilometre boundary, route distance labels use the rounded +ten-metre value to select the unit, switching to kilometres at 995 metres; +a 996-metre leg displays as `1.0 km` rather than `1000 m`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. From 496d024431b95bb5bbddd5dac17b00fd01ad1409 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:18:22 -0700 Subject: [PATCH 61/66] fix(hud): drop a summary reply once the camera starts moving (#1384) Discard stale HUD summaries when camera movement starts and allow cancelled requests to retry for unchanged place context. Contribution by @quanticsoul4772. --- changelog.d/1384-hud-summary-moving.md | 4 + docs/CURRENT-STATE.md | 1 + src/hud.js | 47 +++- src/hudAltitudeDatum.test.mjs | 343 ++++++++++++++++++++++++- 4 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1384-hud-summary-moving.md diff --git a/changelog.d/1384-hud-summary-moving.md b/changelog.d/1384-hud-summary-moving.md new file mode 100644 index 000000000..21965af96 --- /dev/null +++ b/changelog.d/1384-hud-summary-moving.md @@ -0,0 +1,4 @@ +- The HUD's AI place summary drops a reply that arrives after the camera has + started moving, so a summary asked for before a flight no longer names the + old place mid-flight. Cancelled or partially typed summaries retry even if the place + context stays the same (quanticsoul4772, #1384). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index eceb4c239..9afb99c45 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5107,6 +5107,7 @@ and unreachable upstream (502/504) separately from road geometry. - HUD `SUMMARY` readout requests a five-word intelligence-style summary from `/api/openai/hud-summary` (model `OPENAI_HUD_SUMMARY_MODEL`, default `gpt-5-nano`, minimal reasoning). - Input is the live basemap label context (place/street/nearby-place labels + enabled layers) — the model is instructed not to infer from coordinates. - Output is sanitized to exactly five words; falls back to the deterministic telemetry summary on error/timeout (5s abort); typewriter animation on update. +- A summary asked for before the camera starts moving never paints over the next view: `moveStart` marks the summary dirty and bumps its revision, as `moveEnd` does, aborts the request in flight and stops a reply still typing; neither a reply nor a fallback line (on an error, the 5 s timeout, the abort, or a failed context build) paints once the revision has moved. A dropped request forgets its signature and a timeout counts as a failure, so the next 15 s tick asks again even over the same place. An interrupted typewriter reply also forgets its signature; fully displayed answers remain reusable. ### Place-search providers diff --git a/src/hud.js b/src/hud.js index 8190f4b1b..cf7983da7 100644 --- a/src/hud.js +++ b/src/hud.js @@ -224,6 +224,20 @@ export class IntelHUD { // session, so this flips once — and both altitude readouts have to move // together when it does (see the repaint in _updateCameraData). this._geoidCorrectionApplied = false; + // A summary built for one view must not land on the next: a request in + // flight when the camera starts moving is aborted and nothing it ends + // with paints (the revision moves), a reply still typing stops, and the + // summary is marked dirty so the next periodic tick asks about the view + // the camera has moved to. + this._onCameraMoveStart = () => { + this._markSummaryDirty(); + if (this._summaryRequest || this._summaryTypingInterval) { + // Cancelled requests and partially typed replies are not reusable answers. + this._lastSummarySignature = null; + } + this._summaryRequest?.abort(); + this._stopSummaryTyping(); + }; this._onCameraMoveEnd = () => { this._markSummaryDirty(); // The 250 ms telemetry timer and 15 s semantic-summary timer must not @@ -249,6 +263,7 @@ export class IntelHUD { this._passNum = 100 + Math.floor(Math.random() * 200); this._buildDOM(); + this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this._startTimers(); } @@ -766,6 +781,12 @@ export class IntelHUD { return provenance ? `${line} | ${provenance}` : line; } + /** Stop a summary still typing out, leaving what it has typed so far. */ + _stopSummaryTyping() { + clearInterval(this._summaryTypingInterval); + this._summaryTypingInterval = null; + } + /** * Animate the summary text into the DOM using a typewriter effect * (2 characters every 24ms). @@ -816,8 +837,10 @@ export class IntelHUD { } catch (error) { console.warn('[HUD] summary context unavailable:', error); // Left dirty on purpose: the next periodic tick retries instead of - // sticking on the fallback line for the rest of the session. - this._setSummaryText(fallbackText, animate); + // sticking on the fallback line for the rest of the session. The + // fallback was composed for this view; a moved camera keeps its own. + if (revision === this._summaryRevision) + this._setSummaryText(fallbackText, animate); return; } if (revision !== this._summaryRevision) return; @@ -832,7 +855,11 @@ export class IntelHUD { this._summaryDirty = false; this._lastSummarySignature = signature; const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 5000); + let timedOut = false; + const timeout = window.setTimeout(() => { + timedOut = true; + controller.abort(); + }, 5000); this._summaryRequest = controller; try { this.summaryPolicy.onRequest?.(); @@ -855,17 +882,24 @@ export class IntelHUD { animate, ); } catch (error) { - if (error?.name !== 'AbortError') { + // The 5 s timeout is a failure like any other; a move's abort (or + // destroy's) is not. + const moved = revision !== this._summaryRevision; + if (error?.name !== 'AbortError' || timedOut) { console.warn('[HUD] AI summary unavailable:', error); // Invalidate the committed signature so the next periodic tick // retries instead of sticking on the fallback line forever. this._lastSummarySignature = null; this._summaryDirty = true; } - this._setSummaryText(fallbackText, animate); + if (!moved) this._setSummaryText(fallbackText, animate); } finally { window.clearTimeout(timeout); if (this._summaryRequest === controller) this._summaryRequest = null; + // A request whose answer was dropped (aborted by a move, or a reply + // that came back after one) committed a signature it never painted; + // forget it, or the next tick over the same place would skip asking. + if (revision !== this._summaryRevision) this._lastSummarySignature = null; } } @@ -874,6 +908,8 @@ export class IntelHUD { this._typeSummary(text); return; } + // A plain paint replaces any reply still typing. + this._stopSummaryTyping(); const el = document.getElementById('hud-summary'); if (el) el.textContent = text; } @@ -1044,6 +1080,7 @@ export class IntelHUD { clearInterval(this._timestampInterval); clearInterval(this._summaryInterval); clearInterval(this._summaryTypingInterval); + this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this._dataManagerUnsubscribe?.(); this._summaryRequest?.abort(); diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index eeb407da4..ca14569e5 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -16,6 +16,9 @@ // globals exist: Cesium's widget bundle probes for a real `document` at module // scope and a partial stub sends it down the browser path. Hence hook → // import → install DOM, in that order. +// +// The same hook is why the live AI-summary test lives here too: this is the +// one file that can construct a real IntelHUD. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -33,7 +36,8 @@ registerHooks({ return { format: 'module', shortCircuit: true, - source: 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', }; } return next(url, context); @@ -59,10 +63,20 @@ const SFO_ELLIPSOIDAL_M = -15; */ function installHudEnvironment() { const elements = new Map( - ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] - .map((id) => [id, { textContent: '' }]), + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), ); const previousDocument = globalThis.document; + const cameraListeners = {}; globalThis.document = { getElementById: (id) => elements.get(id) ?? null, querySelectorAll: () => [], @@ -78,12 +92,24 @@ function installHudEnvironment() { height: SFO_ELLIPSOIDAL_M, }, computeViewRectangle: () => undefined, - moveEnd: { addEventListener() {}, removeEventListener() {} }, + moveStart: { + addEventListener(listener) { + cameraListeners.moveStart = listener; + }, + removeEventListener() {}, + }, + moveEnd: { + addEventListener(listener) { + cameraListeners.moveEnd = listener; + }, + removeEventListener() {}, + }, }, }; return { elements, viewer, + cameraListeners, restore() { if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; @@ -93,7 +119,9 @@ function installHudEnvironment() { test('hud.js corrects the camera height to MSL through the geoid module', () => { assert.equal( - has(/import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s), + has( + /import \{[^}]*\bellipsoidalToMslDisplayM\b[^}]*\} from '\.\/data\/geoid\.js';/s, + ), true, 'hud.js must take the datum correction from ./data/geoid.js, not re-derive one', ); @@ -108,7 +136,9 @@ test('hud.js corrects the camera height to MSL through the geoid module', () => 'the ~2.7 MB grid must be requested once, on demand — not at HUD construction', ); assert.equal( - has(/\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/), + has( + /\.catch\(\(\) => \{\s*\/\* readout falls back to the uncorrected height \*\/\s*\}\)/, + ), true, 'a failed geoid load must leave the readout uncorrected, not unhandled', ); @@ -141,7 +171,9 @@ test('the summary ALT tag agrees with the corner readout', () => { // Both are on screen together; a viewer reading "ALT -15M" in one corner and // "ALT: 17m" in the other has found a bug, not a distinction. assert.equal( - has(/const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/), + has( + /const altDisplayM = Number\.isFinite\(m\.altMslM\) \? m\.altMslM : m\.altM;/, + ), true, 'the summary altitude tag must prefer the MSL datum and fall back to the raw height', ); @@ -188,7 +220,11 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in // Tick 1 — cold. This is also the tick that requests the grid. hud._updateCameraData(); assert.match(alt(), /^ALT: -15m/, `cold corner readout, got ${alt()}`); - assert.match(summary(), /\| ALT -15M \|/, `cold summary tag, got ${summary()}`); + assert.match( + summary(), + /\| ALT -15M \|/, + `cold summary tag, got ${summary()}`, + ); // The HUD registered its own continuation on this same shared promise // during tick 1, and it registered first, so awaiting here means its @@ -248,3 +284,294 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig env.restore(); } }); + +test('a summary asked for one view is dropped once the camera moves', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context) => + new Promise((resolve) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary, error: null } }), + }); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + let place = 'Yellowstone'; + hud._summaryContext = async () => ({ place }); + const summary = () => env.elements.get('hud-summary').textContent; + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The periodic tick's call (force=false): it asks only for a dirty + // summary. Asked over Yellowstone; the camera leaves before the reply + // lands. + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + pending[0].reply('Canyon Junction Wyoming'); + await first; + assert.doesNotMatch( + summary(), + /Canyon Junction/, + 'a reply for the old view is dropped', + ); + + // The next tick, with the camera still moving (a follow camera or an + // orbit emits no moveEnd), asks about where it is now and paints it. + place = 'Atlantic'; + const moving = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'the move marked the summary dirty'); + assert.deepEqual(pending[1].context, { place: 'Atlantic' }); + pending[1].reply('North Atlantic Ocean'); + await moving; + assert.equal(summary(), 'North Atlantic Ocean'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a request the camera leaves paints nothing: aborted, failed, or its context build throws', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + // Like fetch: an abort rejects with an AbortError, unless `ignoreAbort`. + let ignoreAbort = false; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ signal, reject }); + if (!ignoreAbort) + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'Yellowstone' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // The move aborts the request in flight, and the abort paints nothing. + let update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + assert.equal(pending[0].signal.aborted, true, 'the request is aborted'); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // A request that fails after the move (an error, the 5 s timeout) does + // not paint the old view's fallback line either. + ignoreAbort = true; + update = hud._updateSummary(false, true); + await settle(); + element.textContent = 'shown before the move'; + env.cameraListeners.moveStart(); + pending[1].reject(new Error('network')); + await update; + assert.equal(element.textContent, 'shown before the move'); + + // Nor does a context build that throws once the camera has moved. + hud._summaryContext = async () => { + env.cameraListeners.moveStart(); + throw new Error('view target unavailable'); + }; + element.textContent = 'shown before the move'; + await hud._updateSummary(false, true); + assert.equal(element.textContent, 'shown before the move'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a cancelled summary retries the same context but completed answers stay reusable', async () => { + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ + context, + reply: (summary) => + resolve({ ok: true, status: 200, data: { summary } }), + }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + const first = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 1); + env.cameraListeners.moveStart(); + await first; + + // An orbit can leave all semantic place/layer labels unchanged. The next + // periodic tick must retry even though its context equals the aborted one. + const retry = hud._updateSummary(false); + await settle(); + assert.equal( + pending.length, + 2, + 'the cancelled signature cannot suppress a retry', + ); + assert.deepEqual(pending[1].context, pending[0].context); + pending[1].reply('San Francisco Bay Urban Landscape'); + await retry; + assert.equal( + env.elements.get('hud-summary').textContent, + 'San Francisco Bay Urban Landscape', + ); + + // Movement without an active request must not discard a completed answer. + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(pending.length, 2, 'completed context still deduplicates'); + } finally { + for (const request of pending) request.reply(''); + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('after a dropped or timed-out request the next tick asks again, and a move stops typing', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + const pending = []; + const summaryService = { + summarize: (context, { signal }) => + new Promise((resolve, reject) => { + pending.push({ resolve }); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + // The same place before and after: a slow follow camera, a small pan. + hud._summaryContext = async () => ({ place: 'Lisboa' }); + const element = env.elements.get('hud-summary'); + const settle = () => new Promise((resolve) => setImmediate(resolve)); + hud._updateCameraData(); + + // A move aborts the request; the next tick over the same place asks again. + let update = hud._updateSummary(false); + await settle(); + env.cameraListeners.moveStart(); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 2, 'asked again after the abort'); + + // That one times out at 5 s: retried like any failure. + t.mock.timers.tick(5_000); + await update; + update = hud._updateSummary(false); + await settle(); + assert.equal(pending.length, 3, 'asked again after the timeout'); + pending[2].resolve({ + ok: true, + status: 200, + data: { summary: 'Lisboa Rua Augusta', error: null }, + }); + await update; + + // A reply still typing stops when the camera starts moving. + hud._typeSummary('Canyon Junction Wyoming Yellowstone National Park'); + t.mock.timers.tick(48); + const typed = element.textContent; + env.cameraListeners.moveStart(); + t.mock.timers.tick(2_000); + assert.equal( + element.textContent, + typed, + 'no more characters after the move', + ); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); + +test('a summary interrupted while typing can retry unchanged context', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const env = installHudEnvironment(); + const previousWindow = globalThis.window; + globalThis.window = globalThis; + let calls = 0; + const text = 'San Francisco Bay Urban Landscape'; + const summaryService = { + summarize: async () => { + calls++; + return { ok: true, status: 200, data: { summary: text } }; + }, + }; + let hud; + try { + hud = new IntelHUD(env.viewer, { summaryService }); + hud._summaryContext = async () => ({ place: 'San Francisco' }); + hud._updateCameraData(); + await hud._updateSummary(true); + t.mock.timers.tick(48); + const element = env.elements.get('hud-summary'); + assert.notEqual(element.textContent, text, 'the answer is still typing'); + assert.equal( + hud._summaryRequest, + null, + 'the network request already finished', + ); + env.cameraListeners.moveStart(); + assert.equal(hud._summaryTypingInterval, null); + await hud._updateSummary(false); + assert.equal( + calls, + 2, + 'a partially displayed answer must not suppress retry', + ); + assert.equal(element.textContent, text); + env.cameraListeners.moveStart(); + await hud._updateSummary(false); + assert.equal(calls, 2, 'the fully displayed answer remains reusable'); + } finally { + hud?.destroy(); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + env.restore(); + } +}); From 99429904d4c80709d4e4955aa908aedb9931972d Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:10 -0400 Subject: [PATCH 62/66] fix(draw): show a 999.6 m line as 1.0 km, not 1000 m formatMeasure checked the raw length against 1000 but printed it rounded, so a drawn line of 999.5 m or more read "1000 m" in the hint. Compare the rounded metres so it moves to kilometres. --- changelog.d/1388-draw-line-kilometre.md | 3 +++ src/annotations/drawMode.js | 7 ++++--- src/annotations/drawMode.test.mjs | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 changelog.d/1388-draw-line-kilometre.md diff --git a/changelog.d/1388-draw-line-kilometre.md b/changelog.d/1388-draw-line-kilometre.md new file mode 100644 index 000000000..137075707 --- /dev/null +++ b/changelog.d/1388-draw-line-kilometre.md @@ -0,0 +1,3 @@ +- Show a drawn line of 999.5 m or more as 1.0 km. The draw hint kept metres + below 1000, then rounded 999.6 m up to "1000 m" + (Rudy Celekli, @rudycelekli, #1388). diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index fcfe08be6..538c2e5da 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -208,9 +208,10 @@ export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { const m = pathLengthM(session.vertices); - return m >= 1000 - ? `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km` - : `${Math.round(m)} m`; + // Round before the kilometre cut. 999.6 m was printed as "1000 m". + const whole = Math.round(m); + if (whole < 1000) return `${whole} m`; + return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 50475cc9e..be9894dda 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -330,3 +330,18 @@ test('longitude unwrapping is continuous and re-wraps to one canonical range', ( assert.equal(wrapLongitude(180), -180); assert.equal(unwrapLongitudes([]).length, 0); }); + +test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + const lat = 999.6 / metresPerDegree; + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0, lat }); + const metres = pathLengthM(line.vertices); + assert.ok( + metres >= 999.5 && metres < 1000, + `length should round to 1000 m, got ${metres}`, + ); + assert.equal(formatMeasure(line), '1.0 km'); + assert.match(drawHint(line), /^1\.0 km · /); +}); From 5ef12391e143099e445912222f8105633dad0cb9 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 12:32:18 -0400 Subject: [PATCH 63/66] =?UTF-8?q?fix(cockpit):=20show=20wind=20from=20359.?= =?UTF-8?q?6=C2=B0=20as=200=C2=B0,=20not=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatCockpitWindDirection rounded the bearing and printed that number, so wind from 359.5° or more read "N · 360°". Take the rounded degrees modulo 360 so north reads 0°. --- changelog.d/1389-cockpit-wind-north.md | 3 +++ src/ui/cockpitPresentation.js | 4 +++- src/ui/cockpitPresentation.test.mjs | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1389-cockpit-wind-north.md create mode 100644 src/ui/cockpitPresentation.test.mjs diff --git a/changelog.d/1389-cockpit-wind-north.md b/changelog.d/1389-cockpit-wind-north.md new file mode 100644 index 000000000..cc81c362e --- /dev/null +++ b/changelog.d/1389-cockpit-wind-north.md @@ -0,0 +1,3 @@ +- Show cockpit wind from 359.5° or more as 0°, not 360°. Rounding 359.6° + printed "N · 360°" on the local wind direction + (Rudy Celekli, @rudycelekli, #1389). diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 8285b74b3..b9c000503 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -156,7 +156,9 @@ export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); const normalized = ((value % 360) + 360) % 360; - return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`; + // 359.5° and above is north. Modulo keeps that from printing 360°. + const degrees = Math.round(normalized) % 360; + return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } // Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a diff --git a/src/ui/cockpitPresentation.test.mjs b/src/ui/cockpitPresentation.test.mjs new file mode 100644 index 000000000..da5c8e53b --- /dev/null +++ b/src/ui/cockpitPresentation.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { formatCockpitWindDirection } from './cockpitPresentation.js'; + +test('wind just west of north reads 0°, not 360°', () => { + assert.equal(formatCockpitWindDirection(90), 'E · 90°'); + assert.equal(formatCockpitWindDirection(0), 'N · 0°'); + assert.equal(formatCockpitWindDirection(359.4), 'N · 359°'); + assert.equal(formatCockpitWindDirection(359.6), 'N · 0°'); +}); From 6ebdee6710bbfae5e678feb414b300a72a8a2efa Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:19 -0700 Subject: [PATCH 64/66] docs: document PR #1388 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..438d41776 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5240,6 +5240,7 @@ are omitted rather than framing the wrong part of the globe. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. From 8f025416573a88557ea65797750c31d80975960f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 25 Oct 2026 12:11:49 -0700 Subject: [PATCH 65/66] docs: document PR #1389 rounding behavior --- docs/CURRENT-STATE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9afb99c45..dedc6489b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5460,6 +5460,7 @@ pins and share links already supply complete boxes. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, `NOIR`, and `SNOW`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. From 69fba2ebbe44d04d237852cc6b0c71a00d9db1e6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:36:50 -0700 Subject: [PATCH 66/66] fix(perf): free a turned-off layer's entities that Cesium keeps (#1367) Release stale Cesium command references after disabling a layer while preserving current-frame commands. Contribution by @quanticsoul4772. --- changelog.d/1367-turned-off-layer-memory.md | 4 + docs/CURRENT-STATE.md | 10 ++ scripts/package-boundaries.json | 3 +- src/app/tools.js | 10 ++ src/sceneCommandRelease.js | 74 +++++++++++ src/sceneCommandRelease.test.mjs | 137 ++++++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1367-turned-off-layer-memory.md create mode 100644 src/sceneCommandRelease.js create mode 100644 src/sceneCommandRelease.test.mjs diff --git a/changelog.d/1367-turned-off-layer-memory.md b/changelog.d/1367-turned-off-layer-memory.md new file mode 100644 index 000000000..08bbbad81 --- /dev/null +++ b/changelog.d/1367-turned-off-layer-memory.md @@ -0,0 +1,4 @@ +- Turning a layer off frees the memory its entities held. Cesium kept + removed layers' draw commands in reused per-pass arrays, so Data Centers + kept 116 MiB after it was switched off, submarine cables 74 MiB and dams + 30 MiB; they now keep 13, 9 and 9 MiB (quanticsoul4772, #1367). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9bc87ad2..b15babbb2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2159,6 +2159,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged. > always exits non-zero, `1` meaning the control is healthy and `2` meaning it > is not. Plus the unit pins above. +> **2026-10-24 — a turned-off layer's memory is released:** Cesium 1.138 +> keeps the draw commands past each frame's count in the opaque and other +> per-pass arrays (`View.frustumCommandsList`) and in the offscreen pick view, +> so a turned-off layer's entities stayed reachable (Data Centers kept 116 +> MiB). `src/sceneCommandRelease.js`, installed in `src/app/tools.js`, +> requests one frame through `governorRequestRender` after every layer +> disable and then trims those arrays. It reads private Cesium fields; +> `src/sceneCommandRelease.test.mjs` pins them and fails if Cesium renames +> them or changes which passes it truncates. + > **2026-08-08 — performance waves 1+2:** the app idles via an explicit render > governor (`src/renderGovernor.js` — hold/release from every per-frame > animator; discrete mutators call `governorRequestRender`). Any NEW diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c84a11cad..68a57066a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2574,7 +2574,8 @@ "src/voice/geminiSession.js", "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", - "src/ui/statusBadge.js" + "src/ui/statusBadge.js", + "src/sceneCommandRelease.js" ], "external": [ "hls.js", diff --git a/src/app/tools.js b/src/app/tools.js index 7b3ea83c9..7aea39c22 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -7,6 +7,7 @@ import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installRenderGovernor, getRenderGovernorDiagnostics, @@ -110,6 +111,15 @@ export function createApplicationTools({ // nothing animates per frame. Installed AFTER every module above has had // its chance to register pre-install holds. (perf wave 2) installRenderGovernor(viewer); + // Cesium keeps a turned-off layer's draw commands (and so all its entities) + // alive in reused per-pass arrays; trim them after each disable. + defer( + installLayerReleaseTrim({ + scene: viewer.scene, + dataManager, + requestRender: governorRequestRender, + }), + ); // Install the explicit scope mask used by the DISPLAY controls. installScopeMask(viewer); diff --git a/src/sceneCommandRelease.js b/src/sceneCommandRelease.js new file mode 100644 index 000000000..4d8594401 --- /dev/null +++ b/src/sceneCommandRelease.js @@ -0,0 +1,74 @@ +/** + * Let a turned-off layer's memory go. + * + * Cesium (engine 22.3, `Scene/View.js`) reuses each view's per-pass command + * arrays: every frame resets `frustumCommands.indices[pass]` to 0 and + * overwrites `commands[pass][i]` from the start. `Scene.js` truncates the + * voxel, Gaussian-splat and translucent passes to their counts; the opaque + * and other passes keep the slots past the new count. When a layer is turned + * off the next frame has fewer commands, so that tail keeps the destroyed + * primitives' DrawCommands, and through `_owner` → primitive → entity → + * EntityCollection the whole data source stays reachable. The offscreen view + * `scene.sampleHeight` / `pickFromRay` render into keeps its last list the + * same way, until the next height sample. + * + * Measured (production build, heap after GC, Data Centers on → off): 116 MiB + * kept on main, 13 MiB with this module; heap snapshots traced the entities + * through `View.frustumCommandsList` and `Picking._pickOffscreenView`. + * + * After a layer finishes turning off, this renders one frame, trims the main + * view's arrays to that frame's counts, and empties the offscreen pick view's + * lists (it rebuilds them in full on every pick). It reads private Cesium + * fields: `src/sceneCommandRelease.test.mjs` fails if any of them is renamed + * or if Cesium changes which passes it truncates, and once Cesium clears the + * tail itself this module can go. + */ + +/** Drop command references a view no longer draws. */ +function trimView(view, { keepCurrent }) { + for (const frustum of view?.frustumCommandsList || []) { + const { commands, indices } = frustum; + for (let pass = 0; pass < commands.length; pass += 1) { + const keep = keepCurrent ? indices[pass] : 0; + if (commands[pass].length > keep) commands[pass].length = keep; + if (!keepCurrent) indices[pass] = 0; + } + } + if (!keepCurrent) + for (const extent of view?._commandExtents || []) + extent.command = undefined; +} + +/** + * Release stale command references in the scene's main view (keeping what + * the last frame drew) and in its offscreen pick view (all of it). + * @param {object} scene Cesium Scene, right after a render. + */ +export function trimStaleSceneCommands(scene) { + trimView(scene._defaultView, { keepCurrent: true }); + trimView(scene._picking?._pickOffscreenView, { keepCurrent: false }); +} + +/** + * After every layer disable, render one frame and trim once it is drawn. + * @param {{scene: object, dataManager: object, requestRender: (reason: string) => void}} deps + * @returns {() => void} uninstall + */ +export function installLayerReleaseTrim({ scene, dataManager, requestRender }) { + let removeOneShot = null; + const unsubscribe = dataManager.subscribe((change) => { + if (change?.type !== 'visibility' || change.enabled !== false) return; + if (removeOneShot) return; // a trim is already waiting for its frame + removeOneShot = scene.postRender.addEventListener(() => { + removeOneShot(); + removeOneShot = null; + trimStaleSceneCommands(scene); + }); + requestRender('layer-release-trim'); + }); + return () => { + unsubscribe(); + removeOneShot?.(); + removeOneShot = null; + }; +} diff --git a/src/sceneCommandRelease.test.mjs b/src/sceneCommandRelease.test.mjs new file mode 100644 index 000000000..d88037872 --- /dev/null +++ b/src/sceneCommandRelease.test.mjs @@ -0,0 +1,137 @@ +// A turned-off layer's draw commands must not stay reachable through Cesium's +// reused per-pass command arrays (src/sceneCommandRelease.js). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + installLayerReleaseTrim, + trimStaleSceneCommands, +} from './sceneCommandRelease.js'; + +const frustum = (lists, counts) => ({ + commands: lists.map((list) => [...list]), + indices: [...counts], +}); + +test('the main view keeps what the last frame drew and drops the stale tail', () => { + const view = { + frustumCommandsList: [frustum([['a', 'b', 'stale'], ['c'], []], [2, 1, 0])], + }; + trimStaleSceneCommands({ _defaultView: view }); + assert.deepEqual(view.frustumCommandsList[0].commands, [ + ['a', 'b'], + ['c'], + [], + ]); + assert.deepEqual(view.frustumCommandsList[0].indices, [2, 1, 0]); +}); + +test('the offscreen pick view is emptied, since every pick rebuilds it', () => { + const extents = [{ command: 'x' }, { command: 'y' }]; + const pick = { + frustumCommandsList: [frustum([['p', 'q'], ['r']], [2, 1])], + _commandExtents: extents, + }; + trimStaleSceneCommands({ + _defaultView: { frustumCommandsList: [] }, + _picking: { _pickOffscreenView: pick }, + }); + assert.deepEqual(pick.frustumCommandsList[0].commands, [[], []]); + assert.deepEqual(pick.frustumCommandsList[0].indices, [0, 0]); + assert.deepEqual( + extents.map((e) => e.command), + [undefined, undefined], + ); +}); + +test('a layer disable renders one frame and trims after it', () => { + const listeners = new Set(); + const postRender = new Set(); + const requests = []; + const view = { frustumCommandsList: [frustum([['a', 'stale']], [1])] }; + const scene = { + _defaultView: view, + postRender: { + addEventListener(fn) { + postRender.add(fn); + return () => postRender.delete(fn); + }, + }, + }; + const uninstall = installLayerReleaseTrim({ + scene, + dataManager: { + subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + }, + requestRender: (reason) => requests.push(reason), + }); + const emit = (change) => listeners.forEach((fn) => fn(change)); + + emit({ type: 'visibility', layerId: 'x', enabled: true }); + assert.equal(requests.length, 0, 'enabling trims nothing'); + emit({ type: 'visibility', layerId: 'x', enabled: false }); + emit({ type: 'visibility', layerId: 'y', enabled: false }); + assert.deepEqual( + requests, + ['layer-release-trim'], + 'one frame for back-to-back disables', + ); + assert.equal( + view.frustumCommandsList[0].commands[0].length, + 2, + 'not before the frame', + ); + postRender.forEach((fn) => fn()); + assert.deepEqual(view.frustumCommandsList[0].commands, [['a']]); + assert.equal(postRender.size, 0, 'the frame listener is one-shot'); + uninstall(); + assert.equal(listeners.size, 0); +}); + +// The workaround reads private Cesium fields. Pin the shape it relies on, and +// fail when Cesium starts clearing the tail itself so the module can go. +test('Cesium still reuses per-pass command arrays without clearing their tail', () => { + const require = createRequire(import.meta.url); + const enginePkg = require.resolve('@cesium/engine/package.json'); + const read = (rel) => + readFileSync(new URL(rel, pathToFileURL(enginePkg)), 'utf8'); + const scene = read('./Source/Scene/Scene.js'); + const view = read('./Source/Scene/View.js'); + const frustumCommands = read('./Source/Scene/FrustumCommands.js'); + const picking = read('./Source/Scene/Picking.js'); + // Every private field the trim reads. A rename would otherwise turn the + // trim into a silent no-op (it reaches them through `?.` and `|| []`). + assert.match(scene, /this\._picking = new Picking\(this\);/); + assert.match(scene, /this\._defaultView = new View\(/); + assert.match(picking, /this\._pickOffscreenView = new View\(/); + assert.match(view, /this\.frustumCommandsList = \[\];/); + assert.match(view, /frustumCommandsList\[n\]\.indices\[p\] = 0;/); + assert.match(view, /this\._commandExtents = \[\];/); + assert.match( + frustumCommands, + /this\.commands = commands;\s*this\.indices = indices;/, + ); + // Cesium truncates only the voxel, Gaussian-splat and translucent passes, + // each in its own draw step; the opaque and other passes keep their tail. + // A change here means Cesium's handling moved: re-check whether this + // module is still needed. + assert.doesNotMatch( + view, + /\.length\s*=\s*[^;]*indices\[/, + 'View.js now truncates the per-pass arrays: remove src/sceneCommandRelease.js', + ); + assert.deepEqual( + [ + ...scene.matchAll( + /commands\.length = frustumCommands\.indices\[Pass\.(\w+)\];/g, + ), + ].map((match) => match[1]), + ['VOXELS', 'GAUSSIAN_SPLATS', 'TRANSLUCENT'], + 'Scene.js truncates a different set of passes: re-check src/sceneCommandRelease.js', + ); +});