diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md new file mode 100644 index 000000000..150295823 --- /dev/null +++ b/changelog.d/847-cyclone-shared-selection.md @@ -0,0 +1,3 @@ +- 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 8d082c783..f3b3d3396 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -742,6 +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. +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/scripts/package-boundaries.json b/scripts/package-boundaries.json index ed5b5fa7c..8a8b79283 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2336,6 +2336,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", @@ -3380,6 +3381,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 0946b6ceb..f368d260e 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,7 +1,7 @@ import { resolveCatalogSources } from './sourceComposition.js'; 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'; @@ -182,7 +182,7 @@ export function createApplicationCatalog({ id: 'weather-lightning', clock: weatherClock, }), - createCyclonesLayer({ feed: sources.cyclones }), + createApplicationCyclones({ feed: sources.cyclones }), ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js new file mode 100644 index 000000000..e0cd508b0 --- /dev/null +++ b/src/app/layers/cyclones.js @@ -0,0 +1,10 @@ +import { createCyclonesLayer } from '../../layers/cyclones/index.js'; +import * as context from '../../data/contextStore.js'; + +/** Wire cyclone selection to the application shared context. */ +export function createApplicationCyclones(options) { + return createCyclonesLayer({ + context: typeof window !== 'undefined' ? context : null, + ...options, + }); +} diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 9138e8830..90936edf3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -67,6 +67,7 @@ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, + context = null, cesium = Cesium, createRendering = createCycloneRendering, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -82,6 +83,7 @@ export function createCyclonesLayer({ selectedId = null, selectionIntent = 'auto', navigationGeneration = 0, + selectedContextStormId = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -92,6 +94,62 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; + function clearSelectedContext({ evicted = false } = {}) { + if (selectedContextStormId) { + if (!evicted) + context.clearSelectedEntityContextForLayer('weather-cyclones'); + context.removeEntityContextsForLayer('weather-cyclones'); + } + selectedContextStormId = null; + } + + // Callers publish only while selectedId names a storm in the snapshot. + function publishSelectedContext({ announce = false } = {}) { + const storm = selected(); + if (!context) return; + const entity = {}; + const contextId = `cyclone:${storm.id}`; + context.registerEntityContext(entity, { + id: contextId, + layerId: 'weather-cyclones', + layerName: 'Cyclone advisories', + source: 'NOAA NHC / CPHC', + 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, + classificationName: Object.hasOwn( + CLASSIFICATION_NAMES, + storm.classification, + ) + ? CLASSIFICATION_NAMES[storm.classification] + : undefined, + basin: storm.basin, + advisoryNumber: storm.advisoryNumber, + issuedAt: storm.issuedAt, + positionAt: storm.positionAt, + windKt: storm.windKt, + pressureHpa: storm.pressureHpa, + // Flat scalars: the voice context summary drops nested objects. + movementDirectionDegrees: storm.movement?.directionDegrees, + movementSpeedKt: storm.movement?.speedKt, + advisoryUrl: storm.advisoryUrl, + // Only while the proxy serves its cache because NHC is unreachable. + staleCacheFetchedAt: + snapshot.stale && Number.isFinite(snapshot.fetchedAt) + ? new Date(snapshot.fetchedAt).toISOString() + : undefined, + }, + }); + selectedContextStormId = storm.id; + if (announce) context.selectEntityContext(entity); + context.removeEntityContextsForLayer('weather-cyclones', { + retainIds: new Set([contextId]), + }); + } + function select(id) { if (selectedId !== id) ++navigationGeneration; selectedId = id; @@ -236,6 +294,7 @@ export function createCyclonesLayer({ selectedId = null; selectionIntent = 'auto'; ++navigationGeneration; + clearSelectedContext(); rendering?.clear(); }, async update(_viewer, { signal } = {}) { @@ -259,6 +318,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; + clearSelectedContext({ evicted: true }); error = next.reason || ADVISORIES_UNAVAILABLE; return true; } @@ -281,6 +341,10 @@ export function createCyclonesLayer({ : snapshot.storms[0]?.id || null, ); else rendering.setSelection(selectedId); + if (selectedContextStormId) { + if (selectedId === selectedContextStormId) publishSelectedContext(); + else clearSelectedContext({ evicted: true }); + } return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; @@ -290,6 +354,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; + clearSelectedContext({ evicted: true }); return true; } finally { signal?.removeEventListener('abort', abort); @@ -305,6 +370,7 @@ export function createCyclonesLayer({ if (params.clear === true || params.stormId === null) { selectionIntent = 'cleared'; select(null); + clearSelectedContext(); notify(); } else if ( typeof params.stormId === 'string' && @@ -312,6 +378,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 aff654659..e826f392e 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,9 @@ import { isPointerFree, } 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') => ({ @@ -338,7 +341,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 +371,7 @@ function harness( }; const layer = createCyclonesLayer({ feed, + context, createRendering: () => rendering, matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), @@ -401,6 +405,256 @@ function harness( }, }; } +// 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(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: host, + }); + const layers = []; + t.after(() => { + try { + for (const layer of layers) 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.layerId, detail.reason]), + ); + // 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({ + 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; + }; + 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 }); +} + +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: 'ep152026' }); + 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'); + 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: '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', + ); + + // 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); + }); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next });