Merge remote-tracking branch 'origin/main' into review/pr-1528

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-11-06 17:40:33 +01:00
commit d5d824f705
7 files changed
+345 -3

No files matched your search

@@ -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).
+6
View File
@@ -741,6 +741,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 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. 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. 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. 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. 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.
+2
View File
@@ -2336,6 +2336,7 @@
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
@@ -3380,6 +3381,7 @@
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
+2 -2
View File
@@ -1,7 +1,7 @@
import { resolveCatalogSources } from './sourceComposition.js'; import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.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 { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js'; import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
id: 'weather-lightning', id: 'weather-lightning',
clock: weatherClock, clock: weatherClock,
}), }),
createCyclonesLayer({ feed: sources.cyclones }), createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
+10
View File
@@ -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,
});
}
+67
View File
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
feed, feed,
hitTestOverlay = hitTestWorldOverlay, hitTestOverlay = hitTestWorldOverlay,
overlayHost, overlayHost,
context = null,
cesium = Cesium, cesium = Cesium,
createRendering = createCycloneRendering, createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis), matchMedia = globalThis.matchMedia?.bind(globalThis),
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
selectedId = null, selectedId = null,
selectionIntent = 'auto', selectionIntent = 'auto',
navigationGeneration = 0, navigationGeneration = 0,
selectedContextStormId = null,
clickHandler = null, clickHandler = null,
removeClickCapture = null; removeClickCapture = null;
let enabled = false, let enabled = false,
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
const notify = () => listener?.(); const notify = () => listener?.();
const selected = () => const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null; 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) { function select(id) {
if (selectedId !== id) ++navigationGeneration; if (selectedId !== id) ++navigationGeneration;
selectedId = id; selectedId = id;
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
selectedId = null; selectedId = null;
selectionIntent = 'auto'; selectionIntent = 'auto';
++navigationGeneration; ++navigationGeneration;
clearSelectedContext();
rendering?.clear(); rendering?.clear();
}, },
async update(_viewer, { signal } = {}) { async update(_viewer, { signal } = {}) {
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
snapshot = next; snapshot = next;
selectedId = null; selectedId = null;
++navigationGeneration; ++navigationGeneration;
clearSelectedContext({ evicted: true });
error = next.reason || ADVISORIES_UNAVAILABLE; error = next.reason || ADVISORIES_UNAVAILABLE;
return true; return true;
} }
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
: snapshot.storms[0]?.id || null, : snapshot.storms[0]?.id || null,
); );
else rendering.setSelection(selectedId); else rendering.setSelection(selectedId);
if (selectedContextStormId) {
if (selectedId === selectedContextStormId) publishSelectedContext();
else clearSelectedContext({ evicted: true });
}
return true; return true;
} catch (cause) { } catch (cause) {
if (controller.signal.aborted || request !== controller) return false; if (controller.signal.aborted || request !== controller) return false;
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
snapshot = null; snapshot = null;
selectedId = null; selectedId = null;
++navigationGeneration; ++navigationGeneration;
clearSelectedContext({ evicted: true });
return true; return true;
} finally { } finally {
signal?.removeEventListener('abort', abort); signal?.removeEventListener('abort', abort);
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
if (params.clear === true || params.stormId === null) { if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared'; selectionIntent = 'cleared';
select(null); select(null);
clearSelectedContext();
notify(); notify();
} else if ( } else if (
typeof params.stormId === 'string' && typeof params.stormId === 'string' &&
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
) { ) {
selectionIntent = 'user'; selectionIntent = 'user';
select(params.stormId); select(params.stormId);
publishSelectedContext({ announce: true });
notify(); notify();
} }
const storm = selected(); const storm = selected();
+255 -1
View File
@@ -7,6 +7,9 @@ import {
isPointerFree, isPointerFree,
} from '../../data/inputOwnership.js'; } from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.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 time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({ 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( function harness(
feed = { getSnapshot: async () => snapshot() }, feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {}, { reducedMotion = false, context = null } = {},
) { ) {
const applied = [], const applied = [],
navigation = [], navigation = [],
@@ -368,6 +371,7 @@ function harness(
}; };
const layer = createCyclonesLayer({ const layer = createCyclonesLayer({
feed, feed,
context,
createRendering: () => rendering, createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }), matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url), 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 () => { test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]); let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next }); const h = harness({ getSnapshot: async () => next });