/** * What the board is actually saying about fire — including, almost every day, * that there is none. * * ### The empty state is the feature * * Most days this panel's whole job is to render nothing, convincingly. That * sounds like a small ask and it is the hardest sentence in the round to get * right, because **a quiet board and a dead feed look identical**. On the day * this was written the SoCal board drew zero fires — correctly, nothing was * burning in Los Angeles — while twenty-two live incident records sat inside its * bounds, every one with no acreage and fifteen of them nameless LA County * dispatch numbers. A board that just showed empty terrain would have been right * for a reason nobody could see, and would have been indistinguishable from a * board whose upstream had been down since Tuesday. * * So there are **three** empty states here and they are never collapsed into * one. This is the same argument `HealthBody.degraded` already makes for a log, * applied to a picture: * * 1. `ageMs === null` — nothing has *ever* answered. That is a fault, and it * says "no fire feed configured", not "no fire". * 2. `drawn` empty with `suppressed > 0` — the board is quiet and *n* live * records were refused. That is a finding, and the number is printed, * because "twenty-two records did not meet the threshold" is a far stronger * claim than silence. * 3. `drawn` empty with `suppressed === 0` — nothing is happening inside this * frame at all. Also a finding, and a different one. * * Every one of the three carries the fetch age, because a finding with no * timestamp on it is not a finding. * * ### The board is a crop and says so * * `california.ts` caps at 38.05 N. Eighteen live incidents are north of that * line, and on an ordinary day the largest fire in the whole store is one of * them. A board captioned "California" that reads all-clear while 93,733 acres * burn off-frame is the cry-wolf failure inverted — worse, in fact, because the * viewer has no way to know the frame is the limit rather than the world. So the * off-board section is not decoration; it is the sentence that makes the frame * honest. It is fed from `FirePromotion.offBoard`, which is gated by the same * ladder as `drawn` and capped at three, so it names the largest and never * claims to be a complete list. * * ### Hot pixels are captioned as evidence, every time * * A satellite detection is a report that one cell of one overpass was warm. The * upstream store contains a permanent industrial heat source 4.7 km from its * operator's house that reports on every pass, on every day, with no incident * behind it. The caption says "evidence, not incidents" and the persistent count * is printed beside it, because a reader who can see the number of known flare * stacks can calibrate the ones that are not. * * ### Nothing home-relative reaches this file * * `observations.distance_km`, `bearing_deg`, `threat` and * `detections.distance_km` are computed against the upstream operator's house * and invert to a circle around it. They never leave cloud-1, which is the whole * design of the feed. This panel is the surface where such a value would * eventually be *displayed*, so it is worth writing down here too: there is no * distance-to-anything in this file, and a future "how far is that from me" * feature is a new coordinate the viewer supplies, never one the feed carries. */ // ---- The shape this panel is handed --------------------------------------- /** * One fire, as the panel needs it. * * A structural subset of `DrawnFire` in `src/server/fires.ts`, restated rather * than imported for the same reason the engine restates it: this module is DOM * and strings, and it should not pull a wire module in to render a name. A * `FirePromotion` is assignable to `FirePanelView` with no adapter. */ export interface FirePanelFire { id: string; name: string | null; acres: number; pctContained: number | null; lat: number; lon: number; county?: string | null; url?: string | null; tier?: 1 | 2; } export interface FirePanelView { /** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */ fetchedAt: string; /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */ ageMs: number | null; drawn: readonly FirePanelFire[]; /** Gated fires outside the frame, largest first. Capped upstream at three. */ offBoard?: readonly FirePanelFire[]; /** Live rows inside the frame the gate refused. The number behind a calm board. */ suppressed?: number; /** Hot pixels drawn. Known furniture is not in here; see `persistentDetections`. */ detections?: readonly unknown[]; persistentDetections?: number; detectionWindowHours?: number; } export interface FirePanelOptions { /** * The board's bounds, so an off-board fire can be described as "north of this * frame" rather than as "somewhere else". Optional: without it the direction * is omitted rather than guessed. */ bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }; /** * Who the incidents came from, for the caption. Defaults to the two agencies * the upstream collector actually reads. */ agencies?: string; } export interface FirePanelHandle { root: HTMLElement; apply(view: FirePanelView | null): void; dispose(): void; } const DEFAULT_AGENCIES = "CAL FIRE and WFIGS"; // ---- Style ---------------------------------------------------------------- /** * Exported so the stylesheet test can assert what a Node test cannot see: that * nothing in here writes a raw `z-index`, and that the panel uses the shared * tokens rather than inventing a second palette. */ export const FIRE_PANEL_CSS = ` .tera-fire { display: flex; flex-direction: column; gap: var(--s2, 8px); } .tera-fire__state { margin: 0; font-size: 11px; line-height: 1.5; color: var(--ink, rgba(255,255,255,.78)); } .tera-fire__state[data-state="fault"] { color: var(--amber-ink, #ffd68a); } .tera-fire__note { margin: 0; font-size: 9px; line-height: 1.6; letter-spacing: .04em; color: var(--ink-3, rgba(255,255,255,.4)); } .tera-fire__list { display: flex; flex-direction: column; gap: var(--s2, 8px); margin: 0; padding: 0; list-style: none; } .tera-fire__fire { display: flex; flex-direction: column; gap: 2px; padding: var(--s2, 8px) var(--s3, 12px); border: 1px solid var(--hairline, rgba(255,255,255,.11)); border-left: 2px solid rgba(242, 177, 52, 0.55); border-radius: var(--r-sm, 5px); background: rgba(255, 255, 255, 0.03); } .tera-fire__fire[data-tier="2"] { border-left-color: rgba(255, 122, 42, 0.85); } .tera-fire__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); } .tera-fire__facts { font-size: 9px; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); } .tera-fire__section { margin: 0; font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); } .tera-fire__off { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; } .tera-fire__off li { font-size: 10px; line-height: 1.5; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); } .tera-fire__evidence { margin: 0; font-size: 9px; line-height: 1.6; color: var(--ink-3, rgba(255,255,255,.4)); border-left: 2px solid rgba(143, 196, 232, 0.45); padding-left: var(--s2, 8px); } `; const STYLE_ID = "tera-fire-panel-style"; // ---- Copy, as pure functions ---------------------------------------------- /** * "4 minutes ago". The one number that separates a quiet board from a dead one. * * Coarse on purpose past the hour: this feed polls every ten minutes and an age * printed to the second would imply a precision the cache does not have. */ export function relativeAge(ageMs: number | null): string { if (ageMs === null || !Number.isFinite(ageMs)) return "never"; const seconds = Math.max(0, Math.round(ageMs / 1000)); if (seconds < 45) return "just now"; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.round(minutes / 60); if (hours < 36) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.round(hours / 24); return `${days} day${days === 1 ? "" : "s"} ago`; } /** Grouped thousands, without pulling a locale into a test's expectations. */ export function formatAcres(acres: number): string { if (!Number.isFinite(acres)) return "—"; const rounded = acres >= 100 ? Math.round(acres) : Math.round(acres * 10) / 10; const [whole, fraction] = String(rounded).split("."); const grouped = (whole ?? "0").replace(/\B(?=(\d{3})+(?!\d))/g, ","); return fraction === undefined ? grouped : `${grouped}.${fraction}`; } /** * The headline sentence, and the three cases it must never collapse. * * Returned as a pair so the caller can tag the fault case for the stylesheet * without re-deriving which case it is. */ export function fireHeadline( view: FirePanelView, agencies: string = DEFAULT_AGENCIES, ): { state: "fault" | "quiet" | "active"; text: string } { const age = relativeAge(view.ageMs); if (view.ageMs === null) { return { state: "fault", text: "No fire feed configured — this board has never heard from one.", }; } const drawn = view.drawn.length; if (drawn === 0) { return { state: "quiet", text: `No active fire on this board — ${agencies}, ${age}.` }; } return { state: "active", text: `${drawn} active fire${drawn === 1 ? "" : "s"} on this board — ${agencies}, ${age}.`, }; } /** * The second line of a quiet board: what the gate refused, in a number. * * `null` where there is nothing to add. Twenty-two suppressed records is the * most informative thing the SoCal board can say on an ordinary day, and it is * the sentence that turns "we drew nothing" into "we looked, and here is what we * looked at". */ export function suppressedNote(view: FirePanelView): string | null { const suppressed = view.suppressed ?? 0; if (view.ageMs === null) return null; if (view.drawn.length > 0 || suppressed <= 0) return null; return `${suppressed} live record${suppressed === 1 ? "" : "s"} inside this frame did not meet the threshold: under ten acres or no acreage at all, eighty percent contained or more, or a prescribed burn.`; } /** "340 km north of this frame", or `null` where the bounds are not known. */ export function offBoardDirection( fire: { lat: number; lon: number }, bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number }, ): string | null { if (bounds === undefined) return null; const north = fire.lat - bounds.maxLat; const south = bounds.minLat - fire.lat; const east = fire.lon - bounds.maxLng; const west = bounds.minLng - fire.lon; // Longitude is squashed by the cosine of the latitude before the two are // compared, or a fire two degrees east of a board at 38 N wins against one two // degrees north of it while actually being closer. const squash = Math.cos((fire.lat * Math.PI) / 180); const options: [string, number][] = [ ["north", north * 111.32], ["south", south * 111.32], ["east", east * 111.32 * squash], ["west", west * 111.32 * squash], ]; let best: [string, number] | null = null; for (const option of options) { if (option[1] <= 0) continue; if (best === null || option[1] > best[1]) best = option; } if (best === null) return null; return `${Math.round(best[1])} km ${best[0]} of this frame`; } /** The hot-pixel caption. Always says "evidence, not incidents". Always. */ export function evidenceCaption(view: FirePanelView): string { const count = view.detections?.length ?? 0; const hours = view.detectionWindowHours ?? 0; const window = hours > 0 ? `, last ${Math.round(hours)} h` : ""; const persistent = view.persistentDetections ?? 0; const head = count === 0 ? `No satellite hot pixels on this board${window}.` : `${count} satellite hot pixel${count === 1 ? "" : "s"}${window} — evidence, not incidents.`; if (persistent <= 0) return head; return `${head} ${persistent} known persistent source${persistent === 1 ? "" : "s"} — flare stacks, kilns, landfills — are counted and not drawn.`; } // ---- Mount ---------------------------------------------------------------- export function mountFirePanel( host: HTMLElement, options: FirePanelOptions = {}, ): FirePanelHandle { const doc = host.ownerDocument; const agencies = options.agencies ?? DEFAULT_AGENCIES; const root = doc.createElement("section"); root.className = "tera-fire"; root.setAttribute("aria-label", "Fire"); root.setAttribute("data-state", "fault"); // One stylesheet per document, not per panel: this is torn down and rebuilt on // every board switch, and a `