Preserve fire incident geometry and selection across both globes

This commit is contained in:
German Khokhlov committed 2026-11-03 02:40:36 +04:00
1 parent b7431d7823
commit 1bda760763
16 files changed
+408 -140

No files matched your search

+1
View File
@@ -37,6 +37,7 @@ const RENDERED_LAYER_DIRS = [
'src/layers/alpr/', 'src/layers/alpr/',
'src/layers/airQuality/', 'src/layers/airQuality/',
'src/layers/gnss/', 'src/layers/gnss/',
'src/layers/perimeters/',
'src/layers/radio/', 'src/layers/radio/',
'src/layers/bikeshare/', 'src/layers/bikeshare/',
'src/layers/earthquakes/', 'src/layers/earthquakes/',
+10
View File
@@ -3221,6 +3221,7 @@
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js", "src/app/layers/perimeters.js",
"src/layers/perimeters/index.js", "src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/perimeters/source.js", "src/layers/perimeters/source.js",
"src/layers/perimeters/records.js", "src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js", "src/layers/perimeters/cards.js",
@@ -3659,6 +3660,8 @@
"src/layers/earthquakes/render/navara.js", "src/layers/earthquakes/render/navara.js",
"src/layers/airQuality/render/navara.js", "src/layers/airQuality/render/navara.js",
"src/layers/gnss/render/navara.js", "src/layers/gnss/render/navara.js",
"src/layers/perimeters/render/navara.js",
"src/layers/shared/render/navaraPolygons.js",
"src/layers/earthquakes/render/navaraGeometry.js", "src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js", "src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js", "src/layers/installations/render/navara.js",
@@ -4101,6 +4104,7 @@
"src/layers/earthquakes/records.js", "src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js", "src/app/layers/perimeters.js",
"src/layers/perimeters/index.js", "src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/perimeters/source.js", "src/layers/perimeters/source.js",
"src/layers/perimeters/records.js", "src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js", "src/layers/perimeters/cards.js",
@@ -5214,7 +5218,13 @@
"perimeters": { "perimeters": {
"exports": ["./layers/perimeters"], "exports": ["./layers/perimeters"],
"modules": [ "modules": [
"src/geo/cartesian3.js",
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/geo/math.js",
"src/layers/perimeters/index.js", "src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/layers/perimeters/source.js", "src/layers/perimeters/source.js",
+54
View File
@@ -1,3 +1,4 @@
import { fromDegrees } from '../src/geo/cartesian3.js';
import puppeteer from 'puppeteer'; import puppeteer from 'puppeteer';
import sharp from 'sharp'; import sharp from 'sharp';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -31,6 +32,19 @@ try {
const fetch = window.fetch.bind(window); const fetch = window.fetch.bind(window);
window.fetch = (input, o) => { window.fetch = (input, o) => {
const url = new URL(input.url ?? input, document.baseURI); const url = new URL(input.url ?? input, document.baseURI);
if (url.pathname.startsWith('/api/fire-perimeters/inciweb')) {
return Promise.resolve(Response.json({ rows: [] }));
}
if (url.pathname === '/api/fire-perimeters') {
const square = (dx) => [
[-79.38-dx,43.65-dx],[-79.38+dx,43.65-dx],[-79.38+dx,43.65+dx],
[-79.38-dx,43.65+dx],[-79.38-dx,43.65-dx],
];
return Promise.resolve(Response.json({rows: [{
stableId: 'fire:fixture', name:'QA Fire', acres:500, containedPct:40,
polygons:[[square(0.09), square(0.018).reverse()]],
}]}));
}
if (url.pathname === '/api/gnss-integrity') { if (url.pathname === '/api/gnss-integrity') {
return Promise.resolve(Response.json({ageMs: 0, rows: ['a1','a2','a3'].map(hex => return Promise.resolve(Response.json({ageMs: 0, rows: ['a1','a2','a3'].map(hex =>
({hex, lat: 43.65, lon: -79.38, nic: 0, nacp: 0, degraded: true, gpsLost: false}))})); ({hex, lat: 43.65, lon: -79.38, nic: 0, nacp: 0, degraded: true, gpsLost: false}))}));
@@ -147,6 +161,46 @@ try {
console.log('PASS', kind, 'GNSS high cell, view anchor', gnss.coverage.latitude, gnss.coverage.longitude); console.log('PASS', kind, 'GNSS high cell, view anchor', gnss.coverage.latitude, gnss.coverage.longitude);
await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('air-quality', true)); await p.evaluate(() => window.__godsEyeView.dataManager.setEnabled('air-quality', true));
} }
await p.evaluate(async () => {
const {dataManager} = window.__godsEyeView;
await dataManager.setEnabled('air-quality', false);
await dataManager.setEnabled('fire-perimeters', true, {origin: 'user'});
window.qaFire = dataManager.layers.get('fire-perimeters').module;
if (!dataManager.isEnabled('fire-perimeters') || window.qaFire.getStats().count !== 1)
throw Error('Failed to enable fire perimeter fixture: '+JSON.stringify(window.qaFire.getStats()));
});
const card = async (visible) => p.waitForFunction((want) => {
const canvas = document.getElementById('world-overlay-canvas');
const data = canvas.getContext('2d').getImageData(0,0,canvas.width,canvas.height).data;
let pixels = 0;
for (let i=3; i<data.length; i+=4) if (data[i]) pixels++;
return want ? pixels > 100 : pixels === 0;
}, {timeout: 15000}, visible);
const click = async (lon, lat) => {
const {x, y} = await p.evaluate((world) => {
const {engine} = window.__godsEyeView;
const point = engine.projector.worldToWindow(world, {});
const box = engine.host.canvas.getBoundingClientRect();
return { x: box.left+point.x, y: box.top+point.y };
}, fromDegrees(lon, lat));
await p.mouse.click(x,y);
};
for (const kind of ['navara', 'cesium', 'navara']) {
if (await p.evaluate(() => window.__godsEyeView.engine.kind) !== kind) {
await p.select('#globe-engine', kind);
await ready(kind);
await card(true);
assert.ok(await p.evaluate(() => window.qaFire === window.__godsEyeView.dataManager.layers.get('fire-perimeters').module));
}
await click(-79.51, 43.65);
await card(false);
await click(-79.38, 43.65);
await new Promise(resolve => setTimeout(resolve, 500));
await card(false);
await click(-79.435, 43.65);
await card(true);
console.log('PASS', kind, 'select fire under GNSS, exclude hole, retain incident card');
}
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
} finally { } finally {
await b.close(); await b.close();
+2 -2
View File
@@ -189,9 +189,9 @@ export function createApplicationCatalog({
[ [
'fire-perimeters', 'fire-perimeters',
() => () =>
createApplicationFirePerimeters({ sessionModel('fire-perimeters', () => createApplicationFirePerimeters({
source: sources['fire-perimeters'], source: sources['fire-perimeters'],
}), })),
], ],
[ [
'gnss-integrity', 'gnss-integrity',
+5
View File
@@ -1,3 +1,5 @@
import { rendererFor } from './renderers.js';
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
createFirePerimetersLayer, createFirePerimetersLayer,
@@ -22,6 +24,9 @@ export function createApplicationFirePerimeters(options) {
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) => screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
createRenderer: ({viewer, engine, screenSpaceEventHandlerFactory}) => engine
? rendererFor('fire-perimeters', engine, {viewer, screenSpaceEventHandlerFactory})
: createPerimetersCesiumRenderer({viewer, screenSpaceEventHandlerFactory}),
picking, picking,
pointer: { isPointerFree }, pointer: { isPointerFree },
...options, ...options,
+2
View File
@@ -1,3 +1,4 @@
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js'; import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js'; import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js'; import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
@@ -96,6 +97,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer, earthquakes: createEarthquakesCesiumRenderer,
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options), 'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options), 'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
'fire-perimeters': (_engine, options) => createPerimetersCesiumRenderer(options),
'local-firms': createFirmsCesiumRenderer, 'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer, 'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer, satellites: createSatellitesCesiumRenderer,
+1
View File
@@ -61,6 +61,7 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'gnss-integrity', 'gnss-integrity',
'fire-perimeters',
'local-firms', 'local-firms',
'rocket-launches', 'rocket-launches',
'satellites', 'satellites',
+2
View File
@@ -26,6 +26,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'ais-live-vessels', 'ais-live-vessels',
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'fire-perimeters',
'gnss-integrity', 'gnss-integrity',
'local-firms', 'local-firms',
'military-installations', 'military-installations',
@@ -47,6 +48,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'local-adsb', 'local-adsb',
'earthquakes', 'earthquakes',
'air-quality', 'air-quality',
'fire-perimeters',
'gnss-integrity', 'gnss-integrity',
'alpr-cameras', 'alpr-cameras',
'satellites', 'satellites',
+5
View File
@@ -57,6 +57,7 @@ const NAVARA_CREDIT_KEYS = new Set([
'launch-library-2', 'launch-library-2',
'usgs', 'usgs',
'eccc-aqhi', 'eccc-aqhi',
'wfigs',
'firms', 'firms',
'gbfs', 'gbfs',
'overture-military-names', 'overture-military-names',
@@ -123,6 +124,10 @@ export async function registerNavaraLayerRenderers() {
...navaraViews.get(engine), ...navaraViews.get(engine),
}); });
}, },
'fire-perimeters': async () => {
const { createPerimetersNavaraRenderer } = await import('../layers/perimeters/render/navara.js');
return (engine) => createPerimetersNavaraRenderer(engine, navaraViews.get(engine));
},
'gnss-integrity': async () => { 'gnss-integrity': async () => {
const { createGnssNavaraRenderer } = await import('../layers/gnss/render/navara.js'); const { createGnssNavaraRenderer } = await import('../layers/gnss/render/navara.js');
return (engine) => createGnssNavaraRenderer(engine, navaraViews.get(engine)); return (engine) => createGnssNavaraRenderer(engine, navaraViews.get(engine));
+1
View File
@@ -183,6 +183,7 @@ test('Navara keeps attribution for every available W1/W2 source and excludes una
'launch-library-2', 'launch-library-2',
'usgs', 'usgs',
'eccc-aqhi', 'eccc-aqhi',
'wfigs',
'firms', 'firms',
'gbfs', 'gbfs',
'overture-military-names', 'overture-military-names',
+8 -54
View File
@@ -1,3 +1,4 @@
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
import * as geo from '../../../geo/index.js'; import * as geo from '../../../geo/index.js';
function stationFeature(reading, radius) { function stationFeature(reading, radius) {
@@ -33,68 +34,21 @@ function stationFeature(reading, radius) {
/** One native GeoJSON layer per AQHI colour, independent of station count. */ /** One native GeoJSON layer per AQHI colour, independent of station count. */
export function createAirQualityNavaraRenderer(engine, { view }) { export function createAirQualityNavaraRenderer(engine, { view }) {
let visible = false; const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
let batches = []; polygon: {color, show, transparent: true, opacity: 0.35, clampToGround: true,
function release(items) { outline: true, outlineShow: true, outlineColor: color, outlineWidth: 2},
for (const { layer, source } of items) { }));
layer?.delete();
source.delete();
}
}
return { return {
viewRectangle: () => engine.camera.viewRectangle(), viewRectangle: () => engine.camera.viewRectangle(),
setVisible(show) { setVisible: display.setVisible,
if (visible === show) return; destroy: display.destroy,
visible = show;
for (const { layer, source, paint } of batches) {
layer.update({ type: 'vector', source, polygon: { ...paint, show } });
}
engine.render.request('air-quality-visibility');
},
replace(rows, radius) { replace(rows, radius) {
const groups = new Map(); const groups = new Map();
for (const row of rows) { for (const row of rows) {
if (!groups.has(row.color)) groups.set(row.color, []); if (!groups.has(row.color)) groups.set(row.color, []);
groups.get(row.color).push(stationFeature(row, radius)); groups.get(row.color).push(stationFeature(row, radius));
} }
const next = []; display.replace(groups);
try {
for (const [css, features] of groups) {
const color = Number.parseInt(css.slice(1), 16);
const paint = {
color,
transparent: true,
opacity: 0.35,
clampToGround: true,
outline: true,
outlineShow: true,
outlineColor: color,
outlineWidth: 2,
};
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
const batch = { source, paint };
next.push(batch);
batch.layer = view.addLayer({
type: 'vector',
source,
polygon: { ...paint, show: visible },
});
}
} catch (error) {
release(next);
throw error;
}
release(batches);
batches = next;
engine.render.request('air-quality-readings');
},
destroy() {
release(batches);
batches = [];
engine.render.request('air-quality-destroy');
}, },
}; };
} }
+68 -84
View File
@@ -1,4 +1,6 @@
import * as Cesium from 'cesium'; import { fromDegrees } from '../../geo/cartesian3.js';
import { createPerimetersCesiumRenderer } from './render/cesium.js';
export { containmentColor } from './render/cesium.js';
import { import {
PERIMETER_OVERLAY_SOURCE_ID, PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees, perimeterAnchorDegrees,
@@ -16,14 +18,6 @@ export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js'; export * from './cards.js';
export * from './inciweb.js'; export * from './inciweb.js';
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
export function containmentColor(containedPct) {
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const PICK_PREFIX = 'fire-perimeter:'; const PICK_PREFIX = 'fire-perimeter:';
const CARD_HOST_OPTIONS = Object.freeze({ const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1, cohortLimit: 1,
@@ -35,6 +29,14 @@ const CARD_HOST_OPTIONS = Object.freeze({
// of near-identical bytes. // of near-identical bytes.
const INCIWEB_INDEX_TTL_MS = 6 * 3600000; const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
function samePolygons(left, right) {
if (left === right) return true;
return left?.length === right.length && right.every((rings, i) =>
left[i].length === rings.length && rings.every((ring, j) =>
left[i][j].length === ring.length && ring.every(([x, y], k) =>
left[i][j][k][0] === x && left[i][j][k][1] === y)));
}
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */ /** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
export function createFirePerimetersLayer({ export function createFirePerimetersLayer({
source, source,
@@ -45,14 +47,16 @@ export function createFirePerimetersLayer({
inciwebSource = null, inciwebSource = null,
inciwebPublications = null, inciwebPublications = null,
openExternal = null, openExternal = null,
createRenderer = createPerimetersCesiumRenderer,
} = {}) { } = {}) {
if (typeof source?.getSnapshot !== 'function') if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Fire perimeters require a snapshot source'); throw new TypeError('Fire perimeters require a snapshot source');
let _viewer = null; let initialized = false;
let renderer = null;
const geometry = new Map();
let _request = null; let _request = null;
let _linkRequest = null; let _linkRequest = null;
let _snapshotSignature = null; let _snapshotSignature = null;
let _dataSource = null;
let _count = 0; let _count = 0;
let _lastUpdate = null; let _lastUpdate = null;
// The proxy's last snapshot, served while WFIGS is unreachable. // The proxy's last snapshot, served while WFIGS is unreachable.
@@ -137,7 +141,7 @@ export function createFirePerimetersLayer({
} }
function publishSelectedCard() { function publishSelectedCard() {
if (!canSelect()) return; if (!canSelect() || !renderer) return;
const row = _selectedId ? _rowById.get(_selectedId) : null; const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) { if (!row) {
_selectedId = null; _selectedId = null;
@@ -156,7 +160,7 @@ export function createFirePerimetersLayer({
candidate && _linkVerdicts.get(candidate) === true ? candidate : null; candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
const card = { const card = {
...buildIncidentCard(row, Date.now(), { link: _selectedLink }), ...buildIncidentCard(row, Date.now(), { link: _selectedLink }),
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat), position: fromDegrees(row.anchor.lon, row.anchor.lat),
}; };
if (!openExternal) card.interactive = false; if (!openExternal) card.interactive = false;
if (_selectedLink && openExternal) { if (_selectedLink && openExternal) {
@@ -180,19 +184,18 @@ export function createFirePerimetersLayer({
const pickId = picking.resolvePickId(picked); const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null; return null;
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0]; const incidentId = pickId.slice(PICK_PREFIX.length, pickId.lastIndexOf(':'));
return _rowById.has(incidentId) ? incidentId : null; return _rowById.has(incidentId) ? incidentId : null;
} }
function installClickHandler() { function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return; if (!canSelect() || _clickHandler || !renderer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling // Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests, // layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV // so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a perimeter. Unowned, our picks read as // cards inert anywhere over a perimeter. Unowned, our picks read as
// "empty space" to siblings — the pre-existing behavior for the globe. // "empty space" to siblings — the pre-existing behavior for the globe.
_clickHandler = screenSpaceEventHandlerFactory(_viewer); _clickHandler = renderer.onClick((click) => {
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return; if (pointer && !pointer.isPointerFree()) return;
// A click on the incident card itself opens its InciWeb page (when the // A click on the incident card itself opens its InciWeb page (when the
// incident has one) and never disturbs the selection. // incident has one) and never disturbs the selection.
@@ -205,7 +208,7 @@ export function createFirePerimetersLayer({
if (_selectedLink && openExternal) openExternal(_selectedLink); if (_selectedLink && openExternal) openExternal(_selectedLink);
return; return;
} }
const picked = _viewer.scene.pick(click.position); const picked = renderer.pick(click);
const incidentId = picked ? pickedIncidentId(picked) : null; const incidentId = picked ? pickedIncidentId(picked) : null;
if (incidentId) { if (incidentId) {
abortLinkVerification(); abortLinkVerification();
@@ -224,12 +227,12 @@ export function createFirePerimetersLayer({
_selectedId = null; _selectedId = null;
publishSelectedCard(); publishSelectedCard();
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); });
} }
function removeClickHandler() { function removeClickHandler() {
if (_clickHandler) { if (_clickHandler) {
_clickHandler.destroy(); _clickHandler();
_clickHandler = null; _clickHandler = null;
} }
} }
@@ -252,24 +255,40 @@ export function createFirePerimetersLayer({
source: 'NIFC WFIGS', source: 'NIFC WFIGS',
updateInterval: 300000, updateInterval: 300000,
init(viewer) { init(viewer, { engine = null } = {}) {
if (_viewer) if (initialized) throw new Error('Fire perimeter layer is already initialized');
throw new Error('Fire perimeter layer is already initialized'); initialized = true;
_viewer = viewer; try {
_dataSource = new Cesium.CustomDataSource('fire-perimeters'); if (viewer || engine) this.attachRenderer(viewer, { engine });
_dataSource.show = false; } catch (error) {
viewer.dataSources.add(_dataSource); this.destroy();
_count = 0; throw error;
_lastUpdate = null; }
_lastError = null; },
_stale = false;
_enabled = false; attachRenderer(viewer, { engine = null } = {}) {
console.log('[Data:FirePerimeters] Initialized'); if (!initialized || renderer) throw new Error('Invalid fire perimeter scene attachment');
renderer = createRenderer({ viewer, engine, screenSpaceEventHandlerFactory });
renderer.setVisible(_enabled);
if (geometry.size) renderer.replace([...geometry.values()]);
if (_enabled) {
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler();
if (_selectedId) publishSelectedCard();
}
},
detachRenderer() {
removeClickHandler();
overlayHost?.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
renderer?.destroy();
renderer = null;
}, },
enable() { enable() {
_enabled = true; _enabled = true;
if (_dataSource) _dataSource.show = true; renderer?.setVisible(true);
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true); overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler(); installClickHandler();
}, },
@@ -278,13 +297,13 @@ export function createFirePerimetersLayer({
_request?.abort(); _request?.abort();
_request = null; _request = null;
_enabled = false; _enabled = false;
if (_dataSource) _dataSource.show = false; renderer?.setVisible(false);
removeClickHandler(); removeClickHandler();
clearSelection(); clearSelection();
}, },
async update() { async update() {
if (!_enabled || !_dataSource) return false; if (!_enabled) return false;
_request?.abort(); _request?.abort();
const request = new AbortController(); const request = new AbortController();
_request = request; _request = request;
@@ -312,60 +331,27 @@ export function createFirePerimetersLayer({
.map(({ polygons, ...facts }) => facts) .map(({ polygons, ...facts }) => facts)
.sort((a, b) => a.stableId.localeCompare(b.stableId)), .sort((a, b) => a.stableId.localeCompare(b.stableId)),
); );
if (signature === _snapshotSignature) { if (
signature === _snapshotSignature &&
rows.every(row => samePolygons(geometry.get(row.stableId)?.polygons, row.polygons))
) {
if (_selectedId) publishSelectedCard(); if (_selectedId) publishSelectedCard();
_lastUpdate = updatedAt; _lastUpdate = updatedAt;
_lastError = null; _lastError = null;
return true; return true;
} }
abortLinkVerification(); abortLinkVerification();
const nextEntities = []; renderer?.replace(rows);
geometry.clear();
_rowById.clear(); _rowById.clear();
for (const row of rows) { for (const row of rows) {
const color = containmentColor(row.containedPct); geometry.set(row.stableId, row);
for (const [index, rings] of row.polygons.entries()) {
const [outer, ...holes] = rings;
const outerPositions = ringPositions(outer);
nextEntities.push(
new Cesium.Entity({
id: `fire-perimeter:${row.stableId}:${index}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
outerPositions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(ringPositions(hole)),
),
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.25),
),
},
// The fire line: ground-clamped outline (entity polygons cannot
// outline clamped geometry themselves).
polyline: {
positions: outerPositions,
clampToGround: true,
width: 2,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.9),
),
},
}),
);
}
// Keep a geometry-free projection: the coordinate arrays would
// otherwise be retained for the layer's lifetime while only the
// anchor is ever read again.
const { polygons, ...facts } = row; const { polygons, ...facts } = row;
_rowById.set(row.stableId, { _rowById.set(row.stableId, {
...facts, ...facts,
anchor: perimeterAnchorDegrees(polygons), anchor: perimeterAnchorDegrees(polygons),
}); });
} }
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_snapshotSignature = signature; _snapshotSignature = signature;
// Refresh (or drop) the selected incident's card against the new feed. // Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard(); if (_selectedId) publishSelectedCard();
@@ -373,7 +359,7 @@ export function createFirePerimetersLayer({
_lastUpdate = updatedAt; _lastUpdate = updatedAt;
_lastError = null; _lastError = null;
console.log( console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`, `[Data:FirePerimeters] Updated: ${_count} incidents, ${rows.reduce((n, r) => n + r.polygons.length, 0)} polygons`,
); );
return true; return true;
} catch (e) { } catch (e) {
@@ -389,7 +375,7 @@ export function createFirePerimetersLayer({
} }
}, },
destroy(viewer = _viewer) { destroy() {
_request?.abort(); _request?.abort();
_request = null; _request = null;
removeClickHandler(); removeClickHandler();
@@ -399,12 +385,10 @@ export function createFirePerimetersLayer({
_inciwebIndex = []; _inciwebIndex = [];
_indexFetchedAt = 0; _indexFetchedAt = 0;
_linkVerdicts.clear(); _linkVerdicts.clear();
_viewer = null; initialized = false;
_enabled = false; _enabled = false;
if (_dataSource) { this.detachRenderer();
viewer.dataSources.remove(_dataSource, true); geometry.clear();
_dataSource = null;
}
_count = 0; _count = 0;
_lastUpdate = null; _lastUpdate = null;
_lastError = null; _lastError = null;
@@ -413,7 +397,7 @@ export function createFirePerimetersLayer({
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */ /** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
getAnalystRecords(maxCount = 2000) { getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return []; if (!_enabled) return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
: 2000; : 2000;
+24
View File
@@ -585,3 +585,27 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale',
assert.equal(h.layer.getStats().stale, false); assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502'); assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
}); });
test('a selected fire retains its card and changed polygon through a scene handoff', async () => {
const fire = { ...row, stableId: 'incident:one' };
let geometry = fire.polygons;
let polls = 0;
const h = harness({getSnapshot: async () => { polls++; return [{...fire, polygons: geometry}]; }}, {
pick: () => ({id: 'fire-perimeter:incident:one:0'}),
});
try {
await h.layer.update();
h.clicks.handler({position: {x: 0, y: 0}});
assert.equal(h.overlay.entries.get('fire-perimeters')[0].id, 'fire-perimeter-card:incident:one');
h.layer.detachRenderer();
assert.equal(h.sources.length, 0);
geometry = [[ring.map(([lon, lat]) => [lon+0.1,lat])]];
assert.equal(await h.layer.update(), true);
const record = h.layer.getAnalystRecords()[0];
assert.equal(record.id, 'incident:one');
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.overlay.entries.get('fire-perimeters')[0].id, 'fire-perimeter-card:incident:one');
assert.equal(polls, 2);
} finally { h.layer.destroy(); }
});
+72
View File
@@ -0,0 +1,72 @@
import * as Cesium from 'cesium';
import { containmentAccent } from '../cards.js';
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
export function containmentColor(containedPct) {
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
export function createPerimetersCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}) {
const dataSource = new Cesium.CustomDataSource('fire-perimeters');
dataSource.show = false;
viewer.dataSources.add(dataSource);
return {
setVisible(show) {
dataSource.show = show;
},
pick(click) {
return viewer.scene.pick(click.position);
},
onClick(callback) {
const handler = screenSpaceEventHandlerFactory(viewer);
handler.setInputAction(callback, Cesium.ScreenSpaceEventType.LEFT_CLICK);
return () => handler.destroy();
},
replace(rows) {
const nextEntities = [];
for (const row of rows) {
const color = containmentColor(row.containedPct);
for (const [index, rings] of row.polygons.entries()) {
const [outer, ...holes] = rings;
const outerPositions = ringPositions(outer);
nextEntities.push(
new Cesium.Entity({
id: `fire-perimeter:${row.stableId}:${index}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
outerPositions,
holes.map(
(hole) => new Cesium.PolygonHierarchy(ringPositions(hole)),
),
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.25),
),
},
// The fire line: ground-clamped outline (entity polygons cannot
// outline clamped geometry themselves).
polyline: {
positions: outerPositions,
clampToGround: true,
width: 2,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.9),
),
},
}),
);
}
}
dataSource.entities.removeAll();
for (const entity of nextEntities) dataSource.entities.add(entity);
},
destroy() {
viewer.dataSources.remove(dataSource, true);
},
};
}
+101
View File
@@ -0,0 +1,101 @@
import * as geo from '../../../geo/index.js';
import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js';
import { containmentAccent } from '../cards.js';
// A fire ring is local, including across 180°. Put the click and ring in
// the same longitude interval before casting the ray.
function contains(ring, lon, lat) {
const origin = ring[0][0];
const local = (x) => x - origin - 360 * Math.round((x - origin) / 360);
const x = local(lon);
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [ax, ay] = ring[j],
[bx, by] = ring[i];
if (ay > lat === by > lat) continue;
const a = local(ax),
b = local(bx);
if (x < a + ((b - a) * (lat - ay)) / (by - ay)) inside = !inside;
}
return inside;
}
export function createPerimetersNavaraRenderer(engine, { view }) {
let polygons = [];
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
polygon: {
color,
show,
clampToGround: true,
transparent: true,
opacity: 0.25,
},
polyline: {
color,
show,
clampToGround: true,
width: 2,
transparent: true,
opacity: 0.9,
},
}));
return {
setVisible: display.setVisible,
replace(rows) {
const groups = new Map(),
next = [];
for (const row of rows) {
const color = containmentAccent(row.containedPct);
if (!groups.has(color)) groups.set(color, []);
for (const [index, rings] of row.polygons.entries()) {
const id = `fire-perimeter:${row.stableId}:${index}`;
const properties = { incident: row.stableId };
const group = groups.get(color);
group.push(
{
type: 'Feature',
id,
properties,
geometry: { type: 'Polygon', coordinates: rings },
},
{
type: 'Feature',
id: id + '-outline',
properties,
geometry: { type: 'LineString', coordinates: rings[0] },
},
);
next.push({ id, rings });
}
}
display.replace(groups);
polygons = next;
},
onClick(callback) {
// Perimeters never claim a sibling pick. The input port shares the
// native click result with other consumers of the same gesture.
return engine.input.on('click', callback, { pick: true });
},
pick(event) {
if (event.picked) return event.picked;
const point = engine.picker.pickGlobe(event.position);
if (!point) return null;
const pos = geo.cartographic.fromCartesian(point);
const lon = geo.math.toDegrees(pos.longitude),
lat = geo.math.toDegrees(pos.latitude);
for (let i = polygons.length - 1; i >= 0; i--) {
const { id, rings } = polygons[i];
if (
contains(rings[0], lon, lat) &&
!rings.slice(1).some((ring) => contains(ring, lon, lat))
)
return { id };
}
return null;
},
destroy() {
polygons = [];
display.destroy();
},
};
}
@@ -0,0 +1,52 @@
/** GeoJSON features grouped by paint. Swap complete batches so a failed
* source or layer allocation cannot wipe the last rendered result. */
export function createNavaraPolygonDisplay(engine, view, style) {
let visible = false;
let batches = [];
function release(items) {
for (const { layer, source } of items) {
layer?.delete();
source.delete();
}
}
return {
setVisible(show) {
if (visible === show) return;
visible = show;
for (const { color, source, layer } of batches) {
layer.update({ type: 'vector', source, ...style(color, show) });
}
engine.render.request('polygon-visibility');
},
replace(groups) {
const next = [];
try {
for (const [css, features] of groups) {
const color = Number.parseInt(css.slice(1), 16);
const source = view.addSource({
type: 'geojson',
data: { type: 'FeatureCollection', features },
});
const batch = { source, color };
next.push(batch);
batch.layer = view.addLayer({
type: 'vector',
source,
...style(color, visible),
});
}
} catch (error) {
release(next);
throw error;
}
release(batches);
batches = next;
engine.render.request('polygon-data');
},
destroy() {
release(batches);
batches = [];
engine.render.request('polygon-destroy');
},
};
}