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

+15
View File
@@ -164,6 +164,19 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public
keyless feature service with truncation paging. Clicking a perimeter shows
an incident card (acreage, containment, cause, behavior, personnel, county,
cost, complex membership) and, when the incident has a state- and
recency-verified InciWeb page, a click-through link to it. Recency uses
incident page origin and update times because the publication API was retired.
The layer is reachable from the panel, voice control, share links (token `2`), and the
analyst query engine. WFIGS and InciWeb requests use a capped, cached
same-origin proxy with timeouts and a per-client limit. Unchanged refreshes
retain geometry; incident-link checks abort on disable or selection change,
and the row includes a containment legend.
- Remove the spurious scrollbars that appeared on both panel stacks at narrow
widths (720px and below) as soon as a panel was expanded. The stacks scroll
@@ -524,6 +537,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Added
- Add MODIS NRT (Terra+Aqua, ~1 km) active fires to the FIRMS layer, sharing the
existing `FIRMS_MAP_KEY` and 30-minute cache.
- Two map-orientation controls sit beside Share in the top-center globe
actions. Tilt Map swings between a straight-down map and a 35-degree oblique
around the point under the centre of the view, keeping that point and the
+3 -1
View File
@@ -32,6 +32,8 @@ How to read this:
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" |
| **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) |
@@ -160,7 +162,7 @@ The OSM-derived datasets are under the **Open Database License**. ODbL's share-a
FIRMS active fires are **fetched live at runtime** (CC0 / U.S. public domain data): the
`/api/firms` server-side proxy merges the three VIIRS NRT sources (NOAA-20, NOAA-21,
Suomi-NPP) clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
Suomi-NPP) and MODIS NRT (Terra + Aqua), clamped to the trailing 24 h, cached 30 min to respect the shared MAP_KEY
transaction quota. Requires a free `FIRMS_MAP_KEY`
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
+4 -1
View File
@@ -2691,8 +2691,11 @@ its criteria cannot be silently ignored.
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
+2
View File
@@ -155,6 +155,7 @@
"./layers/vessels": "./src/layers/vessels/index.js",
"./layers/earthquakes": "./src/layers/earthquakes/index.js",
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
"./layers/perimeters": "./src/layers/perimeters/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js",
"./layers/satellites": "./src/layers/satellites/index.js",
@@ -226,6 +227,7 @@
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
"./layers/cctv/source": "./src/layers/cctv/source.js",
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
"./layers/firms/source": "./src/layers/firms/source.js",
"./layers/installations/source": "./src/layers/installations/source.js",
"./layers/launches/source": "./src/layers/launches/source.js",
+13
View File
@@ -338,6 +338,16 @@
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/ownership.test.mjs",
"src/layers/perimeters/index.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/layers/perimeters/records.test.mjs",
"src/layers/perimeters/source.test.mjs",
"src/layers/perimeters/cards.test.mjs",
"src/layers/perimeters/inciweb.test.mjs",
"src/layers/perimeters/ownership.test.mjs",
"src/data/firmsHeatmap.js",
"src/data/fireAnchors.js",
"src/layers/firms/anchors.js",
@@ -605,6 +615,7 @@
"src/app/layers/flights.js",
"src/app/layers/radio.js",
"src/app/layers/earthquakes.js",
"src/app/layers/perimeters.js",
"src/app/layers/cctv.js",
"src/app/layers/firms.js",
"src/app/layers/satellites.js",
@@ -784,7 +795,9 @@
"src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js",
"server/providers/firePerimeters.js",
"src/data/cyclonesProxy.test.mjs",
"src/data/firePerimetersProxy.test.mjs",
"src/layers/cyclones/index.js",
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
+36
View File
@@ -1803,6 +1803,12 @@
"src/data/layerSnapshot.js",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/layers/cctv/sourcePolicy.js",
"src/sources/reference.js",
"src/search/nominatim.js",
@@ -2299,6 +2305,12 @@
"src/app/localGeojsonServices.js",
"src/layers/alpr/records.js",
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/layers/cctv/sourcePolicy.js",
"src/search/nominatim.js",
"src/nominatimGeocode.js",
@@ -2672,6 +2684,9 @@
"src/sources/reference.js",
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js",
"src/sources/httpBody.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/submarineCables/bundledSource.js"
],
"external": []
@@ -2867,5 +2882,26 @@
"src/sources/featureSource.js"
],
"external": []
},
"perimeters": {
"exports": ["./layers/perimeters"],
"modules": [
"src/layers/perimeters/index.js",
"src/sources/httpBody.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js"
],
"external": ["cesium"]
},
"perimeters-source": {
"exports": ["./layers/perimeters/source"],
"modules": [
"src/layers/perimeters/records.js",
"src/sources/httpBody.js",
"src/layers/perimeters/source.js"
],
"external": []
}
}
+232
View File
@@ -0,0 +1,232 @@
import { normalizeFirePerimeterSnapshot } from '../../src/layers/perimeters/records.js';
import {
readResponseJsonCapped,
readResponseTextCapped,
coalesceProxyRequest,
} from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
// NIFC WFIGS current interagency fire perimeters (public, keyless).
// maxAllowableOffset trades ~100 m of boundary fidelity for a payload small
// enough to refresh continuously (~1 MB for a typical fire season).
const API_URL =
'https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/' +
'WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query?' +
new URLSearchParams({
where: '1=1',
outFields: [
'poly_IncidentName',
'attr_UniqueFireIdentifier',
'attr_IncidentSize',
'attr_PercentContained',
'attr_POOState',
'attr_IncidentTypeCategory',
'attr_FireDiscoveryDateTime',
'poly_DateCurrent',
'attr_FireCause',
'attr_FireBehaviorGeneral',
'attr_TotalIncidentPersonnel',
'attr_POOCounty',
'attr_EstimatedCostToDate',
'attr_IncidentComplexityLevel',
'attr_CpxName',
].join(','),
maxAllowableOffset: '0.001',
outSR: '4326',
f: 'geojson',
}).toString();
// The service caps a single response at its maxRecordCount (2000); a peak
// season can exceed that, so follow exceededTransferLimit with offset pages.
const MAX_PAGES = 5;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
payload?.properties?.exceededTransferLimit === true;
const MIB = 1024 * 1024;
/** Fixed-origin, bounded WFIGS and InciWeb routes for dev and preview. */
export function firePerimetersProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const cache = new Map();
const publications = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
async function upstream(url, cap, timeout, options = {}) {
const signal = AbortSignal.timeout(timeout);
const response = await fetchImpl(url, {
...options,
signal,
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
return readResponseJsonCapped(response, cap, signal);
}
async function fetchPerimeters() {
const features = [];
for (let page = 0; page < MAX_PAGES; page++) {
const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
const payload = await upstream(url, 16 * MIB, 30_000);
if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot');
features.push(...payload.features);
if (!exceededTransferLimit(payload) || !payload.features.length) break;
}
const rows = normalizeFirePerimeterSnapshot({ features });
return { fetchedAt: now(), rows };
}
async function fetchIndex() {
const rows = await upstream(
'https://inciweb.wildfire.gov/api/single-publication/',
4 * MIB,
20_000,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: '' }),
},
);
if (!Array.isArray(rows)) throw new Error('invalid_index');
return rows;
}
// InciWeb's publication API was retired; incident pages carry origin and update times.
async function fetchPublication(id) {
const signal = AbortSignal.timeout(15_000);
let response = await fetchImpl(`https://inciweb.wildfire.gov/node/${id}`, {
signal,
redirect: 'manual',
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
const location = response.headers.get('location');
if (!location || /\.\.|[?%\\]/.test(location))
throw new Error('invalid_redirect');
const url = new URL(location, 'https://inciweb.wildfire.gov/');
if (
url.hostname !== 'inciweb.wildfire.gov' ||
!/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)?$/.test(url.pathname) ||
!['http:', 'https:'].includes(url.protocol) ||
url.port ||
url.username ||
url.password
)
throw new Error('invalid_redirect');
url.protocol = 'https:';
url.search = '';
url.hash = '';
response = await fetchImpl(url.href, { signal, redirect: 'error' });
}
if (response.status !== 200) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
const html = await readResponseTextCapped(response, 2 * MIB, signal);
const changed = Date.parse(
html.match(/<meta\s+property="og:updated_time"\s+content="([^"]+)"/)?.[1],
);
const created = Date.parse(
html.match(
/Date of Origin<\/th>\s*<td[^>]*>\s*<time\s+datetime="([^"]+)"/,
)?.[1],
);
const createdMs = Number.isFinite(created) ? created : null;
const changedMs = Number.isFinite(changed) ? changed : null;
if (createdMs === null && changedMs === null)
throw new Error('invalid_incident_page');
return { createdMs, changedMs };
}
async function acquire(key, ttl, load, store) {
const previous = store.get(key);
if (previous && now() - previous.savedAt < ttl)
return { value: previous.value, stale: false };
// Bound distinct concurrent publication requests as well as retained entries.
if (!inFlight.has(key) && inFlight.size >= 256)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
store.delete(key);
store.set(key, { value, savedAt: now() });
if (store === publications && store.size > 256)
store.delete(store.keys().next().value);
return value;
});
return { value: await promise, stale: false };
} catch (error) {
if (previous) return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
let key,
ttl,
load,
store = cache;
if (path === '/' || path === '') {
key = 'perimeters';
ttl = 300_000;
load = fetchPerimeters;
} else if (path === '/inciweb/index') {
key = 'index';
ttl = 3_600_000;
load = fetchIndex;
} else if (path.startsWith('/inciweb/publication/')) {
const id = path.slice('/inciweb/publication/'.length);
if (!/^\d{1,9}$/.test(id))
return json(400, { error: 'invalid_publication_id' });
key = `publication:${id}`;
ttl = 1_800_000;
load = () => fetchPublication(id);
store = publications;
} else return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(key, ttl, load, store);
json(
200,
key === 'perimeters' && stale ? { ...value, stale: true } : value,
stale,
);
} catch (error) {
json(error.status === 429 ? 429 : 502, {
error: 'fire_perimeters_unavailable',
});
}
}
return {
name: 'fire-perimeters',
configureServer({ middlewares }) {
middlewares.use('/api/fire-perimeters', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/fire-perimeters', handler);
},
};
}
+11 -5
View File
@@ -8,10 +8,11 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
* Upstream: https://firms.modaps.eosdis.nasa.gov/api/area/csv/{KEY}/{SOURCE}/world/2
*
* Merges three VIIRS NRT sources (NOAA-20, NOAA-21, Suomi-NPP — independent
* satellites, no cross-source dedup) fetched sequentially with `days=2`
* (`days=1` means "current UTC day", nearly empty just after 00:00Z) and
* clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS quota is
* 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
* satellites, no cross-source dedup) plus MODIS_NRT (combined Terra+Aqua,
* ~1 km, numeric 0-100 confidence), fetched sequentially (quota courtesy)
* with `days=2` (`days=1` means "current UTC day", nearly empty just after
* 00:00Z) and clamps to the trailing 24 h via src/data/firmsCsv.js. FIRMS
* quota is 5,000 transactions / 10 min per MAP_KEY, so the cache is the point:
* TTL 30 min, single-flight refresh, serve-stale-on-failure, and a
* fresh-enough disk cache (.gev-cache/firms.json) prevents ANY upstream
* fetch across dev-server restarts. Pattern mirrors celestrakProxy.
@@ -28,7 +29,12 @@ import { filterTrailing24h, parseFirmsCsv } from '../../src/data/firmsCsv.js';
export function firmsProxy() {
const TTL_MS = 30 * 60_000;
const STATUS_TTL_MS = 5 * 60_000;
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 CACHE_DIR = path.join(process.cwd(), '.gev-cache');
const CACHE_PATH = path.join(CACHE_DIR, 'firms.json');
+2
View File
@@ -22,6 +22,7 @@ import { openAiRealtimeProxy } from './openai.js';
import { googlePlacesContextProxy } from './places.js';
import { keySetupEndpoint } from '../standalone/key-setup.js';
import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -53,6 +54,7 @@ function localProviderPlugins() {
windProxy(),
weatherProxy(),
cycloneProxy(),
firePerimetersProxy(),
keySetupEndpoint(),
];
}
+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'],