acf4d1a510
**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>
974 lines
39 KiB
TypeScript
974 lines
39 KiB
TypeScript
/**
|
|
* 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";
|
|
}
|