/** * Which birds may be drawn, what the sky says when none may, and the one number * that is wrong by six times if you compute it the obvious way. * * ### The empty state ships first, because it is what most people see * * BirdCast measures nocturnal migration and measures it **only after dark**. Of * the 297 granules in the upstream store, 176 are daytime and hold 104 rows * between them, against 7,719 at night. The layer is therefore absent about * fourteen hours in every twenty-four *by construction*, before any question of * season, and every default frame `scripts/look.mjs` takes is a daylight frame. * A layer that only looks right during the small fraction of frames where it has * something to say is a layer that looks broken the rest of the time. * * So `quiet` is not optional and is never blank. It carries a reason and a * sentence, and the sentence is the layer: * * Nothing is aloft. BirdCast measures migration only after dark. Last night * 393,290 birds crossed California heading south-east, peaking at 1,501,193 * aloft at 23:20 PDT, at a mean 726 metres. * * ### The six-times error * * `SELECT SUM(birds_crossed)` over the 58 county rows for the night of * 2026-08-21 gives **2,360,086**. The authoritative figure, on the `US-CA` state * row for the same night, is **393,290**. Both are correct: a bird that crosses * four counties is counted in four of them, so the county rows are a spatial * distribution and the state row is the crossing count. Nothing in this file * ever sums counties into a headline, and `statewideHeadline` reads the state * row or returns `null`. * * ### The state row has no coordinate, and it is enormous * * `counties` in the store is 59 rows, not 58: fifty-eight counties plus `US-CA`, * `kind='state'`, with **NULL lat and lon** and, in tonight's granule, 793,141 * birds aloft against the largest county's 82,549. A per-county path that does * not take it out places a 793,141-bird blob at 0,0 in the Gulf of Guinea — or, * worse, at whatever a `?? 0` turns it into. It is excluded here on three * independent grounds, because one of them is the one that will be missed. * * Pure: this imports nothing but types, touches no DOM and constructs no mesh. * The gate is a statement about data, and a statement about data that lives * inside a mesh builder is a statement nobody can test without a WebGL context. */ import type { MigrationCounty, MigrationField, MigrationNight, MigrationQuiet } from "../engine/types.ts"; import type { BirdsBody, BirdsSourceId, WireBirdCounty } from "./wire.ts"; // ---- The ladder ----------------------------------------------------------- /** * The id of the row that is a state and not a county. * * Named rather than inlined because it is checked in more than one place and * because the *reason* it is checked has to travel with it. See the header. */ export const BIRDS_STATE_ROW_ID = "US-CA"; /** * Solar elevation above which nothing may be drawn, in degrees. * * Civil twilight. BirdCast's product is nocturnal, so a daytime row is not a * small measurement — it is a measurement of something the instrument does not * measure, and 104 of them exist in the store. This is what refuses them, and it * refuses them whatever the feed says, because being wrong about the sun is not * a thing this build is prepared to be. */ export const BIRDS_MAX_SOLAR_ELEVATION_DEG = -6; /** * Birds aloft below which a county is not drawn at all. * * A judgement, and a small one: BirdCast reports continuous fields, so a county * with four birds over it is a rounding artefact of a forecast raster rather * than four birds. Set low enough that a genuinely quiet county still shows. */ export const BIRDS_MIN_ALOFT = 25; /** The most counties one board will draw. Fifty-eight exist; this bounds a bad day. */ export const BIRDS_COUNTY_LIMIT = 64; // ---- Shapes --------------------------------------------------------------- /** Everything a board needs to draw migration, and to explain an empty sky. */ export interface BirdsPromotion { source: BirdsSourceId; /** 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; /** Handed straight to `MigrationLayer.setField`. `null` only when nothing answered. */ field: MigrationField | null; /** Rows the gate refused — the state row, and any county under the floor. */ suppressed: number; /** The panel's sentence. Never blank, and never "no data". */ message: string; } export interface BirdsPromoteOptions { nowMs?: number; /** * The sun's elevation over California, degrees. The same number `Atmosphere` * is working from, injected rather than recomputed — a second opinion about * where the sun is, taken from a clock the scrubber does not own, is how a * night board and a night layer end up disagreeing. */ solarElevationDeg?: number; } /** The answer for a board with no feed behind it at all. */ export function emptyBirdsPromotion(): BirdsPromotion { return { source: "none", fetchedAt: new Date(0).toISOString(), ageMs: null, field: null, suppressed: 0, message: "No migration feed is configured, so nothing is drawn over this board. " + "That is a fact about this box, not about the sky.", }; } // ---- Exclusion ------------------------------------------------------------ /** * Is this row a county, or is it the state pretending to be one? * * Three independent tests, and they are three rather than one because the one * that gets missed is never the one you thought of. The `US-CA` row fails all * three today; a future `US-CA-REGION-N` row with real coordinates would fail * only the first, and a county whose Census join silently produced nulls would * fail only the second. */ export function isCountyRow(row: WireBirdCounty | null | undefined): boolean { if (row === null || row === undefined || typeof row !== "object") return false; if (typeof row.id === "string" && row.id.trim().toUpperCase() === BIRDS_STATE_ROW_ID) return false; if (finite(row.lat) === null || finite(row.lon) === null) return false; if ((finite(row.areaKm2) ?? 0) <= 0) return false; return true; } /** * Every county row, in the order they arrived, with the state row and anything * unplaceable taken out. * * Exported so that `birdsGate.test.ts` can assert the exclusion directly rather * than inferring it from a rendered field, and so `server/src/birds/index.ts` * can apply the same rule on the way out. */ export function countyRows(rows: readonly WireBirdCounty[]): WireBirdCounty[] { return rows.filter((row) => isCountyRow(row)); } // ---- The headline --------------------------------------------------------- /** * Last night, from the state row and from nowhere else. * * `null` when the body carries no state row, which is the honest answer for a * box that has been up for less than one night. It is never reconstructed by * summing counties; see the header. */ export function statewideHeadline(body: BirdsBody | null | undefined): MigrationNight | null { const raw = body?.statewide; if (raw === null || raw === undefined || typeof raw !== "object") return null; const crossed = finite(raw.crossed); const peakAloft = finite(raw.peakAloft); if (crossed === null || peakAloft === null) return null; return { crossed, peakAloft, peakAt: typeof raw.peakAt === "string" ? raw.peakAt : "", meanAltitude: finite(raw.meanAltitude) ?? 0, heading: typeof raw.heading === "string" && raw.heading !== "" ? raw.heading : "", }; } /** * A bearing as the words a caption uses. `130.3` is "south-east". * * Sixteen points would be more precise than the number deserves: the state's own * mean direction is a circular mean over thousands of ten-minute county rows, and * "east-south-east" claims a resolution that mean does not carry. */ export function headingWords(degrees: number): string { if (!Number.isFinite(degrees)) return ""; const points = [ "north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west", ]; const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8; return points[index] as string; } /** * An ISO instant in California's own clock, as `23:20 PDT`. * * `Intl` rather than a fixed offset, because California is UTC-7 for most of the * year and UTC-8 for the rest, and a caption that says PDT in January is the * kind of wrong nobody notices for months. Falls back to the UTC form where * `Intl` has no time-zone data at all, which is a real configuration of Node. */ export function californiaClock(iso: string): string { const ms = Date.parse(iso); if (!Number.isFinite(ms)) return ""; try { return new Intl.DateTimeFormat("en-GB", { timeZone: "America/Los_Angeles", hour: "2-digit", minute: "2-digit", hour12: false, timeZoneName: "short", }).format(new Date(ms)); } catch { return `${new Date(ms).toISOString().slice(11, 16)} UTC`; } } /** * The empty sky's sentence. * * Always says three things: that nothing is aloft, *why* — which is a fact about * the instrument and not about the birds — and what last night did, so that a * viewer who arrives at noon still learns something true. Only the third part is * conditional, and it is missing only on a box that has not yet seen a night. */ export function quietMessage( reason: MigrationQuiet["reason"], statewide: MigrationNight | null, ): string { const why = reason === "daylight" ? "Nothing is aloft. BirdCast measures migration only after dark." : reason === "off-season" ? "Nothing is aloft over California tonight. The radars are reporting and every county is quiet." : "Nothing has answered for the sky over California yet."; if (statewide === null) return why; const heading = statewide.heading === "" ? "" : ` heading ${statewide.heading}`; const peak = statewide.peakAt === "" ? "" : ` at ${californiaClock(statewide.peakAt)}`; const altitude = statewide.meanAltitude > 0 ? `, at a mean ${Math.round(statewide.meanAltitude)} metres` : ""; return ( `${why} Last night ${count(statewide.crossed)} birds crossed California${heading}, ` + `peaking at ${count(statewide.peakAloft)} aloft${peak}${altitude}.` ); } // ---- The gate ------------------------------------------------------------- /** * Apply the ladder to one body. * * Pure and total: `null`, a malformed body, or a body from a server one version * behind all produce an honest empty sky rather than an exception. The consumer * is a render loop. * * **Daylight wins over the feed.** If the sun is up the answer is `daylight` * whatever arrived, because a daytime BirdCast row is a measurement of something * the instrument does not measure. */ export function promoteBirds( body: BirdsBody | null | undefined, options: BirdsPromoteOptions = {}, ): BirdsPromotion { const nowMs = options.nowMs ?? Date.now(); const empty = emptyBirdsPromotion(); if (body === null || body === undefined || typeof body !== "object") return empty; const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt; const fetchedMs = Date.parse(fetchedAt); const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null; const source: BirdsSourceId = body.source === "cloud1" ? "cloud1" : "none"; const observedAt = typeof body.observedAt === "string" ? body.observedAt : ""; const statewide = statewideHeadline(body); if (ageMs === null) { // Nothing has ever answered. Not "the sky is empty" — "nobody has spoken". return { ...empty, source, fetchedAt }; } const rows = Array.isArray(body.counties) ? body.counties : []; const placed = countyRows(rows); const counties: MigrationCounty[] = []; for (const row of placed) { const aloft = finite(row.aloft) ?? 0; if (aloft < BIRDS_MIN_ALOFT) continue; counties.push({ id: typeof row.id === "string" ? row.id : "", name: typeof row.name === "string" ? row.name : "", lat: finite(row.lat) as number, lng: finite(row.lon) as number, areaKm2: finite(row.areaKm2) as number, aloft, altitude: Math.max(0, finite(row.altitude) ?? 0), direction: finite(row.direction) ?? 0, speed: Math.max(0, finite(row.speed) ?? 0), }); } counties.sort((a, b) => b.aloft - a.aloft); const drawn = counties.slice(0, BIRDS_COUNTY_LIMIT); const suppressed = rows.length - drawn.length; const sun = options.solarElevationDeg; const daylight = typeof sun === "number" && Number.isFinite(sun) && sun > BIRDS_MAX_SOLAR_ELEVATION_DEG; let quiet: MigrationQuiet | null = null; if (daylight) { quiet = { reason: "daylight", message: quietMessage("daylight", statewide) }; } else if (drawn.length === 0) { // Dark, the feed answered, and every county is quiet. Reported as the // seasonal case rather than as a fault, because that is what it is: the // store holds two nights and no year of history, so "off-season" is the // honest name for "dark and nothing flying" until there is one. const reason: MigrationQuiet["reason"] = body.quiet?.reason === "no-data" ? "no-data" : "off-season"; quiet = { reason, message: quietMessage(reason, statewide) }; } const field: MigrationField = { counties: quiet === null ? drawn : [], observedAt, statewide, quiet, }; return { source, fetchedAt, ageMs, field, suppressed: quiet === null ? Math.max(0, suppressed) : rows.length, message: quiet !== null ? quiet.message : busyMessage(drawn, statewide, observedAt), }; } /** * The sentence for a night with something in it. * * Note what it does **not** say: how many birds are over California right now. * The only way to get that from what crosses the wire is to add the counties up, * and that is the six-times error. The state row's instantaneous figure is not on * `MigrationNight`, so the honest headline available here is last night's * crossing count, and the present tense is spent on where and how high instead. */ function busyMessage( counties: readonly MigrationCounty[], statewide: MigrationNight | null, observedAt: string, ): string { const mean = counties.length === 0 ? 0 : counties.reduce((sum, c) => sum + c.altitude * c.aloft, 0) / Math.max(1, counties.reduce((sum, c) => sum + c.aloft, 0)); const bearing = counties.length === 0 ? "" : headingWords(circularMean(counties)); const when = observedAt === "" ? "" : ` As of ${californiaClock(observedAt)}.`; const last = statewide === null ? "" : ` Last night ${count(statewide.crossed)} crossed the state.`; return ( `Birds are aloft over ${counties.length} ` + `${counties.length === 1 ? "county" : "counties"}` + `${bearing === "" ? "" : `, drifting ${bearing}`}` + `${mean > 0 ? `, a mean ${Math.round(mean)} metres up` : ""}.` + `${last}${when}` ); } /** * The mean direction of travel, weighted by birds aloft — **circularly**. * * An arithmetic mean of bearings is wrong at the wrap: 350 and 10 average to 180 * and point the whole state due south when it is flying due north. Tonight's * rows sit in the 120-150 degree bucket and would survive a naive mean, which is * exactly why this would ship broken and stay broken. */ function circularMean(counties: readonly MigrationCounty[]): number { let x = 0; let y = 0; for (const county of counties) { const weight = Math.max(0, county.aloft); const radians = (county.direction * Math.PI) / 180; x += weight * Math.cos(radians); y += weight * Math.sin(radians); } if (x === 0 && y === 0) return 0; return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360; } // ---- Small helpers -------------------------------------------------------- function count(value: number): string { return Math.round(value).toLocaleString("en-US"); } function finite(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; }