1
0

feat: the state becomes California, and the port fills with ships

**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.

**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.

The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.

**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.

`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.

**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.

At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.

**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.

"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.

**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."

Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.

Tests 1,340 -> 1,540, server 280 -> 295.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 23:35:09 -07:00
parent b25f217e3e
commit acf4d1a510
52 changed files with 14436 additions and 142 deletions
+400
View File
@@ -0,0 +1,400 @@
/**
* 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;
}
+735
View File
@@ -0,0 +1,735 @@
/**
* Which reflectivity may be drawn — and, on nine days in ten, that none may.
*
* The radar layer has the fire layer's failure mode waiting for it, and the
* evidence is already in the repo: DATA-CATALOG.md:84 records a **54.5 dBZ cell
* between Long Beach and Catalina on a clear day**. Tonight's own frame carries
* 70.5 dBZ over the Catalina channel and thirteen cells at or above 40 dBZ
* strung down the Pacific between 32.6 N and 35.6 N, none of them touching land.
* A thunderstorm painted over the ocean on a blue afternoon spends the board's
* credibility exactly the way twenty-two orange marks over Los Angeles would
* have, and there is no polish that buys it back.
*
* So this file decides three things before any mesh builder sees a cell, and it
* decides them where they can be tested without a GL context — the argument
* `src/engine/fires.ts:16-23` makes, applied to the sky.
*
* ### 1. Anomalous propagation is dropped
*
* A superrefracting marine inversion bends the beam down into the sea surface
* and the radar reports the swell as rain. It is the classic Southern
* California artefact, the collector applies no filter to it, and it shows up
* as *strong* returns — which is what makes it dangerous rather than merely
* untidy.
*
* The rule is deliberately narrow, and it works on **connected blobs rather than
* on single cells**, which is the one thing about it worth arguing over. An
* eight-connected run of echo is refused only when *no cell in the whole run*
* sits over land, its strongest cell reaches `AP_MIN_DBZ`, and it is no larger
* than `AP_MAX_CELLS`. Rain coming ashore is one blob with cells on both sides
* of the shoreline and survives entire; a 47.5 dBZ patch sitting alone off Big
* Sur does not. Weak marine echo is left alone completely, because drizzle under
* a stratus deck offshore is real and is the commonest thing the Pacific has to
* say.
*
* **A per-cell version of this rule was written first and measured wrong.** On
* tonight's frame it refused twelve cells, and three of them — 34.375,-121.125
* at 55 dBZ among them — were the seaward fringe of a fifty-four-cell system
* whose other end is over Santa Barbara. Deleting them punches holes in the
* middle of a real storm, which is a different lie from the one this file
* exists to prevent but is still a lie. Decomposed into blobs, tonight's frame
* has twenty-three of them and exactly **one** is offshore-only: nine cells
* peaking at 38.5 dBZ, which is under the threshold and is therefore kept, and
* which is the right answer for marine drizzle west of Point Arguello.
*
* ### 2. "Not California" is not "the sea", and this nearly went wrong
*
* The obvious land test is "inside the pack's coastline polygon". Measured
* against tonight's frame on the **extended** board, that test calls
* `41.375,-117.125` at 53 dBZ sea clutter. It is in Nevada. The board now
* reaches -114.0 and 42.05 N, so a third of it is land the California polygon
* has never traced, and a rule written for the cropped board would have deleted
* a Great Basin thunderstorm as an ocean artefact.
*
* Hence three classes, not two: `land` (inside the trace), `sea` (outside it
* *and west of the coast at that latitude*), and `unmapped` — Nevada, Oregon,
* Arizona, and the inside of San Francisco Bay. Only `sea` is ever refused.
*
* ### 3. A radar that is down leaves a hole, and a hole is not dry weather
*
* `null` in `RadarField.dbz` means *the radar that should be looking there is
* off the air*. A cell inside a dead station's ring that no working station
* covers is `null`; everything else is `RADAR_DRY_DBZ`. Those are different
* claims and the type is nullable so they can stay different.
*
* It is claimed **narrowly**, and the first draft claimed it too widely. Marking
* every cell outside the range of all sixteen known stations put a pale wash
* over 201 of 1,596 cells — the far Pacific corner and the Nevada line — which
* looked like weather in the frame and was a claim made from a partial list: the
* table is a regional subset of a national composite that every WSR-88D in the
* country feeds. The picture found that; the tests could not have.
*
* **"Not on-line" is not "down", and this is the trap in the operability
* column.** Of sixteen stations right now, six read `RDA - Maintenance Action
* Mandatory` — KVTX (Los Angeles), KNKX (San Diego), KDAX (Sacramento), KBBX,
* KYUX and KLRX. Every one of them is transmitting; the flag is a work order.
* Treating "anything but on-line" as down would blank the southern half of the
* state as unknown on a night with a monsoon over it. Only `Inoperable`,
* `Off-line` and `Shutdown` mean no beam, and `stationIsDown` says so in one
* regular expression rather than in a policy spread over a service.
*
* ### Promotion is decided from coverage, not from cell count
*
* A cell count is a fact about how much of the lattice the board happens to
* contain, and the board's bounds changed *this round*: a threshold tuned
* against the cropped board would have silently changed meaning under it.
* `wetFraction` — the share of California at or above the rain threshold, as
* measured upstream over the whole state's pixels — is scale-free and survives
* the next board too.
*
* Everything here is pure and imports nothing but types. It is imported by the
* browser *and* by `server/src/radar/index.ts`, which is how one statement about
* the data serves both ends of the wire.
*/
import type { RadarField } from "../engine/types.ts";
import type { RadarBody, RadarSourceId } from "./wire.ts";
// ---- The ladder -----------------------------------------------------------
/**
* At and above this, an isolated marine cell is treated as anomalous
* propagation rather than as weather.
*
* Forty dBZ is heavy rain — 12 mm an hour and up. Real convection that strong
* over open water does happen, and when it does it is almost never detached
* from everything else on the map, which is why the isolation test carries as
* much weight here as the threshold does. Below forty nothing is refused at
* all: marine stratus drizzle is genuine and is most of what the Pacific ever
* shows.
*/
export const AP_MIN_DBZ = 40;
/**
* The largest offshore-only blob this will refuse.
*
* **An untested judgement, and it is worth saying so out loud** — the same
* posture `FIRE_TIER_MIN_ACRES` takes, for the same reason. Anomalous
* propagation is a near-field artefact of one radar's clutter ring, so it comes
* in patches; a winter frontal band arriving off the Pacific is enormous and is
* in any case joined to the coastal echo long before its core reaches 40 dBZ.
* Twenty-four cells is about 18,000 km², roughly one thunderstorm's rain shield,
* and it is a cap on how much weather one bad night can delete rather than a
* claim about clutter. The store has never yet held an offshore-only blob above
* the threshold at all, so this boundary has never been exercised against the
* case it exists for. If it bites, it is one constant — and `RadarFieldBuild`
* carries every refused cell so the number is visible rather than silent.
*/
export const AP_MAX_CELLS = 24;
/**
* The lowest reflectivity this build treats as precipitation, in dBZ.
*
* 20 dBZ is the conventional rain threshold and it is also where the upstream
* collector cuts — every one of the 8,782 rows in the store is at or above it —
* so it is a fact about the data rather than a judgement made here. It lives in
* the gate rather than beside the colour ramp because it is a statement about
* water, not about pixels; `assets/radarRamp.ts` and `engine/precip.ts` both
* read it from here so that the threshold cannot disagree with itself.
*/
export const RADAR_RAIN_DBZ = 20;
/**
* The share of California that must be raining before the sheet is drawn.
*
* A tenth of a percent is about 420 km² — one thunderstorm's rain shield.
* Tonight's quietest frame measured 0.472% and the twenty-six stored frames run
* 0.472% to 1.95%, so this floor is five times below anything real that has been
* seen; what it refuses is a frame whose entire content was clutter the rule
* above has already taken out. Below it the honest picture is an empty sky and a
* sentence, not sixteen hundred texels at two percent opacity.
*/
export const RADAR_MIN_WET_FRACTION = 0.001;
/**
* What a working radar reports for a cell it can see nothing in.
*
* Not `null`, which is reserved for "nobody is looking". The upstream collector
* thresholds at 20 dBZ and writes no row below it, so the honest statement about
* a covered, empty cell is "under twenty", and this is the number that stands
* for it. Anything below `RADAR_RAIN_DBZ` draws at zero alpha, so the value
* itself is never seen — only the difference between it and `null` is.
*/
export const RADAR_DRY_DBZ = 0;
/**
* Unambiguous reach of one radar, in kilometres, by station type.
*
* 230 km is the WSR-88D's base reflectivity range; the TDWR is a terminal radar
* and reaches about 90. Used only to answer "is anybody looking at this cell",
* so an error here costs a hole in the wrong place rather than a wrong echo.
*/
export const RADAR_RANGE_KM: Readonly<Record<string, number>> = {
"WSR-88D": 230,
TDWR: 90,
};
const DEFAULT_RANGE_KM = 230;
/** The lattice pitch of the NEXRAD composite the collector reduces to. Degrees. */
export const RADAR_CELL_DEG = 0.25;
/**
* The operability strings that mean the beam is off.
*
* Read the header before widening this. `Maintenance Action Mandatory` and
* `Maintenance Action Required` are work orders on a radar that is transmitting,
* and six of sixteen stations carry one right now.
*/
const RDA_DOWN = /\b(inoperable|off-?line|shut-?down|shutdown)\b/i;
// ---- Shapes ---------------------------------------------------------------
/** A rectangle in degrees. Restated rather than imported; see `fires.ts`. */
export interface RadarBounds {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
/**
* A vertex of a coastline trace: `[lat, lng]`.
*
* Structurally `LatLng` from `engine/types.ts`, restated as a tuple rather than
* imported so this module stays importable by a test with no renderer in it.
* The tuple, and not an object, because a city pack's `landmasses` is passed
* straight in and a shape mismatch here reads as "every cell is inland" — which
* is silent, and which cost the first draft of this file an hour.
*/
export type RadarPoint = readonly [number, number];
/** One reduced cell as the collector writes it: a centre and a maximum. */
export interface RadarCell {
lat: number;
lon: number;
dbz: number;
}
/**
* One radar and whether it is producing.
*
* `operability` is carried verbatim rather than pre-judged upstream, for the
* same reason `FireDetection.confidence` is: the branch belongs in one place
* where the argument for it can be read, and MODIS/VIIRS taught this repo what
* happens when it is not.
*/
export interface RadarStation {
id: string;
lat: number;
lon: number;
/** `WSR-88D`, `TDWR`, or whatever the upstream said. */
type?: string | null;
operability?: string | null;
}
/** Where a cell centre sits, as far as the coastline trace can tell. */
export type CellGround = "land" | "sea" | "unmapped";
/** What `buildRadarField` decided, including what it refused. */
export interface RadarFieldBuild {
/** The lattice, or `null` when nothing was promoted. */
field: RadarField | null;
/** Cells the anomalous-propagation rule refused. Kept, never silently dropped. */
suppressed: RadarCell[];
/** Lattice cells no operating radar covers. */
unknown: number;
/** Lattice cells at or above `RADAR_RAIN_DBZ` after the gate. */
wetCells: number;
/** The strongest surviving cell, and where it was. */
peak: { dbz: number; lat: number; lng: number } | null;
/** Why nothing was promoted, when nothing was. `null` when the field is drawn. */
quiet: "no-coverage" | "below-threshold" | "nothing-survived" | null;
}
/** Everything a board needs to draw rain, and to explain an empty sky. */
export interface RadarPromotion {
source: RadarSourceId;
/** 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 `PrecipLayer.setField`. `null` draws nothing at all. */
field: RadarField | null;
/** Statewide rain coverage, 0..1, as measured upstream over the whole state. */
wetFraction: number;
stations: number;
stationsDown: number;
/** One true, specific sentence. Never blank, and never "no data". */
message: string;
}
/** The answer for a board with no feed behind it at all. */
export function emptyRadarPromotion(): RadarPromotion {
return {
source: "none",
fetchedAt: new Date(0).toISOString(),
ageMs: null,
field: null,
wetFraction: 0,
stations: 0,
stationsDown: 0,
message:
"No radar feed is configured, so this board draws no weather. " +
"That is a fact about this box, not about the sky.",
};
}
// ---- Land, sea, and the third thing ---------------------------------------
/**
* Even-odd ray cast. A copy of `World.pointInPolygon`, because this module must
* be importable by a test with no renderer in it and by the server, and the
* engine's copy arrives with three.js attached.
*/
export function insidePolygon(lat: number, lng: number, poly: readonly RadarPoint[]): boolean {
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
if (a === undefined || b === undefined) continue;
const [latI, lngI] = a;
const [latJ, lngJ] = b;
if (latI > lat === latJ > lat) continue;
const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
if (lng < crossing) inside = !inside;
}
return inside;
}
/**
* Where the Pacific shore sits at one latitude: the westernmost point at which
* the coastline trace crosses that parallel.
*
* **Interpolated across the crossing edge, not taken from the nearest vertex.**
* The first draft took the westernmost *vertex* within half a degree, and on a
* coast that runs diagonally that is wrong by the whole diagonal: between 35.1 N
* and 36.1 N the shore moves from -120.7 to -121.9, so a band rule put the sea
* boundary a degree too far west and quietly declared the Big Sur clutter to be
* inland. Crossings are exact and cost the same.
*
* `null` where the trace says nothing about that latitude at all, which is the
* conservative answer: with no coast to be west of, nothing is called sea and
* nothing is refused.
*/
export function coastLongitudeAt(
lat: number,
coast: readonly (readonly RadarPoint[])[],
): number | null {
let west: number | null = null;
for (const poly of coast) {
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i];
const b = poly[j];
if (a === undefined || b === undefined) continue;
const [latI, lngI] = a;
const [latJ, lngJ] = b;
if (latI > lat === latJ > lat) continue;
const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
if (west === null || crossing < west) west = crossing;
}
}
return west;
}
/**
* Land, open sea, or somewhere this board's coastline has no opinion about.
*
* The third class is the whole point — see the header. Nevada is not the
* Pacific, and on the extended board there is a great deal of Nevada.
*/
export function classifyGround(
lat: number,
lng: number,
coast: readonly (readonly RadarPoint[])[],
): CellGround {
for (const poly of coast) {
if (insidePolygon(lat, lng, poly)) return "land";
}
const west = coastLongitudeAt(lat, coast);
if (west !== null && lng < west) return "sea";
return "unmapped";
}
// ---- Stations -------------------------------------------------------------
/**
* Whether this radar's beam is off.
*
* See the header: six of sixteen stations read `Maintenance Action Mandatory`
* right now and every one of them is transmitting. An absent or empty string is
* *unknown*, and unknown is not down — a station this box has never heard a
* status for is assumed to be working, because the alternative is a board that
* greys out whenever the status feed hiccups.
*/
export function stationIsDown(operability: string | null | undefined): boolean {
if (typeof operability !== "string") return false;
return RDA_DOWN.test(operability);
}
/** Unambiguous reach of one station, in kilometres. */
export function stationRangeKm(station: RadarStation): number {
const type = typeof station.type === "string" ? station.type.trim() : "";
return RADAR_RANGE_KM[type] ?? DEFAULT_RANGE_KM;
}
/**
* Great-circle distance in kilometres. Haversine, on a spherical earth.
*
* Good to a few parts in a thousand at these ranges, against a 230 km radius
* that is itself a round number, so the error is three orders of magnitude below
* the thing being decided.
*/
export function distanceKm(
aLat: number,
aLng: number,
bLat: number,
bLng: number,
): number {
const R = 6371.0088;
const dLat = ((bLat - aLat) * Math.PI) / 180;
const dLng = ((bLng - aLng) * Math.PI) / 180;
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos((aLat * Math.PI) / 180) *
Math.cos((bLat * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
// ---- Building the lattice -------------------------------------------------
export interface RadarFieldInput {
cells: readonly RadarCell[];
stations: readonly RadarStation[];
bounds: RadarBounds;
/** The board's own coastline traces. `city.landmasses`, passed rather than reached for. */
coast: readonly (readonly RadarPoint[])[];
/** ISO-8601 of the volume scan. */
observedAt: string;
/** Statewide rain coverage 0..1, measured upstream over the whole state's pixels. */
wetFraction: number;
cellDeg?: number;
}
/**
* Turn a bag of cells into the lattice the layer draws, or into nothing.
*
* Pure and total. Called on **both** sides of the wire — by
* `server/src/radar/index.ts` when it builds a `RadarBody`, and by a test — so
* that the statement about the data is made once.
*/
export function buildRadarField(input: RadarFieldInput): RadarFieldBuild {
const cellDeg = input.cellDeg ?? RADAR_CELL_DEG;
const bounds = input.bounds;
const wetFraction = finite(input.wetFraction) ?? 0;
const stations = input.stations.filter(
(s) => finite(s.lat) !== null && finite(s.lon) !== null,
);
const down = stations.filter((s) => stationIsDown(s.operability));
const up = stations.filter((s) => !stationIsDown(s.operability));
// The lattice is the product's own grid clipped to the board, so a texel is a
// cell and never a resampling of one. Centres sit on the half-cell.
const minLat = Math.ceil((bounds.minLat - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2;
const minLng = Math.ceil((bounds.minLng - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2;
const rows = Math.floor((bounds.maxLat - minLat) / cellDeg) + 1;
const cols = Math.floor((bounds.maxLng - minLng) / cellDeg) + 1;
if (rows <= 0 || cols <= 0) {
return { field: null, suppressed: [], unknown: 0, wetCells: 0, peak: null, quiet: "no-coverage" };
}
const index = (row: number, col: number) => row * cols + col;
const rowOf = (lat: number) => Math.round((lat - minLat) / cellDeg);
const colOf = (lng: number) => Math.round((lng - minLng) / cellDeg);
// Ground class per lattice cell, computed once. The AP rule needs a cell's
// neighbours' classes, and re-testing a 135-point polygon eight times per cell
// would be eight times the work for the same answer.
const ground: CellGround[] = new Array<CellGround>(rows * cols);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
ground[index(r, c)] = classifyGround(minLat + r * cellDeg, minLng + c * cellDeg, input.coast);
}
}
// Echo, keyed by lattice slot. A cell arriving twice keeps the stronger
// return, which is what the collector's own "max within the cell" means.
const echo = new Map<number, RadarCell>();
for (const cell of input.cells) {
const lat = finite(cell?.lat);
const lon = finite(cell?.lon);
const dbz = finite(cell?.dbz);
if (lat === null || lon === null || dbz === null) continue;
const r = rowOf(lat);
const c = colOf(lon);
if (r < 0 || r >= rows || c < 0 || c >= cols) continue;
const slot = index(r, c);
const held = echo.get(slot);
if (held === undefined || dbz > held.dbz) echo.set(slot, { lat, lon, dbz });
}
// Anomalous propagation. Refused by CONNECTED BLOB, not cell by cell, and the
// difference is the whole rule — see `AP_MAX_CELLS` and the header.
const suppressed: RadarCell[] = [];
{
const slots = [...echo.keys()];
const visited = new Set<number>();
for (const start of slots) {
if (visited.has(start)) continue;
const blob: number[] = [];
const stack = [start];
visited.add(start);
let touchesLand = false;
let peakDbz = -Infinity;
while (stack.length > 0) {
const slot = stack.pop() as number;
blob.push(slot);
if (ground[slot] !== "sea") touchesLand = true;
const cell = echo.get(slot);
if (cell !== undefined && cell.dbz > peakDbz) peakDbz = cell.dbz;
const r = Math.floor(slot / cols);
const c = slot % cols;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const neighbour = index(nr, nc);
if (!echo.has(neighbour) || visited.has(neighbour)) continue;
visited.add(neighbour);
stack.push(neighbour);
}
}
}
if (touchesLand) continue;
if (peakDbz < AP_MIN_DBZ) continue;
if (blob.length > AP_MAX_CELLS) continue;
for (const slot of blob) {
const cell = echo.get(slot);
if (cell !== undefined) suppressed.push(cell);
echo.delete(slot);
}
}
}
// Fill. `null` is "no operating radar covers this", which is a different
// statement from "a radar looked and saw nothing" and has its own value.
const dbz: (number | null)[] = new Array<number | null>(rows * cols);
let unknown = 0;
let wetCells = 0;
let peak: { dbz: number; lat: number; lng: number } | null = null;
for (let r = 0; r < rows; r++) {
const lat = minLat + r * cellDeg;
for (let c = 0; c < cols; c++) {
const slot = index(r, c);
const lng = minLng + c * cellDeg;
const hit = echo.get(slot);
if (hit !== undefined) {
dbz[slot] = hit.dbz;
if (hit.dbz >= RADAR_RAIN_DBZ) wetCells += 1;
if (peak === null || hit.dbz > peak.dbz) peak = { dbz: hit.dbz, lat, lng };
continue;
}
// `null` is claimed narrowly, and the reason is worth reading. The
// station table this box is handed is a REGIONAL subset of the national
// composite's contributors — sixteen radars around California, against a
// product every WSR-88D in the country feeds. So "outside the range of
// all sixteen" is not "unobserved", and painting the far Pacific corner
// and the Nevada line as unknown on that basis would be a claim made from
// a partial list. What this box *can* say is that a radar it has a status
// for is off the air and nothing it knows of covers the hole.
const covered = up.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s));
const blind = !covered && down.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s));
if (!blind) {
dbz[slot] = RADAR_DRY_DBZ;
continue;
}
dbz[slot] = null;
unknown += 1;
}
}
const quiet: RadarFieldBuild["quiet"] =
wetCells === 0
? "nothing-survived"
: wetFraction < RADAR_MIN_WET_FRACTION
? "below-threshold"
: null;
if (quiet !== null) {
return { field: null, suppressed, unknown, wetCells, peak, quiet };
}
return {
field: {
minLat,
minLng,
cellLat: cellDeg,
cellLng: cellDeg,
rows,
cols,
dbz,
observedAt: input.observedAt,
wetFraction,
stations: stations.length,
stationsDown: down.length,
},
suppressed,
unknown,
wetCells,
peak,
quiet: null,
};
}
// ---- The client gate ------------------------------------------------------
/**
* Read one `RadarBody` and decide what this board draws.
*
* Pure and total: a malformed body, a body from a server one version behind, or
* `null` all produce an honest empty sky rather than an exception. The consumer
* is a render loop.
*/
export function promoteRadar(
body: RadarBody | null | undefined,
nowMs: number = Date.now(),
): RadarPromotion {
const empty = emptyRadarPromotion();
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: RadarSourceId = body.source === "cloud1" ? "cloud1" : "none";
const field = readField(body.field);
const wetFraction = field?.wetFraction ?? 0;
const stations = field?.stations ?? 0;
const stationsDown = field?.stationsDown ?? 0;
if (field === null) {
return {
source,
fetchedAt,
ageMs,
field: null,
wetFraction,
stations,
stationsDown,
message:
ageMs === null
? empty.message
: `Nothing is falling on this board. ${stationLine(stations, stationsDown)} ` +
`Last scan ${age(ageMs)}.`,
};
}
let wet = 0;
let unknown = 0;
let peak = -Infinity;
for (const value of field.dbz) {
if (value === null) {
unknown += 1;
continue;
}
if (value >= RADAR_RAIN_DBZ) wet += 1;
if (value > peak) peak = value;
}
return {
source,
fetchedAt,
ageMs,
field,
wetFraction,
stations,
stationsDown,
message:
`Rain over ${(wetFraction * 100).toFixed(2)}% of California — ` +
`${wet} cell${wet === 1 ? "" : "s"} of ${field.rows * field.cols}, ` +
`strongest ${peak.toFixed(1)} dBZ. ` +
`${stationLine(stations, stationsDown)}` +
(unknown > 0 ? ` ${unknown} cells have no radar over them. ` : " ") +
`Scan ${field.observedAt}${ageMs === null ? "" : `, ${age(ageMs)}`}.`,
};
}
// ---- Small helpers --------------------------------------------------------
function stationLine(stations: number, down: number): string {
if (stations === 0) return "No radar has reported.";
const up = Math.max(0, stations - down);
return down === 0
? `All ${stations} radars reporting.`
: `${up} of ${stations} radars reporting, ${down} off the air.`;
}
function age(ms: number): string {
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "under a minute old";
if (minutes < 120) return `${minutes} minute${minutes === 1 ? "" : "s"} old`;
const hours = Math.round(minutes / 60);
return `${hours} hour${hours === 1 ? "" : "s"} old`;
}
/**
* Read the wire's lattice, or refuse it.
*
* A `dbz` array whose length disagrees with `rows * cols` is not a field with a
* problem, it is a field that would be drawn *rotated* — every row after the
* first offset by the difference. Refused whole rather than padded.
*/
function readField(raw: RadarBody["field"] | undefined): RadarField | null {
if (raw === null || raw === undefined || typeof raw !== "object") return null;
const rows = finite(raw.rows);
const cols = finite(raw.cols);
const minLat = finite(raw.minLat);
const minLng = finite(raw.minLng);
const cellLat = finite(raw.cellLat);
const cellLng = finite(raw.cellLng);
if (rows === null || cols === null || rows <= 0 || cols <= 0) return null;
if (minLat === null || minLng === null || cellLat === null || cellLng === null) return null;
if (cellLat <= 0 || cellLng <= 0) return null;
if (!Array.isArray(raw.dbz) || raw.dbz.length !== rows * cols) return null;
const dbz: (number | null)[] = new Array<number | null>(rows * cols);
for (let i = 0; i < dbz.length; i++) {
dbz[i] = finite(raw.dbz[i]);
}
return {
minLat,
minLng,
cellLat,
cellLng,
rows,
cols,
dbz,
observedAt: typeof raw.observedAt === "string" ? raw.observedAt : "",
wetFraction: clamp01(finite(raw.wetFraction) ?? 0),
stations: Math.max(0, Math.round(finite(raw.stations) ?? 0)),
stationsDown: Math.max(0, Math.round(finite(raw.stationsDown) ?? 0)),
};
}
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function clamp01(value: number): number {
return Math.min(1, Math.max(0, value));
}
+973
View File
@@ -0,0 +1,973 @@
/**
* Which hulls may be drawn, which way they point, and where they are *now*.
*
* This is the vessel feed's whole conscience and, like `server/fires.ts`, it is
* the file with the least code in it. Everything else moves bytes; this decides
* whether a harbour that looks alive is telling the truth.
*
* It is pure, it imports nothing but types, and it touches neither three.js nor
* the DOM. That is not tidiness: every claim below is a claim about *data*, and
* a claim about data that lives inside a mesh builder is a claim nobody can test
* without a WebGL context. `src/test/integration/barrel.test.ts` asserts the no-
* three half of that out loud.
*
* ### Three AIS sentinels, none of them NULL
*
* AIS encodes "not available" as perfectly valid numbers, in band, in the same
* column as the real ones:
*
* | field | sentinel | how often, in the store behind this |
* |---------|----------|-------------------------------------|
* | `sog` | 102.3 kn | 7 of 1,138 rows — and the only sog >= 40 |
* | `heading`| 511 | 450 of 1,138 rows — **40%** |
* | `cog` | 360.0 | 120 of 1,138 rows |
*
* Zero of the three columns are ever NULL, so a null check catches none of
* them. Two of the three are actively dangerous rather than merely wrong:
*
* 1. **102.3 kn is 52.6 m/s.** Dead-reckoned for sixty seconds that is 3.2 km —
* eight SoCal scene units — so one unstripped row throws a hull across the
* breakwater and out to sea while every test still passes.
* 2. **`cog % 360` is a booby trap and it will look correct.** Real course over
* ground reaches 358.7 (355.0, 355.4, 355.7, 356.3, 356.9, 357.0 and 358.7
* all occur in the store) and the sentinel is exactly 360.0. The obvious
* normalisation therefore turns every unknown course into *due north*: the
* unknown-course fleet quietly lines up facing the same way and nothing in
* the suite notices. `aisCourse` rejects on the exact value and passes 358.7
* through untouched, and `vesselGate.test.ts` asserts both halves — the
* second assertion is the one that matters.
*
* ### Gate motion on speed; label with `nav_status`
*
* Never the reverse. Of 197 vessels reporting `nav_status` 0, "under way using
* engine", **83 are sitting at under half a knot** — 42% disagreement, and
* speed over ground is the truthful one of the pair. So `nav_status` reaches the
* renderer as a word on a card (`VesselStatus`) and never as a gate on the
* dead-reckoner, and a fix that says "under way" at 0.2 kn is stopped.
*
* ### Orientation comes from the berth, because half the fleet has none
*
* Of 150 vessels whose latest fix is under 0.5 kn, only 75 report a real
* heading, 126 a real course, and **21 have neither**. For `nav_status` 5
* (moored) specifically it is 24 of 37. A berthed hull's orientation therefore
* cannot come from the wire for at least half the fleet, and taking it from the
* berth is the only correct answer rather than a shortcut: a ship lying
* alongside a quay points the way the quay does, which is a fact about the
* concrete and is known before any ship arrives.
*
* The ladder in `resolveBearing` is berth, then heading, then course, and the
* berth wins outright when there is one. A hull we can neither orient from the
* quay nor from the wire is **suppressed and counted**, not spun to an invented
* angle — see `VesselPromotion.withoutOrientation`.
*
* ### Dead-reckon along the course; never spline between fixes
*
* Upstream listens for thirty seconds every fifteen minutes, so a hull under way
* has moved about five kilometres between two samples. The chord between two
* fixes is not a path anything took — a ship rounding the breakwater would be
* drawn cutting straight across it — and this is the lesson the aircraft layer
* already paid for: `Aircraft` sat frozen between snapshots for months precisely
* because the wire could not express a velocity. AIS *can*: every fix carries
* `sog` and `cog`, so `reckonVessel` advances along the reported course at the
* reported speed and no code path in this module can produce a point between two
* observations. `vesselGate.test.ts` asserts that as a geometric property rather
* than as an absence of a function.
*
* ### The source is `modelled` this round, and it is anonymous
*
* `modelHarbour` builds a `VesselsBody` from a board's own authored berths and
* channels. It exists because the cloud-1 projection for `sea.sqlite` does not:
* `/api/fires/incidents` answers, `/api/sea`, `/api/vessels` and `/api/ships`
* all 404, and the aisstream licence that would let real positions reach a page
* served `Cache-Control: public` is unread. Building the seam and driving it
* from a deterministic simulator is what lets the layer be finished and honest
* at the same time.
*
* It is called `modelled` rather than `sim` deliberately, and it carries **no
* name, no MMSI, no callsign and no destination**. The store has real ones in it
* right now — the temptation is to hardcode them — and that would be the fire
* layer's twenty-two orange marks in a nicer costume: plausible, specific, and a
* claim about a named commercial vessel behind which this deployment has no
* licensed feed. Identity arrives with a licence entry or it does not arrive.
*/
import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts";
import type { VesselsBody, VesselsSourceId, WireVessel } from "./wire.ts";
// ---- The sentinels --------------------------------------------------------
/**
* Speed over ground, knots, meaning "not available".
*
* The raw AIS field is 1023 in tenths of a knot. It is the only value at or
* above 40 kn anywhere in the store, which is a useful sanity check but not the
* test: a container ship does not do forty knots, and a gate that guessed at a
* plausible ceiling would be a gate with an opinion instead of a fact.
*/
export const AIS_SOG_UNAVAILABLE_KN = 102.3;
/** True heading, degrees, meaning "not available". 40% of fixes carry it. */
export const AIS_HEADING_UNAVAILABLE = 511;
/**
* Course over ground, degrees, meaning "not available".
*
* Exactly 360.0, and exactly why `% 360` must never be applied to this field.
*/
export const AIS_COURSE_UNAVAILABLE = 360;
/** One knot in metres per second. */
export const KNOTS_TO_MPS = 0.514_444;
/**
* The speed below which a hull is stopped, in knots.
*
* Half a knot is drift, moored slack and GPS noise. It is also the threshold the
* store's own numbers are quoted against — 150 of 305 vessels are under it, 554
* of 1,138 individual fixes are *exactly* 0.0 — so using anything else here
* would make every figure in this file's comments unverifiable.
*/
export const VESSEL_MAKING_WAY_KN = 0.5;
/** The same threshold in the units `Vessel.speed` is expressed in. */
export const VESSEL_MAKING_WAY_MPS = VESSEL_MAKING_WAY_KN * KNOTS_TO_MPS;
/**
* How far a hull may be from a berth and still be counted as lying alongside it,
* in metres.
*
* Generous on purpose. An AIS position is reported from the antenna, which on a
* 400 m ship is a couple of hundred metres from either end of it, and a berth is
* one authored point rather than a line. 400 m keeps a ULCV on its own berth
* without reaching across a slip to the next one — the container berths at San
* Pedro are roughly 350-400 m apart along a quay.
*/
export const BERTH_REACH_METRES = 400;
/**
* The furthest a fix may be advanced by dead reckoning, in seconds.
*
* Fifteen minutes and no further, because that is the upstream sample interval:
* past it, the next fix is overdue and the honest picture is a hull that has
* stopped moving rather than one that has sailed a straight line for an hour.
* The same argument `flights.ts` makes for aircraft, at a longer interval.
*/
export const VESSEL_MAX_RECKON_SECONDS = 900;
/**
* How many hulls a board draws at most.
*
* Not a data claim: the LA/LB box holds 81 vessels on a typical latest fix and
* SF Bay 82. This is the ceiling the renderer preallocates against, and it is
* applied here so that the cap is a decision taken over data, in a module a test
* can read, rather than an array length in a mesh builder.
*/
export const VESSEL_DRAW_LIMIT = 192;
const METRES_PER_DEGREE_LAT = 111_320;
const DEG = Math.PI / 180;
// ---- Shapes ---------------------------------------------------------------
/**
* A rectangle in degrees. Structurally the `bounds` a city pack declares,
* restated rather than imported for the same reason `server/fires.ts` restates
* `FireBounds`: a city pack is three thousand lines of coastline that pulls in
* three.js, and this module has to be importable by a test with no renderer.
*/
export interface VesselBounds {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
/**
* The one thing the gate needs from a `Berth`: where it is and which way a hull
* lying on it points.
*
* A structural subset rather than `Berth` itself, so that a caller which has
* berths from somewhere other than a city pack — a test, a fixture, a future
* feed — can answer without inventing a `maxLength`.
*/
export interface BerthAnchor {
id: string;
lat: number;
lng: number;
/** TRUE bearing of the bow, degrees clockwise from north. */
bearing: number;
}
/**
* Everything a board needs to draw ships, and everything it needs to explain a
* harbour with none in it.
*
* The four counts after `drawn` are what make an empty harbour a *finding*
* rather than a blank: "the feed is not configured", "the feed answered and
* nothing is on this board", "eleven hulls answered and every one of them is
* outside the frame" and "nine hulls answered and none of them would say which
* way it was pointing" are four different sentences, and a layer that cannot
* tell them apart is a layer that gets guessed at. This is the same argument
* `FirePromotion.suppressed` makes, and it is the reason the quiet day is the
* case that gets designed first.
*/
export interface VesselPromotion {
source: VesselsSourceId;
/** 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;
/** Seconds between upstream samples. What licenses the dead reckoning. */
intervalSeconds: number;
/** Hulls inside `bounds`, longest first. At most `VESSEL_DRAW_LIMIT`. */
drawn: Vessel[];
/** Hulls that passed the gate but fall outside `bounds`. */
offBoard: number;
/** Rows the gate refused for a bad position, a bad size or a sentinel. */
suppressed: number;
/** Rows refused because neither the quay nor the wire would orient them. */
withoutOrientation: number;
/** How many of `drawn` are making way. The number the wakes are drawn from. */
makingWay: number;
/** How many of `drawn` are lying on an authored berth. */
alongside: number;
}
/** The answer for a board with no feed behind it at all. */
export function emptyVesselPromotion(): VesselPromotion {
return {
source: "none",
fetchedAt: new Date(0).toISOString(),
ageMs: null,
intervalSeconds: 0,
drawn: [],
offBoard: 0,
suppressed: 0,
withoutOrientation: 0,
makingWay: 0,
alongside: 0,
};
}
// ---- The three readers ----------------------------------------------------
//
// One function per AIS field, each total, each returning `null` for "the source
// did not know". They are exported individually because they are the three
// assertions the whole feed rests on and a test that has to reach them through a
// promotion is a test that is really about something else.
/**
* Speed over ground in metres per second, or `null`.
*
* `0` is a real answer and a common one — 554 of 1,138 fixes are exactly zero —
* so this must never conflate "stopped" with "unknown". A stopped ship is drawn;
* an unknown one is not dead-reckoned.
*/
export function aisSpeedMps(sogKnots: number | null | undefined): number | null {
if (typeof sogKnots !== "number" || !Number.isFinite(sogKnots)) return null;
if (sogKnots < 0) return null;
if (sogKnots === AIS_SOG_UNAVAILABLE_KN) return null;
return sogKnots * KNOTS_TO_MPS;
}
/**
* True heading in degrees, or `null`.
*
* Rejects 511 on the exact value. The range check that follows is a second,
* independent condition rather than a restatement of it: 511 is out of range,
* but so is a corrupted 720, and neither is a heading.
*/
export function aisHeading(degrees: number | null | undefined): number | null {
if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null;
if (degrees === AIS_HEADING_UNAVAILABLE) return null;
if (degrees < 0 || degrees >= 360) return null;
return degrees;
}
/**
* Course over ground in degrees, or `null`.
*
* **The exact-value rejection is the whole point and it is not interchangeable
* with a range check plus a modulo.** 358.7 is a real course and survives here;
* 360.0 is "not available" and does not. Writing this as `cog % 360` returns 0
* for the sentinel, which is a course — due north — and every unknown-course
* hull on the board then points the same way, correctly, forever, with nothing
* to notice it. `vesselGate.test.ts` asserts 358.7 survives for exactly this
* reason.
*/
export function aisCourse(degrees: number | null | undefined): number | null {
if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null;
if (degrees === AIS_COURSE_UNAVAILABLE) return null;
if (degrees < 0 || degrees >= 360) return null;
return degrees;
}
/** Is this speed motion, or is it slack? Gate on this and never on `nav_status`. */
export function isMakingWay(speedMps: number | null): boolean {
return typeof speedMps === "number" && Number.isFinite(speedMps) && speedMps >= VESSEL_MAKING_WAY_MPS;
}
/**
* `nav_status` as a word for a card.
*
* Sixteen AIS codes collapse to four, because fifteen of the sixteen are
* distinctions no renderer can draw: "constrained by her draught" and "engaged
* in fishing" are the same hull at the same angle from four kilometres up.
* Anything not 0, 1 or 5 is `unknown`, which is honest — including the codes
* that do mean something, because meaning something is not the same as being
* drawable.
*/
export function vesselStatus(navStatus: number | null | undefined): VesselStatus {
switch (navStatus) {
case 0:
return "under-way";
case 1:
return "at-anchor";
case 5:
return "moored";
default:
return "unknown";
}
}
// ---- Orientation ----------------------------------------------------------
/**
* Which way the bow points, or `null` when nothing knows.
*
* The ladder, in order, and the order is the finding:
*
* 1. **The berth**, whenever the hull is lying on one. It wins outright — over a
* reported heading, and over a course — because a ship alongside a quay
* points the way the quay points, and because half the fleet at rest reports
* no heading at all. The wire heading is allowed a small perturbation on top
* (`BERTH_HEADING_PERTURBATION_DEG`) so that a hull whose antenna *does*
* report can sit a degree or two off square, which is what a real berth looks
* like; it can never swing the hull off the quay.
* 2. **The reported heading**, for a hull at rest that is not on a berth — an
* anchored ship swinging on its cable is genuinely pointing where it says.
* 3. **The course over ground**, for a hull making way. A ship crabbing across a
* tide is not pointing exactly where it is going, but the difference is a
* couple of degrees and the course is the field that is present.
*
* `null` is a real outcome and is not padded out with a default. Fourteen
* percent of stopped hulls have neither heading nor course, and drawing them at
* an invented angle would be an invented fact in a medium that reads as truthful.
*/
export function resolveBearing(input: {
heading: number | null;
course: number | null;
speedMps: number | null;
berthBearing?: number | null;
}): number | null {
const berth = input.berthBearing;
if (typeof berth === "number" && Number.isFinite(berth)) {
const heading = input.heading;
if (heading === null) return normaliseDegrees(berth);
// The perturbation, clamped: a reported heading nudges the hull off square
// and can never turn it round. A ship reported 180 degrees from its berth is
// a ship whose AIS is wrong about which end is the bow, not a ship moored
// backwards, and the quay is the thing that cannot be wrong.
const delta = signedDelta(heading, berth);
const nudge = Math.max(
-BERTH_HEADING_PERTURBATION_DEG,
Math.min(BERTH_HEADING_PERTURBATION_DEG, delta),
);
return normaliseDegrees(berth + nudge);
}
if (!isMakingWay(input.speedMps)) {
if (input.heading !== null) return normaliseDegrees(input.heading);
if (input.course !== null) return normaliseDegrees(input.course);
return null;
}
if (input.course !== null) return normaliseDegrees(input.course);
if (input.heading !== null) return normaliseDegrees(input.heading);
return null;
}
/**
* How far a reported heading may pull a berthed hull off the bearing its quay
* says it has, in degrees.
*
* Three, which is about the width of a fender pack plus the angle a ship sits at
* when it is warped forward for a crane. Big enough that a row of berthed hulls
* is not suspiciously parallel; small enough that a garbage heading cannot put a
* ship across its own quay.
*/
export const BERTH_HEADING_PERTURBATION_DEG = 3;
/** The berth a fix is lying on, or `null`. Nearest inside `BERTH_REACH_METRES`. */
export function nearestBerth(
lat: number,
lng: number,
berths: readonly BerthAnchor[],
reachMetres: number = BERTH_REACH_METRES,
): BerthAnchor | null {
let best: BerthAnchor | null = null;
let bestMetres = reachMetres;
for (const berth of berths) {
if (!Number.isFinite(berth.lat) || !Number.isFinite(berth.lng)) continue;
const metres = metresBetween(lat, lng, berth.lat, berth.lng);
if (metres <= bestMetres) {
best = berth;
bestMetres = metres;
}
}
return best;
}
/** Flatten a board's ports into the anchors the gate reads. */
export function berthAnchors(ports: readonly Port[] | undefined): BerthAnchor[] {
const anchors: BerthAnchor[] = [];
for (const port of ports ?? []) {
for (const berth of port.berths ?? []) {
anchors.push({ id: berth.id, lat: berth.lat, lng: berth.lng, bearing: berth.bearing });
}
}
return anchors;
}
// ---- Dead reckoning -------------------------------------------------------
/**
* Where a fix has got to after `seconds`, along its **reported course** at its
* **reported speed**.
*
* Pure, closed form, and the only function in this repo permitted to move a
* ship. What it cannot do is the point of it: it takes one fix, so there is no
* second fix for it to interpolate toward, and therefore no code path anywhere
* downstream can produce a point on the chord between two observations. That is
* a property of the signature rather than of the discipline of the caller, which
* is why the signature is this and not `(from, to, t)`.
*
* A hull with no course, or one that is not making way, does not move. Neither
* does one whose fix is older than `VESSEL_MAX_RECKON_SECONDS`: past the sample
* interval the next fix is overdue, and a ship drawn sailing a perfectly
* straight line for an hour is a ship the feed has lost.
*/
export function reckonVessel(
fix: { lat: number; lng: number; speed: number; course: number | null },
seconds: number,
): { lat: number; lng: number } {
const here = { lat: fix.lat, lng: fix.lng };
if (!Number.isFinite(seconds) || seconds <= 0) return here;
if (fix.course === null || !Number.isFinite(fix.course)) return here;
if (!isMakingWay(fix.speed)) return here;
const dt = Math.min(seconds, VESSEL_MAX_RECKON_SECONDS);
const distance = fix.speed * dt;
const radians = fix.course * DEG;
const lat = fix.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT;
// The cosine is taken at the starting latitude rather than the mean of the
// two, exactly as `flights.ts` does: fifteen minutes of steaming is under five
// kilometres, over which the correction differs in the seventh decimal place,
// and using the start keeps this a closed form rather than an iteration.
const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(fix.lat * DEG);
const lng =
metresPerDegreeLng > 1 ? fix.lng + (Math.sin(radians) * distance) / metresPerDegreeLng : fix.lng;
return { lat, lng };
}
// ---- The gate -------------------------------------------------------------
/**
* Default hull dimensions, in metres, for a source that did not send any.
*
* The static AIS message carries length and beam and is absent for most hulls
* most of the time, so this is the difference between drawing a plausible ship
* and drawing nothing. It is a **display default and not an observation**: it is
* per-kind, it is stated here where it can be read, and nothing downstream may
* present it as a measurement. There is no draught in this table for the reason
* `Vessel` gives at length — draught is a hull dimension we author, never a
* cargo claim, and `engine/vessels.ts` derives it from the length.
*/
export const DEFAULT_HULL: Readonly<Record<VesselKind, { length: number; beam: number }>> = {
container: { length: 300, beam: 45 },
tanker: { length: 250, beam: 44 },
bulk: { length: 225, beam: 32 },
"vehicle-carrier": { length: 200, beam: 32 },
tug: { length: 30, beam: 11 },
ferry: { length: 60, beam: 14 },
fishing: { length: 25, beam: 7 },
other: { length: 90, beam: 16 },
};
const KINDS = new Set<string>(Object.keys(DEFAULT_HULL));
/** A wire `kind` narrowed to the union, falling back to `other`. */
export function vesselKind(kind: string | null | undefined): VesselKind {
return typeof kind === "string" && KINDS.has(kind) ? (kind as VesselKind) : "other";
}
/**
* One wire row to one drawable hull, or `null`.
*
* Total: a malformed row returns `null` rather than throwing, because the
* consumer is a render loop and a body one server version behind is a normal
* thing to be handed.
*/
export function readVessel(
row: WireVessel | null | undefined,
berths: readonly BerthAnchor[],
): { vessel: Vessel; berthed: boolean } | { vessel: null; reason: "invalid" | "unoriented" } {
if (!row || typeof row !== "object") return { vessel: null, reason: "invalid" };
const lat = row.lat;
const lng = row.lon;
if (typeof lat !== "number" || !Number.isFinite(lat) || lat < -90 || lat > 90) {
return { vessel: null, reason: "invalid" };
}
if (typeof lng !== "number" || !Number.isFinite(lng) || lng < -180 || lng > 180) {
return { vessel: null, reason: "invalid" };
}
const id = typeof row.id === "string" && row.id.length > 0 ? row.id : null;
if (id === null) return { vessel: null, reason: "invalid" };
/**
* The wire speed is already metres per second and already stripped upstream —
* and it is re-checked here anyway, in the units it arrives in.
*
* Not belt and braces: the upstream half of this feed lives in a different
* repo on a different box and does not yet exist, so "already stripped" is a
* promise nobody can currently keep. A sentinel that gets through moves a hull
* eight scene units a minute. The check is one comparison.
*/
const speed = readWireSpeed(row.speed);
if (speed === null) return { vessel: null, reason: "invalid" };
const heading = aisHeading(row.heading);
const course = aisCourse(row.course);
const berth = nearestBerth(lat, lng, berths);
const bearing = resolveBearing({
heading,
course,
speedMps: speed,
berthBearing: berth ? berth.bearing : null,
});
if (bearing === null) return { vessel: null, reason: "unoriented" };
const kind = vesselKind(row.kind);
const fallback = DEFAULT_HULL[kind];
const length = positive(row.length) ?? fallback.length;
const beam = positive(row.beam) ?? fallback.beam;
const ageSeconds =
typeof row.ageSeconds === "number" && Number.isFinite(row.ageSeconds) && row.ageSeconds >= 0
? row.ageSeconds
: 0;
const vessel: Vessel = {
id,
kind,
lat,
lng,
bearing,
length,
beam,
speed: isMakingWay(speed) ? speed : 0,
course,
status: vesselStatus(row.navStatus),
...(berth ? { berthId: berth.id } : {}),
ageSeconds,
};
return { vessel, berthed: berth !== null };
}
/**
* Apply the gate to one body, for one board.
*
* Pure and total: a malformed body, a body from a server one version behind, or
* `null` all produce an empty promotion rather than an exception — the same
* posture `server/fires.ts` takes, and for the same reason.
*
* `nowMs` is injected so a test can assert on `ageMs` without owning the clock.
*/
export function promoteVessels(
body: VesselsBody | null | undefined,
bounds: VesselBounds,
berths: readonly BerthAnchor[] = [],
nowMs: number = Date.now(),
): VesselPromotion {
const empty = emptyVesselPromotion();
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 intervalSeconds =
typeof body.intervalSeconds === "number" && body.intervalSeconds > 0 ? body.intervalSeconds : 0;
const source: VesselsSourceId =
body.source === "cloud1" || body.source === "modelled" ? body.source : "none";
const rows = Array.isArray(body.vessels) ? body.vessels : [];
const drawn: Vessel[] = [];
let suppressed = 0;
let withoutOrientation = 0;
let offBoard = 0;
for (const row of rows) {
const read = readVessel(row, berths);
if (read.vessel === null) {
if (read.reason === "unoriented") withoutOrientation += 1;
else suppressed += 1;
continue;
}
const { vessel } = read;
if (!inBounds(vessel.lat, vessel.lng, bounds)) {
offBoard += 1;
continue;
}
drawn.push(vessel);
}
// Longest first, so that a board over the draw limit loses the hulls least
// able to carry a pixel rather than whichever the feed happened to list last.
drawn.sort((a, b) => b.length - a.length);
const kept = drawn.slice(0, VESSEL_DRAW_LIMIT);
suppressed += drawn.length - kept.length;
return {
source,
fetchedAt,
ageMs,
intervalSeconds,
drawn: kept,
offBoard,
suppressed,
withoutOrientation,
makingWay: kept.filter((v) => isMakingWay(v.speed)).length,
alongside: kept.filter((v) => v.berthId !== undefined).length,
};
}
/**
* The sentence a panel writes when a harbour is empty.
*
* Designed before the field was, which is the rule this repo arrived at the hard
* way: a layer with a beautiful full state and a blank empty one is a layer that
* looks broken on most days. Every branch below names both what is being shown
* and what is being withheld, because "there are no ships here" and "I have not
* heard from the feed since Tuesday" are the same picture and different facts.
*/
export function vesselSummary(promotion: VesselPromotion): string {
const { drawn, source } = promotion;
if (source === "none") {
return "No vessel feed is configured for this deployment, so no ships are drawn.";
}
const modelled = source === "modelled";
const provenance = modelled
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured."
: "Live AIS.";
if (drawn.length === 0) {
const parts: string[] = ["The feed answered and no ship is on this board."];
if (promotion.offBoard > 0) parts.push(`${promotion.offBoard} outside the frame.`);
if (promotion.withoutOrientation > 0) {
parts.push(`${promotion.withoutOrientation} would not say which way they were pointing.`);
}
if (promotion.suppressed > 0) parts.push(`${promotion.suppressed} unreadable.`);
parts.push(provenance);
return parts.join(" ");
}
const still = drawn.length - promotion.makingWay;
const parts = [
`${drawn.length} ${drawn.length === 1 ? "hull" : "hulls"}: ${promotion.makingWay} making way, ${still} at rest, ${promotion.alongside} alongside a berth.`,
];
if (promotion.withoutOrientation > 0) {
parts.push(
`${promotion.withoutOrientation} withheld — neither the quay nor the wire would orient them.`,
);
}
parts.push("No hull is labelled laden or in ballast: that is a port figure, not a ship one.");
parts.push(provenance);
return parts.join(" ");
}
// ---- The modelled harbour -------------------------------------------------
/** What `modelHarbour` needs to be reproducible. */
export interface ModelledHarbourOptions {
/**
* The seed, so two people see the same harbour and a capture script shoots the
* same frame twice. Everything below is a hash of this and a stable string
* (a berth id, a port id), never a call to `Math.random`.
*/
seed?: number;
/** Wall clock for the body's `fetchedAt`, and the phase of the moving hulls. */
atMs?: number;
/** Sample interval to declare. 900 s, matching the store this stands in for. */
intervalSeconds?: number;
/** How many hulls are under way per port with a channel. */
underWayPerPort?: number;
/** What proportion of a port's berths are occupied, 0..1. */
occupancy?: number;
}
/**
* A harbour built from a board's own authored geometry.
*
* Berths carry hulls; channels carry the handful making way. Both are things the
* pack already declares, which is what makes this a *model* of the board rather
* than a fiction laid on top of it: a berth with no ship on it is an empty berth
* you can see, and moving a berth moves the ship.
*
* Deliberately absent, and the absences are the design: no name, no MMSI, no
* callsign, no destination, and no laden state. The output is a `VesselsBody`
* with `source: "modelled"` and it goes through `promoteVessels` exactly like a
* live one, so the seam is exercised rather than bypassed.
*/
export function modelHarbour(
ports: readonly Port[] | undefined,
options: ModelledHarbourOptions = {},
): VesselsBody {
const seed = options.seed ?? 115;
const atMs = options.atMs ?? 0;
const intervalSeconds = options.intervalSeconds ?? 900;
const occupancy = clamp01(options.occupancy ?? 0.72);
const underWayPerPort = Math.max(0, Math.floor(options.underWayPerPort ?? 3));
const vessels: WireVessel[] = [];
for (const port of ports ?? []) {
for (const berth of port.berths ?? []) {
const key = `${port.id}:${berth.id}`;
if (hash01(seed, `${key}:occupied`) > occupancy) continue;
const kind = berthKind(seed, key, berth);
const fallback = DEFAULT_HULL[kind];
const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
/**
* 70-88% of the berth, and the ceiling is what stops a terminal reading
* as one continuous wall of steel.
*
* Photographed: Pier 400's authored berths are 356 m apart and it fills to
* 400 m, so at 97% two consecutive hulls touched stem to stern and the two
* vehicle carriers alongside read as one 700 m object. A berth whose hull
* exactly fills it every time also reads as a diagram rather than as a
* working quay.
*/
const length = Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
vessels.push({
id: `m-${key}`,
kind,
lat: berth.lat,
lon: berth.lng,
speed: 0,
course: null,
/**
* `null`, always, and this is the most deliberate line in the simulator.
*
* Half the fleet at rest reports no heading, so a modelled harbour whose
* every hull volunteered one would exercise the easy path and leave the
* berth-supplied orientation — the thing this workstream exists to get
* right — permanently untested by the picture.
*/
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
}
const channel = port.channel ?? [];
if (channel.length < 2 || underWayPerPort === 0) continue;
for (let i = 0; i < underWayPerPort; i++) {
const key = `${port.id}:under-way:${i}`;
const kind = i === underWayPerPort - 1 ? "tug" : underWayKind(seed, key);
const fallback = DEFAULT_HULL[kind];
const length = Math.round(fallback.length * (0.85 + 0.3 * hash01(seed, `${key}:length`)));
// Speed first, because it is what the phase is measured in: a tug at six
// knots and a container ship at twelve are at different places on the same
// channel a minute later, which is the whole reason the wakes differ.
const speed = (kind === "tug" ? 3.2 : 6.4) * (0.8 + 0.4 * hash01(seed, `${key}:speed`));
const phase = (hash01(seed, `${key}:phase`) + (atMs / 1000 / (intervalSeconds * 6))) % 1;
const along = i % 2 === 0 ? phase : 1 - phase;
const point = alongPath(channel, along);
if (!point) continue;
vessels.push({
id: `m-${key}`,
kind,
lat: point.lat,
lon: point.lng,
speed,
course: i % 2 === 0 ? point.bearing : normaliseDegrees(point.bearing + 180),
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
}
}
return {
source: "modelled",
fetchedAt: new Date(atMs).toISOString(),
vessels,
intervalSeconds,
ttlSeconds: intervalSeconds,
attribution: [
"Modelled from this board's authored berths and channels. Not an observation of any vessel.",
],
};
}
// ---- Arithmetic -----------------------------------------------------------
function readWireSpeed(speed: number | null | undefined): number | null {
if (typeof speed !== "number" || !Number.isFinite(speed) || speed < 0) return null;
// The sentinel in the units the wire uses. Compared with a tolerance because
// 102.3 * 0.514444 does not round-trip exactly through a float.
if (Math.abs(speed - AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS) < 1e-6) return null;
return speed;
}
function positive(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
}
function inBounds(lat: number, lng: number, bounds: VesselBounds): boolean {
return (
lat >= bounds.minLat && lat <= bounds.maxLat && lng >= bounds.minLng && lng <= bounds.maxLng
);
}
/** Great-circle-enough distance for a few hundred metres of harbour. */
export function metresBetween(
aLat: number,
aLng: number,
bLat: number,
bLng: number,
): number {
const dLat = (bLat - aLat) * METRES_PER_DEGREE_LAT;
const dLng = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG);
return Math.hypot(dLat, dLng);
}
/** Bearing from one point to another, degrees clockwise from true north. */
export function bearingBetween(
aLat: number,
aLng: number,
bLat: number,
bLng: number,
): number {
const north = (bLat - aLat) * METRES_PER_DEGREE_LAT;
const east = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG);
return normaliseDegrees((Math.atan2(east, north) / DEG));
}
export function normaliseDegrees(degrees: number): number {
const wrapped = degrees % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
}
/** `a - b` folded into -180..180, so a clamp on it is a clamp on a turn. */
function signedDelta(a: number, b: number): number {
return ((((a - b) % 360) + 540) % 360) - 180;
}
function clamp01(value: number): number {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
/**
* A point a fraction of the way along a polyline, with the path's bearing there.
*
* By segment length rather than by index, so a channel authored with a long
* outer leg and three short dogleg points does not park every modelled ship in
* the dogleg.
*/
export function alongPath(
path: readonly [number, number][],
fraction: number,
): { lat: number; lng: number; bearing: number } | null {
if (path.length === 0) return null;
const first = path[0];
if (!first) return null;
if (path.length === 1) return { lat: first[0], lng: first[1], bearing: 0 };
const legs: number[] = [];
let total = 0;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
const metres = metresBetween(a[0], a[1], b[0], b[1]);
legs.push(metres);
total += metres;
}
if (total <= 0) return { lat: first[0], lng: first[1], bearing: 0 };
let want = clamp01(fraction) * total;
for (let i = 0; i < legs.length; i++) {
const leg = legs[i] ?? 0;
const a = path[i];
const b = path[i + 1];
if (!a || !b) continue;
if (want <= leg || i === legs.length - 1) {
const t = leg > 0 ? clamp01(want / leg) : 0;
return {
lat: a[0] + (b[0] - a[0]) * t,
lng: a[1] + (b[1] - a[1]) * t,
bearing: bearingBetween(a[0], a[1], b[0], b[1]),
};
}
want -= leg;
}
return null;
}
/**
* A stable 0..1 from a seed and a string.
*
* FNV-1a, the same shape every other deterministic thing in this repo uses. It
* is here rather than imported because the alternative is a dependency from a
* pure data module on a renderer helper, and the whole of this file's value is
* that it depends on nothing.
*/
export function hash01(seed: number, key: string): number {
let h = (2_166_136_261 ^ Math.trunc(seed)) >>> 0;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 16_777_619) >>> 0;
}
return h / 4_294_967_296;
}
/**
* Beam from length, per kind.
*
* A display default like `DEFAULT_HULL`, and stated as a ratio because that is
* what it is: a container ship is about 6.6 times as long as it is wide, a tug
* under 3. The one number worth knowing is that a Panamax-plus box ship is 400 x
* 61 m, which this returns to within a metre.
*/
export function beamFor(kind: VesselKind, length: number): number {
const ratio =
kind === "tug"
? 2.8
: kind === "fishing"
? 3.6
: kind === "ferry"
? 4.4
: kind === "bulk"
? 7.0
: kind === "tanker"
? 5.7
: 6.6;
return length / ratio;
}
function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): VesselKind {
const maxLength = "maxLength" in berth ? berth.maxLength : 0;
const roll = hash01(seed, `${key}:kind`);
// The berth's own length is the strongest signal there is: a 120 m berth is
// not a container terminal and a 400 m one is not a fishing dock.
if (maxLength > 0 && maxLength < 80) return roll < 0.6 ? "fishing" : "tug";
if (maxLength > 0 && maxLength < 180) return roll < 0.5 ? "ferry" : "tug";
if (roll < 0.62) return "container";
if (roll < 0.78) return "tanker";
if (roll < 0.9) return "bulk";
return "vehicle-carrier";
}
function underWayKind(seed: number, key: string): VesselKind {
const roll = hash01(seed, `${key}:kind`);
if (roll < 0.55) return "container";
if (roll < 0.75) return "tanker";
if (roll < 0.9) return "bulk";
return "vehicle-carrier";
}
+235
View File
@@ -26,6 +26,10 @@
* | `GET /fires` | `FiresBody` | yes |
* | `GET /weather` | `WeatherBody` | yes |
* | `GET /markers` | `MarkersBody` | yes |
* | `GET /vessels` | `VesselsBody` | yes |
* | `GET /ports` | `PortsBody` | yes |
* | `GET /radar` | `RadarBody` | yes |
* | `GET /birds` | `BirdsBody` | yes |
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
* | `GET /offices/:id/presence` | `PresenceBody` | never |
* | `GET /offices/:id/devices` | `DevicesBody` | never |
@@ -94,6 +98,26 @@ export type SatellitesSourceId = "none" | "celestrak";
*/
export type FiresSourceId = "none" | "cloud1";
export type MarkersSourceId = "none" | "file";
/**
* Where ship positions come from.
*
* `none` is the default and it is the honest default for longer than usual:
* cloud-1 serves no `/api/sea` and no `/api/vessels` today, and aisstream.io's
* terms have not been read. `flights/licence.ts` exists because this box once
* re-served an unchecked feed under a credit line the source had never seen, and
* that mistake is one line of config away from being repeated here.
*
* `modelled` is not a simulator in the `FlightsSourceId: "sim"` sense and the
* different word is deliberate. A simulated aeroplane carries a callsign and a
* plausible identity; a modelled vessel is an **anonymous** hull at an authored
* berth, with no name and no MMSI, and the panel says the feed is not
* configured. An invented named ship is a claim about a real commercial vessel.
*/
export type VesselsSourceId = "none" | "modelled" | "cloud1";
/** Where the reflectivity raster comes from. `none` draws no sheet at all. */
export type RadarSourceId = "none" | "cloud1";
/** Where nocturnal migration comes from. `none` draws no motes and says so. */
export type BirdsSourceId = "none" | "cloud1";
/**
* Where device readings come from.
*
@@ -170,6 +194,21 @@ export interface HealthBody {
* optional.
*/
fires?: FiresSourceId;
/**
* The three feeds this build added, all optional and all read the same
* defensive way `devices` and `fires` are: a browser meeting a server one
* version behind sees `undefined` and must conclude the box serves none of
* them, which is the safe direction for a missing field to fall.
*
* All three default to `none`, and for all three an empty answer is the
* commonest correct one. California is under rain a mean 0.596% of the time
* and birds are absent about fourteen hours in every twenty-four by
* construction, so a board with nothing on it is the normal picture and only
* the age beside it separates that from a dead feed.
*/
vessels?: VesselsSourceId;
radar?: RadarSourceId;
birds?: BirdsSourceId;
};
auth: {
mode: AuthMode;
@@ -824,3 +863,199 @@ export interface DeviceCommandResultBody {
/** Epoch milliseconds. */
observedAt: number;
}
// ---- Vessels --------------------------------------------------------------
/**
* One ship position, with **every AIS sentinel already stripped**.
*
* This is the shape after the gate, not the shape off the wire from a receiver,
* and the difference is the whole point of the type. AIS encodes "not available"
* as in-band values that are perfectly valid numbers: speed over ground 102.3
* knots, heading 511, course 360.0. None of the three is ever NULL, so a
* null-check catches nothing, and `cog % 360` turns the course sentinel into due
* north — which looks entirely correct until you notice the whole fleet is
* facing the same way.
*
* So absence is expressed as absence here: `heading` and `course` are
* `number | null`, `speed` is a real speed or zero, and anything that could not
* be believed was dropped upstream where one hand-written SELECT can be read.
*
* **No name, no MMSI, no callsign, no destination.** See `Vessel` in
* `engine/types.ts`: identity arrives with a licensed feed and a written licence
* entry, or it does not arrive.
*/
export interface WireVessel {
/** Opaque and stable for the life of a fix set. Never an MMSI. */
id: string;
/** AIS ship-type reduced to a display bucket. Structurally `VesselKind`. */
kind: string;
lat: number;
lon: number;
/** Metres per second over the ground. Never 102.3 knots. */
speed: number;
/** Course over ground, degrees true, or `null`. Never exactly 360. */
course: number | null;
/** True heading, degrees, or `null`. Never 511. */
heading: number | null;
/** `0` under way, `1` at anchor, `5` moored. Labels; never gates motion. */
navStatus: number | null;
/** Overall length and beam in metres, where the static message gave them. */
length: number | null;
beam: number | null;
/** Seconds between the fix and `fetchedAt`. */
ageSeconds: number;
}
/**
* Everything afloat this deployment knows about, and when it last asked.
*
* `fetchedAt` is not optional for the same reason `FiresBody.fetchedAt` is not:
* an empty harbour and a dead feed are the same picture.
*
* `interval` is stated rather than implied because it changes what a client is
* allowed to do with the positions. Upstream listens for thirty seconds every
* fifteen minutes, so a hull under way has moved about five kilometres between
* two fixes. A client may dead-reckon **along the reported course at the
* reported speed**; it may never spline between two fixes, because the chord
* between them is not a path anything took.
*/
export interface VesselsBody {
source: VesselsSourceId;
/** ISO-8601, the last time a fetch **succeeded**. Epoch zero when never. */
fetchedAt: string;
vessels: WireVessel[];
/** Seconds between upstream samples. 900 for the store behind this. */
intervalSeconds: number;
ttlSeconds: number;
attribution?: string[];
}
// ---- Ports ----------------------------------------------------------------
/**
* A month of boxes across one quay, and the freight rates that explain it.
*
* Static in this build and served with the month it describes, because that is
* what the honest version of this looks like: `teu_obs` is monthly and the
* newest row is July 2026. A figure with no month on it is a claim about now.
*/
export interface WirePortThroughput {
/** UN/LOCODE. */
portId: string;
/** `YYYY-MM`. */
asOf: string;
loadedExport: number;
emptyExport: number;
loadedImport: number;
emptyImport: number;
}
/** One published freight index, in dollars per forty-foot equivalent. */
export interface WireFreightRate {
/** The index code: `FBX01`. */
id: string;
lane: string;
usdPerFeu: number;
}
/**
* Port throughput, as the card reads it.
*
* There is deliberately no observation timestamp on a rate. The store's
* `observed_at` is our own read clock — Freightos publishes none — and a card
* that renders it as "as of" is lying about precision.
*/
export interface PortsBody {
source: FiresSourceId;
fetchedAt: string;
throughput: WirePortThroughput[];
rates: WireFreightRate[];
ttlSeconds: number;
attribution?: string[];
}
// ---- The sky --------------------------------------------------------------
/**
* The statewide reflectivity lattice.
*
* Structurally `RadarField` in `engine/types.ts` minus the two panel counters,
* restated here for the reason the head of this file gives: the browser hands
* the promoted field straight to the layer and the server must be able to build
* one without importing three.js.
*
* **`null` in `dbz` is unknown, not dry.** A cell inside the coverage radius of
* a radar whose RDA is inoperable is a hole, and a hole drawn as clear sky is a
* claim nobody made. It is also why this is a nullable array and not a packed
* buffer.
*
* `distance_km` from the upstream store is **not** in this type and must never
* be requested. It is measured from a private home, and the protection is that
* the projection never emits the column — not that a client politely avoids
* selecting it. That is the whole reason `fires/cloud1.ts` has no database.
*/
export interface RadarBody {
source: RadarSourceId;
fetchedAt: string;
/** `null` when nothing has been promoted — an honest empty sky. */
field: {
minLat: number;
minLng: number;
cellLat: number;
cellLng: number;
rows: number;
cols: number;
dbz: (number | null)[];
observedAt: string;
wetFraction: number;
stations: number;
stationsDown: number;
} | null;
ttlSeconds: number;
attribution?: string[];
}
/** One county's ten-minute migration granule. Structurally `MigrationCounty`. */
export interface WireBirdCounty {
id: string;
name: string;
lat: number;
lon: number;
areaKm2: number;
aloft: number;
/** Metres above ground. */
altitude: number;
/** Bearing they are heading toward, degrees true. */
direction: number;
/** Metres per second. */
speed: number;
}
/**
* Nocturnal migration, including — and especially — the empty case.
*
* `quiet` carries the reason and it is not optional. 168 of 297 granules are
* daytime with 77 rows in them against 7,498 at night, so the empty sky is what
* most visitors see and the sentence beside it is the layer.
*
* `statewide` comes from the **state row** and never from a sum over counties.
* Summing all 58 gives 2,360,086 against an authoritative 393,290, because a
* bird crossing four counties is counted in four of them.
*/
export interface BirdsBody {
source: BirdsSourceId;
fetchedAt: string;
observedAt: string | null;
counties: WireBirdCounty[];
statewide: {
crossed: number;
peakAloft: number;
peakAt: string;
meanAltitude: number;
heading: string;
} | null;
quiet: { reason: "daylight" | "off-season" | "no-data"; message: string } | null;
ttlSeconds: number;
attribution?: string[];
}