From 28f3243090fe2aa766d9df2f096e095811f04ac0 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:20:16 -0500 Subject: [PATCH 1/9] feat(cyclones): publish explicit selection to shared context --- scripts/package-boundaries.json | 2 + src/app/constructCatalog.js | 4 +- src/app/layers/cyclones.js | 11 +++++ src/layers/cyclones/index.js | 52 +++++++++++++++++++++ src/layers/cyclones/index.test.mjs | 72 +++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/app/layers/cyclones.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..93bdcf7e1 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1340,6 +1340,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", @@ -2075,6 +2076,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 7ed03d0d2..0437e1749 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,6 +1,6 @@ import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; -import { createCyclonesLayer } from '../layers/cyclones/index.js'; +import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; @@ -181,7 +181,7 @@ export function createApplicationCatalog({ id: 'weather-lightning', clock: weatherClock, }), - createCyclonesLayer({ feed: sources.cyclones }), + createApplicationCyclones({ feed: sources.cyclones }), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), createApplicationFirms({ diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js new file mode 100644 index 000000000..d671a11eb --- /dev/null +++ b/src/app/layers/cyclones.js @@ -0,0 +1,11 @@ +import { createCyclonesLayer } from '../../layers/cyclones/index.js'; +import * as context from '../../data/contextStore.js'; + +/** Wire cyclone selection into the application shared-context owner. */ +export function createApplicationCyclones(options) { + const browserContext = typeof window !== 'undefined' ? { context } : {}; + return createCyclonesLayer({ + ...browserContext, + ...options, + }); +} diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 666614176..2738bebe0 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -37,6 +37,7 @@ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, + context = null, cesium = Cesium, createRendering = createCycloneRendering, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -52,6 +53,8 @@ export function createCyclonesLayer({ selectedId = null, selectionIntent = 'auto', navigationGeneration = 0, + selectedContextStormId = null, + selectedContextEntity = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -62,6 +65,46 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; + + function clearSelectedContext() { + context?.removeEntityContextsForLayer?.('weather-cyclones'); + selectedContextStormId = null; + selectedContextEntity = null; + } + + function publishSelectedContext({ announce = false } = {}) { + const storm = selected(); + if (!storm || !context) { + if (!storm) clearSelectedContext(); + return null; + } + selectedContextEntity ||= { show: true }; + const record = context.registerEntityContext?.(selectedContextEntity, { + id: `cyclone:${storm.id}`, + layerId: 'weather-cyclones', + layerName: 'Cyclone advisories', + source: 'NOAA NHC / CPHC', + label: `Cyclone · ${storm.name}`, + latitude: storm.position.latitude, + longitude: storm.position.longitude, + properties: { + classification: storm.classification, + basin: storm.basin, + advisoryNumber: storm.advisoryNumber, + issuedAt: storm.issuedAt, + positionAt: storm.positionAt, + windKt: storm.windKt, + pressureHpa: storm.pressureHpa, + movement: storm.movement, + advisoryUrl: storm.advisoryUrl, + }, + }); + if (!record) return null; + selectedContextStormId = storm.id; + if (announce) context.selectEntityContext?.(selectedContextEntity); + return record; + } + function select(id) { if (selectedId !== id) ++navigationGeneration; selectedId = id; @@ -206,6 +249,7 @@ export function createCyclonesLayer({ selectedId = null; selectionIntent = 'auto'; ++navigationGeneration; + clearSelectedContext(); rendering?.clear(); }, async update(_viewer, { signal } = {}) { @@ -229,6 +273,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; + clearSelectedContext(); error = next.reason || 'Cyclone advisories unavailable'; return true; } @@ -251,6 +296,10 @@ export function createCyclonesLayer({ : snapshot.storms[0]?.id || null, ); else rendering.setSelection(selectedId); + if (selectedContextStormId) { + if (selectedId === selectedContextStormId) publishSelectedContext(); + else clearSelectedContext(); + } return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; @@ -260,6 +309,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; + clearSelectedContext(); return true; } finally { signal?.removeEventListener('abort', abort); @@ -275,6 +325,7 @@ export function createCyclonesLayer({ if (params.clear === true || params.stormId === null) { selectionIntent = 'cleared'; select(null); + clearSelectedContext(); notify(); } else if ( typeof params.stormId === 'string' && @@ -282,6 +333,7 @@ export function createCyclonesLayer({ ) { selectionIntent = 'user'; select(params.stormId); + publishSelectedContext({ announce: true }); notify(); } const storm = selected(); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 4127871f4..7d796bef7 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -338,7 +338,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick }); function harness( feed = { getSnapshot: async () => snapshot() }, - { reducedMotion = false } = {}, + { reducedMotion = false, context = null } = {}, ) { const applied = [], navigation = [], @@ -368,6 +368,7 @@ function harness( }; const layer = createCyclonesLayer({ feed, + context, createRendering: () => rendering, matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), @@ -401,6 +402,75 @@ function harness( }, }; } +test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { + let next = snapshot([storm(), storm('ep162026')]); + const records = new Map(); + let selectedContext = null; + let selectedEvents = 0; + const context = { + registerEntityContext(entity, metadata) { + entity.__gevContextId = metadata.id; + const record = { ...metadata, entity }; + records.set(metadata.id, record); + return record; + }, + selectEntityContext(entity) { + selectedContext = records.get(entity.__gevContextId) || null; + selectedEvents += 1; + return selectedContext; + }, + removeEntityContextsForLayer(layerId) { + for (const [id, record] of records) { + if (record.layerId === layerId) records.delete(id); + } + if (selectedContext?.layerId === layerId) selectedContext = null; + }, + }; + const h = harness({ getSnapshot: async () => next }, { context }); + h.layer.enable(); + await h.layer.update(); + + assert.equal( + selectedEvents, + 0, + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(records.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + assert.equal(selectedEvents, 1); + assert.equal(selectedContext?.layerId, 'weather-cyclones'); + assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); + assert.equal(selectedContext?.label, 'Cyclone · Another system'); + assert.equal(selectedContext?.latitude, 15.5); + assert.equal(selectedContext?.longitude, -125.8); + assert.equal(selectedContext?.properties.advisoryNumber, '10'); + + await h.layer.update(); + assert.equal( + selectedEvents, + 1, + 'refresh updates the selected record without re-announcing the click', + ); + + next = snapshot([storm()]); + await h.layer.update(); + assert.equal( + selectedContext, + null, + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(records.size, 0); + + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(selectedEvents, 2); + h.layer.setParams({ clear: true }); + assert.equal(selectedContext, null); + assert.equal(records.size, 0); + + h.layer.destroy(); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); From a803be1af273051738e633e778ca04fce1ae0387 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:01:44 -0500 Subject: [PATCH 2/9] fix(cyclones): evict prior shared selection on storm switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression: explicit storm A→B→A keeps one active record. Reviewer finding: quanticsoul4772 (#847). --- changelog.d/847-cyclone-shared-selection.md | 4 ++++ docs/CURRENT-STATE.md | 5 +++++ src/layers/cyclones/index.js | 6 +++++- src/layers/cyclones/index.test.mjs | 20 +++++++++++++++----- 4 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 changelog.d/847-cyclone-shared-selection.md diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md new file mode 100644 index 000000000..a2f58ca71 --- /dev/null +++ b/changelog.d/847-cyclone-shared-selection.md @@ -0,0 +1,4 @@ +- Explicit cyclone selections publish only the currently selected NHC/CPHC + storm into shared context. Switching storms evicts the previous identity + instead of leaving stale coordinates active (kvnloo, #847; reviewed by + quanticsoul4772). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..8e2662bf4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -218,6 +218,11 @@ 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. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. +Explicit cyclone selections publish one shared `weather-cyclones` record +with official advisory provenance and source position. Switching storms removes +the prior record; automatic row selection never claims global context, and +refreshes do not re-announce selection. Clearing or losing an explicitly selected +storm removes shared context rather than assigning a fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 2738bebe0..ae4e5175a 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -79,8 +79,12 @@ export function createCyclonesLayer({ return null; } selectedContextEntity ||= { show: true }; + const contextId = `cyclone:${storm.id}`; + context.removeEntityContextsForLayer?.('weather-cyclones', { + retainIds: new Set([contextId]), + }); const record = context.registerEntityContext?.(selectedContextEntity, { - id: `cyclone:${storm.id}`, + id: contextId, layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 7d796bef7..d1b7fefd4 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -419,11 +419,13 @@ test('explicit cyclone selection publishes shared context without auto-claiming selectedEvents += 1; return selectedContext; }, - removeEntityContextsForLayer(layerId) { + removeEntityContextsForLayer(layerId, { retainIds } = {}) { for (const [id, record] of records) { - if (record.layerId === layerId) records.delete(id); + if (record.layerId === layerId && !retainIds?.has(id)) + records.delete(id); } - if (selectedContext?.layerId === layerId) selectedContext = null; + if (selectedContext && !records.has(selectedContext.id)) + selectedContext = null; }, }; const h = harness({ getSnapshot: async () => next }, { context }); @@ -446,10 +448,18 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(selectedContext?.longitude, -125.8); assert.equal(selectedContext?.properties.advisoryNumber, '10'); + h.layer.setParams({ stormId: 'ep152026' }); + assert.deepEqual([...records.keys()], ['cyclone:ep152026']); + assert.equal(selectedContext?.id, 'cyclone:ep152026'); + h.layer.setParams({ stormId: 'ep162026' }); + assert.deepEqual([...records.keys()], ['cyclone:ep162026']); + assert.equal(selectedContext?.id, 'cyclone:ep162026'); + assert.equal(selectedEvents, 3, 'each explicit storm switch announces once'); + await h.layer.update(); assert.equal( selectedEvents, - 1, + 3, 'refresh updates the selected record without re-announcing the click', ); @@ -463,7 +473,7 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(records.size, 0); h.layer.setParams({ stormId: 'ep152026' }); - assert.equal(selectedEvents, 2); + assert.equal(selectedEvents, 4); h.layer.setParams({ clear: true }); assert.equal(selectedContext, null); assert.equal(records.size, 0); From 3a7c7b7c6a9c1c2a126bb6c415386f6016a2cdec Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:36:07 -0500 Subject: [PATCH 3/9] fix(cyclones): keep selected names source-owned for i18n --- src/layers/cyclones/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 2529b98e0..66bf13bbd 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -111,7 +111,9 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - label: `Cyclone · ${storm.name}`, + // Layer identity is already carried separately. Keep source names literal + // rather than adding an untranslated user-facing prefix. + label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, properties: { From 0e2ae76f0ab0f75df72010c1c6ae41c3c052b505 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:36:09 -0500 Subject: [PATCH 4/9] test(cyclones): assert source-owned context label --- src/layers/cyclones/index.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 06342464b..f7cbcdc12 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -443,7 +443,7 @@ test('explicit cyclone selection publishes shared context without auto-claiming assert.equal(selectedEvents, 1); assert.equal(selectedContext?.layerId, 'weather-cyclones'); assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); - assert.equal(selectedContext?.label, 'Cyclone · Another system'); + assert.equal(selectedContext?.label, 'Another system'); assert.equal(selectedContext?.latitude, 15.5); assert.equal(selectedContext?.longitude, -125.8); assert.equal(selectedContext?.properties.advisoryNumber, '10'); From b358b5dba176e744fa53de73074a0b242b768a4f Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:17:39 +0100 Subject: [PATCH 5/9] fix(cyclones): distinguish deliberate clears from feed eviction --- changelog.d/847-cyclone-shared-selection.md | 6 +-- docs/CURRENT-STATE.md | 2 + src/layers/cyclones/index.js | 18 ++++--- src/layers/cyclones/index.test.mjs | 59 +++++++++++++++++++++ 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md index a2f58ca71..24edf2ebb 100644 --- a/changelog.d/847-cyclone-shared-selection.md +++ b/changelog.d/847-cyclone-shared-selection.md @@ -1,4 +1,4 @@ - Explicit cyclone selections publish only the currently selected NHC/CPHC - storm into shared context. Switching storms evicts the previous identity - instead of leaving stale coordinates active (kvnloo, #847; reviewed by - quanticsoul4772). + storm into shared context. Switching storms removes the previous identity + without a false eviction event. Deliberate clears remain distinct from feed + disappearance (kvnloo, #847; reviewed by quanticsoul4772). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 42ebf7555..9d6680086 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -394,6 +394,8 @@ with official advisory provenance and source position. Switching storms removes the prior record; automatic row selection never claims global context, and refreshes do not re-announce selection. Clearing or losing an explicitly selected storm removes shared context rather than assigning a fallback. +Deliberate clear/disable reports `deliberate`; feed disappearance reports +`evicted`. Switching storms selects the replacement before pruning the old record. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 66bf13bbd..1b45265c5 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -89,7 +89,9 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; - function clearSelectedContext() { + function clearSelectedContext({ evicted = false } = {}) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); context?.removeEntityContextsForLayer?.('weather-cyclones'); selectedContextStormId = null; selectedContextEntity = null; @@ -98,14 +100,11 @@ export function createCyclonesLayer({ function publishSelectedContext({ announce = false } = {}) { const storm = selected(); if (!storm || !context) { - if (!storm) clearSelectedContext(); + if (!storm) clearSelectedContext({ evicted: true }); return null; } selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; - context.removeEntityContextsForLayer?.('weather-cyclones', { - retainIds: new Set([contextId]), - }); const record = context.registerEntityContext?.(selectedContextEntity, { id: contextId, layerId: 'weather-cyclones', @@ -131,6 +130,9 @@ export function createCyclonesLayer({ if (!record) return null; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); + context.removeEntityContextsForLayer?.('weather-cyclones', { + retainIds: new Set([contextId]), + }); return record; } @@ -302,7 +304,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); error = next.reason || ADVISORIES_UNAVAILABLE; return true; } @@ -327,7 +329,7 @@ export function createCyclonesLayer({ else rendering.setSelection(selectedId); if (selectedContextStormId) { if (selectedId === selectedContextStormId) publishSelectedContext(); - else clearSelectedContext(); + else clearSelectedContext({ evicted: true }); } return true; } catch (cause) { @@ -338,7 +340,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; - clearSelectedContext(); + clearSelectedContext({ evicted: true }); return true; } finally { signal?.removeEventListener('abort', abort); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f7cbcdc12..f1c7656b1 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,7 @@ import { isPointerFree, } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; +import * as contextStore from '../../data/contextStore.js'; const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -481,6 +482,64 @@ test('explicit cyclone selection publishes shared context without auto-claiming h.layer.destroy(); }); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const host = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: host, + }); + let h; + t.after(() => { + try { + h?.layer.destroy(); + } finally { + if (previousWindow) + Object.defineProperty(globalThis, 'window', previousWindow); + else delete globalThis.window; + } + }); + const events = []; + host.addEventListener('gev:entity-selected', ({ detail }) => + events.push(['selected', detail.id]), + ); + host.addEventListener('gev:entity-selection-cleared', ({ detail }) => + events.push(['cleared', detail.reason]), + ); + let next = snapshot([storm(), storm('ep162026')]); + h = harness({ getSnapshot: async () => next }, { context: contextStore }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.setParams({ stormId: 'ep162026' }); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual( + [...contextStore.getContextStore().entities.keys()], + ['cyclone:ep162026'], + ); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual(events.at(-1), ['cleared', 'evicted']); + assert.equal(contextStore.getContextStore().entities.size, 0); + assert.equal(contextStore.getContextStore().selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); + assert.equal(contextStore.getContextStore().entities.size, 0); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); From 92666fdbafe61e81f52dfa481095e70cabb60d35 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:10:20 +0400 Subject: [PATCH 6/9] fix(cyclones): preserve movement in selected voice context --- src/layers/cyclones/index.js | 4 ++- src/layers/cyclones/index.test.mjs | 48 +++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 1b45265c5..ba7db3df6 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -123,7 +123,9 @@ export function createCyclonesLayer({ positionAt: storm.positionAt, windKt: storm.windKt, pressureHpa: storm.pressureHpa, - movement: storm.movement, + // Flat scalars: the voice context summary drops nested objects. + movementDirectionDegrees: storm.movement?.directionDegrees, + movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, }, }); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f1c7656b1..406cdd257 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -8,6 +8,8 @@ import { } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; import * as contextStore from '../../data/contextStore.js'; +import { createGevActionRunner } from '../../voice/gevActions.js'; +import * as Cesium from 'cesium'; const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -506,12 +508,56 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed host.addEventListener('gev:entity-selection-cleared', ({ detail }) => events.push(['cleared', detail.reason]), ); - let next = snapshot([storm(), storm('ep162026')]); + // The voice runner reads the same store and bounds its basemap wait. + Object.assign(host, { setTimeout, clearTimeout }); + const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: 0, + }, + }, + styleManager: {}, + dataManager: { + layers: new Map(), + isEnabled: (id) => id === 'weather-cyclones', + getAll: () => [], + }, + }); + const selectedProperties = async () => { + const result = await runner('get_entity_context', { scope: 'selected' }); + assert.equal(result.scope, 'selected'); + return result.selected.properties; + }; + let next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, + ]); h = harness({ getSnapshot: async () => next }, { context: contextStore }); h.layer.enable(); await h.layer.update(); h.layer.setParams({ stormId: 'ep152026' }); + let properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '315', + 'selected storm movement should survive voice summarization', + ); + assert.equal(properties.movementSpeedKt, '12'); h.layer.setParams({ stormId: 'ep162026' }); + properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '0', + 'a due-north heading is a value, not a missing one', + ); + assert.equal(properties.movementSpeedKt, '0', 'stationary is a value'); assert.deepEqual(events, [ ['selected', 'cyclone:ep152026'], ['selected', 'cyclone:ep162026'], From 4e518821b514e688d6e2663eded607395ea4e45f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 19:43:04 +0400 Subject: [PATCH 7/9] fix(cyclones): name the classification and mark a stale cache in shared context - Publish classificationName next to the raw NHC code; unknown codes stay unexpanded. - Publish staleCacheFetchedAt only while the proxy serves its cache. - Touch the shared store on clear only after this layer published, so a feed failure no longer evicts another layer's dangling selection. - Drop the unreachable no-storm branch and the unused return value. - Tests: split the heading fixture into due north and stationary, cover the unavailable and failure paths with the real store, and check that clear/disable leave another layer's selection alone; fold the fake-store test into the real-store one. --- docs/CURRENT-STATE.md | 12 +- src/layers/cyclones/index.js | 33 ++- src/layers/cyclones/index.test.mjs | 349 ++++++++++++++++++----------- 3 files changed, 247 insertions(+), 147 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa535c295..a1b0874c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -743,12 +743,12 @@ 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. Explicit cyclone selections publish one shared `weather-cyclones` record -with official advisory provenance and source position. Switching storms removes -the prior record; automatic row selection never claims global context, and -refreshes do not re-announce selection. Clearing or losing an explicitly selected -storm removes shared context rather than assigning a fallback. -Deliberate clear/disable reports `deliberate`; feed disappearance reports -`evicted`. Switching storms selects the replacement before pruning the old record. +with official advisory provenance, source position, the English classification +name and, while the proxy serves its cache, the cache fetch time. Switching +storms selects the replacement before pruning the old record; automatic row +selection never claims global context, and refreshes do not re-announce +selection. Deliberate clear/disable reports `deliberate`; losing the selected +storm from the feed reports `evicted` and assigns no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index ef8bbad27..8741ad8a3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -96,19 +96,19 @@ export function createCyclonesLayer({ const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; function clearSelectedContext({ evicted = false } = {}) { - if (!evicted) - context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); - context?.removeEntityContextsForLayer?.('weather-cyclones'); + if (selectedContextStormId) { + if (!evicted) + context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); + context?.removeEntityContextsForLayer?.('weather-cyclones'); + } selectedContextStormId = null; selectedContextEntity = null; } + // Callers publish only while selectedId names a storm in the snapshot. function publishSelectedContext({ announce = false } = {}) { const storm = selected(); - if (!storm || !context) { - if (!storm) clearSelectedContext({ evicted: true }); - return null; - } + if (!context) return; selectedContextEntity ||= { show: true }; const contextId = `cyclone:${storm.id}`; const record = context.registerEntityContext?.(selectedContextEntity, { @@ -116,13 +116,20 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - // Layer identity is already carried separately. Keep source names literal - // rather than adding an untranslated user-facing prefix. + // Keep official storm names verbatim; layerName identifies the layer. label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, + // At most 12 properties reach the voice summary; keep the list short. properties: { classification: storm.classification, + // English source meaning; unknown codes stay unexpanded. + classificationName: Object.hasOwn( + CLASSIFICATION_NAMES, + storm.classification, + ) + ? CLASSIFICATION_NAMES[storm.classification] + : undefined, basin: storm.basin, advisoryNumber: storm.advisoryNumber, issuedAt: storm.issuedAt, @@ -133,15 +140,19 @@ export function createCyclonesLayer({ movementDirectionDegrees: storm.movement?.directionDegrees, movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, + // Present only while the server serves its cache with NHC unreachable. + staleCacheFetchedAt: + snapshot.stale && Number.isFinite(snapshot.fetchedAt) + ? new Date(snapshot.fetchedAt).toISOString() + : undefined, }, }); - if (!record) return null; + if (!record) return; selectedContextStormId = storm.id; if (announce) context.selectEntityContext?.(selectedContextEntity); context.removeEntityContextsForLayer?.('weather-cyclones', { retainIds: new Set([contextId]), }); - return record; } function select(id) { diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index f70f4f6a8..a3e40fc95 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -405,96 +405,19 @@ function harness( }, }; } -test('explicit cyclone selection publishes shared context without auto-claiming it', async () => { - let next = snapshot([storm(), storm('ep162026')]); - const records = new Map(); - let selectedContext = null; - let selectedEvents = 0; - const context = { - registerEntityContext(entity, metadata) { - entity.__gevContextId = metadata.id; - const record = { ...metadata, entity }; - records.set(metadata.id, record); - return record; - }, - selectEntityContext(entity) { - selectedContext = records.get(entity.__gevContextId) || null; - selectedEvents += 1; - return selectedContext; - }, - removeEntityContextsForLayer(layerId, { retainIds } = {}) { - for (const [id, record] of records) { - if (record.layerId === layerId && !retainIds?.has(id)) - records.delete(id); - } - if (selectedContext && !records.has(selectedContext.id)) - selectedContext = null; - }, - }; - const h = harness({ getSnapshot: async () => next }, { context }); - h.layer.enable(); - await h.layer.update(); - - assert.equal( - selectedEvents, - 0, - 'the automatic first-storm row selection must not claim shared context', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep162026' }); - assert.equal(selectedEvents, 1); - assert.equal(selectedContext?.layerId, 'weather-cyclones'); - assert.equal(selectedContext?.source, 'NOAA NHC / CPHC'); - assert.equal(selectedContext?.label, 'Another system'); - assert.equal(selectedContext?.latitude, 15.5); - assert.equal(selectedContext?.longitude, -125.8); - assert.equal(selectedContext?.properties.advisoryNumber, '10'); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep152026']); - assert.equal(selectedContext?.id, 'cyclone:ep152026'); - h.layer.setParams({ stormId: 'ep162026' }); - assert.deepEqual([...records.keys()], ['cyclone:ep162026']); - assert.equal(selectedContext?.id, 'cyclone:ep162026'); - assert.equal(selectedEvents, 3, 'each explicit storm switch announces once'); - - await h.layer.update(); - assert.equal( - selectedEvents, - 3, - 'refresh updates the selected record without re-announcing the click', - ); - - next = snapshot([storm()]); - await h.layer.update(); - assert.equal( - selectedContext, - null, - 'a vanished user-selected storm must clear instead of transferring context to the fallback row', - ); - assert.equal(records.size, 0); - - h.layer.setParams({ stormId: 'ep152026' }); - assert.equal(selectedEvents, 4); - h.layer.setParams({ clear: true }); - assert.equal(selectedContext, null); - assert.equal(records.size, 0); - - h.layer.destroy(); -}); - -test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { +// Runs a test body against the real shared-context store on a stand-in window, +// with the voice runner reading the same store. +async function withSharedContext(t, body) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const host = new EventTarget(); Object.defineProperty(globalThis, 'window', { configurable: true, value: host, }); - let h; + const layers = []; t.after(() => { try { - h?.layer.destroy(); + for (const layer of layers) layer.destroy(); } finally { if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); @@ -506,9 +429,9 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed events.push(['selected', detail.id]), ); host.addEventListener('gev:entity-selection-cleared', ({ detail }) => - events.push(['cleared', detail.reason]), + events.push(['cleared', detail.layerId, detail.reason]), ); - // The voice runner reads the same store and bounds its basemap wait. + // The voice runner bounds its basemap wait with the window timers. Object.assign(host, { setTimeout, clearTimeout }); const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); const runner = createGevActionRunner({ @@ -535,55 +458,221 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed assert.equal(result.scope, 'selected'); return result.selected.properties; }; - let next = snapshot([ - { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, - { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, - ]); - h = harness({ getSnapshot: async () => next }, { context: contextStore }); - h.layer.enable(); - await h.layer.update(); - h.layer.setParams({ stormId: 'ep152026' }); - let properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '315', - 'selected storm movement should survive voice summarization', - ); - assert.equal(properties.movementSpeedKt, '12'); - h.layer.setParams({ stormId: 'ep162026' }); - properties = await selectedProperties(); - assert.equal( - properties.movementDirectionDegrees, - '0', - 'a due-north heading is a value, not a missing one', - ); - assert.equal(properties.movementSpeedKt, '0', 'stationary is a value'); - assert.deepEqual(events, [ - ['selected', 'cyclone:ep152026'], - ['selected', 'cyclone:ep162026'], - ]); - assert.deepEqual( - [...contextStore.getContextStore().entities.keys()], - ['cyclone:ep162026'], - ); - await h.layer.update(); - assert.equal(events.length, 2, 'refresh must not announce selection'); + const store = contextStore.getContextStore(); + const open = (feed) => { + const h = harness(feed, { context: contextStore }); + layers.push(h.layer); + return h; + }; + await body({ events, selectedProperties, store, open }); +} - h.layer.setParams({ clear: true }); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { + ...storm('ep162026'), + movement: { directionDegrees: 0, speedKt: 10 }, + }, + ]); + const h = open({ getSnapshot: async () => next }); + h.layer.enable(); + await h.layer.update(); + assert.deepEqual( + events, + [], + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(store.entities.size, 0); - h.layer.setParams({ stormId: 'ep162026' }); - next = snapshot([storm()]); - await h.layer.update(); - assert.deepEqual(events.at(-1), ['cleared', 'evicted']); - assert.equal(contextStore.getContextStore().entities.size, 0); - assert.equal(contextStore.getContextStore().selectedEntityId, null); + h.layer.setParams({ stormId: 'ep152026' }); + const record = store.entities.get('cyclone:ep152026'); + assert.equal(record.layerId, 'weather-cyclones'); + assert.equal(record.source, 'NOAA NHC / CPHC'); + assert.equal(record.label, 'Fifteen-E'); + assert.equal(record.latitude, 15.5); + assert.equal(record.longitude, -125.8); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'PTC'); + assert.equal(properties.classificationName, 'Post-tropical cyclone'); + assert.equal(properties.advisoryNumber, '10'); + assert.equal( + properties.movementDirectionDegrees, + '315', + 'selected storm movement should survive voice summarization', + ); + assert.equal(properties.movementSpeedKt, '12'); + assert.equal( + properties.advisoryUrl, + 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', + ); + assert.equal(properties.staleCacheFetchedAt, undefined); - h.layer.setParams({ stormId: 'ep152026' }); - h.layer.disable(); - assert.deepEqual(events.at(-1), ['cleared', 'deliberate']); - assert.equal(contextStore.getContextStore().entities.size, 0); + h.layer.setParams({ stormId: 'ep162026' }); + properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '0', + 'a due-north heading is a value, not a missing one', + ); + assert.equal(properties.movementSpeedKt, '10'); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']); + + next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, + ]); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + properties = await selectedProperties(); + assert.equal( + properties.movementSpeedKt, + '0', + 'stationary is a value, refreshed into the selected record', + ); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual( + events.at(-1), + ['cleared', 'weather-cyclones', 'evicted'], + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + }, + ); +}); + +test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([{ ...storm(), classification: 'XX' }]); + let fail = false; + const h = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return next; + }, + }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'XX'); + assert.equal( + properties.classificationName, + undefined, + 'an unknown code is not given a guessed meaning', + ); + + next = { ...snapshot(), stale: true }; + await h.layer.update(); + properties = await selectedProperties(); + assert.equal(properties.staleCacheFetchedAt, time); + assert.equal(events.length, 1, 'refresh must not announce selection'); + + next = { + ...snapshot([]), + unavailable: true, + reason: 'Cyclone data unavailable', + }; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + + next = snapshot(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(store.selectedEntityId, 'cyclone:ep152026'); + fail = true; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + }, + ); +}); + +test('cyclone clears leave another layer selection and its records alone', async (t) => { + await withSharedContext(t, async ({ events, store, open }) => { + const h = open({ getSnapshot: async () => snapshot() }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + const foreign = {}; + contextStore.registerEntityContext(foreign, { + id: 'flight:abc123', + layerId: 'flights', + label: 'Foreign', + }); + contextStore.selectEntityContext(foreign); + const before = events.length; + + h.layer.setParams({ clear: true }); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.ok(store.entities.has('flight:abc123')); + assert.equal(store.entities.has('cyclone:ep152026'), false); + assert.equal( + events.length, + before, + 'no clear is reported for a foreign selection', + ); + + h.layer.disable(); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.equal(events.length, before); + + // A foreign record removed without clearing its selection: a cyclone + // feed failure must not report that selection as evicted by cyclones. + store.entities.delete('flight:abc123'); + let fail = false; + const other = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return snapshot(); + }, + }); + other.layer.enable(); + await other.layer.update(); + fail = true; + await other.layer.update(); + assert.equal(events.length, before); + }); }); test('refresh preserves selection intent, including explicit clears and missing storms', async () => { From 5e64e599e1c2079d08a7ddf153a4ad66d7c07c37 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:07:56 +0400 Subject: [PATCH 8/9] docs(cyclones): tighten shared-context comments, changelog and notes --- changelog.d/847-cyclone-shared-selection.md | 7 +++---- docs/CURRENT-STATE.md | 13 ++++++------- src/app/layers/cyclones.js | 2 +- src/layers/cyclones/index.js | 4 +--- src/layers/cyclones/index.test.mjs | 6 ++---- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md index 24edf2ebb..150295823 100644 --- a/changelog.d/847-cyclone-shared-selection.md +++ b/changelog.d/847-cyclone-shared-selection.md @@ -1,4 +1,3 @@ -- Explicit cyclone selections publish only the currently selected NHC/CPHC - storm into shared context. Switching storms removes the previous identity - without a false eviction event. Deliberate clears remain distinct from feed - disappearance (kvnloo, #847; reviewed by quanticsoul4772). +- Let the voice assistant read the storm you select in Cyclone advisories: + its official advisory, position, intensity and movement. Clearing the + selection and the storm leaving the feed are reported apart (kvnloo, #847). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6df75c27e..f3b3d3396 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -742,13 +742,12 @@ 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. -Explicit cyclone selections publish one shared `weather-cyclones` record -with official advisory provenance, source position, the English classification -name and, while the proxy serves its cache, the cache fetch time. Switching -storms selects the replacement before pruning the old record; automatic row -selection never claims global context, and refreshes do not re-announce -selection. Deliberate clear/disable reports `deliberate`; losing the selected -storm from the feed reports `evicted` and assigns no fallback. +Explicitly selecting a storm publishes one shared `weather-cyclones` record: +advisory provenance, source position, the English classification name and, +while the proxy serves its cache, the cache fetch time. The automatic row +selection does not claim shared context; refreshes do not re-announce it. +Clear or disable reports `deliberate`; a storm leaving the feed reports +`evicted`, with no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index d671a11eb..bed75c76e 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -1,7 +1,7 @@ import { createCyclonesLayer } from '../../layers/cyclones/index.js'; import * as context from '../../data/contextStore.js'; -/** Wire cyclone selection into the application shared-context owner. */ +/** Wire cyclone selection to the application shared context. */ export function createApplicationCyclones(options) { const browserContext = typeof window !== 'undefined' ? { context } : {}; return createCyclonesLayer({ diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 8741ad8a3..95655d5d0 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -116,14 +116,12 @@ export function createCyclonesLayer({ layerId: 'weather-cyclones', layerName: 'Cyclone advisories', source: 'NOAA NHC / CPHC', - // Keep official storm names verbatim; layerName identifies the layer. label: storm.name, latitude: storm.position.latitude, longitude: storm.position.longitude, // At most 12 properties reach the voice summary; keep the list short. properties: { classification: storm.classification, - // English source meaning; unknown codes stay unexpanded. classificationName: Object.hasOwn( CLASSIFICATION_NAMES, storm.classification, @@ -140,7 +138,7 @@ export function createCyclonesLayer({ movementDirectionDegrees: storm.movement?.directionDegrees, movementSpeedKt: storm.movement?.speedKt, advisoryUrl: storm.advisoryUrl, - // Present only while the server serves its cache with NHC unreachable. + // Only while the proxy serves its cache because NHC is unreachable. staleCacheFetchedAt: snapshot.stale && Number.isFinite(snapshot.fetchedAt) ? new Date(snapshot.fetchedAt).toISOString() diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index a3e40fc95..41ecaf3d1 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -405,8 +405,7 @@ function harness( }, }; } -// Runs a test body against the real shared-context store on a stand-in window, -// with the voice runner reading the same store. +// Real shared-context store on a stand-in window; the voice runner reads it. async function withSharedContext(t, body) { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); const host = new EventTarget(); @@ -657,8 +656,7 @@ test('cyclone clears leave another layer selection and its records alone', async assert.equal(store.selectedEntityId, 'flight:abc123'); assert.equal(events.length, before); - // A foreign record removed without clearing its selection: a cyclone - // feed failure must not report that selection as evicted by cyclones. + // A cyclone feed failure does not evict a dangling foreign selection. store.entities.delete('flight:abc123'); let fail = false; const other = open({ From 5bab5ec26d9f4b13510e50660f06a1895340a4e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 20:15:27 +0400 Subject: [PATCH 9/9] refactor(cyclones): drop defensive shared-context state and checks --- src/app/layers/cyclones.js | 3 +-- src/layers/cyclones/index.js | 15 ++++++--------- src/layers/cyclones/index.test.mjs | 24 +++--------------------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index bed75c76e..e0cd508b0 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -3,9 +3,8 @@ import * as context from '../../data/contextStore.js'; /** Wire cyclone selection to the application shared context. */ export function createApplicationCyclones(options) { - const browserContext = typeof window !== 'undefined' ? { context } : {}; return createCyclonesLayer({ - ...browserContext, + context: typeof window !== 'undefined' ? context : null, ...options, }); } diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 95655d5d0..90936edf3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -84,7 +84,6 @@ export function createCyclonesLayer({ selectionIntent = 'auto', navigationGeneration = 0, selectedContextStormId = null, - selectedContextEntity = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -98,20 +97,19 @@ export function createCyclonesLayer({ function clearSelectedContext({ evicted = false } = {}) { if (selectedContextStormId) { if (!evicted) - context?.clearSelectedEntityContextForLayer?.('weather-cyclones'); - context?.removeEntityContextsForLayer?.('weather-cyclones'); + context.clearSelectedEntityContextForLayer('weather-cyclones'); + context.removeEntityContextsForLayer('weather-cyclones'); } selectedContextStormId = null; - selectedContextEntity = null; } // Callers publish only while selectedId names a storm in the snapshot. function publishSelectedContext({ announce = false } = {}) { const storm = selected(); if (!context) return; - selectedContextEntity ||= { show: true }; + const entity = {}; const contextId = `cyclone:${storm.id}`; - const record = context.registerEntityContext?.(selectedContextEntity, { + context.registerEntityContext(entity, { id: contextId, layerId: 'weather-cyclones', layerName: 'Cyclone advisories', @@ -145,10 +143,9 @@ export function createCyclonesLayer({ : undefined, }, }); - if (!record) return; selectedContextStormId = storm.id; - if (announce) context.selectEntityContext?.(selectedContextEntity); - context.removeEntityContextsForLayer?.('weather-cyclones', { + if (announce) context.selectEntityContext(entity); + context.removeEntityContextsForLayer('weather-cyclones', { retainIds: new Set([contextId]), }); } diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 41ecaf3d1..e826f392e 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -488,12 +488,7 @@ test('shared cyclone context distinguishes switches, deliberate clears and feed assert.equal(store.entities.size, 0); h.layer.setParams({ stormId: 'ep152026' }); - const record = store.entities.get('cyclone:ep152026'); - assert.equal(record.layerId, 'weather-cyclones'); - assert.equal(record.source, 'NOAA NHC / CPHC'); - assert.equal(record.label, 'Fifteen-E'); - assert.equal(record.latitude, 15.5); - assert.equal(record.longitude, -125.8); + assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E'); let properties = await selectedProperties(); assert.equal(properties.classification, 'PTC'); assert.equal(properties.classificationName, 'Post-tropical cyclone'); @@ -652,24 +647,11 @@ test('cyclone clears leave another layer selection and its records alone', async 'no clear is reported for a foreign selection', ); + // A cyclone clear does not report a dangling foreign selection as evicted. + store.entities.delete('flight:abc123'); h.layer.disable(); assert.equal(store.selectedEntityId, 'flight:abc123'); assert.equal(events.length, before); - - // A cyclone feed failure does not evict a dangling foreign selection. - store.entities.delete('flight:abc123'); - let fail = false; - const other = open({ - getSnapshot: async () => { - if (fail) throw new Error('Network unavailable'); - return snapshot(); - }, - }); - other.layer.enable(); - await other.layer.update(); - fail = true; - await other.layer.update(); - assert.equal(events.length, before); }); });