1
0

feat: the crane grows a mast, the harbour works a shift, and the site is re-shot

**The Asset Factory verdict, and it mostly went against the vote.** Nine
candidates were thumbed up. One was taken.

TOOK the STS crane. Rebuilt in `ports.ts` from 5 unit boxes to 11 — an A-frame
mast and apex cap, a forestay to the boom, a backstay to the tail, a sill, the
truck-lane portal beam, a machinery house — still exactly ONE InstancedMesh.
What was missing is the thing that makes a gantry a gantry: on a real STS the
tallest part of a WORKING crane is the A-frame apex, not the boom, and a parked
raised boom clears its own apex by only 15-25%. Before, 56 gantries read from
altitude as 56 crosses — two coincident verticals with one bar through them and
nothing above it — so a berth flattened into a picket fence.

Proportions came from both upvoted candidates agreeing independently (hinge ~58 m
under an apex at 99-104 m), taken conservatively because Tera's packs already
author an 82 m hinge against a real 55-60.

The apex beacon came across as EMISSION: `craneLights()` returns bare positions,
`nightlights.ts` turns them into one additive Points cloud, 56 points, one draw
call, night only, no THREE.Light anywhere. 0.09 units was invisible against the
port's own cream emissive; 0.17 — half a bridge head light — is right, and the
screenshot at 0.09 is what condemned it.

REJECTED all three bridges, city-lights and both aircraft: the incumbents won on
the picture, decisively for the bridge.

TWO PARTS WERE BUILT FROM THE APPROVED CANDIDATES, PHOTOGRAPHED, AND CUT. Four
legs: 14 m of quay spacing is 0.036 units at 391 m/unit against a 0.032 member
floor, so 90% overlap. A portal X-brace: the bay is 0.115 wide by 0.38 tall, so
both diagonals come out near-vertical and add a lump at mid-leg. Both are among
the best things about the factory cranes AT THE FACTORY'S FRAMING. Neither
survives at board scale. That gap is the whole reason a factory asset is
reference geometry and not a drop-in.

Fixed a defect the rebuild exposed: the backreach started a full rail-gauge
behind the hinge, leaving a gap over the portal with the beam floating below it.
One unbroken girder now. And every inclined member goes through a `strut()` that
takes two points in the (distance-along-boom, height) plane, so the
vertical-exaggeration bug the module header warns about is no longer reachable —
it needs a length and an angle, and there is now no way to start from those.

**The harbour works a shift.** It was a frozen tableau: 19 hulls placed from the
pack's berths that never changed. Vessels now arrive through the channel, are met
by a tug, berth, work and depart — seeded, so two people see the same harbour and
a capture script shoots the same frame twice. A ship loses its wake when it ties
up, because the wake is the information.

**Every still and film re-shot.** The site was showing a Tera that no longer
existed — SHOTS_COMMIT b7f5c41, FILMS_COMMIT 2aa4049, against an engine that has
since gained fires, the whole state, ports, ships and night infrastructure. Two
frames were bad and are fixed by moving the hour, not by retouching:
`bay-relief-day` and `peninsula-day` were white lids of marine layer. Four
captions described a Tera that no longer existed and are rewritten to the
delivered frame. `california-relief-night` is measurably brighter than the frame
it replaces (canvas mean 7.91 -> 10.57) despite the state being 30% larger.

Ten budget cells pass, run twice. socal 1,422,025 -> 1,429,993 triangles against
1,700,000, 218 draws against 320. The measured delta is double the geometry
because the crane mesh casts shadow, so renderer.info counts it in both passes —
worth knowing before anyone reads that number as geometry.

