feat(cyclones): publish explicit selection to shared context

This commit is contained in:
Kevin Rajan committed 2026-09-29 00:20:16 -05:00
1 parent e7707d9a0f
commit 28f3243090
5 files changed
+138 -3

No files matched your search

+2
View File
@@ -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",
+2 -2
View File
@@ -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({
+11
View File
@@ -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,
});
}
+52
View File
@@ -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();
+71 -1
View File
@@ -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 });