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* |