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_COMMITb7f5c41, FILMS_COMMIT2aa4049, 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:
+762
-74
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user