mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Preserve fire incident geometry and selection across both globes
This commit is contained in:
1 parent
b7431d7823
commit
1bda760763
16 files changed
+408
-140
No files matched your search
@@ -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/',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -189,9 +189,9 @@ export function createApplicationCatalog({
|
||||
[
|
||||
'fire-perimeters',
|
||||
() =>
|
||||
createApplicationFirePerimeters({
|
||||
sessionModel('fire-perimeters', () => createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
})),
|
||||
],
|
||||
[
|
||||
'gnss-integrity',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user