mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
40 files changed
+3201
-26
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
})));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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 : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user