/** * 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> = { 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(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"; }