mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24: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/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/',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
@@ -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