Tests 1,540 -> 1,570, server 295.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 03:29:58 -07:00
parent e7a8aa028b
commit bcac6aa41a
15 changed files with 2429 additions and 304 deletions
+762 -74
View File
@@ -89,6 +89,30 @@
* 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.
*
* ### The harbour has a working day, and the day is a plan
*
* The first version of it was a diorama: berth occupancy was a time-invariant
* hash and the handful of hulls in the channel looped it on a ninety-minute
* carousel, so a port that takes fifteen ships a day never received one. It now
* runs a schedule — a ship stands in from open water, takes a tug inside the
* breakwater, lies alongside, works, and leaves — and the schedule is a **plan
* evaluated at an instant** rather than a simulation that is running: nothing in
* this file holds a clock or an accumulator, and `modelHarbour(ports, { atMs })`
* is a closed form of that number. `server/wire.ts` makes the same argument for
* the simulated sky and for the same reasons, and the two useful consequences
* are identical: two people on two machines see the same ships, and `look.mjs
* --at` shoots the same frame twice.
*
* One number in it is compressed and it is said out loud in both the body's
* attribution and `vesselSummary`'s sentence: **the rate of arrivals**, at
* roughly seven times a real day's, because a truthful rate gives a board that
* looks identical from breakfast to bedtime. Nothing else is. The channel is the
* charted one, the berths are the authored ones, and every speed is real —
* which is not a nicety, because a fix's `sog` is what a consumer dead-reckons
* along, so a speed that is not the derivative of its own position is a lie the
* renderer will draw faithfully. `harbourDay.test.ts` asserts that derivative
* numerically.
*/
import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts";
@@ -535,7 +559,24 @@ export function readVessel(
const heading = aisHeading(row.heading);
const course = aisCourse(row.course);
const berth = nearestBerth(lat, lng, berths);
/**
* A hull is bound to a berth only when it is **not making way**.
*
* `nearestBerth` reaches four hundred metres, and `resolveBearing` lets the
* quay win outright over a reported course — which is right for a ship lying
* alongside and wrong for one steaming past. Long Beach's Pier T berths sit
* 190 m off their own channel centreline, so before this line a ship doing ten
* knots up the channel was silently swung to the quay's bearing and drawn
* crabbing sideways with her wake off the beam. It only became visible once
* ships started arriving; the picture found it in the first frame.
*
* "Alongside" therefore now means what the word means, and
* `VesselPromotion.alongside` counts hulls that are actually stopped at a
* berth. A ship creeping the last fifty metres in at under half a knot is not
* making way by `isMakingWay`, so she binds and lies the way the quay does —
* which is the moment she should.
*/
const berth = isMakingWay(speed) ? null : nearestBerth(lat, lng, berths);
const bearing = resolveBearing({
heading,
course,
@@ -652,8 +693,23 @@ export function vesselSummary(promotion: VesselPromotion): string {
return "No vessel feed is configured for this deployment, so no ships are drawn.";
}
const modelled = source === "modelled";
/**
* The modelled clause names **both halves**, and the second half is new.
*
* The harbour now has a working day in it: ships stand in from open water,
* take a tug, lie alongside and leave. A reader watching a berth change hands
* twice in an afternoon would reasonably conclude that is how often San Pedro
* changes hands, and it is not — the rate is the one thing here that is a
* modelling choice rather than the board's own geometry. Every other number in
* the picture is true: the channel is the charted one, the berths are the
* authored ones, and a ship comes up the channel at thirteen knots because
* that is how fast she comes up the channel.
*
* Saying so costs a clause. Not saying so is the fire layer's twenty-two
* orange marks again, in a slower costume.
*/
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."
? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured. Ship speeds, the channel and the berths are true; arrivals run at about seven times a real day's rate, so the harbour changes while you watch."
: "Live AIS.";
if (drawn.length === 0) {
const parts: string[] = ["The feed answered and no ship is on this board."];
@@ -679,33 +735,375 @@ export function vesselSummary(promotion: VesselPromotion): string {
return parts.join(" ");
}
// ---- The modelled harbour -------------------------------------------------
// ---- The modelled harbour, and its working day ----------------------------
/** 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`.
* (a berth id, a port id, a call number), never a call to `Math.random`.
*/
seed?: number;
/** Wall clock for the body's `fetchedAt`, and the phase of the moving hulls. */
/**
* Wall clock for the body's `fetchedAt`, **and the instant the working day is
* evaluated at**.
*
* This is the whole of the simulator's state. `harbourCalls` is a plan — a
* berth, a route and a repeating slot — and `modelHarbour` is that plan
* evaluated as a closed form of this number, which is exactly the trick
* `FlightsPlanBody` plays with `t0` and for exactly the same reason: two
* people on two machines looking at the same instant see the same ships, and a
* capture script that shoots the Southland twice gets the same photograph.
* There is no accumulator anywhere in this file, so there is nothing that can
* drift, and nothing that has to be replayed to reach a given moment.
*/
atMs?: number;
/** Sample interval to declare. 900 s, matching the store this stands in for. */
/** Sample interval to declare. See `MODELLED_INTERVAL_SECONDS`. */
intervalSeconds?: number;
/** How many hulls are under way per port with a channel. */
/**
* Roughly how many hulls are under way per port at any instant.
*
* A *target*, not a guarantee, and the difference is the honest one: berth
* slots are laid out evenly around the cycle so the channel carries a steady
* stream rather than a convoy, but the legs differ in length — the West Basin
* is eight kilometres up the Main Channel and Pier 400 is two — so the count
* breathes by one either side. `harbourDay.test.ts` asserts the band rather
* than a number.
*/
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.
* The interval the modelled source declares, in seconds.
*
* 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.
* **A minute, and not the nine hundred seconds a real AIS listener would
* declare** — that number moved, and the reason it moved is the most useful
* thing in this file.
*
* `intervalSeconds` is a property *of the source*. It says how long a consumer
* may dead-reckon a fix before the fix is stale, and for the store this stands
* in for it is fifteen minutes because upstream listens for thirty seconds every
* fifteen minutes. This source is not that: it is a closed-form function of the
* clock, it can be asked for any instant at any time, and declaring a quarter of
* an hour was mimicry rather than description.
*
* It also drew a ship over the land. `engine/vessels.ts` dead-reckons **along a
* straight reported course** — correctly, because a course is all a fix carries
* — so a hull at thirteen knots reckoned for nine hundred seconds runs six
* kilometres in a straight line. The Main Channel bends about twenty degrees in
* that distance, and the arriving ships this working day added therefore sailed
* up over Terminal Island for the last third of every interval, dead-reckoned
* exactly as instructed. A minute is 400 m, which is under a scene unit.
*
* None of that softens when a real feed lands. `/api/sea` will hand over a body
* carrying its own `intervalSeconds`, main.ts already reads it off the body, and
* a fifteen-minute AIS feed will dead-reckon for fifteen minutes and overshoot
* the bends — which is a true fact about a fifteen-minute feed and belongs in
* the panel's sentence rather than in a smoothing filter. What must never happen
* is the other repair: splining between two fixes to hide it.
*/
export const MODELLED_INTERVAL_SECONDS = 60;
/**
* How far seaward of the charted channel a ship is picked up, in metres.
*
* The pack's channel is the dredged water and stops where the dredging does, a
* kilometre or so outside the breakwater. A ship that appeared exactly there
* would pop into being at the gate; extending the first leg back out to sea by
* two and a half kilometres means an arrival is first seen against open water,
* standing in toward the entrance, which is what an arrival looks like.
*
* Extended **in the simulator and not in the pack**, because `engine/ports.ts`
* draws `Port.channel` as dredged water and this is not dredged water. The
* picture would gain a dark strip two miles out to sea that no chart has.
*/
export const APPROACH_SEAWARD_METRES = 2500;
/**
* How far off the charted channel a berth may be and still be given a route, in
* metres.
*
* **This is a land check standing in for the land check this file cannot do.**
* A berth's route is the channel as far as the point nearest the berth and then
* a straight run in, and a straight run of three kilometres from the Main
* Channel to the East Basin crosses Terminal Island — a container ship driven
* over a container yard, at ten knots, with a wake. There is no water mask in
* `Port`, so the honest gate is distance: a berth the channel reaches keeps a
* working day, and a berth it does not reach keeps a hull lying alongside and
* takes no calls.
*
* At Los Angeles that admits Pier 400 and the West Basin and holds back Pier 300
* and the East Basin; at Long Beach it admits all five. The fix is not a bigger
* number — it is per-berth approach geometry in the pack, which wants a field on
* `Berth` that does not exist yet.
*/
export const BERTH_APPROACH_REACH_METRES = 1100;
/**
* The shape of the run in, as an exponent.
*
* Distance made good is `L * (1 - (1-x)^k)` and speed is its derivative, so a
* ship enters at `k * L / T` and arrives at nothing. That is not a fade for
* looks: it is how a ship berths, and it is also what keeps the dead reckoner
* honest, because the hull whose fix could be extrapolated furthest — the one
* closest to a quay it must not be drawn on top of — is the one moving slowest.
*
* 1.6 rather than 2 because a square root of a decay spends too much of the leg
* crawling; at 1.6 a ship holds better than half her entry speed for the first
* two thirds of the channel and is down to two knots at the berth.
*/
export const APPROACH_EASE = 1.6;
/** Entry speed at the seaward end of the run in, m/s. Thirteen knots. */
export const INBOUND_PEAK_MPS = 6.7;
/** Speed at the seaward end of the run out, m/s. Fourteen knots. */
export const OUTBOUND_PEAK_MPS = 7.2;
/** The least clear water between one ship leaving a berth and the next arriving. */
const MIN_BERTH_GAP_SECONDS = 600;
/**
* When the tug joins an arriving ship, and when it lets a departing one go, as a
* fraction of the leg.
*
* The escort is the second half of the run in and the first third of the run
* out, which is inside the breakwater in both cases — a harbour tug meets a ship
* in sheltered water, not at sea.
*/
const TUG_MEETS_AT = 0.55;
const TUG_LEAVES_AT = 0.94;
const TUG_DEPARTURE_UNTIL = 0.3;
/** How long before the meeting the tug is seen running seaward to make it. */
const TUG_RUN_OUT = 0.13;
/** Where the tug lies between jobs, as a fraction along the berth's own route. */
const TUG_STATION = 0.88;
/** Where the tug sits relative to the ship it is attending, in metres. */
const TUG_STATION_ASTERN = 220;
const TUG_STATION_ABEAM = 110;
/** One berth's endlessly repeating port call: a route, and a slot in the day. */
export interface HarbourCall {
portId: string;
berthId: string;
/** The berth's own bearing, so a ship alongside lies the way the quay does. */
berthBearing: number;
maxLength: number;
/**
* Open water, then the charted channel, then **where the ship lies** — which
* is not the berth's own coordinate. See `lyingPosition`.
*/
approach: readonly [number, number][];
approachMetres: number;
inboundSeconds: number;
dwellSeconds: number;
outboundSeconds: number;
/** Inbound, alongside, outbound, and the empty berth before the next ship. */
cycleSeconds: number;
/** Seconds after the epoch at which this berth's call zero starts inbound. */
offsetSeconds: number;
}
/** Which leg of her call a berth's ship is on. `empty` is a berth with no ship. */
export type HarbourPhase = "inbound" | "alongside" | "outbound" | "empty";
export interface HarbourMoment {
phase: HarbourPhase;
/** Which call this is, counting from the epoch. Half of the ship's identity. */
index: number;
/** 0..1 through whichever leg `phase` names. */
progress: number;
}
/**
* The plan: one repeating call per berth the channel can reach.
*
* Pure, and a function of the port's own geometry and the seed alone — no clock
* reaches this function, which is what makes it a *plan* rather than a state.
* `harbourMoment` is the evaluator.
*
* ### The slots are even, and that is a claim about ports rather than a shortcut
*
* Every berth in a port shares one cycle and the offsets are laid out evenly
* around it, so arrivals come at a steady drumbeat instead of in clumps. Real
* ports do allocate berth windows this way — a ship books a slot and is charged
* for missing it — and the alternative here, a random offset per berth, gives a
* harbour that is deserted for an hour and then has five ships in one channel.
* The variety is in the ships instead: kind, length, dwell and tug all come out
* of the hash of the berth id and the call number.
*
* ### The rate is compressed, and the panel says so
*
* Los Angeles and Long Beach between them take on the order of fourteen deep-sea
* calls a day and a box ship lies alongside for one to three days. Drawn
* truthfully, this board would show one arrival every hour and a half and a quay
* that looked identical from breakfast to bedtime. `underWayPerPort` sets the
* compression and the default runs roughly seven times a real day's call rate,
* which is a stated modelling choice and is written into the body's own
* attribution — not a claim about how busy San Pedro is.
*
* **What is not compressed is the motion.** Every speed below is a real speed, so
* a ship takes twenty-five to forty-five minutes to come up the channel because
* that is how long it takes, and — the part that matters downstream — the sog a
* fix reports is the derivative of the position that fix reports. A simulator
* that sped the hulls up while reporting truthful knots would have the dead
* reckoner and the schedule disagree, and the disagreement would land as a jump
* on every fix.
*/
export function harbourCalls(
port: Port,
options: ModelledHarbourOptions = {},
): HarbourCall[] {
const seed = options.seed ?? 115;
const occupancy = clamp01(options.occupancy ?? 0.72);
/**
* The target, clamped to half the berths.
*
* A berth is under way for `inbound + outbound` of every cycle, so asking for
* three of a four-berth port in the channel at once leaves at most a quarter of
* the cycle to lie alongside in — and once the minimum clear water between one
* ship leaving and the next arriving is taken out, none. A port where most of
* the fleet is steaming and the quays are bare is not a busy port, it is a
* parade. Half is the ceiling; a caller asking for more gets a working harbour
* instead of the number it asked for.
*/
const asked = Math.max(0, Math.floor(options.underWayPerPort ?? 2));
const target = Math.min(asked, Math.max(1, Math.floor((port.berths ?? []).length / 2)));
const channel = port.channel ?? [];
const berths = port.berths ?? [];
if (channel.length < 2 || berths.length === 0 || target === 0) return [];
const fairway = seawardApproach(channel);
/** Berth, route and the two transit times, before the slots are laid out. */
const routed: {
berth: Berth;
approach: [number, number][];
metres: number;
inbound: number;
outbound: number;
}[] = [];
for (const berth of berths) {
const approach = berthApproach(fairway, berth);
if (approach === null) continue;
const metres = pathMetres(approach);
if (metres <= 0) continue;
routed.push({
berth,
approach,
metres,
inbound: (APPROACH_EASE * metres) / INBOUND_PEAK_MPS,
outbound: (APPROACH_EASE * metres) / OUTBOUND_PEAK_MPS,
});
}
if (routed.length === 0) return [];
/**
* One cycle for the whole port, sized so that the transits add up to the
* target.
*
* A berth is under way for `inbound + outbound` of every cycle, so the number
* of hulls moving at any instant is the sum of those over the cycle. Solving
* that for the cycle is the one line that turns "about two ships in the
* channel" into a schedule.
*/
const transit = routed.reduce((total, r) => total + r.inbound + r.outbound, 0);
const cycleSeconds = Math.max(transit / routed.length, transit / target);
const calls: HarbourCall[] = [];
routed.forEach((route, index) => {
const legs = route.inbound + route.outbound;
// The dwell the occupancy asks for, or the longest one that still leaves the
// berth clear water before the next ship — whichever is shorter. A long leg
// eats its own berth's dwell rather than overrunning the slot behind it.
const dwellSeconds = Math.max(
0,
Math.min(occupancy * cycleSeconds, cycleSeconds - legs - MIN_BERTH_GAP_SECONDS),
);
calls.push({
portId: port.id,
berthId: route.berth.id,
berthBearing: route.berth.bearing,
maxLength: route.berth.maxLength > 0 ? route.berth.maxLength : 0,
approach: route.approach,
approachMetres: route.metres,
inboundSeconds: route.inbound,
dwellSeconds,
outboundSeconds: route.outbound,
cycleSeconds,
// Evenly spaced, and rotated by a hash of the port so that Los Angeles and
// Long Beach are not in step with each other.
offsetSeconds:
((index / routed.length) + hash01(seed, `${port.id}:rotation`)) * cycleSeconds,
});
});
return calls;
}
/** Where a berth's call has got to at `atSeconds` after the epoch. */
export function harbourMoment(call: HarbourCall, atSeconds: number): HarbourMoment {
const cycle = call.cycleSeconds;
if (!(cycle > 0)) return { phase: "empty", index: 0, progress: 0 };
const since = atSeconds - call.offsetSeconds;
const index = Math.floor(since / cycle);
const u = since - index * cycle;
if (u < call.inboundSeconds) {
return { phase: "inbound", index, progress: u / call.inboundSeconds };
}
const afterDwell = call.inboundSeconds + call.dwellSeconds;
if (u < afterDwell) {
return {
phase: "alongside",
index,
progress: call.dwellSeconds > 0 ? (u - call.inboundSeconds) / call.dwellSeconds : 0,
};
}
const afterOut = afterDwell + call.outboundSeconds;
if (u < afterOut) {
return { phase: "outbound", index, progress: (u - afterDwell) / call.outboundSeconds };
}
return { phase: "empty", index, progress: (u - afterOut) / Math.max(1, cycle - afterOut) };
}
/**
* Distance made good along the route, and the speed that is making it.
*
* The speed is the analytic derivative of the distance rather than a plausible
* number written beside it, which is the property the whole seam rests on: a fix
* carries `sog` and a consumer is licensed to dead-reckon along it, so a `sog`
* that is not the derivative of the position it arrives with is a lie that the
* renderer will faithfully draw.
*/
export function approachRun(
metres: number,
seconds: number,
progress: number,
leg: "inbound" | "outbound",
): { arcMetres: number; speedMps: number } {
if (!(metres > 0) || !(seconds > 0)) return { arcMetres: 0, speedMps: 0 };
const x = clamp01(progress);
const k = APPROACH_EASE;
if (leg === "inbound") {
const left = 1 - x;
return {
arcMetres: metres * (1 - left ** k),
speedMps: (metres * k * left ** (k - 1)) / seconds,
};
}
return {
arcMetres: metres * (1 - x ** k),
speedMps: (metres * k * x ** (k - 1)) / seconds,
};
}
/**
* A harbour built from a board's own authored geometry, at one instant.
*
* Berths carry hulls; the channel carries the ones arriving and leaving; a tug
* comes out to meet each of them. All of it is a closed form of `atMs`, so the
* same instant gives the same harbour on every machine and at every replay.
*
* 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`
@@ -718,80 +1116,82 @@ export function modelHarbour(
): VesselsBody {
const seed = options.seed ?? 115;
const atMs = options.atMs ?? 0;
const intervalSeconds = options.intervalSeconds ?? 900;
const intervalSeconds = options.intervalSeconds ?? MODELLED_INTERVAL_SECONDS;
const occupancy = clamp01(options.occupancy ?? 0.72);
const underWayPerPort = Math.max(0, Math.floor(options.underWayPerPort ?? 3));
const atSeconds = atMs / 1000;
const vessels: WireVessel[] = [];
for (const port of ports ?? []) {
const calls = harbourCalls(port, { ...options, seed, occupancy });
const scheduled = new Set(calls.map((call) => call.berthId));
/**
* A berth the channel does not reach keeps a hull alongside.
*
* The same static occupancy this file drew before there was a working day,
* kept for exactly the berths a route cannot honestly be drawn to — see
* `BERTH_APPROACH_REACH_METRES`. It is the difference between a quay that is
* quiet and a quay that is empty, and the empty one would read as a bug.
*/
for (const berth of port.berths ?? []) {
if (scheduled.has(berth.id)) continue;
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 length = berthLength(seed, key, berth, kind);
// Off the wall, exactly as a scheduled one is. The water side comes from
// the channel rather than from a route, because this berth has none — the
// fairway is the one thing on a port that is certainly afloat.
const afloat = nearestOnPath(port.channel ?? [], berth.lat, berth.lng);
const lying = afloat
? lyingPosition(berth, afloat.lat, afloat.lng)
: { lat: berth.lat, lng: berth.lng };
vessels.push(alongsideFix(`m-${key}`, kind, lying.lat, lying.lng, length));
}
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;
for (const call of calls) {
const moment = harbourMoment(call, atSeconds);
if (moment.phase === "empty") continue;
const key = `${call.portId}:${call.berthId}:${moment.index}`;
const kind = callKind(seed, key, call);
const length = callLength(seed, key, call, kind);
if (moment.phase === "alongside") {
const lying = call.approach[call.approach.length - 1];
if (!lying) continue;
vessels.push(alongsideFix(`m-${key}`, kind, lying[0], lying[1], length));
continue;
}
const leg = moment.phase;
const seconds = leg === "inbound" ? call.inboundSeconds : call.outboundSeconds;
const run = approachRun(call.approachMetres, seconds, moment.progress, leg);
const at = alongPath(call.approach, run.arcMetres / call.approachMetres);
if (!at) continue;
// The route is authored seaward-end-first, so its bearing at any point is
// the inbound course and a departure is the reciprocal of it.
const course = leg === "inbound" ? at.bearing : normaliseDegrees(at.bearing + 180);
vessels.push({
id: `m-${key}`,
kind,
lat: point.lat,
lon: point.lng,
speed,
course: i % 2 === 0 ? point.bearing : normaliseDegrees(point.bearing + 180),
lat: at.lat,
lon: at.lng,
speed: run.speedMps,
course,
// `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: 0,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
});
const tug = attendingTug(call, moment, run, seed, key);
if (tug) vessels.push(tug);
}
}
@@ -803,10 +1203,305 @@ export function modelHarbour(
ttlSeconds: intervalSeconds,
attribution: [
"Modelled from this board's authored berths and channels. Not an observation of any vessel.",
"Ship speeds, the channel and the berths are true; the rate of arrivals is compressed to about seven times a real day's so the harbour changes while you watch.",
],
};
}
/**
* The tug attending one arriving or departing ship, or `null`.
*
* A tug is the smallest hull on the board and almost always the one that is
* moving, which at 391 m to the scene unit makes it the hull that reads: a
* thirty-metre hull is a twelfth of a unit and invisible, and the V behind it is
* four hundred metres of foam and is not. So the tug is here for the wake as
* much as for the ship it is attending.
*
* Three sub-legs, and the first is the one worth having: for a short window
* before the meeting the tug is drawn **running seaward**, out from its station
* up-harbour and down past the incoming ship, on a reciprocal course at ten
* knots. Two wakes crossing in opposite directions in a channel is a harbour
* doing something, where a tug that simply materialised alongside is a decal.
*/
function attendingTug(
call: HarbourCall,
moment: HarbourMoment,
run: { arcMetres: number; speedMps: number },
seed: number,
key: string,
): WireVessel | null {
const inbound = moment.phase === "inbound";
const x = moment.progress;
const station = TUG_STATION * call.approachMetres;
const length = Math.round(26 + 12 * hash01(seed, `${key}:tug`));
const escortArc = Math.max(0, run.arcMetres - TUG_STATION_ASTERN);
let arcMetres: number;
let speedMps: number;
let outbound: boolean;
if (inbound && x >= TUG_MEETS_AT - TUG_RUN_OUT && x < TUG_MEETS_AT) {
// Running out to meet her: from the station down-channel to the rendezvous,
// over the window, at whatever speed that distance and that window imply.
const meeting = approachRun(call.approachMetres, call.inboundSeconds, TUG_MEETS_AT, "inbound");
const target = Math.max(0, meeting.arcMetres - TUG_STATION_ASTERN);
const t = (x - (TUG_MEETS_AT - TUG_RUN_OUT)) / TUG_RUN_OUT;
arcMetres = station + (target - station) * t;
speedMps = Math.abs(station - target) / (TUG_RUN_OUT * call.inboundSeconds);
outbound = station > target;
} else if (inbound && x >= TUG_MEETS_AT && x < TUG_LEAVES_AT) {
arcMetres = escortArc;
speedMps = run.speedMps;
outbound = false;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL) {
arcMetres = Math.min(call.approachMetres, run.arcMetres + TUG_STATION_ASTERN);
speedMps = run.speedMps;
outbound = true;
} else if (!inbound && x <= TUG_DEPARTURE_UNTIL + TUG_RUN_OUT) {
// Letting her go and running home, back up the channel toward the station.
const release = approachRun(
call.approachMetres,
call.outboundSeconds,
TUG_DEPARTURE_UNTIL,
"outbound",
);
const from = Math.min(call.approachMetres, release.arcMetres + TUG_STATION_ASTERN);
const t = (x - TUG_DEPARTURE_UNTIL) / TUG_RUN_OUT;
arcMetres = from + (station - from) * t;
speedMps = Math.abs(station - from) / (TUG_RUN_OUT * call.outboundSeconds);
outbound = station < from;
} else {
return null;
}
if (!isMakingWay(speedMps)) return null;
const at = alongPath(call.approach, arcMetres / call.approachMetres);
if (!at) return null;
const course = outbound ? normaliseDegrees(at.bearing + 180) : at.bearing;
// Off the ship's quarter rather than in her wake, so both Vs are drawn rather
// than one on top of the other.
const abeam = offsetMetres(at.lat, at.lng, normaliseDegrees(course + 90), TUG_STATION_ABEAM);
return {
id: `m-${key}:tug`,
kind: "tug",
lat: abeam.lat,
lon: abeam.lng,
speed: speedMps,
course,
heading: null,
navStatus: 0,
length,
beam: Math.round(beamFor("tug", length)),
ageSeconds: 0,
};
}
/** A hull lying alongside: no speed, no course, and therefore no wake. */
function alongsideFix(
id: string,
kind: VesselKind,
lat: number,
lng: number,
length: number,
): WireVessel {
return {
id,
kind,
lat,
lon: lng,
/**
* Zero, and it is the load-bearing zero in this file.
*
* A wake is a function of speed through water, so a ship that has just tied
* up must lose hers in the same fix that puts her on the berth — a quay
* lined with wakeless hulls and one long V curving in past the breakwater is
* a picture of a working harbour, and a moored ship trailing foam is a
* picture of a bug. `engine/vessels.ts` gates the wake on this number and
* nothing else.
*/
speed: 0,
course: null,
heading: null,
navStatus: 5,
length,
beam: Math.round(beamFor(kind, length)),
ageSeconds: 0,
};
}
/**
* The channel with a seaward leg on the front of it.
*
* The extension runs back along the reciprocal of the first charted leg, so a
* ship stands in on the course the channel is already pointing at rather than
* arriving from an invented direction.
*/
function seawardApproach(channel: readonly [number, number][]): [number, number][] {
const first = channel[0];
const second = channel[1];
if (!first || !second) return channel.map(([lat, lng]) => [lat, lng]);
const inbound = bearingBetween(first[0], first[1], second[0], second[1]);
const out = offsetMetres(
first[0],
first[1],
normaliseDegrees(inbound + 180),
APPROACH_SEAWARD_METRES,
);
return [[out.lat, out.lng], ...channel.map(([lat, lng]): [number, number] => [lat, lng])];
}
/**
* One berth's route in: the fairway as far as the point nearest the berth, then
* a straight run alongside. `null` when the channel does not reach it.
*/
function berthApproach(
fairway: readonly [number, number][],
berth: Berth,
): [number, number][] | null {
const best = nearestOnPath(fairway, berth.lat, berth.lng);
if (best === null || best.metres > BERTH_APPROACH_REACH_METRES) return null;
const leave: [number, number] = [best.lat, best.lng];
const path: [number, number][] = [];
for (let i = 0; i < best.index; i++) {
const point = fairway[i];
if (point) path.push([point[0], point[1]]);
}
const tail = path[path.length - 1];
if (!tail || metresBetween(leave[0], leave[1], tail[0], tail[1]) > 1) path.push(leave);
// The route ends where the ship lies, not on the wall she lies against, so
// that the last minute of the run in and the hours alongside are the same
// point and she does not step sideways the moment she is reported moored.
const lying = lyingPosition(berth, leave[0], leave[1]);
path.push([lying.lat, lying.lng]);
return path.length >= 2 ? path : null;
}
/**
* Where a hull lying at a berth actually floats: half a beam off the quay.
*
* `Berth.lat/lng` is a point **on the wall**, which is what a berth is — the
* quay's own coordinate, authored with the concrete. A hull centred on it has
* half its width inside the terminal, and the first photograph of the working
* day showed exactly that: eleven ships reported alongside and barely a hull
* visible, because each was buried to the centreline in its own quay and roofed
* by a crane rail.
*
* Which way is water is not in `Berth` and is not guessed. It is taken from a
* point that is definitely afloat — the place the ship left the fairway, or for
* a berth with no route the nearest point on the channel — and then squared up:
* the offset runs along whichever perpendicular to the **quay's own bearing**
* agrees with that direction, so a ship lies parallel to the wall however
* oblique her approach was.
*
* The offset is sized from the berth rather than from the ship, and that is
* deliberate: where a hull lies is a property of the fender line, so every ship
* on a 400 m berth lies on the same line whether she is 280 m or 350 m long. The
* beam used is the widest hull the berth can take — a full-length container ship
* is the broadest thing in `beamFor` that a deep-sea berth ever sees — so no
* ship's plating ever reaches back over the coping.
*/
function lyingPosition(
berth: Pick<Berth, "lat" | "lng" | "bearing" | "maxLength">,
towardLat: number,
towardLng: number,
): { lat: number; lng: number } {
const seaward = bearingBetween(berth.lat, berth.lng, towardLat, towardLng);
const side = normaliseDegrees(berth.bearing + 90);
const water = Math.abs(signedDelta(side, seaward)) <= 90 ? side : normaliseDegrees(side + 180);
const widest = berth.maxLength > 0 ? berth.maxLength : DEFAULT_HULL.container.length;
const beam = beamFor("container", widest * 0.88);
return offsetMetres(berth.lat, berth.lng, water, beam / 2 + BERTH_STANDOFF_METRES);
}
/** Fenders, camels and the gap a ship actually lies off a wall at, in metres. */
const BERTH_STANDOFF_METRES = 6;
/** The point on a polyline nearest a place, and how far off it is, in metres. */
function nearestOnPath(
path: readonly [number, number][],
lat: number,
lng: number,
): { lat: number; lng: number; index: number; t: number; metres: number } | null {
let best: { index: number; t: number; metres: number } | null = null;
for (let i = 1; i < path.length; i++) {
const a = path[i - 1];
const b = path[i];
if (!a || !b) continue;
const scale = Math.cos(((a[0] + b[0]) / 2) * DEG);
const bx = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * scale;
const by = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
const px = (lng - a[1]) * METRES_PER_DEGREE_LAT * scale;
const py = (lat - a[0]) * METRES_PER_DEGREE_LAT;
const square = bx * bx + by * by;
const t = square > 0 ? clamp01((px * bx + py * by) / square) : 0;
const metres = Math.hypot(px - bx * t, py - by * t);
if (best === null || metres < best.metres) best = { index: i, t, metres };
}
if (best === null) return null;
const a = path[best.index - 1];
const b = path[best.index];
if (!a || !b) return null;
return {
lat: a[0] + (b[0] - a[0]) * best.t,
lng: a[1] + (b[1] - a[1]) * best.t,
index: best.index,
t: best.t,
metres: best.metres,
};
}
/** Total length of a polyline, in metres. */
export function pathMetres(path: readonly [number, number][]): 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;
total += metresBetween(a[0], a[1], b[0], b[1]);
}
return total;
}
/** A point `metres` away on a true bearing. Flat-earth, over a few kilometres. */
export function offsetMetres(
lat: number,
lng: number,
bearingDegrees: number,
metres: number,
): { lat: number; lng: number } {
const radians = bearingDegrees * DEG;
const north = (Math.cos(radians) * metres) / METRES_PER_DEGREE_LAT;
const perDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(lat * DEG);
const east = perDegreeLng > 1 ? (Math.sin(radians) * metres) / perDegreeLng : 0;
return { lat: lat + north, lng: lng + east };
}
/** A berthed hull's length: most of the berth, never all of it. */
function berthLength(seed: number, key: string, berth: Berth, kind: VesselKind): number {
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.
*/
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
/** The kind of ship this call brought, from the berth it is for. */
function callKind(seed: number, key: string, call: HarbourCall): VesselKind {
return berthKind(seed, key, { maxLength: call.maxLength } as Berth);
}
function callLength(seed: number, key: string, call: HarbourCall, kind: VesselKind): number {
const fallback = DEFAULT_HULL[kind];
const maxLength = call.maxLength > 0 ? call.maxLength : fallback.length;
return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}
// ---- Arithmetic -----------------------------------------------------------
function readWireSpeed(speed: number | null | undefined): number | null {
@@ -964,10 +1659,3 @@ function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): Vesse
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";
}