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/airQuality/',
'src/layers/gnss/',
'src/layers/perimeters/',
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
+10
View File
@@ -3221,6 +3221,7 @@
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
@@ -3659,6 +3660,8 @@
"src/layers/earthquakes/render/navara.js",
"src/layers/airQuality/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/navaraGrid.js",
"src/layers/installations/render/navara.js",
@@ -4101,6 +4104,7 @@
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
@@ -5214,7 +5218,13 @@
"perimeters": {
"exports": ["./layers/perimeters"],
"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/render/cesium.js",
"src/sources/httpBody.js",
"src/i18n/marker.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 sharp from 'sharp';
import assert from 'node:assert/strict';
@@ -31,6 +32,19 @@ try {
const fetch = window.fetch.bind(window);
window.fetch = (input, o) => {
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') {
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}))}));
@@ -147,6 +161,46 @@ try {
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(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, []);
} finally {
await b.close();
+2 -2
View File
@@ -189,9 +189,9 @@ export function createApplicationCatalog({
[
'fire-perimeters',
() =>
createApplicationFirePerimeters({
sessionModel('fire-perimeters', () => createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
})),
],
[
'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 {
createFirePerimetersLayer,
@@ -22,6 +24,9 @@ export function createApplicationFirePerimeters(options) {
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
createRenderer: ({viewer, engine, screenSpaceEventHandlerFactory}) => engine
? rendererFor('fire-perimeters', engine, {viewer, screenSpaceEventHandlerFactory})
: createPerimetersCesiumRenderer({viewer, screenSpaceEventHandlerFactory}),
picking,
pointer: { isPointerFree },
...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 { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
@@ -96,6 +97,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer,
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
'fire-perimeters': (_engine, options) => createPerimetersCesiumRenderer(options),
'local-firms': createFirmsCesiumRenderer,
'rocket-launches': createLaunchesCesiumRenderer,
satellites: createSatellitesCesiumRenderer,
+1
View File
@@ -61,6 +61,7 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
'earthquakes',
'air-quality',
'gnss-integrity',
'fire-perimeters',
'local-firms',
'rocket-launches',
'satellites',
+2
View File
@@ -26,6 +26,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'ais-live-vessels',
'earthquakes',
'air-quality',
'fire-perimeters',
'gnss-integrity',
'local-firms',
'military-installations',
@@ -47,6 +48,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'local-adsb',
'earthquakes',
'air-quality',
'fire-perimeters',
'gnss-integrity',
'alpr-cameras',
'satellites',
+5
View File
@@ -57,6 +57,7 @@ const NAVARA_CREDIT_KEYS = new Set([
'launch-library-2',
'usgs',
'eccc-aqhi',
'wfigs',
'firms',
'gbfs',
'overture-military-names',
@@ -123,6 +124,10 @@ export async function registerNavaraLayerRenderers() {
...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 () => {
const { createGnssNavaraRenderer } = await import('../layers/gnss/render/navara.js');
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',
'usgs',
'eccc-aqhi',
'wfigs',
'firms',
'gbfs',
'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';
function stationFeature(reading, radius) {
@@ -33,68 +34,21 @@ function stationFeature(reading, radius) {
/** One native GeoJSON layer per AQHI colour, independent of station count. */
export function createAirQualityNavaraRenderer(engine, { view }) {
let visible = false;
let batches = [];
function release(items) {
for (const { layer, source } of items) {
layer?.delete();
source.delete();
}
}
const display = createNavaraPolygonDisplay(engine, view, (color, show) => ({
polygon: {color, show, transparent: true, opacity: 0.35, clampToGround: true,
outline: true, outlineShow: true, outlineColor: color, outlineWidth: 2},
}));
return {
viewRectangle: () => engine.camera.viewRectangle(),
setVisible(show) {
if (visible === show) return;
visible = show;
for (const { layer, source, paint } of batches) {
layer.update({ type: 'vector', source, polygon: { ...paint, show } });
}
engine.render.request('air-quality-visibility');
},
setVisible: display.setVisible,
destroy: display.destroy,
replace(rows, radius) {
const groups = new Map();
for (const row of rows) {
if (!groups.has(row.color)) groups.set(row.color, []);
groups.get(row.color).push(stationFeature(row, radius));
}
const next = [];
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');
display.replace(groups);
},
};
}
+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 {
PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees,
@@ -16,14 +18,6 @@ export { createWfigsPerimeterSource } from './source.js';
export * from './cards.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 CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
@@ -35,6 +29,14 @@ const CARD_HOST_OPTIONS = Object.freeze({
// of near-identical bytes.
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. */
export function createFirePerimetersLayer({
source,
@@ -45,14 +47,16 @@ export function createFirePerimetersLayer({
inciwebSource = null,
inciwebPublications = null,
openExternal = null,
createRenderer = createPerimetersCesiumRenderer,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
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 _linkRequest = null;
let _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
// The proxy's last snapshot, served while WFIGS is unreachable.
@@ -137,7 +141,7 @@ export function createFirePerimetersLayer({
}
function publishSelectedCard() {
if (!canSelect()) return;
if (!canSelect() || !renderer) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
@@ -156,7 +160,7 @@ export function createFirePerimetersLayer({
candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
const card = {
...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 (_selectedLink && openExternal) {
@@ -180,19 +184,18 @@ export function createFirePerimetersLayer({
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
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;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
if (!canSelect() || _clickHandler || !renderer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a perimeter. Unowned, our picks read as
// "empty space" to siblings — the pre-existing behavior for the globe.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
_clickHandler = renderer.onClick((click) => {
if (pointer && !pointer.isPointerFree()) return;
// A click on the incident card itself opens its InciWeb page (when the
// incident has one) and never disturbs the selection.
@@ -205,7 +208,7 @@ export function createFirePerimetersLayer({
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const picked = renderer.pick(click);
const incidentId = picked ? pickedIncidentId(picked) : null;
if (incidentId) {
abortLinkVerification();
@@ -224,12 +227,12 @@ export function createFirePerimetersLayer({
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
});
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler();
_clickHandler = null;
}
}
@@ -252,24 +255,40 @@ export function createFirePerimetersLayer({
source: 'NIFC WFIGS',
updateInterval: 300000,
init(viewer) {
if (_viewer)
throw new Error('Fire perimeter layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('fire-perimeters');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_stale = false;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
init(viewer, { engine = null } = {}) {
if (initialized) throw new Error('Fire perimeter layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
}
},
attachRenderer(viewer, { engine = null } = {}) {
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() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
renderer?.setVisible(true);
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
@@ -278,13 +297,13 @@ export function createFirePerimetersLayer({
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
renderer?.setVisible(false);
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
if (!_enabled) return false;
_request?.abort();
const request = new AbortController();
_request = request;
@@ -312,60 +331,27 @@ export function createFirePerimetersLayer({
.map(({ polygons, ...facts }) => facts)
.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();
_lastUpdate = updatedAt;
_lastError = null;
return true;
}
abortLinkVerification();
const nextEntities = [];
renderer?.replace(rows);
geometry.clear();
_rowById.clear();
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),
),
},
}),
);
}
// 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.
geometry.set(row.stableId, row);
const { polygons, ...facts } = row;
_rowById.set(row.stableId, {
...facts,
anchor: perimeterAnchorDegrees(polygons),
});
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_snapshotSignature = signature;
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
@@ -373,7 +359,7 @@ export function createFirePerimetersLayer({
_lastUpdate = updatedAt;
_lastError = null;
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;
} catch (e) {
@@ -389,7 +375,7 @@ export function createFirePerimetersLayer({
}
},
destroy(viewer = _viewer) {
destroy() {
_request?.abort();
_request = null;
removeClickHandler();
@@ -399,12 +385,10 @@ export function createFirePerimetersLayer({
_inciwebIndex = [];
_indexFetchedAt = 0;
_linkVerdicts.clear();
_viewer = null;
initialized = false;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
this.detachRenderer();
geometry.clear();
_count = 0;
_lastUpdate = null;
_lastError = null;
@@ -413,7 +397,7 @@ export function createFirePerimetersLayer({
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
if (!_enabled) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 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().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');
},
};
}