From 1bda760763539e977d4efe2026bcef40e90de827 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 02:40:36 +0400 Subject: [PATCH] Preserve fire incident geometry and selection across both globes --- scripts/check-import-directions.mjs | 1 + scripts/package-boundaries.json | 10 ++ scripts/qa-ground-layer-switch.mjs | 54 ++++++++ src/app/constructCatalog.js | 4 +- src/app/layers/perimeters.js | 5 + src/app/layers/renderers.js | 2 + src/app/layers/renderers.test.mjs | 1 + src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 5 + src/app/navaraScene.test.mjs | 1 + src/layers/airQuality/render/navara.js | 62 ++------- src/layers/perimeters/index.js | 152 +++++++++------------ src/layers/perimeters/ownership.test.mjs | 24 ++++ src/layers/perimeters/render/cesium.js | 72 ++++++++++ src/layers/perimeters/render/navara.js | 101 ++++++++++++++ src/layers/shared/render/navaraPolygons.js | 52 +++++++ 16 files changed, 408 insertions(+), 140 deletions(-) create mode 100644 src/layers/perimeters/render/cesium.js create mode 100644 src/layers/perimeters/render/navara.js create mode 100644 src/layers/shared/render/navaraPolygons.js diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index dce7525ca..cd2d5de53 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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/', diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cbd903b1a..62f692f4d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-ground-layer-switch.mjs b/scripts/qa-ground-layer-switch.mjs index 9f8110877..7d4a32299 100644 --- a/scripts/qa-ground-layer-switch.mjs +++ b/scripts/qa-ground-layer-switch.mjs @@ -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 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(); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 9a8309847..61cee9bb0 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -189,9 +189,9 @@ export function createApplicationCatalog({ [ 'fire-perimeters', () => - createApplicationFirePerimeters({ + sessionModel('fire-perimeters', () => createApplicationFirePerimeters({ source: sources['fire-perimeters'], - }), + })), ], [ 'gnss-integrity', diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index f5110f630..8b18e39e1 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -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, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index e429dc50d..7bbe026b3 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index c2f5d892d..90717641c 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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', diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 7b6c199cc..a1160e648 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index c77aa1f40..b75bb1b9f 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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)); diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index 3286a1938..cc23114e0 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -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', diff --git a/src/layers/airQuality/render/navara.js b/src/layers/airQuality/render/navara.js index e020f3561..3518c0db9 100644 --- a/src/layers/airQuality/render/navara.js +++ b/src/layers/airQuality/render/navara.js @@ -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); }, }; } diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 83826a865..52dd57e91 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -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; diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index bd5c3fc70..100ff4729 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -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(); } +}); diff --git a/src/layers/perimeters/render/cesium.js b/src/layers/perimeters/render/cesium.js new file mode 100644 index 000000000..585d0cae1 --- /dev/null +++ b/src/layers/perimeters/render/cesium.js @@ -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); + }, + }; +} diff --git a/src/layers/perimeters/render/navara.js b/src/layers/perimeters/render/navara.js new file mode 100644 index 000000000..488066a1f --- /dev/null +++ b/src/layers/perimeters/render/navara.js @@ -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(); + }, + }; +} diff --git a/src/layers/shared/render/navaraPolygons.js b/src/layers/shared/render/navaraPolygons.js new file mode 100644 index 000000000..916b733a9 --- /dev/null +++ b/src/layers/shared/render/navaraPolygons.js @@ -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'); + }, + }; +}