feat(fires): MODIS in Active Fires and a Fire Perimeters layer (#737)

Adds MODIS NRT to Active Fires and a keyless Fire Perimeters layer (NIFC WFIGS) with
InciWeb incident links, served through a capped, cached /api/fire-perimeters proxy.

Co-authored-by: Gustavo Beneduzi <[email protected]>
Co-authored-by: James Cooke <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 21:21:52 -05:00
1 parent 4b56d0e917
commit f913066cf2
40 files changed
+3201 -26

No files matched your search

+5
View File
@@ -23,6 +23,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
@@ -52,6 +53,7 @@ const SOURCE_METHODS = Object.freeze({
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
'fire-perimeters': ['getSnapshot'],
cables: ['fetch'],
});
@@ -142,6 +144,9 @@ export function createApplicationCatalog({
...(resolveAsset ? { resolveAsset } : {}),
}),
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
+2 -1
View File
@@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 28);
assert.equal(first.layers.length, 29);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -58,6 +58,7 @@ test('catalogs construct distinct layers and classification from their supplied
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
import {
createFirePerimetersLayer,
createInciwebIndexSource,
createInciwebPublicationSource,
} from '../../layers/perimeters/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */
export function createApplicationFirePerimeters(options) {
return createFirePerimetersLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
...options,
});
}
+5
View File
@@ -77,6 +77,11 @@ export const ANALYST_LAYERS = {
text: ['name', 'operator', 'river', 'output'],
flags: [],
},
'fire-perimeters': {
numeric: ['acres', 'containedPct', 'personnel', 'costToDate'],
text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'],
flags: [],
},
};
const EARTH_R_KM = 6371;
+8
View File
@@ -86,6 +86,14 @@ export const DATA_CREDITS = [
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
},
{
key: 'wfigs',
html:
'Wildfire perimeters: ' +
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
' · Incident information: ' +
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
},
{
key: 'overpass',
html:
+410
View File
@@ -0,0 +1,410 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { firePerimetersProxy } from '../../server/providers/firePerimeters.js';
const incidentHtml = `<meta property="og:updated_time" content="2026-09-23T11:32:31-04:00" />
<th>Date of Origin</th>
<td class="text-tabular text-left"><time datetime="2026-07-15T20:20:00Z">Origin</time></td>`;
const incidentTimes = {
createdMs: Date.parse('2026-07-15T20:20:00Z'),
changedMs: Date.parse('2026-09-23T11:32:31-04:00'),
};
const incidentRedirect = () =>
new Response('redirect', {
status: 301,
headers: { location: 'http://inciweb.wildfire.gov/test-fire' },
});
const feature = (id = 'fire') => ({
properties: { attr_UniqueFireIdentifier: id, poly_DateCurrent: 123 },
geometry: {
type: 'Polygon',
coordinates: [
[
[-108, 35],
[-107, 35],
[-107, 36],
[-108, 35],
],
],
},
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = firePerimetersProxy(options);
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/fire-perimeters');
handler = callback;
},
},
});
return async (url = '/', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json({
features: [feature(String(calls.length))],
exceededTransferLimit: true,
});
},
},
hook,
);
const res = await request('/?url=https://invalid.example&resultOffset=999');
assert.equal(res.status, 200);
assert.equal(res.body.fetchedAt, 1234);
assert.equal(res.body.rows.length, 5);
assert.deepEqual(
calls.map((url) => url.searchParams.get('resultOffset')),
[null, '1', '2', '3', '4'],
);
assert.ok(calls.every((url) => url.hostname === 'services3.arcgis.com'));
assert.equal(calls[0].searchParams.get('maxAllowableOffset'), '0.001');
});
}
test('an explicit transfer-limit false stops paging; a fresh cache does not fetch', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({
features: [feature()],
exceededTransferLimit: false,
});
},
});
const first = await request();
assert.deepEqual((await request()).body, first.body);
assert.equal(calls, 1);
});
test('a failed refresh serves the last good perimeter snapshot as stale', async () => {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
if (++calls > 1) throw new Error('private upstream details');
return Response.json({ features: [feature()] });
},
});
await request();
clock = 300_000;
const res = await request();
assert.equal(res.status, 200);
assert.equal(res.body.stale, true);
assert.equal(res.body.fetchedAt, 0);
assert.equal(res.body.rows[0].stableId, 'fire');
});
test('oversized streamed and declared bodies produce sanitized 502 responses', async () => {
for (const declared of [true, false]) {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'Content-Length': String(17 * 1024 * 1024) }
: {},
},
),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'fire_perimeters_unavailable' });
assert.equal(cancelled, true);
}
});
test('malformed WFIGS data does not become a cached empty snapshot', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ error: 'upstream detail' });
},
});
assert.equal((await request()).status, 502);
assert.equal((await request()).status, 502);
assert.equal(calls, 2);
});
test('concurrent requests share one upstream operation for every route', async () => {
for (const [path, payload] of [
['/', { features: [] }],
['/inciweb/index', []],
['/inciweb/publication/42', {}],
]) {
let release,
calls = 0;
const pending = new Promise((resolve) => {
release = resolve;
});
const request = install({
fetchImpl: async () => {
calls++;
await pending;
if (path.includes('/publication/'))
return calls === 1 ? incidentRedirect() : new Response(incidentHtml);
return Response.json(payload);
},
});
const responses = [request(path), request(path)];
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
for (const res of await Promise.all(responses))
assert.equal(res.status, 200);
assert.equal(calls, path.includes('/publication/') ? 2 : 1);
}
});
test('bad publication ids and non-GET requests never fetch upstream', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const id of ['', 'abc', '-1', '1234567890', '1/2', '%31', '1.0'])
assert.equal((await request(`/inciweb/publication/${id}`)).status, 400);
for (const path of ['/', '/inciweb/index', '/inciweb/publication/123']) {
for (const method of ['POST', 'HEAD', 'PUT', 'DELETE'])
assert.equal((await request(path, method)).status, 405);
}
assert.equal(calls, 0);
});
test('InciWeb index preserves the fixed POST, caches for one hour and serves stale arrays', async () => {
let clock = 0,
calls = 0;
const rows = [{ incident_id: '42', incident_title: 'Fire' }];
const request = install({
now: () => clock,
fetchImpl: async (url, options) => {
calls++;
assert.equal(url, 'https://inciweb.wildfire.gov/api/single-publication/');
assert.equal(options.method, 'POST');
assert.deepEqual(options.headers, { 'Content-Type': 'application/json' });
assert.deepEqual(JSON.parse(options.body), { title: '' });
if (calls > 1) throw new Error('offline');
return Response.json(rows);
},
});
assert.deepEqual((await request('/inciweb/index')).body, rows);
clock = 3_599_999;
assert.deepEqual((await request('/inciweb/index')).body, rows);
assert.equal(calls, 1);
clock++;
const stale = await request('/inciweb/index');
assert.deepEqual(stale.body, rows);
assert.equal(stale.headers['X-Data-Stale'], 'true');
});
test('publication timestamps, thirty-minute TTL and oldest-entry eviction', async () => {
let clock = 0;
const calls = [];
const request = install({
now: () => clock,
fetchImpl: async (url) => {
calls.push(url);
return url.includes('/node/')
? incidentRedirect()
: new Response(incidentHtml);
},
});
const get = (id) =>
request(`/inciweb/publication/${id}`, 'GET', `peer-${id}`);
assert.deepEqual((await get(1)).body, incidentTimes);
clock = 1_799_999;
await get(1);
assert.equal(calls.length, 2);
clock++;
await get(1);
assert.equal(calls.length, 4);
for (let id = 2; id <= 257; id++) assert.equal((await get(id)).status, 200);
await get(2);
assert.equal(calls.length, 516);
await get(1);
assert.equal(calls.length, 518);
assert.equal(calls.at(-2), 'https://inciweb.wildfire.gov/node/1');
});
test('InciWeb index and publication reads enforce their smaller caps', async () => {
for (const [path, cap] of [
['/inciweb/index', 4 * 1024 * 1024],
['/inciweb/publication/1', 2 * 1024 * 1024],
]) {
const request = install({
fetchImpl: async () =>
new Response('{}', { headers: { 'Content-Length': String(cap + 1) } }),
});
assert.equal((await request(path)).status, 502);
}
});
test('the per-client limit stops a loop while another client can read the cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return Response.json({ features: [] });
},
});
for (let i = 0; i < 60; i++) assert.equal((await request()).status, 200);
assert.equal((await request()).status, 429);
assert.equal((await request('/', 'GET', 'another-client')).status, 200);
assert.equal(calls, 1);
});
test('publication redirects share a signal, cancel the redirect body and force HTTPS', async () => {
for (const status of [301, 302, 303, 307, 308]) {
const calls = [];
const redirect = new Response('redirect', {
status,
headers: {
location: 'http://inciweb.wildfire.gov/incident/test-fire#details',
},
});
const request = install({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return calls.length === 1 ? redirect : new Response(incidentHtml);
},
});
assert.deepEqual(
(await request('/inciweb/publication/42')).body,
incidentTimes,
);
assert.equal(calls.length, 2);
assert.equal(redirect.bodyUsed, true);
assert.equal(calls[0].url, 'https://inciweb.wildfire.gov/node/42');
assert.equal(calls[0].options.redirect, 'manual');
assert.equal(
calls[1].url,
'https://inciweb.wildfire.gov/incident/test-fire',
);
assert.equal(calls[1].options.redirect, 'error');
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(calls[0].options.signal, calls[1].options.signal);
}
});
test('unsafe publication redirect locations fail before a second fetch', async () => {
for (const location of [
'https://evil.example/x',
'/a/../x',
'/x?q=1',
'/x?',
'/%2e%2e/x',
'/a/b/c',
'',
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response('redirect', { status: 301, headers: { location } });
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 1);
}
});
test('incident pages without valid timestamps fail closed and are not cached', async () => {
for (const html of [
'<html>No markers</html>',
incidentHtml.replaceAll(/2026-[^"]+/g, 'invalid'),
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return calls % 2 ? incidentRedirect() : new Response(html);
},
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(calls, 4);
}
});
test('direct 200 incident pages work, including one missing timestamp', async () => {
for (const [html, expected] of [
[incidentHtml, incidentTimes],
[
incidentHtml.replace('og:updated_time', 'unrelated'),
{ ...incidentTimes, changedMs: null },
],
[
incidentHtml.replace('Date of Origin', 'Unrelated'),
{ ...incidentTimes, createdMs: null },
],
]) {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls++;
return new Response(html);
},
});
assert.deepEqual((await request('/inciweb/publication/42')).body, expected);
assert.equal(calls, 1);
}
});
test('publication HTML streams are capped at 2 MiB', async () => {
let cancelled = false;
const request = install({
fetchImpl: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(1024 * 1024));
},
cancel() {
cancelled = true;
},
}),
),
});
assert.equal((await request('/inciweb/publication/42')).status, 502);
assert.equal(cancelled, true);
});
+7 -1
View File
@@ -5,9 +5,15 @@
* VIIRS header (confirmed live 2026-07-16):
* latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_ti5,frp,daynight
* MODIS header:
* latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,
* instrument,confidence,version,bright_t31,frp,daynight
*
* Quirks this module owns:
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw.
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) and numeric 0-100 for
* MODIS — passed through raw.
* - Brightness maps from `bright_ti4`/`brightness`; the secondary band maps
* from `bright_ti5`/`bright_t31`.
* - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records;
* {@link acquisitionMsUtc} does the padding.
* - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"),
+19
View File
@@ -48,6 +48,25 @@ test('fixture first row maps every field, categorical confidence preserved raw',
assert.equal(first.instrument, 'VIIRS');
});
test('MODIS schema maps fields and preserves numeric confidence raw', () => {
const header =
'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight';
const row =
'34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D';
const [record] = parseFirmsCsv(`${header}\n${row}\n`);
assert.equal(record.lat, 34.123);
assert.equal(record.lon, -117.456);
assert.equal(record.frp, 12.4);
assert.equal(record.brightness, 321.5);
assert.equal(record.brightnessTi5, 290.25);
assert.equal(record.daynight, 'D');
assert.equal(record.acqDate, '2026-07-16');
assert.equal(record.acqTime, '1045');
assert.equal(record.satellite, 'Aqua');
assert.equal(record.instrument, 'MODIS');
assert.equal(record.confidence, '78');
});
test('acquisitionMsUtc: "1006" = 10:06 UTC', () => {
assert.equal(
acquisitionMsUtc('2026-07-16', '1006'),
+4 -4
View File
@@ -9,7 +9,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
const end = config.indexOf('\n }', start);
assert.notEqual(end, -1, 'FIRMS refresh function closes');
const refreshSource = config.slice(start, end + 4);
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
const NOW = Date.UTC(2026, 8, 11, 12);
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
@@ -42,7 +42,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
assert.equal(result.fires[199_999], large.at(-1));
assert.equal(result.fires.at(-1), last);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: [200_000, 1, 0][index], ok: true,
source, count: [200_000, 1, 0, 0][index], ok: true,
})));
});
@@ -51,7 +51,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
if (source === SOURCES[1]) throw new Error('upstream unavailable');
return [recent];
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 1 ? 0 : 1, ok: index !== 1,
})));
@@ -65,7 +65,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
// Fault injection for aggregation; ordinary parsed CSV returns an array.
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
})('fixture');
assert.equal(result.fires.length, 2);
assert.equal(result.fires.length, 3);
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
source, count: index === 0 ? 0 : 1, ok: index !== 0,
})));
+5
View File
@@ -480,6 +480,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
Object.freeze({
id: 'fire-perimeters',
token: '2',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'flights',
token: 'f',
+9 -2
View File
@@ -194,8 +194,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 27);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 27);
assert.equal(REGISTERED_LAYER_IDS.length, 28);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.throws(
@@ -2418,3 +2418,10 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
50,
);
});
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, ['fire-perimeters', 'recent-imagery', 'wind']);
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2');
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded);
});
+2 -2
View File
@@ -664,10 +664,10 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
const block = JSON.stringify(GEV_REALTIME_TOOLS);
// Re-derived for the additive `local-adsb` set_layer_visibility value and
// its common-name mapping; the missions still ride existing tools.
assert.equal(block.length, 27378, 'serialized tool schema length drifted');
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'1947e9df228b3fd66a874ca462b5e5a29d800191b0180823903628be8888bc52',
'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+157
View File
@@ -0,0 +1,157 @@
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
/** CSS accent for a perimeter card by containment progress (mirrors the fill). */
export function containmentAccent(containedPct) {
if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30';
if (containedPct < 50) return '#ff7a00';
if (containedPct < 100) return '#ffb300';
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
sum += x1 * y2 - x2 * y1;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
export function perimeterAnchorDegrees(polygons) {
let best = null;
let bestArea = -1;
for (const rings of polygons) {
const outer = rings[0];
const area = ringArea(outer);
if (area > bestArea) {
bestArea = area;
best = outer;
}
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let lat = 0;
const count = best.length - 1;
for (let i = 0; i < count; i++) {
lon += best[i][0];
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
}
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
function formatCost(dollars) {
if (!Number.isFinite(dollars) || dollars < 1000) return null;
const units = [
[1e3, 'K'],
[1e6, 'M'],
[1e9, 'B'],
];
let index = 0;
for (let i = units.length - 1; i >= 0; i--) {
if (dollars >= units[i][0]) {
index = i;
break;
}
}
// 999,500 must promote to $1M, never render as $1000K.
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index][0];
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
}
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
function titleCase(name) {
return String(name)
.toLowerCase()
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = Math.floor(deltaMs / 3600000);
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* Build the overlay-host entry for one selected incident. The caller supplies
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
* @param {Object} row - Normalized perimeter row (see records.js).
* @param {number} nowMs - Current epoch milliseconds.
* @param {{link: ?string}} [extras] - Optional InciWeb page for this incident.
* @returns {Object} World-overlay entry without `position`.
*/
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
const facts = [];
if (Number.isFinite(row.acres))
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
facts.push(
Number.isFinite(row.containedPct)
? `${Math.round(row.containedPct)}% contained`
: 'containment unknown',
);
if (row.state) facts.push(row.state);
else if (row.category) facts.push(row.category);
const situation = [];
if (row.cause) situation.push(`${row.cause} cause`);
if (row.behavior) situation.push(row.behavior);
if (row.complexity) situation.push(row.complexity);
const response = [];
if (Number.isFinite(row.personnel))
response.push(
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
);
if (row.county) response.push(`${row.county} County`);
if (Number.isFinite(row.costToDate)) {
const cost = formatCost(row.costToDate);
if (cost) response.push(`${cost} to date`);
}
const ages = [];
const discovered = formatAge(nowMs - row.discoveredTime);
if (row.discoveredTime != null && discovered)
ages.push(`discovered ${discovered} ago`);
const updated = formatAge(nowMs - row.updatedTime);
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
const details = [facts.join(' · ')];
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
if (situation.length) details.push(situation.join(' · '));
if (response.length) details.push(response.join(' · '));
if (ages.length) details.push(ages.join(' · '));
if (link) details.push('InciWeb ↗ · click card to open');
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
return {
id: `fire-perimeter-card:${row.stableId}`,
// `selected` picks the paint lane and collision protection; an explicit
// paintLane would override it in the host's lane resolver.
selected: true,
// Only a linked card is clickable — the overlay host registers hit
// rectangles solely for interactive entries.
interactive: Boolean(link),
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
title,
details,
accent: containmentAccent(row.containedPct),
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js';
const square = [
[-108.2, 35.0],
[-108.0, 35.0],
[-108.0, 35.2],
[-108.2, 35.2],
[-108.2, 35.0],
];
const tiny = [
[-107.01, 34.0],
[-107.0, 34.0],
[-107.0, 34.01],
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
{
stableId: '2026-NMGNF-000123',
name: 'Frijoles',
acres: 15956.4,
containedPct: 74,
state: 'US-NM',
category: 'WF',
discoveredTime: now - 36 * 3600000,
updatedTime: now - 2 * 3600000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
},
now,
);
assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(card.title, 'FIRE · Frijoles');
assert.deepEqual(card.details, [
'15,956 ac · 74% contained · US-NM',
'Natural cause · Active · Type 3 Incident',
'380 personnel · Sandoval County · $4.2M to date',
'discovered 1d ago · updated 2h ago',
]);
assert.equal(card.selected, true);
assert.equal(typeof card.accent, 'string');
});
test('missing attributes degrade to available facts instead of placeholders', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: null,
acres: null,
containedPct: null,
state: null,
category: 'RX',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.title, 'FIRE · Unnamed incident');
assert.deepEqual(card.details, ['containment unknown · RX']);
});
test('a known InciWeb page adds a link line to the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Timber',
acres: 25426,
containedPct: 44,
state: 'US-CA',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
{
link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire',
},
);
assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open');
assert.equal(card.interactive, true);
assert.match(card.accessibilityLabel, /InciWeb/);
});
test('the selected card claims the selected paint lane, not the ambient lane', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Any',
acres: 1,
containedPct: 1,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
1758000000000,
);
assert.equal(card.selected, true);
// An explicit paintLane would override `selected` in the overlay host's
// lane resolver and push the card under other selected cards.
assert.equal('paintLane' in card, false);
assert.equal('collisionGroup' in card, false);
});
test('costs near a unit boundary promote to the larger unit', () => {
const base = {
stableId: 'x',
name: 'Any',
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
complexity: null,
complexName: null,
};
const costLine = (costToDate) =>
buildIncidentCard({ ...base, costToDate }, 0).details.find((line) =>
line.includes('to date'),
);
assert.equal(costLine(999500), '$1M to date');
assert.equal(costLine(999499), '$999K to date');
assert.equal(costLine(999600000), '$1B to date');
assert.equal(costLine(4200000), '$4.2M to date');
});
test('a complex member names its complex on the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 342923,
containedPct: 99,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: 'ROWE CREEK COMPLEX',
},
1758000000000,
);
assert.ok(card.details.includes('part of Rowe Creek Complex'));
});
test('a card without a link is not interactive', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 1,
containedPct: 1,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.interactive, false);
});
test('partial incident details render only the facts that exist', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Whiskey',
acres: 542,
containedPct: 48,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: 'Human',
behavior: null,
personnel: null,
county: 'Catron',
costToDate: 85000,
complexity: null,
},
1758000000000,
);
assert.deepEqual(card.details, [
'542 ac · 48% contained · US-NM',
'Human cause',
'Catron County · $85K to date',
]);
});
+173
View File
@@ -0,0 +1,173 @@
/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/**
* Normalize an incident title for matching: lowercase, collapsed whitespace,
* a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped
* ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and
* WFIGS names carry neither.
*/
function normalizeName(name) {
return String(name)
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/^(19|20)\d{2} /, '')
.replace(/ fire$/, '');
}
/**
* Resolve one WFIGS incident against the publication catalog.
* Only rows whose normalized title equals the incident's normalized name
* count. Ambiguity resolves by the tau dispatch unit's state prefix, then
* by newest incident id (same-state duplicates are usually reburns of the
* same name). The `/node/{id}` link 301s to the canonical page, so no slug
* construction is needed.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string}} incident - WFIGS row facts.
* @returns {?string} InciWeb node URL.
*/
export function resolveInciwebNodeLink(publications, { name, state }) {
if (!Array.isArray(publications) || !name) return null;
const wanted = normalizeName(name);
let candidates = publications.filter(
(row) =>
typeof row?.incident_title === 'string' &&
/^\d+$/.test(String(row?.incident_id ?? '')) &&
normalizeName(row.incident_title) === wanted,
);
// The catalog is all-time and nationwide, so the dispatch unit's state
// must corroborate every match — a globally unique title is NOT evidence
// it's the same fire (live data shows same-name incidents across states).
const statePrefix =
typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state)
? state.slice(3).toLowerCase()
: null;
if (statePrefix) {
candidates = candidates.filter(
(row) =>
typeof row.tau === 'string' &&
row.tau.slice(0, 2).toLowerCase() === statePrefix,
);
} else if (candidates.length > 1) {
return null;
}
if (candidates.length > 1) {
// Same-state duplicates are usually reburns of the same name — newest
// wins here, and the publication-currency check guards the final link.
candidates = [
candidates.reduce((a, b) =>
Number(a.incident_id) >= Number(b.incident_id) ? a : b,
),
];
}
return candidates.length === 1
? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}`
: null;
}
/** The publication id inside a node link produced above, or null. */
export function inciwebNodeId(link) {
const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec(
String(link ?? ''),
);
return match ? match[1] : null;
}
// A fire's InciWeb page is created around the incident's start; a match
// whose page predates discovery by more than this is an archived
// same-name incident from a earlier season.
const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000;
// Without a discovery date, require recent editorial activity on the page.
const CHANGED_WITHIN_MS = 180 * 24 * 3600000;
/**
* Whether a publication plausibly describes the given current incident.
* Fails closed on unusable timestamps.
* @param {{createdMs: ?number, changedMs: ?number}} publication
* @param {{discoveredTime: ?number, nowMs?: number}} incident
* @returns {boolean}
*/
export function isCurrentPublication(
{ createdMs, changedMs },
{ discoveredTime, nowMs = Date.now() },
) {
if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) {
return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS;
}
if (Number.isFinite(changedMs)) {
return nowMs - changedMs <= CHANGED_WITHIN_MS;
}
return false;
}
/** Fetch one publication's created/changed timestamps for currency checks. */
export function createInciwebPublicationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getPublication(id, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`,
{ signal },
);
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
512 * 1024,
signal,
);
signal?.throwIfAborted();
return {
createdMs: Number.isFinite(payload?.createdMs)
? payload.createdMs
: null,
changedMs: Number.isFinite(payload?.changedMs)
? payload.changedMs
: null,
};
},
};
}
/**
* Resolve one WFIGS incident to its InciWeb page, or null.
* The incident's own name is tried first; a member of a complex whose own
* name has no page falls back to the complex's page (InciWeb tracks the
* managing complex, not each member fire). Anything ambiguous yields null
* rather than a wrong page.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string, complexName: ?string}} incident
* - WFIGS row facts.
* @returns {?string} InciWeb URL.
*/
export function findInciwebLink(publications, { name, state, complexName }) {
return (
resolveInciwebNodeLink(publications, { name, state }) ??
resolveInciwebNodeLink(publications, { name: complexName, state })
);
}
/** Fetch the full InciWeb incident catalog. */
export function createInciwebIndexSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getIndex({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters/inciweb/index', {
signal,
});
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const rows = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
return Array.isArray(rows) ? rows : [];
},
};
}
+334
View File
@@ -0,0 +1,334 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
findInciwebLink,
resolveInciwebNodeLink,
createInciwebIndexSource,
createInciwebPublicationSource,
isCurrentPublication,
inciwebNodeId,
} from './inciweb.js';
const catalog = [
{
incident_id: '322812',
tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District',
incident_title: 'Gobernador Pile Burn - Coyote Ranger District',
},
{
incident_id: '327504',
tau: 'SDBKF Coyote Flats Fire',
incident_title: 'Coyote Flats Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '329195',
tau: 'ORBUD Second Flat',
incident_title: 'Second Flat',
},
{
incident_id: '329273',
tau: 'ORBUD 2026 Coleman Creek',
incident_title: '2026 Coleman Creek',
},
{
incident_id: '328923',
tau: 'ORPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
{
incident_id: '291765',
tau: 'CALPF Timber Fire',
incident_title: 'Timber Fire',
},
];
test('incidents resolve by exact normalized title to a node link', () => {
// Title formats vary: with/without a 'Fire' suffix, with/without a year
// prefix ('2026 Coleman Creek'). All normalize to the WFIGS name.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329195',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329273',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
// Substring-style near-misses never match.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }),
null,
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }),
null,
);
assert.equal(
resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }),
null,
);
});
test('a unique candidate in the wrong state is rejected, not linked', () => {
// The catalog is all-time and nationwide: a current Arizona "Willow"
// must not link to the only "Willow Fire" on file when it's a Montana
// incident.
const willow = [
{
incident_id: '300001',
tau: 'MTFNF Willow Fire',
incident_title: 'Willow Fire',
},
];
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }),
null,
);
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }),
'https://inciweb.wildfire.gov/node/300001',
);
});
test('ambiguous titles disambiguate by state, then newest id', () => {
const twoCoyotes = [
{
incident_id: '100',
tau: 'AZASF Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }),
'https://inciweb.wildfire.gov/node/100',
);
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }),
null,
);
const twoSameState = [
{
incident_id: '100',
tau: 'ORXXX Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
// Same-state duplicates are usually reburns of the same name — newest wins.
assert.equal(
resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
});
test('a complex member falls back to its complex page', () => {
assert.equal(
findInciwebLink(catalog, {
name: 'Crosswhite',
state: 'US-OR',
complexName: 'ROWE CREEK COMPLEX',
}),
'https://inciweb.wildfire.gov/node/328923',
);
// A fire with its own page keeps it even when it belongs to a complex.
assert.equal(
findInciwebLink(catalog, {
name: 'Timber',
state: 'US-CA',
complexName: 'SOME COMPLEX',
}),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
findInciwebLink(catalog, {
name: 'Nope',
state: 'US-NM',
complexName: null,
}),
null,
);
});
test('a node link exposes its publication id', () => {
assert.equal(
inciwebNodeId('https://inciweb.wildfire.gov/node/329195'),
'329195',
);
assert.equal(inciwebNodeId('https://example.com/other'), null);
assert.equal(inciwebNodeId(null), null);
});
test('publication currency accepts pages created around or after discovery', () => {
const day = 24 * 3600000;
const discovery = 1785000000000;
// Created shortly before discovery (page opened as the fire started).
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 10 * day, changedMs: discovery + 30 * day },
{ discoveredTime: discovery },
),
true,
);
// Created years before discovery: an archived same-name incident.
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 900 * day, changedMs: discovery - 800 * day },
{ discoveredTime: discovery },
),
false,
);
// No discovery date: fall back to recent activity on the page.
const now = 1787600000000;
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 20 * day },
{ discoveredTime: null, nowMs: now },
),
true,
);
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 400 * day },
{ discoveredTime: null, nowMs: now },
),
false,
);
// Unusable timestamps fail closed.
assert.equal(
isCurrentPublication(
{ createdMs: null, changedMs: null },
{ discoveredTime: discovery },
),
false,
);
});
test('the publication source fetches timestamps and honors cancellation', async () => {
let requested;
const source = createInciwebPublicationSource({
fetchImpl: async (url) => {
requested = String(url);
return {
ok: true,
headers: new Headers(),
text: async () =>
JSON.stringify({
createdMs: 1785025540000,
changedMs: 1787589503000,
}),
};
},
});
const publication = await source.getPublication('329195');
assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195');
assert.deepEqual(publication, {
createdMs: 1785025540000,
changedMs: 1787589503000,
});
const malformed = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getPublication('1'), {
createdMs: null,
changedMs: null,
});
const failing = createInciwebPublicationSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/);
const abort = new AbortController();
const cancelled = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({});
},
}),
});
await assert.rejects(
cancelled.getPublication('1', { signal: abort.signal }),
{
name: 'AbortError',
},
);
});
test('the index source requests the same-origin full catalog', async () => {
let request;
const source = createInciwebIndexSource({
fetchImpl: async (url, options) => {
request = { url: String(url), options };
return {
ok: true,
headers: new Headers(),
text: async () => JSON.stringify(catalog),
};
},
});
const rows = await source.getIndex();
assert.equal(rows.length, catalog.length);
assert.equal(request.url, '/api/fire-perimeters/inciweb/index');
assert.equal(request.options.method, undefined);
assert.equal(request.options.body, undefined);
});
test('the index source rejects failures and honors cancellation', async () => {
const failing = createInciwebIndexSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/);
const malformed = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getIndex(), []);
const abort = new AbortController();
const cancelled = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify(catalog);
},
}),
});
await assert.rejects(cancelled.getIndex({ signal: abort.signal }), {
name: 'AbortError',
});
});
+448
View File
@@ -0,0 +1,448 @@
import * as Cesium from 'cesium';
import {
PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees,
buildIncidentCard,
containmentAccent,
} from './cards.js';
import {
findInciwebLink,
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
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,
collisionCapacity: 1,
moving: false,
});
// The InciWeb catalog changes on the order of days; refreshing it on the
// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB
// of near-identical bytes.
const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
export function createFirePerimetersLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
inciwebSource = null,
inciwebPublications = null,
openExternal = null,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Fire perimeters require a snapshot source');
let _viewer = null;
let _request = null;
let _linkRequest = null;
let _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
let _selectedId = null;
let _selectedLink = null;
let _selectedCardId = null;
let _inciwebIndex = [];
let _indexFetchedAt = 0;
let _indexFetchInFlight = false;
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
// Link → currency verdict from the publication check; unknown links are
// absent. Failed checks are not cached so a transient outage retries.
const _linkVerdicts = new Map();
function abortLinkVerification() {
_linkRequest?.controller.abort();
_linkRequest = null;
}
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
/**
* Refresh the InciWeb catalog off the render path: a hanging or failed
* fetch only costs the link line, never the perimeter refresh — the
* catalog is consumed solely by the selected card.
*/
function refreshInciwebIndex(signal) {
if (!inciwebSource || _indexFetchInFlight) return;
if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return;
_indexFetchInFlight = true;
inciwebSource
.getIndex({ signal })
.then((rows) => {
if (signal.aborted || !_enabled || !Array.isArray(rows)) return;
_inciwebIndex = rows;
_indexFetchedAt = Date.now();
if (_selectedId) publishSelectedCard();
})
.catch(() => {
// Catalog unavailable — cards stay linkless until the next attempt.
})
.finally(() => {
_indexFetchInFlight = false;
});
}
/**
* Verify that a matched publication describes the current incident, not
* an archived same-name fire — the catalog is all-time. Async: the card
* republishes with the link once (and only if) the check passes.
*/
function verifyLink(link, row) {
const id = inciwebNodeId(link);
if (!id || !inciwebPublications || _linkRequest?.link === link) return;
abortLinkVerification();
const request = { link, controller: new AbortController() };
_linkRequest = request;
const { signal } = request.controller;
inciwebPublications
.getPublication(id, { signal })
.then((publication) => {
if (signal.aborted || _linkRequest !== request || !_enabled) return;
_linkVerdicts.set(
link,
isCurrentPublication(publication, {
discoveredTime: row.discoveredTime,
}),
);
if (_selectedId === row.stableId) publishSelectedCard();
})
.catch(() => {
// Fail closed without caching: no link now, retry on reselection.
})
.finally(() => {
if (_linkRequest === request) _linkRequest = null;
});
}
function publishSelectedCard() {
if (!canSelect()) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const candidate = findInciwebLink(_inciwebIndex, row);
if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row);
_selectedLink =
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),
};
if (!openExternal) card.interactive = false;
if (_selectedLink && openExternal) {
const link = _selectedLink;
// Keyboard/assistive activation mirrors the pointer click-through.
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
}
/** Resolve a scene pick to one of this layer's incident ids, or null. */
function pickedIncidentId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0];
return _rowById.has(incidentId) ? incidentId : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) 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) => {
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.
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: PERIMETER_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const incidentId = picked ? pickedIncidentId(picked) : null;
if (incidentId) {
abortLinkVerification();
_selectedId = incidentId;
publishSelectedCard();
return;
}
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
// "empty space" — leave the selection alone and let that layer handle it.
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
}
if (_selectedId) {
abortLinkVerification();
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
}
function clearSelection() {
abortLinkVerification();
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
if (overlayHost) {
overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
}
}
const layer = {
id: 'fire-perimeters',
name: 'Fire Perimeters',
icon: '🔥',
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;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
rows
.map(({ polygons, ...facts }) => facts)
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastError = null;
return true;
}
abortLinkVerification();
const nextEntities = [];
_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.
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();
_count = rows.length;
_lastUpdate = Date.now();
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
);
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || 'Perimeter source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
removeClickHandler();
clearSelection();
_rowById.clear();
_snapshotSignature = null;
_inciwebIndex = [];
_indexFetchedAt = 0;
_linkVerdicts.clear();
_viewer = null;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const row of _rowById.values()) {
if (result.length >= limit) break;
const { anchor, stableId, ...facts } = row;
result.push({
id: stableId,
...facts,
lat: anchor.lat,
lon: anchor.lon,
});
}
return result;
},
getRowControls() {
const bands = [
{ label: 'Not contained or unknown', color: containmentAccent(0) },
{ label: 'Under 50% contained', color: containmentAccent(1) },
{ label: '50–99% contained', color: containmentAccent(50) },
{ label: 'Fully contained', color: containmentAccent(100) },
];
return {
chips: [],
legend: bands.map((band, index) => ({
...band,
count: [..._rowById.values()].filter(
(row) => containmentAccent(row.containedPct) === band.color,
).length,
...(index === 0
? {
blurb:
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
+554
View File
@@ -0,0 +1,554 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFirePerimetersLayer } from './index.js';
function harness(
source,
{
pick = () => null,
inciwebIndex = null,
cardHit = () => null,
publication = async () => ({
createdMs: 1757000000000,
changedMs: 1757900000000,
}),
} = {},
) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const opened = [];
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createFirePerimetersLayer({
source,
overlayHost: {
setEntries(sourceId, entries) {
overlay.entries.set(sourceId, entries);
},
setVisible(sourceId, visible) {
overlay.visible = visible;
},
clearSource(sourceId) {
overlay.entries.delete(sourceId);
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
String(pickedId).startsWith('other-layer:'),
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
unregisterPickOwner: (layerId) => owners.delete(layerId),
},
pointer: { isPointerFree: () => true },
inciwebSource: inciwebIndex
? { getIndex: async () => inciwebIndex }
: { getIndex: async () => [] },
inciwebPublications: { getPublication: publication },
openExternal: (url) => opened.push(url),
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners, opened };
}
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const row = {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
};
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
assert.ok(entities[0].polygon, 'perimeter fill missing');
assert.ok(entities[0].polyline, 'fire line missing');
assert.equal(entities[1].id, 'fire-perimeter:other:0');
assert.equal(entities[2].id, 'fire-perimeter:other:1');
assert.equal(h.layer.getStats().count, 2);
});
test('late refresh cannot publish after disable or destroy', async () => {
for (const action of ['disable', 'destroy']) {
let resolve, signal;
const h = harness({
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
}
});
test('clicking a perimeter publishes its incident card; empty space clears it', async () => {
let pickResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => pickResult,
},
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
// Deliberately NOT registered as a pick owner: sibling layers' click
// handlers yield to owned picks before their own card hit-tests, so
// claiming these large ground polygons would make FIRMS/vessel/CCTV
// overlay cards inert anywhere over a perimeter.
assert.equal(h.owners.size, 0);
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(entries[0].title, 'FIRE · Fixture Fire');
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('fire-perimeters')?.length,
1,
'sibling-layer picks must not clear the selection',
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('disable removes the click handler, pick ownership, and any card', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
h.layer.disable(h.viewer);
assert.equal(h.clicks.destroyed, 1);
assert.equal(h.owners.has('fire-perimeters'), false);
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
});
test('a refresh that drops the selected incident also drops its card', async () => {
let rows = [row];
const h = harness(
{ getSnapshot: async () => rows },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
rows = [{ ...row, stableId: 'different' }];
await h.layer.update(h.viewer);
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('a matched incident card carries the InciWeb line and click-through', async () => {
const inciwebIndex = [
{
incident_id: '329300',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [{ ...row, name: 'Fixture' }] },
{
pick: () => null,
inciwebIndex,
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
// Select via a perimeter pick.
h.viewer.scene.pick = () => ({
id: 'fire-perimeter:2026-NMGNF-000123:0',
});
h.clicks.handler({ position: { x: 10, y: 10 } });
// The card publishes immediately; the link line lands once the
// publication's currency is validated.
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
// Clicking the card itself opens the InciWeb page.
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
assert.equal(
h.overlay.entries.get('fire-perimeters').length,
1,
'opening the link must not clear the selection',
);
});
test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => {
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [],
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, []);
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
});
test('a complex member resolves to its complex page from the catalog', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [
{
// Same state as the member fire: the state filter applies to
// complex fallbacks too.
incident_id: '328923',
tau: 'NMPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
],
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
});
test('a stale or unverifiable publication yields no link', async () => {
const inciwebIndex = [
{
incident_id: '100',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
for (const publication of [
// Created two years before this fire's discovery: an archived page.
async () => ({
createdMs: 1757900000000 - 700 * 86400000,
changedMs: null,
}),
// Validation endpoint down: fail closed, no link.
async () => {
throw new Error('InciWeb HTTP 503');
},
]) {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex,
publication,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
assert.equal(entries[0].interactive, false);
h.layer.destroy(h.viewer);
}
});
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
// Replace the index source with one that never resolves.
h.layer.destroy(h.viewer);
const hanging = harness({ getSnapshot: async () => [row] });
hanging.layer.destroy(hanging.viewer);
const sources = [];
const viewer = {
scene: { pick: () => null },
dataSources: {
add: (v) => sources.push(v),
remove: (v) => sources.splice(sources.indexOf(v), 1),
},
};
const layer = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: { getIndex: () => new Promise(() => {}) },
inciwebPublications: {
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
},
openExternal: () => {},
});
layer.init(viewer);
layer.enable(viewer);
assert.equal(await layer.update(viewer), true);
assert.equal(layer.getStats().count, 1);
layer.destroy(viewer);
});
test('an InciWeb outage never breaks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] });
const failing = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: {
getIndex: async () => {
throw new Error('InciWeb HTTP 503');
},
},
openExternal: () => {},
});
const viewer = {
scene: { pick: () => null },
dataSources: { add() {}, remove() {} },
};
failing.init(viewer);
failing.enable(viewer);
assert.equal(await failing.update(viewer), true);
assert.equal(failing.getStats().count, 1);
failing.destroy(viewer);
h.layer.destroy(h.viewer);
});
test('analyst records expose incident facts without geometry payloads', async () => {
const h = harness({ getSnapshot: async () => [row] });
await h.layer.update(h.viewer);
const records = h.layer.getAnalystRecords();
assert.equal(records.length, 1);
assert.equal(records[0].name, 'Fixture Fire');
assert.equal(records[0].acres, 512.5);
assert.equal(records[0].containedPct, 40);
assert.equal(records[0].state, 'US-NM');
assert.equal(records[0].cause, 'Natural');
assert.equal(records[0].behavior, 'Active');
assert.equal(records[0].personnel, 380);
assert.equal(records[0].county, 'Sandoval');
assert.equal(records[0].costToDate, 4200000);
assert.equal(records[0].complexity, 'Type 3 Incident');
// Spatial scoping in the analyst engine requires coordinates.
assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01);
assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01);
assert.equal('polygons' in records[0], false);
});
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
let rows = [row, { ...row, stableId: 'other' }];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update();
const original = [...h.sources[0].entities.values];
let removals = 0;
const entities = h.sources[0].entities;
const removeAll = entities.removeAll.bind(entities);
entities.removeAll = () => {
removals++;
removeAll();
};
rows = [...rows].reverse();
await h.layer.update();
assert.equal(removals, 0);
assert.equal(entities.values[0], original[0]);
rows = rows.map((value) => ({
...value,
updatedTime: value.updatedTime + 1,
}));
await h.layer.update();
assert.equal(removals, 1);
assert.notEqual(entities.values[0], original[0]);
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
await h.layer.update();
assert.equal(
removals,
2,
'incident facts can change without a new polygon timestamp',
);
assert.equal(h.layer.getRowControls().legend[3].count, 2);
rows = [];
await h.layer.update();
assert.equal(entities.values.length, 0);
h.layer.destroy();
});
test('containment legend uses the existing thresholds, colours and incident counts', async () => {
const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101];
const h = harness({
getSnapshot: async () =>
values.map((containedPct, index) => ({
...row,
stableId: String(index),
containedPct,
})),
});
await h.layer.update();
const controls = h.layer.getRowControls();
assert.deepEqual(controls.chips, []);
assert.deepEqual(
controls.legend.map(({ color, count }) => [color, count]),
[
['#ff3b30', 3],
['#ff7a00', 2],
['#ffb300', 2],
['#8bc34a', 2],
],
);
assert.ok(
controls.legend.every(
({ label }) => typeof label === 'string' && label.length,
),
);
assert.equal(
controls.legend[0].blurb,
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
);
h.layer.destroy();
assert.deepEqual(
h.layer.getRowControls().legend.map(({ count }) => count),
[0, 0, 0, 0],
);
});
for (const action of ['disable', 'destroy', 'selection']) {
test(`incident-link verification aborts on ${action} and ignores late completion`, async () => {
let finish, signal;
let pick = { id: `fire-perimeter:${row.stableId}:0` };
const h = harness(
{
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', name: 'Other Fire' },
],
},
{
pick: () => pick,
inciwebIndex: [
{ incident_id: '123', incident_title: row.name, tau: 'nm' },
],
publication: (_id, options) => {
signal = options.signal;
return new Promise((resolve) => {
finish = resolve;
});
},
},
);
await h.layer.update();
h.clicks.handler({ position: { x: 1, y: 1 } });
assert.equal(signal.aborted, false);
if (action === 'selection') {
pick = { id: 'fire-perimeter:other:0' };
h.clicks.handler({ position: { x: 1, y: 1 } });
} else h.layer[action]();
assert.equal(signal.aborted, true);
const entries = h.overlay.entries.get('fire-perimeters');
finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime });
await new Promise(setImmediate);
assert.equal(h.overlay.entries.get('fire-perimeters'), entries);
if (action !== 'destroy') h.layer.destroy();
});
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Normalize a WFIGS feed, skipping individually invalid features.
* Only a payload that is not a feature collection at all rejects the whole
* snapshot: a single degenerate incident (ArcGIS emits geometry:null when
* maxAllowableOffset generalizes a tiny polygon away) must never blank the
* layer — a failed first update makes the lifecycle disable it entirely.
*/
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
const textOrNull = (value) => (typeof value === 'string' ? value : null);
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return false;
for (const position of ring) {
if (!Array.isArray(position) || position.length < 2) return false;
const [lon, lat] = position;
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
}
return true;
}
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
* array of rings). Returns null on malformed geometry, [] when empty. */
function normalizePolygons(geometry) {
if (!geometry || typeof geometry !== 'object') return null;
let polygons;
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
if (!Array.isArray(polygons)) return null;
const result = [];
for (const rings of polygons) {
if (!Array.isArray(rings)) return null;
if (!rings.length) continue;
if (!rings.every(validRing)) return null;
result.push(rings);
}
return result;
}
export function normalizeFirePerimeterSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
continue;
const polygons = normalizePolygons(feature.geometry);
if (polygons === null || !polygons.length) continue;
const uniqueId = properties.attr_UniqueFireIdentifier;
const stableId =
typeof uniqueId === 'string' && uniqueId !== ''
? uniqueId
: feature.id == null || feature.id === ''
? null
: String(feature.id);
if (stableId == null || ids.has(stableId)) continue;
ids.add(stableId);
rows.push({
stableId,
name:
typeof properties.poly_IncidentName === 'string'
? properties.poly_IncidentName
: null,
acres: finiteOrNull(properties.attr_IncidentSize),
containedPct: finiteOrNull(properties.attr_PercentContained),
state:
typeof properties.attr_POOState === 'string'
? properties.attr_POOState
: null,
category:
typeof properties.attr_IncidentTypeCategory === 'string'
? properties.attr_IncidentTypeCategory
: null,
discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime),
updatedTime: finiteOrNull(properties.poly_DateCurrent),
cause: textOrNull(properties.attr_FireCause),
behavior: textOrNull(properties.attr_FireBehaviorGeneral),
personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel),
county: textOrNull(properties.attr_POOCounty),
costToDate: finiteOrNull(properties.attr_EstimatedCostToDate),
complexity: textOrNull(properties.attr_IncidentComplexityLevel),
complexName: textOrNull(properties.attr_CpxName),
polygons,
});
}
return rows;
}
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeFirePerimeterSnapshot } from './records.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
function feature(overrides = {}) {
return {
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
poly_IncidentName: 'Fixture Fire',
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 512.5,
attr_PercentContained: 40,
attr_POOState: 'US-NM',
attr_IncidentTypeCategory: 'WF',
attr_FireDiscoveryDateTime: 1757900000000,
poly_DateCurrent: 1757950000000,
attr_FireCause: 'Natural',
attr_FireBehaviorGeneral: 'Active',
attr_TotalIncidentPersonnel: 380,
attr_POOCounty: 'Sandoval',
attr_EstimatedCostToDate: 4200000,
attr_IncidentComplexityLevel: 'Type 3 Incident',
attr_CpxName: 'ROWE CREEK COMPLEX',
...overrides.properties,
},
...overrides,
};
}
test('a valid snapshot yields one row per incident with polygons preserved', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
id: 2,
geometry: {
type: 'MultiPolygon',
coordinates: [[ring], [ring]],
},
properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' },
}),
],
});
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
});
assert.equal(rows[1].polygons.length, 2);
});
test('a missing unique fire identifier falls back to the feature id', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [feature({ properties: { attr_UniqueFireIdentifier: null } })],
});
assert.equal(rows[0].stableId, '1');
});
test('optional attributes normalize to null rather than leaking undefined', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({
properties: {
poly_IncidentName: null,
attr_IncidentSize: null,
attr_PercentContained: null,
attr_POOState: null,
attr_IncidentTypeCategory: null,
attr_FireDiscoveryDateTime: null,
poly_DateCurrent: null,
attr_FireCause: null,
attr_FireBehaviorGeneral: null,
attr_TotalIncidentPersonnel: null,
attr_POOCounty: null,
attr_EstimatedCostToDate: null,
attr_IncidentComplexityLevel: null,
attr_CpxName: null,
},
}),
],
});
assert.deepEqual(
{
name: rows[0].name,
acres: rows[0].acres,
containedPct: rows[0].containedPct,
state: rows[0].state,
category: rows[0].category,
discoveredTime: rows[0].discoveredTime,
updatedTime: rows[0].updatedTime,
cause: rows[0].cause,
behavior: rows[0].behavior,
personnel: rows[0].personnel,
county: rows[0].county,
costToDate: rows[0].costToDate,
complexity: rows[0].complexity,
complexName: rows[0].complexName,
},
{
name: null,
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
);
});
test('a feed that is not a feature collection is rejected as a whole', () => {
for (const payload of [null, {}, { features: null }, { features: {} }]) {
assert.equal(normalizeFirePerimeterSnapshot(payload), null);
}
});
test('an invalid feature is skipped so one bad incident cannot blank the layer', () => {
const good = feature({
id: 2,
properties: { attr_UniqueFireIdentifier: 'good' },
});
for (const bad of [
feature({ geometry: null }),
feature({ geometry: { type: 'Point', coordinates: [0, 1] } }),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[Infinity, 35.2], ...ring.slice(1)]],
},
}),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[-200, 35.2], ...ring.slice(1)]],
},
}),
feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }),
feature({ properties: null }),
feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }),
]) {
const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] });
assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal');
assert.equal(rows[0].stableId, 'good');
}
});
test('a duplicate incident id keeps the first occurrence and skips the rest', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
properties: {
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 999,
},
}),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].acres, 512.5);
});
test('a perimeter with no rings is skipped rather than rendered empty', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({ geometry: { type: 'Polygon', coordinates: [] } }),
feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, 'other');
});
+23
View File
@@ -0,0 +1,23 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot');
return payload.rows;
},
};
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWfigsPerimeterSource } from './source.js';
import { normalizeFirePerimeterSnapshot } from './records.js';
import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const validPayload = {
features: [
{
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' },
},
],
};
test('a successful response yields normalized perimeter rows', async () => {
let requested;
const source = createWfigsPerimeterSource({
fetchImpl: async (url) => {
requested = String(url);
return Response.json({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
});
const rows = await source.getSnapshot();
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, '2026-NMGNF-000123');
assert.equal(requested, '/api/fire-perimeters');
});
test('a truncated response pages until the feed is complete', async () => {
const pageRing = (id) => ({
id,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: `fire-${id}` },
});
const requests = [];
let handler;
const plugin = firePerimetersProxy({
fetchImpl: async (url) => {
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
const page = requests.length;
return Response.json({
features: [pageRing(page)],
properties: { exceededTransferLimit: page < 3 },
});
},
});
plugin.configureServer({
middlewares: {
use: (_path, callback) => {
handler = callback;
},
},
});
let payload;
await handler(
{ url: '/', method: 'GET' },
{
writeHead(status) {
assert.equal(status, 200);
},
end(body) {
payload = JSON.parse(body);
},
},
);
const rows = payload.rows;
assert.deepEqual(
rows.map((row) => row.stableId),
['fire-1', 'fire-2', 'fire-3'],
);
assert.deepEqual(requests, [null, '1', '2']);
});
test('an upstream failure surfaces its HTTP status', async () => {
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/);
});
test('a malformed successful response is never accepted as an empty snapshot', async () => {
for (const payload of [{}, { rows: null }, { rows: {} }]) {
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json(payload),
});
await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/);
}
});
test('response-body completion honors cancellation without replacing records', async () => {
const abort = new AbortController();
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
}),
});
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
name: 'AbortError',
});
});
+1 -1
View File
@@ -195,7 +195,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
.digest('hex')
.slice(0, 16);
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
assert.equal(digest, '8494f344fabb67f5', 'an unchanged Realtime tool definition drifted');
assert.equal(digest, '91935845ef2598b1', 'an unchanged Realtime tool definition drifted');
});
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
+2
View File
@@ -1,10 +1,12 @@
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
/** Construct the existing reference feeds independently of application setup. */
export function createReferenceSources() {
return {
earthquakes: createUsgsEarthquakeSource(),
'fire-perimeters': createWfigsPerimeterSource(),
cables: createBundledCableSource(),
};
}
+7 -1
View File
@@ -12,10 +12,16 @@ test('reference factories retain compatibility without starting acquisition or s
assert.equal(createReferenceSources, createStandaloneReferenceSources);
const first = createReferenceSources();
const second = createReferenceSources();
assert.deepEqual(Object.keys(first), ['earthquakes', 'cables']);
assert.deepEqual(Object.keys(first), [
'earthquakes',
'fire-perimeters',
'cables',
]);
assert.notEqual(first.earthquakes, second.earthquakes);
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
assert.notEqual(first.cables, second.cables);
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
assert.equal(typeof first.cables.fetch, 'function');
assert.equal(requests, 0);
});
+2 -2
View File
@@ -196,9 +196,9 @@ test('FIRMS retains a large successful source during partial failure and filters
const first = json(await request());
assert.equal(first.count, 130001);
assert.equal(first.sources.filter((s) => s.ok).length, 1);
assert.equal(calls, 3);
assert.equal(calls, 4);
assert.equal(json(await request()).count, 130001);
assert.equal(calls, 3);
assert.equal(calls, 4);
assert.deepEqual(json(await request('/status')).transactions, {
used: 3,
limit: 5000,
+4 -1
View File
@@ -8,7 +8,9 @@ import { apiNotFoundPlugin } from '../../server/standalone/api-not-found.js';
import { makeFixtureRoot } from './fixtureRoot.mjs';
test('data providers have both hooks; credential editing stays development-only', () => {
for (const plugin of localProviderPlugins()) {
const plugins = localProviderPlugins();
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
for (const plugin of plugins) {
if (plugin.name === 'gev-key-setup') {
assert.equal(plugin.configurePreviewServer, undefined);
assert.equal(
@@ -99,6 +101,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/adsblol/mil', 200],
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
['/api/terrain/heights?points=invalid', 400],
['/api/overpass', 405],
['/api/cctv/sources', 200],
+1 -1
View File
@@ -46,7 +46,7 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms'],
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
},
{
label: 'Weather',
+3
View File
@@ -137,6 +137,7 @@ const schemas = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'fire-perimeters',
'alpr-cameras',
'local-adsb',
],
@@ -170,6 +171,7 @@ const schemas = [
'local-dams',
'telegeography-submarine-cables',
'local-firms',
'fire-perimeters',
'alpr-cameras',
],
},
@@ -775,6 +777,7 @@ const schemas = [
'satellites',
'local-datacenters',
'local-dams',
'fire-perimeters',
],
},
},
+17 -3
View File
@@ -22,7 +22,7 @@ test('the complete Realtime tool payload pins the additive analyst, satellite an
assert.equal(
digest,
// Re-derived for the additive `local-adsb` set_layer_visibility value.
'07c2439e085cce9b9e1b36dfd01e2a688e66957db464c740627dd378536d904b',
'4de5c78425d8233794cfd37fdb797605ae93611ba71b7208f4d912b587a0e859',
);
});
@@ -85,12 +85,26 @@ test('all legacy action arguments are byte-identical after removing the delibera
const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters
.properties.layers.items;
layers.enum = layers.enum.filter(
(key) => !['satellites', 'local-datacenters', 'local-dams'].includes(key),
(key) =>
![
'satellites',
'local-datacenters',
'local-dams',
'fire-perimeters',
].includes(key),
);
// Local ADS-B is an additive set_layer_visibility enum value.
const visibility = legacy.find((tool) => tool.name === 'set_layer_visibility')
.parameters.properties.layerId;
visibility.enum = visibility.enum.filter((key) => key !== 'local-adsb');
visibility.enum = visibility.enum.filter(
(key) => !['local-adsb', 'fire-perimeters'].includes(key),
);
for (const tool of legacy) {
for (const value of Object.values(tool.parameters.properties)) {
if (value.enum)
value.enum = value.enum.filter((key) => key !== 'fire-perimeters');
}
}
// Independently derived by executing trusted c9f9896 actionSchemas in the restricted container.
assert.equal(
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
+3
View File
@@ -217,6 +217,9 @@ const LAYER_ALIASES = new Map([
['submarine cables', 'telegeography-submarine-cables'],
['cables', 'telegeography-submarine-cables'],
['telegeography', 'telegeography-submarine-cables'],
['fire perimeters', 'fire-perimeters'],
['perimeters', 'fire-perimeters'],
['wildfire perimeters', 'fire-perimeters'],
['firms', 'local-firms'],
['fires', 'local-firms'],
['active fires', 'local-firms'],