mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(fires): MODIS in Active Fires and a Fire Perimeters layer (#737)
Adds MODIS NRT to Active Fires and a keyless Fire Perimeters layer (NIFC WFIGS) with InciWeb incident links, served through a capped, cached /api/fire-perimeters proxy. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: James Cooke <[email protected]>
This commit is contained in:
40 files changed
+3201
-26
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
|
||||
|
||||
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
|
||||
|
||||
/** CSS accent for a perimeter card by containment progress (mirrors the fill). */
|
||||
export function containmentAccent(containedPct) {
|
||||
if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30';
|
||||
if (containedPct < 50) return '#ff7a00';
|
||||
if (containedPct < 100) return '#ffb300';
|
||||
return '#8bc34a';
|
||||
}
|
||||
|
||||
/** Shoelace area (degree², sign dropped) — relative sizes only. */
|
||||
function ringArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const [x1, y1] = ring[i];
|
||||
const [x2, y2] = ring[i + 1];
|
||||
sum += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor point for the incident card: the centroid of the largest polygon's
|
||||
* outer ring. Degrees, so callers own the Cartesian conversion.
|
||||
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
|
||||
* @returns {{lon: number, lat: number}}
|
||||
*/
|
||||
export function perimeterAnchorDegrees(polygons) {
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const rings of polygons) {
|
||||
const outer = rings[0];
|
||||
const area = ringArea(outer);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = outer;
|
||||
}
|
||||
}
|
||||
// Vertex mean over the closed ring (first == last point dropped): perimeters
|
||||
// are compact enough that this stays inside or near the polygon.
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const count = best.length - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lon += best[i][0];
|
||||
lat += best[i][1];
|
||||
}
|
||||
return { lon: lon / count, lat: lat / count };
|
||||
}
|
||||
|
||||
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
|
||||
function formatCost(dollars) {
|
||||
if (!Number.isFinite(dollars) || dollars < 1000) return null;
|
||||
const units = [
|
||||
[1e3, 'K'],
|
||||
[1e6, 'M'],
|
||||
[1e9, 'B'],
|
||||
];
|
||||
let index = 0;
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (dollars >= units[i][0]) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 999,500 must promote to $1M, never render as $1000K.
|
||||
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
|
||||
const value = dollars / units[index][0];
|
||||
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
|
||||
}
|
||||
|
||||
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
|
||||
function titleCase(name) {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = Math.floor(deltaMs / 3600000);
|
||||
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the overlay-host entry for one selected incident. The caller supplies
|
||||
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
|
||||
* @param {Object} row - Normalized perimeter row (see records.js).
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @param {{link: ?string}} [extras] - Optional InciWeb page for this incident.
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
const facts = [];
|
||||
if (Number.isFinite(row.acres))
|
||||
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
|
||||
facts.push(
|
||||
Number.isFinite(row.containedPct)
|
||||
? `${Math.round(row.containedPct)}% contained`
|
||||
: 'containment unknown',
|
||||
);
|
||||
if (row.state) facts.push(row.state);
|
||||
else if (row.category) facts.push(row.category);
|
||||
|
||||
const situation = [];
|
||||
if (row.cause) situation.push(`${row.cause} cause`);
|
||||
if (row.behavior) situation.push(row.behavior);
|
||||
if (row.complexity) situation.push(row.complexity);
|
||||
|
||||
const response = [];
|
||||
if (Number.isFinite(row.personnel))
|
||||
response.push(
|
||||
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
|
||||
);
|
||||
if (row.county) response.push(`${row.county} County`);
|
||||
if (Number.isFinite(row.costToDate)) {
|
||||
const cost = formatCost(row.costToDate);
|
||||
if (cost) response.push(`${cost} to date`);
|
||||
}
|
||||
|
||||
const ages = [];
|
||||
const discovered = formatAge(nowMs - row.discoveredTime);
|
||||
if (row.discoveredTime != null && discovered)
|
||||
ages.push(`discovered ${discovered} ago`);
|
||||
const updated = formatAge(nowMs - row.updatedTime);
|
||||
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
|
||||
|
||||
const details = [facts.join(' · ')];
|
||||
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
|
||||
if (situation.length) details.push(situation.join(' · '));
|
||||
if (response.length) details.push(response.join(' · '));
|
||||
if (ages.length) details.push(ages.join(' · '));
|
||||
if (link) details.push('InciWeb ↗ · click card to open');
|
||||
|
||||
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
|
||||
return {
|
||||
id: `fire-perimeter-card:${row.stableId}`,
|
||||
// `selected` picks the paint lane and collision protection; an explicit
|
||||
// paintLane would override it in the host's lane resolver.
|
||||
selected: true,
|
||||
// Only a linked card is clickable — the overlay host registers hit
|
||||
// rectangles solely for interactive entries.
|
||||
interactive: Boolean(link),
|
||||
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
|
||||
title,
|
||||
details,
|
||||
accent: containmentAccent(row.containedPct),
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js';
|
||||
|
||||
const square = [
|
||||
[-108.2, 35.0],
|
||||
[-108.0, 35.0],
|
||||
[-108.0, 35.2],
|
||||
[-108.2, 35.2],
|
||||
[-108.2, 35.0],
|
||||
];
|
||||
const tiny = [
|
||||
[-107.01, 34.0],
|
||||
[-107.0, 34.0],
|
||||
[-107.0, 34.01],
|
||||
[-107.01, 34.0],
|
||||
];
|
||||
|
||||
test('the card anchors at the centroid of the largest polygon', () => {
|
||||
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
|
||||
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
|
||||
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
|
||||
});
|
||||
|
||||
test('a full incident row renders name, size, containment, and ages', () => {
|
||||
const now = 1758000000000;
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Frijoles',
|
||||
acres: 15956.4,
|
||||
containedPct: 74,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: now - 36 * 3600000,
|
||||
updatedTime: now - 2 * 3600000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123');
|
||||
assert.equal(card.title, 'FIRE · Frijoles');
|
||||
assert.deepEqual(card.details, [
|
||||
'15,956 ac · 74% contained · US-NM',
|
||||
'Natural cause · Active · Type 3 Incident',
|
||||
'380 personnel · Sandoval County · $4.2M to date',
|
||||
'discovered 1d ago · updated 2h ago',
|
||||
]);
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(typeof card.accent, 'string');
|
||||
});
|
||||
|
||||
test('missing attributes degrade to available facts instead of placeholders', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: null,
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: 'RX',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.title, 'FIRE · Unnamed incident');
|
||||
assert.deepEqual(card.details, ['containment unknown · RX']);
|
||||
});
|
||||
|
||||
test('a known InciWeb page adds a link line to the card', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Timber',
|
||||
acres: 25426,
|
||||
containedPct: 44,
|
||||
state: 'US-CA',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
{
|
||||
link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire',
|
||||
},
|
||||
);
|
||||
assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
assert.equal(card.interactive, true);
|
||||
assert.match(card.accessibilityLabel, /InciWeb/);
|
||||
});
|
||||
|
||||
test('the selected card claims the selected paint lane, not the ambient lane', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Any',
|
||||
acres: 1,
|
||||
containedPct: 1,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.selected, true);
|
||||
// An explicit paintLane would override `selected` in the overlay host's
|
||||
// lane resolver and push the card under other selected cards.
|
||||
assert.equal('paintLane' in card, false);
|
||||
assert.equal('collisionGroup' in card, false);
|
||||
});
|
||||
|
||||
test('costs near a unit boundary promote to the larger unit', () => {
|
||||
const base = {
|
||||
stableId: 'x',
|
||||
name: 'Any',
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: null,
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
};
|
||||
const costLine = (costToDate) =>
|
||||
buildIncidentCard({ ...base, costToDate }, 0).details.find((line) =>
|
||||
line.includes('to date'),
|
||||
);
|
||||
assert.equal(costLine(999500), '$1M to date');
|
||||
assert.equal(costLine(999499), '$999K to date');
|
||||
assert.equal(costLine(999600000), '$1B to date');
|
||||
assert.equal(costLine(4200000), '$4.2M to date');
|
||||
});
|
||||
|
||||
test('a complex member names its complex on the card', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Crosswhite',
|
||||
acres: 342923,
|
||||
containedPct: 99,
|
||||
state: 'US-OR',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.ok(card.details.includes('part of Rowe Creek Complex'));
|
||||
});
|
||||
|
||||
test('a card without a link is not interactive', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Crosswhite',
|
||||
acres: 1,
|
||||
containedPct: 1,
|
||||
state: 'US-OR',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.interactive, false);
|
||||
});
|
||||
|
||||
test('partial incident details render only the facts that exist', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Whiskey',
|
||||
acres: 542,
|
||||
containedPct: 48,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: 'Catron',
|
||||
costToDate: 85000,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.deepEqual(card.details, [
|
||||
'542 ac · 48% contained · US-NM',
|
||||
'Human cause',
|
||||
'Catron County · $85K to date',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */
|
||||
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/**
|
||||
* Normalize an incident title for matching: lowercase, collapsed whitespace,
|
||||
* a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped
|
||||
* ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and
|
||||
* WFIGS names carry neither.
|
||||
*/
|
||||
function normalizeName(name) {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^(19|20)\d{2} /, '')
|
||||
.replace(/ fire$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one WFIGS incident against the publication catalog.
|
||||
* Only rows whose normalized title equals the incident's normalized name
|
||||
* count. Ambiguity resolves by the tau dispatch unit's state prefix, then
|
||||
* by newest incident id (same-state duplicates are usually reburns of the
|
||||
* same name). The `/node/{id}` link 301s to the canonical page, so no slug
|
||||
* construction is needed.
|
||||
* @param {?Array} publications - Publication catalog rows.
|
||||
* @param {{name: ?string, state: ?string}} incident - WFIGS row facts.
|
||||
* @returns {?string} InciWeb node URL.
|
||||
*/
|
||||
export function resolveInciwebNodeLink(publications, { name, state }) {
|
||||
if (!Array.isArray(publications) || !name) return null;
|
||||
const wanted = normalizeName(name);
|
||||
let candidates = publications.filter(
|
||||
(row) =>
|
||||
typeof row?.incident_title === 'string' &&
|
||||
/^\d+$/.test(String(row?.incident_id ?? '')) &&
|
||||
normalizeName(row.incident_title) === wanted,
|
||||
);
|
||||
// The catalog is all-time and nationwide, so the dispatch unit's state
|
||||
// must corroborate every match — a globally unique title is NOT evidence
|
||||
// it's the same fire (live data shows same-name incidents across states).
|
||||
const statePrefix =
|
||||
typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state)
|
||||
? state.slice(3).toLowerCase()
|
||||
: null;
|
||||
if (statePrefix) {
|
||||
candidates = candidates.filter(
|
||||
(row) =>
|
||||
typeof row.tau === 'string' &&
|
||||
row.tau.slice(0, 2).toLowerCase() === statePrefix,
|
||||
);
|
||||
} else if (candidates.length > 1) {
|
||||
return null;
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
// Same-state duplicates are usually reburns of the same name — newest
|
||||
// wins here, and the publication-currency check guards the final link.
|
||||
candidates = [
|
||||
candidates.reduce((a, b) =>
|
||||
Number(a.incident_id) >= Number(b.incident_id) ? a : b,
|
||||
),
|
||||
];
|
||||
}
|
||||
return candidates.length === 1
|
||||
? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The publication id inside a node link produced above, or null. */
|
||||
export function inciwebNodeId(link) {
|
||||
const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec(
|
||||
String(link ?? ''),
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// A fire's InciWeb page is created around the incident's start; a match
|
||||
// whose page predates discovery by more than this is an archived
|
||||
// same-name incident from a earlier season.
|
||||
const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000;
|
||||
// Without a discovery date, require recent editorial activity on the page.
|
||||
const CHANGED_WITHIN_MS = 180 * 24 * 3600000;
|
||||
|
||||
/**
|
||||
* Whether a publication plausibly describes the given current incident.
|
||||
* Fails closed on unusable timestamps.
|
||||
* @param {{createdMs: ?number, changedMs: ?number}} publication
|
||||
* @param {{discoveredTime: ?number, nowMs?: number}} incident
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCurrentPublication(
|
||||
{ createdMs, changedMs },
|
||||
{ discoveredTime, nowMs = Date.now() },
|
||||
) {
|
||||
if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) {
|
||||
return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS;
|
||||
}
|
||||
if (Number.isFinite(changedMs)) {
|
||||
return nowMs - changedMs <= CHANGED_WITHIN_MS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Fetch one publication's created/changed timestamps for currency checks. */
|
||||
export function createInciwebPublicationSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getPublication(id, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
512 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
createdMs: Number.isFinite(payload?.createdMs)
|
||||
? payload.createdMs
|
||||
: null,
|
||||
changedMs: Number.isFinite(payload?.changedMs)
|
||||
? payload.changedMs
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one WFIGS incident to its InciWeb page, or null.
|
||||
* The incident's own name is tried first; a member of a complex whose own
|
||||
* name has no page falls back to the complex's page (InciWeb tracks the
|
||||
* managing complex, not each member fire). Anything ambiguous yields null
|
||||
* rather than a wrong page.
|
||||
* @param {?Array} publications - Publication catalog rows.
|
||||
* @param {{name: ?string, state: ?string, complexName: ?string}} incident
|
||||
* - WFIGS row facts.
|
||||
* @returns {?string} InciWeb URL.
|
||||
*/
|
||||
export function findInciwebLink(publications, { name, state, complexName }) {
|
||||
return (
|
||||
resolveInciwebNodeLink(publications, { name, state }) ??
|
||||
resolveInciwebNodeLink(publications, { name: complexName, state })
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch the full InciWeb incident catalog. */
|
||||
export function createInciwebIndexSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getIndex({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters/inciweb/index', {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
|
||||
const rows = await readResponseJsonCapped(
|
||||
response,
|
||||
4 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
findInciwebLink,
|
||||
resolveInciwebNodeLink,
|
||||
createInciwebIndexSource,
|
||||
createInciwebPublicationSource,
|
||||
isCurrentPublication,
|
||||
inciwebNodeId,
|
||||
} from './inciweb.js';
|
||||
|
||||
const catalog = [
|
||||
{
|
||||
incident_id: '322812',
|
||||
tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District',
|
||||
incident_title: 'Gobernador Pile Burn - Coyote Ranger District',
|
||||
},
|
||||
{
|
||||
incident_id: '327504',
|
||||
tau: 'SDBKF Coyote Flats Fire',
|
||||
incident_title: 'Coyote Flats Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '329195',
|
||||
tau: 'ORBUD Second Flat',
|
||||
incident_title: 'Second Flat',
|
||||
},
|
||||
{
|
||||
incident_id: '329273',
|
||||
tau: 'ORBUD 2026 Coleman Creek',
|
||||
incident_title: '2026 Coleman Creek',
|
||||
},
|
||||
{
|
||||
incident_id: '328923',
|
||||
tau: 'ORPRD Rowe Creek Complex',
|
||||
incident_title: 'Rowe Creek Complex',
|
||||
},
|
||||
{
|
||||
incident_id: '291765',
|
||||
tau: 'CALPF Timber Fire',
|
||||
incident_title: 'Timber Fire',
|
||||
},
|
||||
];
|
||||
|
||||
test('incidents resolve by exact normalized title to a node link', () => {
|
||||
// Title formats vary: with/without a 'Fire' suffix, with/without a year
|
||||
// prefix ('2026 Coleman Creek'). All normalize to the WFIGS name.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/329195',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/329273',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }),
|
||||
'https://inciweb.wildfire.gov/node/291765',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/328922',
|
||||
);
|
||||
// Substring-style near-misses never match.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a unique candidate in the wrong state is rejected, not linked', () => {
|
||||
// The catalog is all-time and nationwide: a current Arizona "Willow"
|
||||
// must not link to the only "Willow Fire" on file when it's a Montana
|
||||
// incident.
|
||||
const willow = [
|
||||
{
|
||||
incident_id: '300001',
|
||||
tau: 'MTFNF Willow Fire',
|
||||
incident_title: 'Willow Fire',
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }),
|
||||
'https://inciweb.wildfire.gov/node/300001',
|
||||
);
|
||||
});
|
||||
|
||||
test('ambiguous titles disambiguate by state, then newest id', () => {
|
||||
const twoCoyotes = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'AZASF Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }),
|
||||
'https://inciweb.wildfire.gov/node/100',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }),
|
||||
null,
|
||||
);
|
||||
const twoSameState = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'ORXXX Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
];
|
||||
// Same-state duplicates are usually reburns of the same name — newest wins.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/328922',
|
||||
);
|
||||
});
|
||||
|
||||
test('a complex member falls back to its complex page', () => {
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Crosswhite',
|
||||
state: 'US-OR',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
}),
|
||||
'https://inciweb.wildfire.gov/node/328923',
|
||||
);
|
||||
// A fire with its own page keeps it even when it belongs to a complex.
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Timber',
|
||||
state: 'US-CA',
|
||||
complexName: 'SOME COMPLEX',
|
||||
}),
|
||||
'https://inciweb.wildfire.gov/node/291765',
|
||||
);
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Nope',
|
||||
state: 'US-NM',
|
||||
complexName: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a node link exposes its publication id', () => {
|
||||
assert.equal(
|
||||
inciwebNodeId('https://inciweb.wildfire.gov/node/329195'),
|
||||
'329195',
|
||||
);
|
||||
assert.equal(inciwebNodeId('https://example.com/other'), null);
|
||||
assert.equal(inciwebNodeId(null), null);
|
||||
});
|
||||
|
||||
test('publication currency accepts pages created around or after discovery', () => {
|
||||
const day = 24 * 3600000;
|
||||
const discovery = 1785000000000;
|
||||
// Created shortly before discovery (page opened as the fire started).
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: discovery - 10 * day, changedMs: discovery + 30 * day },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Created years before discovery: an archived same-name incident.
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: discovery - 900 * day, changedMs: discovery - 800 * day },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
false,
|
||||
);
|
||||
// No discovery date: fall back to recent activity on the page.
|
||||
const now = 1787600000000;
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: now - 900 * day, changedMs: now - 20 * day },
|
||||
{ discoveredTime: null, nowMs: now },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: now - 900 * day, changedMs: now - 400 * day },
|
||||
{ discoveredTime: null, nowMs: now },
|
||||
),
|
||||
false,
|
||||
);
|
||||
// Unusable timestamps fail closed.
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: null, changedMs: null },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the publication source fetches timestamps and honors cancellation', async () => {
|
||||
let requested;
|
||||
const source = createInciwebPublicationSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
createdMs: 1785025540000,
|
||||
changedMs: 1787589503000,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
const publication = await source.getPublication('329195');
|
||||
assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195');
|
||||
assert.deepEqual(publication, {
|
||||
createdMs: 1785025540000,
|
||||
changedMs: 1787589503000,
|
||||
});
|
||||
|
||||
const malformed = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify({ nope: 1 }),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await malformed.getPublication('1'), {
|
||||
createdMs: null,
|
||||
changedMs: null,
|
||||
});
|
||||
|
||||
const failing = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/);
|
||||
|
||||
const abort = new AbortController();
|
||||
const cancelled = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
cancelled.getPublication('1', { signal: abort.signal }),
|
||||
{
|
||||
name: 'AbortError',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the index source requests the same-origin full catalog', async () => {
|
||||
let request;
|
||||
const source = createInciwebIndexSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url: String(url), options };
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify(catalog),
|
||||
};
|
||||
},
|
||||
});
|
||||
const rows = await source.getIndex();
|
||||
assert.equal(rows.length, catalog.length);
|
||||
assert.equal(request.url, '/api/fire-perimeters/inciweb/index');
|
||||
assert.equal(request.options.method, undefined);
|
||||
assert.equal(request.options.body, undefined);
|
||||
});
|
||||
|
||||
test('the index source rejects failures and honors cancellation', async () => {
|
||||
const failing = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/);
|
||||
|
||||
const malformed = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify({ nope: 1 }),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await malformed.getIndex(), []);
|
||||
|
||||
const abort = new AbortController();
|
||||
const cancelled = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify(catalog);
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(cancelled.getIndex({ signal: abort.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
perimeterAnchorDegrees,
|
||||
buildIncidentCard,
|
||||
containmentAccent,
|
||||
} from './cards.js';
|
||||
import {
|
||||
findInciwebLink,
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
export * from './inciweb.js';
|
||||
|
||||
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
|
||||
export function containmentColor(containedPct) {
|
||||
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
|
||||
}
|
||||
|
||||
const ringPositions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
|
||||
const PICK_PREFIX = 'fire-perimeter:';
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
// The InciWeb catalog changes on the order of days; refreshing it on the
|
||||
// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB
|
||||
// of near-identical bytes.
|
||||
const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
|
||||
|
||||
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
|
||||
export function createFirePerimetersLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
inciwebSource = null,
|
||||
inciwebPublications = null,
|
||||
openExternal = null,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Fire perimeters require a snapshot source');
|
||||
let _viewer = null;
|
||||
let _request = null;
|
||||
let _linkRequest = null;
|
||||
let _snapshotSignature = null;
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
let _selectedId = null;
|
||||
let _selectedLink = null;
|
||||
let _selectedCardId = null;
|
||||
let _inciwebIndex = [];
|
||||
let _indexFetchedAt = 0;
|
||||
let _indexFetchInFlight = false;
|
||||
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||
const _rowById = new Map();
|
||||
// Link → currency verdict from the publication check; unknown links are
|
||||
// absent. Failed checks are not cached so a transient outage retries.
|
||||
const _linkVerdicts = new Map();
|
||||
function abortLinkVerification() {
|
||||
_linkRequest?.controller.abort();
|
||||
_linkRequest = null;
|
||||
}
|
||||
|
||||
const canSelect = () =>
|
||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||
|
||||
/**
|
||||
* Refresh the InciWeb catalog off the render path: a hanging or failed
|
||||
* fetch only costs the link line, never the perimeter refresh — the
|
||||
* catalog is consumed solely by the selected card.
|
||||
*/
|
||||
function refreshInciwebIndex(signal) {
|
||||
if (!inciwebSource || _indexFetchInFlight) return;
|
||||
if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return;
|
||||
_indexFetchInFlight = true;
|
||||
inciwebSource
|
||||
.getIndex({ signal })
|
||||
.then((rows) => {
|
||||
if (signal.aborted || !_enabled || !Array.isArray(rows)) return;
|
||||
_inciwebIndex = rows;
|
||||
_indexFetchedAt = Date.now();
|
||||
if (_selectedId) publishSelectedCard();
|
||||
})
|
||||
.catch(() => {
|
||||
// Catalog unavailable — cards stay linkless until the next attempt.
|
||||
})
|
||||
.finally(() => {
|
||||
_indexFetchInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a matched publication describes the current incident, not
|
||||
* an archived same-name fire — the catalog is all-time. Async: the card
|
||||
* republishes with the link once (and only if) the check passes.
|
||||
*/
|
||||
function verifyLink(link, row) {
|
||||
const id = inciwebNodeId(link);
|
||||
if (!id || !inciwebPublications || _linkRequest?.link === link) return;
|
||||
abortLinkVerification();
|
||||
const request = { link, controller: new AbortController() };
|
||||
_linkRequest = request;
|
||||
const { signal } = request.controller;
|
||||
inciwebPublications
|
||||
.getPublication(id, { signal })
|
||||
.then((publication) => {
|
||||
if (signal.aborted || _linkRequest !== request || !_enabled) return;
|
||||
_linkVerdicts.set(
|
||||
link,
|
||||
isCurrentPublication(publication, {
|
||||
discoveredTime: row.discoveredTime,
|
||||
}),
|
||||
);
|
||||
if (_selectedId === row.stableId) publishSelectedCard();
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail closed without caching: no link now, retry on reselection.
|
||||
})
|
||||
.finally(() => {
|
||||
if (_linkRequest === request) _linkRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
function publishSelectedCard() {
|
||||
if (!canSelect()) return;
|
||||
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
||||
if (!row) {
|
||||
_selectedId = null;
|
||||
_selectedLink = null;
|
||||
_selectedCardId = null;
|
||||
overlayHost.setEntries(
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const candidate = findInciwebLink(_inciwebIndex, row);
|
||||
if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row);
|
||||
_selectedLink =
|
||||
candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
|
||||
const card = {
|
||||
...buildIncidentCard(row, Date.now(), { link: _selectedLink }),
|
||||
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
||||
};
|
||||
if (!openExternal) card.interactive = false;
|
||||
if (_selectedLink && openExternal) {
|
||||
const link = _selectedLink;
|
||||
// Keyboard/assistive activation mirrors the pointer click-through.
|
||||
card.activate = () => {
|
||||
openExternal(link);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
_selectedCardId = card.id;
|
||||
overlayHost.setEntries(
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a scene pick to one of this layer's incident ids, or null. */
|
||||
function pickedIncidentId(picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0];
|
||||
return _rowById.has(incidentId) ? incidentId : null;
|
||||
}
|
||||
|
||||
function installClickHandler() {
|
||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||
// Deliberately NOT registered in the pick-ownership registry: sibling
|
||||
// layers yield to owned picks before their own overlay-card hit-tests,
|
||||
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// cards inert anywhere over a perimeter. Unowned, our picks read as
|
||||
// "empty space" to siblings — the pre-existing behavior for the globe.
|
||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
// A click on the incident card itself opens its InciWeb page (when the
|
||||
// incident has one) and never disturbs the selection.
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{ sourceId: PERIMETER_OVERLAY_SOURCE_ID },
|
||||
);
|
||||
if (cardHit && cardHit.entryId === _selectedCardId) {
|
||||
if (_selectedLink && openExternal) openExternal(_selectedLink);
|
||||
return;
|
||||
}
|
||||
const picked = _viewer.scene.pick(click.position);
|
||||
const incidentId = picked ? pickedIncidentId(picked) : null;
|
||||
if (incidentId) {
|
||||
abortLinkVerification();
|
||||
_selectedId = incidentId;
|
||||
publishSelectedCard();
|
||||
return;
|
||||
}
|
||||
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
|
||||
// "empty space" — leave the selection alone and let that layer handle it.
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
abortLinkVerification();
|
||||
_selectedId = null;
|
||||
publishSelectedCard();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (_clickHandler) {
|
||||
_clickHandler.destroy();
|
||||
_clickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
abortLinkVerification();
|
||||
_selectedId = null;
|
||||
_selectedLink = null;
|
||||
_selectedCardId = null;
|
||||
if (overlayHost) {
|
||||
overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: 'fire-perimeters',
|
||||
name: 'Fire Perimeters',
|
||||
icon: '🔥',
|
||||
source: 'NIFC WFIGS',
|
||||
updateInterval: 300000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
throw new Error('Fire perimeter layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource('fire-perimeters');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_enabled = false;
|
||||
console.log('[Data:FirePerimeters] Initialized');
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
|
||||
installClickHandler();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
refreshInciwebIndex(request.signal);
|
||||
try {
|
||||
const rows = await source.getSnapshot({ signal: request.signal });
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
|
||||
// Incident facts may change independently of the polygon's source clock.
|
||||
const signature = JSON.stringify(
|
||||
rows
|
||||
.map(({ polygons, ...facts }) => facts)
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
abortLinkVerification();
|
||||
const nextEntities = [];
|
||||
_rowById.clear();
|
||||
for (const row of rows) {
|
||||
const color = containmentColor(row.containedPct);
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const [outer, ...holes] = rings;
|
||||
const outerPositions = ringPositions(outer);
|
||||
nextEntities.push(
|
||||
new Cesium.Entity({
|
||||
id: `fire-perimeter:${row.stableId}:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
outerPositions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||
),
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
// The fire line: ground-clamped outline (entity polygons cannot
|
||||
// outline clamped geometry themselves).
|
||||
polyline: {
|
||||
positions: outerPositions,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.9),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Keep a geometry-free projection: the coordinate arrays would
|
||||
// otherwise be retained for the layer's lifetime while only the
|
||||
// anchor is ever read again.
|
||||
const { polygons, ...facts } = row;
|
||||
_rowById.set(row.stableId, {
|
||||
...facts,
|
||||
anchor: perimeterAnchorDegrees(polygons),
|
||||
});
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
_snapshotSignature = signature;
|
||||
// Refresh (or drop) the selected incident's card against the new feed.
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || 'Perimeter source unavailable';
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_rowById.clear();
|
||||
_snapshotSignature = null;
|
||||
_inciwebIndex = [];
|
||||
_indexFetchedAt = 0;
|
||||
_linkVerdicts.clear();
|
||||
_viewer = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
},
|
||||
|
||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const row of _rowById.values()) {
|
||||
if (result.length >= limit) break;
|
||||
const { anchor, stableId, ...facts } = row;
|
||||
result.push({
|
||||
id: stableId,
|
||||
...facts,
|
||||
lat: anchor.lat,
|
||||
lon: anchor.lon,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
legend: bands.map((band, index) => ({
|
||||
...band,
|
||||
count: [..._rowById.values()].filter(
|
||||
(row) => containmentAccent(row.containedPct) === band.color,
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb:
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFirePerimetersLayer } from './index.js';
|
||||
|
||||
function harness(
|
||||
source,
|
||||
{
|
||||
pick = () => null,
|
||||
inciwebIndex = null,
|
||||
cardHit = () => null,
|
||||
publication = async () => ({
|
||||
createdMs: 1757000000000,
|
||||
changedMs: 1757900000000,
|
||||
}),
|
||||
} = {},
|
||||
) {
|
||||
const sources = [];
|
||||
const overlay = { entries: new Map(), visible: null };
|
||||
const clicks = { handler: null, destroyed: 0 };
|
||||
const owners = new Map();
|
||||
const opened = [];
|
||||
const viewer = {
|
||||
scene: { pick },
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createFirePerimetersLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(sourceId, entries) {
|
||||
overlay.entries.set(sourceId, entries);
|
||||
},
|
||||
setVisible(sourceId, visible) {
|
||||
overlay.visible = visible;
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
overlay.entries.delete(sourceId);
|
||||
},
|
||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(callback) {
|
||||
clicks.handler = callback;
|
||||
},
|
||||
destroy() {
|
||||
clicks.destroyed += 1;
|
||||
clicks.handler = null;
|
||||
},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: (picked) => picked?.id ?? null,
|
||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||
String(pickedId).startsWith('other-layer:'),
|
||||
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
|
||||
unregisterPickOwner: (layerId) => owners.delete(layerId),
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: inciwebIndex
|
||||
? { getIndex: async () => inciwebIndex }
|
||||
: { getIndex: async () => [] },
|
||||
inciwebPublications: { getPublication: publication },
|
||||
openExternal: (url) => opened.push(url),
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, overlay, clicks, owners, opened };
|
||||
}
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
const row = {
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Fixture Fire',
|
||||
acres: 512.5,
|
||||
containedPct: 40,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: 1757900000000,
|
||||
updatedTime: 1757950000000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
polygons: [[ring]],
|
||||
};
|
||||
|
||||
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
|
||||
],
|
||||
});
|
||||
assert.equal(await h.layer.update(h.viewer), true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
assert.equal(entities.length, 3);
|
||||
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
|
||||
assert.ok(entities[0].polygon, 'perimeter fill missing');
|
||||
assert.ok(entities[0].polyline, 'fire line missing');
|
||||
assert.equal(entities[1].id, 'fire-perimeter:other:0');
|
||||
assert.equal(entities[2].id, 'fire-perimeter:other:1');
|
||||
assert.equal(h.layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('late refresh cannot publish after disable or destroy', async () => {
|
||||
for (const action of ['disable', 'destroy']) {
|
||||
let resolve, signal;
|
||||
const h = harness({
|
||||
getSnapshot(options) {
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = h.layer.update(h.viewer);
|
||||
h.layer[action](h.viewer);
|
||||
assert.equal(signal.aborted, true);
|
||||
if (action === 'disable') h.layer.enable(h.viewer);
|
||||
resolve([row]);
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a perimeter publishes its incident card; empty space clears it', async () => {
|
||||
let pickResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => pickResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
||||
// Deliberately NOT registered as a pick owner: sibling layers' click
|
||||
// handlers yield to owned picks before their own card hit-tests, so
|
||||
// claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// overlay cards inert anywhere over a perimeter.
|
||||
assert.equal(h.owners.size, 0);
|
||||
|
||||
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
|
||||
assert.equal(entries[0].title, 'FIRE · Fixture Fire');
|
||||
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||
|
||||
pickResult = { id: 'other-layer:aircraft-1' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(
|
||||
h.overlay.entries.get('fire-perimeters')?.length,
|
||||
1,
|
||||
'sibling-layer picks must not clear the selection',
|
||||
);
|
||||
|
||||
pickResult = null;
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('disable removes the click handler, pick ownership, and any card', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
h.layer.disable(h.viewer);
|
||||
assert.equal(h.clicks.destroyed, 1);
|
||||
assert.equal(h.owners.has('fire-perimeters'), false);
|
||||
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
|
||||
});
|
||||
|
||||
test('a refresh that drops the selected incident also drops its card', async () => {
|
||||
let rows = [row];
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => rows },
|
||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
rows = [{ ...row, stableId: 'different' }];
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('a matched incident card carries the InciWeb line and click-through', async () => {
|
||||
const inciwebIndex = [
|
||||
{
|
||||
incident_id: '329300',
|
||||
tau: 'NMGNF Fixture Fire',
|
||||
incident_title: 'Fixture Fire',
|
||||
},
|
||||
];
|
||||
let cardHitResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [{ ...row, name: 'Fixture' }] },
|
||||
{
|
||||
pick: () => null,
|
||||
inciwebIndex,
|
||||
cardHit: () => cardHitResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
// Select via a perimeter pick.
|
||||
h.viewer.scene.pick = () => ({
|
||||
id: 'fire-perimeter:2026-NMGNF-000123:0',
|
||||
});
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
// The card publishes immediately; the link line lands once the
|
||||
// publication's currency is validated.
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
|
||||
// Clicking the card itself opens the InciWeb page.
|
||||
cardHitResult = { entryId: entries[0].id };
|
||||
h.viewer.scene.pick = () => null;
|
||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
||||
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
|
||||
assert.equal(
|
||||
h.overlay.entries.get('fire-perimeters').length,
|
||||
1,
|
||||
'opening the link must not clear the selection',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => {
|
||||
let cardHitResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex: [],
|
||||
cardHit: () => cardHitResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(
|
||||
entries[0].details.some((line) => line.includes('InciWeb')),
|
||||
false,
|
||||
);
|
||||
cardHitResult = { entryId: entries[0].id };
|
||||
h.viewer.scene.pick = () => null;
|
||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
||||
assert.deepEqual(h.opened, []);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
});
|
||||
|
||||
test('a complex member resolves to its complex page from the catalog', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex: [
|
||||
{
|
||||
// Same state as the member fire: the state filter applies to
|
||||
// complex fallbacks too.
|
||||
incident_id: '328923',
|
||||
tau: 'NMPRD Rowe Creek Complex',
|
||||
incident_title: 'Rowe Creek Complex',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
});
|
||||
|
||||
test('a stale or unverifiable publication yields no link', async () => {
|
||||
const inciwebIndex = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'NMGNF Fixture Fire',
|
||||
incident_title: 'Fixture Fire',
|
||||
},
|
||||
];
|
||||
for (const publication of [
|
||||
// Created two years before this fire's discovery: an archived page.
|
||||
async () => ({
|
||||
createdMs: 1757900000000 - 700 * 86400000,
|
||||
changedMs: null,
|
||||
}),
|
||||
// Validation endpoint down: fail closed, no link.
|
||||
async () => {
|
||||
throw new Error('InciWeb HTTP 503');
|
||||
},
|
||||
]) {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex,
|
||||
publication,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(
|
||||
entries[0].details.some((line) => line.includes('InciWeb')),
|
||||
false,
|
||||
);
|
||||
assert.equal(entries[0].interactive, false);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
|
||||
// Replace the index source with one that never resolves.
|
||||
h.layer.destroy(h.viewer);
|
||||
const hanging = harness({ getSnapshot: async () => [row] });
|
||||
hanging.layer.destroy(hanging.viewer);
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
scene: { pick: () => null },
|
||||
dataSources: {
|
||||
add: (v) => sources.push(v),
|
||||
remove: (v) => sources.splice(sources.indexOf(v), 1),
|
||||
},
|
||||
};
|
||||
const layer = createFirePerimetersLayer({
|
||||
source: { getSnapshot: async () => [row] },
|
||||
overlayHost: {
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
hitTest: () => null,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: () => null,
|
||||
isOwnedByOtherLayer: () => false,
|
||||
registerPickOwner() {},
|
||||
unregisterPickOwner() {},
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: { getIndex: () => new Promise(() => {}) },
|
||||
inciwebPublications: {
|
||||
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
|
||||
},
|
||||
openExternal: () => {},
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
layer.destroy(viewer);
|
||||
});
|
||||
|
||||
test('an InciWeb outage never breaks the perimeter refresh', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] });
|
||||
const failing = createFirePerimetersLayer({
|
||||
source: { getSnapshot: async () => [row] },
|
||||
overlayHost: {
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
hitTest: () => null,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: () => null,
|
||||
isOwnedByOtherLayer: () => false,
|
||||
registerPickOwner() {},
|
||||
unregisterPickOwner() {},
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: {
|
||||
getIndex: async () => {
|
||||
throw new Error('InciWeb HTTP 503');
|
||||
},
|
||||
},
|
||||
openExternal: () => {},
|
||||
});
|
||||
const viewer = {
|
||||
scene: { pick: () => null },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
failing.init(viewer);
|
||||
failing.enable(viewer);
|
||||
assert.equal(await failing.update(viewer), true);
|
||||
assert.equal(failing.getStats().count, 1);
|
||||
failing.destroy(viewer);
|
||||
h.layer.destroy(h.viewer);
|
||||
});
|
||||
|
||||
test('analyst records expose incident facts without geometry payloads', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] });
|
||||
await h.layer.update(h.viewer);
|
||||
const records = h.layer.getAnalystRecords();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].name, 'Fixture Fire');
|
||||
assert.equal(records[0].acres, 512.5);
|
||||
assert.equal(records[0].containedPct, 40);
|
||||
assert.equal(records[0].state, 'US-NM');
|
||||
assert.equal(records[0].cause, 'Natural');
|
||||
assert.equal(records[0].behavior, 'Active');
|
||||
assert.equal(records[0].personnel, 380);
|
||||
assert.equal(records[0].county, 'Sandoval');
|
||||
assert.equal(records[0].costToDate, 4200000);
|
||||
assert.equal(records[0].complexity, 'Type 3 Incident');
|
||||
// Spatial scoping in the analyst engine requires coordinates.
|
||||
assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01);
|
||||
assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01);
|
||||
assert.equal('polygons' in records[0], false);
|
||||
});
|
||||
|
||||
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
|
||||
let rows = [row, { ...row, stableId: 'other' }];
|
||||
const h = harness({ getSnapshot: async () => rows });
|
||||
await h.layer.update();
|
||||
const original = [...h.sources[0].entities.values];
|
||||
let removals = 0;
|
||||
const entities = h.sources[0].entities;
|
||||
const removeAll = entities.removeAll.bind(entities);
|
||||
entities.removeAll = () => {
|
||||
removals++;
|
||||
removeAll();
|
||||
};
|
||||
rows = [...rows].reverse();
|
||||
await h.layer.update();
|
||||
assert.equal(removals, 0);
|
||||
assert.equal(entities.values[0], original[0]);
|
||||
rows = rows.map((value) => ({
|
||||
...value,
|
||||
updatedTime: value.updatedTime + 1,
|
||||
}));
|
||||
await h.layer.update();
|
||||
assert.equal(removals, 1);
|
||||
assert.notEqual(entities.values[0], original[0]);
|
||||
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
|
||||
await h.layer.update();
|
||||
assert.equal(
|
||||
removals,
|
||||
2,
|
||||
'incident facts can change without a new polygon timestamp',
|
||||
);
|
||||
assert.equal(h.layer.getRowControls().legend[3].count, 2);
|
||||
rows = [];
|
||||
await h.layer.update();
|
||||
assert.equal(entities.values.length, 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('containment legend uses the existing thresholds, colours and incident counts', async () => {
|
||||
const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101];
|
||||
const h = harness({
|
||||
getSnapshot: async () =>
|
||||
values.map((containedPct, index) => ({
|
||||
...row,
|
||||
stableId: String(index),
|
||||
containedPct,
|
||||
})),
|
||||
});
|
||||
await h.layer.update();
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.deepEqual(controls.chips, []);
|
||||
assert.deepEqual(
|
||||
controls.legend.map(({ color, count }) => [color, count]),
|
||||
[
|
||||
['#ff3b30', 3],
|
||||
['#ff7a00', 2],
|
||||
['#ffb300', 2],
|
||||
['#8bc34a', 2],
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
controls.legend.every(
|
||||
({ label }) => typeof label === 'string' && label.length,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
controls.legend[0].blurb,
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
);
|
||||
h.layer.destroy();
|
||||
assert.deepEqual(
|
||||
h.layer.getRowControls().legend.map(({ count }) => count),
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
for (const action of ['disable', 'destroy', 'selection']) {
|
||||
test(`incident-link verification aborts on ${action} and ignores late completion`, async () => {
|
||||
let finish, signal;
|
||||
let pick = { id: `fire-perimeter:${row.stableId}:0` };
|
||||
const h = harness(
|
||||
{
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: 'other', name: 'Other Fire' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pick: () => pick,
|
||||
inciwebIndex: [
|
||||
{ incident_id: '123', incident_title: row.name, tau: 'nm' },
|
||||
],
|
||||
publication: (_id, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
await h.layer.update();
|
||||
h.clicks.handler({ position: { x: 1, y: 1 } });
|
||||
assert.equal(signal.aborted, false);
|
||||
if (action === 'selection') {
|
||||
pick = { id: 'fire-perimeter:other:0' };
|
||||
h.clicks.handler({ position: { x: 1, y: 1 } });
|
||||
} else h.layer[action]();
|
||||
assert.equal(signal.aborted, true);
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters'), entries);
|
||||
if (action !== 'destroy') h.layer.destroy();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Normalize a WFIGS feed, skipping individually invalid features.
|
||||
* Only a payload that is not a feature collection at all rejects the whole
|
||||
* snapshot: a single degenerate incident (ArcGIS emits geometry:null when
|
||||
* maxAllowableOffset generalizes a tiny polygon away) must never blank the
|
||||
* layer — a failed first update makes the lifecycle disable it entirely.
|
||||
*/
|
||||
|
||||
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
|
||||
const textOrNull = (value) => (typeof value === 'string' ? value : null);
|
||||
|
||||
function validRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||
for (const position of ring) {
|
||||
if (!Array.isArray(position) || position.length < 2) return false;
|
||||
const [lon, lat] = position;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
|
||||
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
|
||||
* array of rings). Returns null on malformed geometry, [] when empty. */
|
||||
function normalizePolygons(geometry) {
|
||||
if (!geometry || typeof geometry !== 'object') return null;
|
||||
let polygons;
|
||||
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
if (!Array.isArray(polygons)) return null;
|
||||
const result = [];
|
||||
for (const rings of polygons) {
|
||||
if (!Array.isArray(rings)) return null;
|
||||
if (!rings.length) continue;
|
||||
if (!rings.every(validRing)) return null;
|
||||
result.push(rings);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeFirePerimeterSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
continue;
|
||||
const polygons = normalizePolygons(feature.geometry);
|
||||
if (polygons === null || !polygons.length) continue;
|
||||
const uniqueId = properties.attr_UniqueFireIdentifier;
|
||||
const stableId =
|
||||
typeof uniqueId === 'string' && uniqueId !== ''
|
||||
? uniqueId
|
||||
: feature.id == null || feature.id === ''
|
||||
? null
|
||||
: String(feature.id);
|
||||
if (stableId == null || ids.has(stableId)) continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
name:
|
||||
typeof properties.poly_IncidentName === 'string'
|
||||
? properties.poly_IncidentName
|
||||
: null,
|
||||
acres: finiteOrNull(properties.attr_IncidentSize),
|
||||
containedPct: finiteOrNull(properties.attr_PercentContained),
|
||||
state:
|
||||
typeof properties.attr_POOState === 'string'
|
||||
? properties.attr_POOState
|
||||
: null,
|
||||
category:
|
||||
typeof properties.attr_IncidentTypeCategory === 'string'
|
||||
? properties.attr_IncidentTypeCategory
|
||||
: null,
|
||||
discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime),
|
||||
updatedTime: finiteOrNull(properties.poly_DateCurrent),
|
||||
cause: textOrNull(properties.attr_FireCause),
|
||||
behavior: textOrNull(properties.attr_FireBehaviorGeneral),
|
||||
personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel),
|
||||
county: textOrNull(properties.attr_POOCounty),
|
||||
costToDate: finiteOrNull(properties.attr_EstimatedCostToDate),
|
||||
complexity: textOrNull(properties.attr_IncidentComplexityLevel),
|
||||
complexName: textOrNull(properties.attr_CpxName),
|
||||
polygons,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
|
||||
function feature(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
poly_IncidentName: 'Fixture Fire',
|
||||
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
|
||||
attr_IncidentSize: 512.5,
|
||||
attr_PercentContained: 40,
|
||||
attr_POOState: 'US-NM',
|
||||
attr_IncidentTypeCategory: 'WF',
|
||||
attr_FireDiscoveryDateTime: 1757900000000,
|
||||
poly_DateCurrent: 1757950000000,
|
||||
attr_FireCause: 'Natural',
|
||||
attr_FireBehaviorGeneral: 'Active',
|
||||
attr_TotalIncidentPersonnel: 380,
|
||||
attr_POOCounty: 'Sandoval',
|
||||
attr_EstimatedCostToDate: 4200000,
|
||||
attr_IncidentComplexityLevel: 'Type 3 Incident',
|
||||
attr_CpxName: 'ROWE CREEK COMPLEX',
|
||||
...overrides.properties,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a valid snapshot yields one row per incident with polygons preserved', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
id: 2,
|
||||
geometry: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[ring], [ring]],
|
||||
},
|
||||
properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Fixture Fire',
|
||||
acres: 512.5,
|
||||
containedPct: 40,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: 1757900000000,
|
||||
updatedTime: 1757950000000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
polygons: [[ring]],
|
||||
});
|
||||
assert.equal(rows[1].polygons.length, 2);
|
||||
});
|
||||
|
||||
test('a missing unique fire identifier falls back to the feature id', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [feature({ properties: { attr_UniqueFireIdentifier: null } })],
|
||||
});
|
||||
assert.equal(rows[0].stableId, '1');
|
||||
});
|
||||
|
||||
test('optional attributes normalize to null rather than leaking undefined', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
poly_IncidentName: null,
|
||||
attr_IncidentSize: null,
|
||||
attr_PercentContained: null,
|
||||
attr_POOState: null,
|
||||
attr_IncidentTypeCategory: null,
|
||||
attr_FireDiscoveryDateTime: null,
|
||||
poly_DateCurrent: null,
|
||||
attr_FireCause: null,
|
||||
attr_FireBehaviorGeneral: null,
|
||||
attr_TotalIncidentPersonnel: null,
|
||||
attr_POOCounty: null,
|
||||
attr_EstimatedCostToDate: null,
|
||||
attr_IncidentComplexityLevel: null,
|
||||
attr_CpxName: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: rows[0].name,
|
||||
acres: rows[0].acres,
|
||||
containedPct: rows[0].containedPct,
|
||||
state: rows[0].state,
|
||||
category: rows[0].category,
|
||||
discoveredTime: rows[0].discoveredTime,
|
||||
updatedTime: rows[0].updatedTime,
|
||||
cause: rows[0].cause,
|
||||
behavior: rows[0].behavior,
|
||||
personnel: rows[0].personnel,
|
||||
county: rows[0].county,
|
||||
costToDate: rows[0].costToDate,
|
||||
complexity: rows[0].complexity,
|
||||
complexName: rows[0].complexName,
|
||||
},
|
||||
{
|
||||
name: null,
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: null,
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed that is not a feature collection is rejected as a whole', () => {
|
||||
for (const payload of [null, {}, { features: null }, { features: {} }]) {
|
||||
assert.equal(normalizeFirePerimeterSnapshot(payload), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid feature is skipped so one bad incident cannot blank the layer', () => {
|
||||
const good = feature({
|
||||
id: 2,
|
||||
properties: { attr_UniqueFireIdentifier: 'good' },
|
||||
});
|
||||
for (const bad of [
|
||||
feature({ geometry: null }),
|
||||
feature({ geometry: { type: 'Point', coordinates: [0, 1] } }),
|
||||
feature({
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[[Infinity, 35.2], ...ring.slice(1)]],
|
||||
},
|
||||
}),
|
||||
feature({
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[[-200, 35.2], ...ring.slice(1)]],
|
||||
},
|
||||
}),
|
||||
feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }),
|
||||
feature({ properties: null }),
|
||||
feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }),
|
||||
]) {
|
||||
const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] });
|
||||
assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal');
|
||||
assert.equal(rows[0].stableId, 'good');
|
||||
}
|
||||
});
|
||||
|
||||
test('a duplicate incident id keeps the first occurrence and skips the rest', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
properties: {
|
||||
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
|
||||
attr_IncidentSize: 999,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].acres, 512.5);
|
||||
});
|
||||
|
||||
test('a perimeter with no rings is skipped rather than rendered empty', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature({ geometry: { type: 'Polygon', coordinates: [] } }),
|
||||
feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, 'other');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
|
||||
export function createWfigsPerimeterSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed perimeter snapshot');
|
||||
return payload.rows;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWfigsPerimeterSource } from './source.js';
|
||||
import { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js';
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
const validPayload = {
|
||||
features: [
|
||||
{
|
||||
id: 1,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a successful response yields normalized perimeter rows', async () => {
|
||||
let requested;
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return Response.json({
|
||||
rows: normalizeFirePerimeterSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const rows = await source.getSnapshot();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, '2026-NMGNF-000123');
|
||||
assert.equal(requested, '/api/fire-perimeters');
|
||||
});
|
||||
|
||||
test('a truncated response pages until the feed is complete', async () => {
|
||||
const pageRing = (id) => ({
|
||||
id,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { attr_UniqueFireIdentifier: `fire-${id}` },
|
||||
});
|
||||
const requests = [];
|
||||
let handler;
|
||||
const plugin = firePerimetersProxy({
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
const page = requests.length;
|
||||
return Response.json({
|
||||
features: [pageRing(page)],
|
||||
properties: { exceededTransferLimit: page < 3 },
|
||||
});
|
||||
},
|
||||
});
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_path, callback) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: '/', method: 'GET' },
|
||||
{
|
||||
writeHead(status) {
|
||||
assert.equal(status, 200);
|
||||
},
|
||||
end(body) {
|
||||
payload = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
const rows = payload.rows;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['fire-1', 'fire-2', 'fire-3'],
|
||||
);
|
||||
assert.deepEqual(requests, [null, '1', '2']);
|
||||
});
|
||||
|
||||
test('an upstream failure surfaces its HTTP status', async () => {
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/);
|
||||
});
|
||||
|
||||
test('a malformed successful response is never accepted as an empty snapshot', async () => {
|
||||
for (const payload of [{}, { rows: null }, { rows: {} }]) {
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/);
|
||||
}
|
||||
});
|
||||
|
||||
test('response-body completion honors cancellation without replacing records', async () => {
|
||||
const abort = new AbortController();
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify({
|
||||
rows: normalizeFirePerimeterSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user