From f913066cf2157398ffb6814585f8249da433988e Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu <106619546+bilawalsidhu@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:21:52 -0500 Subject: [PATCH] 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 Co-authored-by: James Cooke <33430976+Frankenleg@users.noreply.github.com> --- CHANGELOG.md | 15 + DATA_SOURCES.md | 4 +- docs/CURRENT-STATE.md | 5 +- package.json | 2 + scripts/format-scope.json | 13 + scripts/package-boundaries.json | 36 ++ server/providers/firePerimeters.js | 232 +++++++++ server/providers/firms.js | 16 +- server/providers/local.js | 2 + src/app/constructCatalog.js | 5 + src/app/constructCatalog.test.mjs | 3 +- src/app/layers/perimeters.js | 29 ++ src/data/analystEngine.js | 5 + src/data/dataCredits.js | 8 + src/data/firePerimetersProxy.test.mjs | 410 ++++++++++++++++ src/data/firmsCsv.js | 8 +- src/data/firmsCsv.test.mjs | 19 + src/data/firmsProxy.test.mjs | 8 +- src/data/layerState.js | 5 + src/data/layerState.test.mjs | 11 +- src/firstRunExperience.test.mjs | 4 +- src/layers/perimeters/cards.js | 157 ++++++ src/layers/perimeters/cards.test.mjs | 237 +++++++++ src/layers/perimeters/inciweb.js | 173 +++++++ src/layers/perimeters/inciweb.test.mjs | 334 +++++++++++++ src/layers/perimeters/index.js | 448 +++++++++++++++++ src/layers/perimeters/ownership.test.mjs | 554 ++++++++++++++++++++++ src/layers/perimeters/records.js | 94 ++++ src/layers/perimeters/records.test.mjs | 201 ++++++++ src/layers/perimeters/source.js | 23 + src/layers/perimeters/source.test.mjs | 117 +++++ src/radioMarkup.test.mjs | 2 +- src/sources/reference.js | 2 + src/sources/reference.test.mjs | 8 +- src/tooling/environmentProviders.test.mjs | 4 +- src/tooling/previewServing.test.mjs | 5 +- src/ui/layerPanel.js | 2 +- src/voice/actionSchemas.js | 3 + src/voice/actionSchemas.test.mjs | 20 +- src/voice/gevActions.js | 3 + 40 files changed, 3201 insertions(+), 26 deletions(-) create mode 100644 server/providers/firePerimeters.js create mode 100644 src/app/layers/perimeters.js create mode 100644 src/data/firePerimetersProxy.test.mjs create mode 100644 src/layers/perimeters/cards.js create mode 100644 src/layers/perimeters/cards.test.mjs create mode 100644 src/layers/perimeters/inciweb.js create mode 100644 src/layers/perimeters/inciweb.test.mjs create mode 100644 src/layers/perimeters/index.js create mode 100644 src/layers/perimeters/ownership.test.mjs create mode 100644 src/layers/perimeters/records.js create mode 100644 src/layers/perimeters/records.test.mjs create mode 100644 src/layers/perimeters/source.js create mode 100644 src/layers/perimeters/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7b13888..ccfdccb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 3a547d6d0..b1439230e 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 47ef461ce..361c6dee5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index cd1a90695..3e80dc55e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d8ebb6fe3..0d629d0f3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 199d948c1..14e10f898 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/server/providers/firePerimeters.js b/server/providers/firePerimeters.js new file mode 100644 index 000000000..4e6a933e1 --- /dev/null +++ b/server/providers/firePerimeters.js @@ -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(/\s*]*>\s*= 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); + }, + }; +} diff --git a/server/providers/firms.js b/server/providers/firms.js index 3a4c5ea9f..636968348 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -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'); diff --git a/server/providers/local.js b/server/providers/local.js index 65ced9551..398519d92 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -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(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index b6bb44c3b..40ea46596 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -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 }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f667ab022..6043b1262 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -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( diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js new file mode 100644 index 000000000..f5110f630 --- /dev/null +++ b/src/app/layers/perimeters.js @@ -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, + }); +} diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index e2a288fc8..b77121c12 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -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; diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index e5ef9d2b2..ab9213c26 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -86,6 +86,14 @@ export const DATA_CREDITS = [ 'gibs.earthdata.nasa.gov · ' + 'HLS product page', }, + { + key: 'wfigs', + html: + 'Wildfire perimeters: ' + + 'National Interagency Fire Center (WFIGS)' + + ' · Incident information: ' + + 'InciWeb', + }, { key: 'overpass', html: diff --git a/src/data/firePerimetersProxy.test.mjs b/src/data/firePerimetersProxy.test.mjs new file mode 100644 index 000000000..6c559c0d7 --- /dev/null +++ b/src/data/firePerimetersProxy.test.mjs @@ -0,0 +1,410 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { firePerimetersProxy } from '../../server/providers/firePerimeters.js'; + +const incidentHtml = ` +Date of Origin +`; +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 [ + 'No markers', + 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); +}); diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 138b1a4db..7f9019e18 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -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"), diff --git a/src/data/firmsCsv.test.mjs b/src/data/firmsCsv.test.mjs index 798946e25..301fb4938 100644 --- a/src/data/firmsCsv.test.mjs +++ b/src/data/firmsCsv.test.mjs @@ -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'), diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index a6dcdb774..a54bd42fe 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -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, }))); diff --git a/src/data/layerState.js b/src/data/layerState.js index da1b86b9e..e559a4c40 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 51d39bc61..fb6d04a5d 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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); +}); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 3a2d91047..538a006dd 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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'); diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js new file mode 100644 index 000000000..6609d0a73 --- /dev/null +++ b/src/layers/perimeters/cards.js @@ -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>>} 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', + }; +} diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs new file mode 100644 index 000000000..1b32a5e06 --- /dev/null +++ b/src/layers/perimeters/cards.test.mjs @@ -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', + ]); +}); diff --git a/src/layers/perimeters/inciweb.js b/src/layers/perimeters/inciweb.js new file mode 100644 index 000000000..3cc3ae538 --- /dev/null +++ b/src/layers/perimeters/inciweb.js @@ -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 : []; + }, + }; +} diff --git a/src/layers/perimeters/inciweb.test.mjs b/src/layers/perimeters/inciweb.test.mjs new file mode 100644 index 000000000..70d4e147c --- /dev/null +++ b/src/layers/perimeters/inciweb.test.mjs @@ -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', + }); +}); diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js new file mode 100644 index 000000000..f59f4aef3 --- /dev/null +++ b/src/layers/perimeters/index.js @@ -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} */ + 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; +} diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs new file mode 100644 index 000000000..debd76aa8 --- /dev/null +++ b/src/layers/perimeters/ownership.test.mjs @@ -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(); + }); +} diff --git a/src/layers/perimeters/records.js b/src/layers/perimeters/records.js new file mode 100644 index 000000000..faed5ba64 --- /dev/null +++ b/src/layers/perimeters/records.js @@ -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; +} diff --git a/src/layers/perimeters/records.test.mjs b/src/layers/perimeters/records.test.mjs new file mode 100644 index 000000000..fb11193ed --- /dev/null +++ b/src/layers/perimeters/records.test.mjs @@ -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'); +}); diff --git a/src/layers/perimeters/source.js b/src/layers/perimeters/source.js new file mode 100644 index 000000000..9147fe0f9 --- /dev/null +++ b/src/layers/perimeters/source.js @@ -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; + }, + }; +} diff --git a/src/layers/perimeters/source.test.mjs b/src/layers/perimeters/source.test.mjs new file mode 100644 index 000000000..a9f8fbd4c --- /dev/null +++ b/src/layers/perimeters/source.test.mjs @@ -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', + }); +}); diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index be71997cb..06a290112 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -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', () => { diff --git a/src/sources/reference.js b/src/sources/reference.js index e3accf58e..4bdf7e9ac 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -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(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 9b39277bc..902a72c83 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -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); }); diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 1541d25e2..f8f02595c 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -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, diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index 87a49b196..88ca8c94e 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -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], diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index a2861f87e..1d963dd21 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -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', diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 2280baef3..6e0eab60e 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -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', ], }, }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d91ff6f74..2e259e8f9 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -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'), diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 04c9e17dd..4646749bc 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -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'],