bcac6aa41a
**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>
500 lines
20 KiB
TypeScript
500 lines
20 KiB
TypeScript
/**
|
|
* The vessel gate: three AIS sentinels, one berth, and the position that must
|
|
* never exist.
|
|
*
|
|
* Every assertion in this file is about a defect that **typechecks, throws
|
|
* nothing and renders a perfectly plausible harbour**. That is why they are
|
|
* here rather than left to a picture: a fleet of ships all facing due north
|
|
* looks like a design choice, a hull three kilometres out to sea looks like a
|
|
* hull three kilometres out to sea, and a ship cutting the corner of a
|
|
* breakwater looks like a ship. None of the three is visible in a still frame
|
|
* and all three are wrong.
|
|
*
|
|
* The load-bearing one is `cog 358.7 survives`. `cog % 360` is the obvious
|
|
* normalisation, it is what anybody would write, and it silently converts the
|
|
* "not available" sentinel — exactly 360.0 — into a course of zero, due north.
|
|
* Real course over ground reaches 358.7 in the store behind this feed, so a
|
|
* range check cannot separate them either: the value, and only the value, can.
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
|
|
import {
|
|
AIS_COURSE_UNAVAILABLE,
|
|
AIS_HEADING_UNAVAILABLE,
|
|
AIS_SOG_UNAVAILABLE_KN,
|
|
KNOTS_TO_MPS,
|
|
VESSEL_MAKING_WAY_MPS,
|
|
aisCourse,
|
|
aisHeading,
|
|
aisSpeedMps,
|
|
berthAnchors,
|
|
isMakingWay,
|
|
metresBetween,
|
|
modelHarbour,
|
|
MODELLED_INTERVAL_SECONDS,
|
|
promoteVessels,
|
|
reckonVessel,
|
|
resolveBearing,
|
|
vesselStatus,
|
|
vesselSummary,
|
|
type BerthAnchor,
|
|
type VesselBounds,
|
|
} from "../../server/vessels.ts";
|
|
import type { Port } from "../../engine/types.ts";
|
|
import type { VesselsBody, WireVessel } from "../../server/wire.ts";
|
|
|
|
// ---- Fixtures --------------------------------------------------------------
|
|
|
|
const SAN_PEDRO: VesselBounds = {
|
|
minLat: 33.55,
|
|
maxLat: 33.85,
|
|
minLng: -118.4,
|
|
maxLng: -118.0,
|
|
};
|
|
|
|
/**
|
|
* One berth on Pier 400, bearing 118° — a hull lying alongside it points
|
|
* east-south-east. Hand-typed like everything else on these boards; the point of
|
|
* it here is that the number is *authored* and therefore known before any ship
|
|
* arrives.
|
|
*/
|
|
const BERTH: BerthAnchor = { id: "uslax-p400-a", lat: 33.72, lng: -118.24, bearing: 118 };
|
|
|
|
function wire(overrides: Partial<WireVessel> = {}): WireVessel {
|
|
return {
|
|
id: "w-1",
|
|
kind: "container",
|
|
lat: 33.72,
|
|
lon: -118.24,
|
|
speed: 0,
|
|
course: null,
|
|
heading: null,
|
|
navStatus: 5,
|
|
length: 300,
|
|
beam: 45,
|
|
ageSeconds: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function body(vessels: WireVessel[], source: VesselsBody["source"] = "cloud1"): VesselsBody {
|
|
return {
|
|
source,
|
|
fetchedAt: "2026-08-22T02:00:00.000Z",
|
|
vessels,
|
|
intervalSeconds: 900,
|
|
ttlSeconds: 900,
|
|
};
|
|
}
|
|
|
|
// ---- The three sentinels ---------------------------------------------------
|
|
|
|
describe("the AIS sentinels, none of which is ever NULL", () => {
|
|
it("rejects sog 102.3, which is 'not available' and 52.6 m/s", () => {
|
|
assert.equal(aisSpeedMps(AIS_SOG_UNAVAILABLE_KN), null);
|
|
assert.equal(aisSpeedMps(102.3), null);
|
|
// Sixty seconds of it is 3.2 km — eight SoCal scene units — which is the
|
|
// whole reason this is a rejection and not a clamp.
|
|
assert.ok(AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS * 60 > 3_000);
|
|
});
|
|
|
|
it("keeps a real speed, including the zero half the fleet reports", () => {
|
|
assert.equal(aisSpeedMps(0), 0);
|
|
assert.ok(Math.abs((aisSpeedMps(12) ?? 0) - 12 * KNOTS_TO_MPS) < 1e-9);
|
|
// Zero is an answer and not an absence: 554 of 1,138 fixes in the store are
|
|
// exactly 0.0, and conflating them with "unknown" would stop every moored
|
|
// ship in the product being drawn.
|
|
assert.notEqual(aisSpeedMps(0), null);
|
|
});
|
|
|
|
it("rejects heading 511, which 40% of fixes carry", () => {
|
|
assert.equal(aisHeading(AIS_HEADING_UNAVAILABLE), null);
|
|
assert.equal(aisHeading(511), null);
|
|
assert.equal(aisHeading(0), 0);
|
|
assert.equal(aisHeading(359.9), 359.9);
|
|
});
|
|
|
|
it("rejects cog exactly 360.0", () => {
|
|
assert.equal(aisCourse(AIS_COURSE_UNAVAILABLE), null);
|
|
assert.equal(aisCourse(360), null);
|
|
assert.equal(aisCourse(360.0), null);
|
|
});
|
|
|
|
it("ACCEPTS cog 358.7 — the assertion this whole file exists for", () => {
|
|
// Real course over ground reaches 358.7, and 355.0, 355.4, 355.7, 356.3,
|
|
// 356.9 and 357.0 all occur in the same store. A naive range check near
|
|
// north, or a `% 360`, eats every one of them or turns the sentinel into a
|
|
// course. Neither may happen.
|
|
assert.equal(aisCourse(358.7), 358.7);
|
|
for (const cog of [355.0, 355.4, 355.7, 356.3, 356.9, 357.0, 358.7, 359.99]) {
|
|
assert.equal(aisCourse(cog), cog, `real course ${cog} was eaten by the gate`);
|
|
}
|
|
// And the trap itself, stated so nobody reintroduces it: `% 360` maps the
|
|
// sentinel onto a perfectly good course.
|
|
assert.equal(AIS_COURSE_UNAVAILABLE % 360, 0);
|
|
assert.notEqual(aisCourse(AIS_COURSE_UNAVAILABLE), 0);
|
|
});
|
|
|
|
it("survives garbage without throwing", () => {
|
|
for (const bad of [null, undefined, Number.NaN, Infinity, -1]) {
|
|
assert.equal(aisSpeedMps(bad as number), null);
|
|
assert.equal(aisHeading(bad as number), null);
|
|
assert.equal(aisCourse(bad as number), null);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---- Motion is gated on speed, never on nav_status -------------------------
|
|
|
|
describe("speed gates the motion; nav_status only labels it", () => {
|
|
it("treats nav_status 0 at 0.2 kn as stopped", () => {
|
|
// 83 of 197 vessels reporting "under way using engine" are under half a
|
|
// knot. The status is a word on a card; the speed is the fact.
|
|
const speed = aisSpeedMps(0.2);
|
|
assert.notEqual(speed, null);
|
|
assert.equal(isMakingWay(speed), false);
|
|
assert.equal(vesselStatus(0), "under-way");
|
|
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: 33.7, lon: -118.2, speed: 0.2 * KNOTS_TO_MPS, navStatus: 0, course: 90 })]),
|
|
SAN_PEDRO,
|
|
[],
|
|
);
|
|
const drawn = promotion.drawn[0];
|
|
assert.ok(drawn, "the hull was dropped rather than drawn stopped");
|
|
assert.equal(drawn.status, "under-way", "the label must survive");
|
|
assert.equal(drawn.speed, 0, "the motion must not");
|
|
assert.equal(promotion.makingWay, 0);
|
|
});
|
|
|
|
it("lets a hull at 6 kn make way", () => {
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: 33.7, lon: -118.2, speed: 6 * KNOTS_TO_MPS, navStatus: 0, course: 210 })]),
|
|
SAN_PEDRO,
|
|
[],
|
|
);
|
|
assert.equal(promotion.makingWay, 1);
|
|
assert.ok((promotion.drawn[0]?.speed ?? 0) >= VESSEL_MAKING_WAY_MPS);
|
|
});
|
|
|
|
it("refuses a wire speed that is the sentinel in metres per second", () => {
|
|
// The upstream half of this feed is in another repo on another box and does
|
|
// not exist yet, so "already stripped" is a promise nobody can keep today.
|
|
const promotion = promoteVessels(
|
|
body([wire({ speed: AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS })]),
|
|
SAN_PEDRO,
|
|
[BERTH],
|
|
);
|
|
assert.equal(promotion.drawn.length, 0);
|
|
assert.equal(promotion.suppressed, 1);
|
|
});
|
|
});
|
|
|
|
// ---- Orientation comes from the berth --------------------------------------
|
|
|
|
describe("a berthed hull points the way its quay does", () => {
|
|
it("gives a stopped hull with no heading and no cog its berth's bearing", () => {
|
|
// 21 of 150 stopped vessels have neither heading nor cog, and only 75 of the
|
|
// 150 have a heading at all. This is the case the layer must not be spun by.
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: BERTH.lat, lon: BERTH.lng, heading: null, course: null, speed: 0 })]),
|
|
SAN_PEDRO,
|
|
[BERTH],
|
|
);
|
|
const drawn = promotion.drawn[0];
|
|
assert.ok(drawn, "a hull with no orientation from the wire was dropped");
|
|
assert.equal(drawn.bearing, BERTH.bearing);
|
|
assert.equal(drawn.berthId, BERTH.id);
|
|
assert.equal(promotion.alongside, 1);
|
|
});
|
|
|
|
it("lets the wire nudge a berthed hull, and never swing it", () => {
|
|
const nudged = resolveBearing({ heading: 121, course: null, speedMps: 0, berthBearing: 118 });
|
|
assert.equal(nudged, 121);
|
|
// A heading 180 degrees off the quay is an AIS unit that is wrong about
|
|
// which end is the bow, not a ship moored backwards. The concrete wins.
|
|
const absurd = resolveBearing({ heading: 298, course: null, speedMps: 0, berthBearing: 118 });
|
|
assert.ok(absurd !== null && Math.abs(absurd - 118) <= 3, `berth bearing was swung to ${absurd}`);
|
|
});
|
|
|
|
it("uses the course for a hull making way, and the heading for one at anchor", () => {
|
|
assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 5, berthBearing: null }), 210);
|
|
assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 0, berthBearing: null }), 30);
|
|
});
|
|
|
|
it("withholds a hull nothing will orient, rather than inventing an angle", () => {
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: 33.6, lon: -118.1, heading: null, course: null, speed: 0 })]),
|
|
SAN_PEDRO,
|
|
[BERTH],
|
|
);
|
|
assert.equal(promotion.drawn.length, 0);
|
|
assert.equal(promotion.withoutOrientation, 1);
|
|
assert.equal(promotion.suppressed, 0, "an unoriented hull is not the same as an unreadable one");
|
|
assert.match(vesselSummary(promotion), /which way/);
|
|
});
|
|
|
|
it("does not reach across the harbour for a berth", () => {
|
|
// A berth is one authored point and the reach is 400 m; a hull a kilometre
|
|
// away is not lying on it.
|
|
assert.ok(metresBetween(BERTH.lat, BERTH.lng, 33.73, -118.24) > 400);
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: 33.73, lon: -118.24, heading: 44, course: null, speed: 0 })]),
|
|
SAN_PEDRO,
|
|
[BERTH],
|
|
);
|
|
assert.equal(promotion.drawn[0]?.bearing, 44, "the wire heading should stand off the berth");
|
|
assert.equal(promotion.drawn[0]?.berthId, undefined);
|
|
});
|
|
});
|
|
|
|
// ---- Dead reckoning, and the position that must not exist ------------------
|
|
|
|
describe("motion is along the reported course and never between two fixes", () => {
|
|
/**
|
|
* Two fixes fifteen minutes apart, on a hull that turned.
|
|
*
|
|
* `A` reports a course of 090 — due east — and eight knots. Fifteen minutes
|
|
* later it is reported at `B`, which is to the *south* east, because it came
|
|
* round the breakwater in between. The chord from A to B is therefore a line
|
|
* nothing sailed, and every point on it except the ends is a place the ship
|
|
* never was.
|
|
*/
|
|
const A = { lat: 33.72, lng: -118.28, speed: 8 * KNOTS_TO_MPS, course: 90 };
|
|
const B = { lat: 33.69, lng: -118.2 };
|
|
|
|
it("advances along the course at the speed", () => {
|
|
const after = reckonVessel(A, 450);
|
|
const metres = metresBetween(A.lat, A.lng, after.lat, after.lng);
|
|
assert.ok(
|
|
Math.abs(metres - A.speed * 450) < 1,
|
|
`advanced ${metres.toFixed(1)} m instead of ${(A.speed * 450).toFixed(1)}`,
|
|
);
|
|
// Due east means the latitude does not move.
|
|
assert.ok(Math.abs(after.lat - A.lat) < 1e-9, "a course of 090 changed the latitude");
|
|
assert.ok(after.lng > A.lng, "a course of 090 went west");
|
|
});
|
|
|
|
it("does not arrive at the second fix, which is what an interpolator does", () => {
|
|
// The crispest statement of the rule. An interpolator hands back exactly `B`
|
|
// at the end of the interval; a dead-reckoner hands back wherever the
|
|
// reported course took the ship, which here is five kilometres away because
|
|
// the ship turned and the course did not say so until the next fix.
|
|
const atInterval = reckonVessel(A, 900);
|
|
const missBy = metresBetween(atInterval.lat, atInterval.lng, B.lat, B.lng);
|
|
assert.ok(missBy > 3_000, `reckoning landed ${missBy.toFixed(0)} m from the second fix`);
|
|
});
|
|
|
|
it("never lands on the chord between two fixes", () => {
|
|
// The geometric statement of "no interpolation": walk the interval and
|
|
// assert every reckoned point stays clear of the segment A->B. It cannot be
|
|
// otherwise, because `reckonVessel` is handed one fix and has no second
|
|
// point to reach toward — but that is the property being pinned.
|
|
//
|
|
// From 150 s rather than from zero, because the first fix *is* an endpoint
|
|
// of the chord and the ship genuinely was there: a clearance test that
|
|
// started at t=0 would be asserting the ship was never at its own reported
|
|
// position.
|
|
let closest = Infinity;
|
|
for (let t = 150; t <= 900; t += 30) {
|
|
const at = reckonVessel(A, t);
|
|
closest = Math.min(closest, metresToSegment(at, A, B));
|
|
}
|
|
assert.ok(
|
|
closest > 200,
|
|
`a reckoned position came within ${closest.toFixed(0)} m of the chord between two fixes`,
|
|
);
|
|
});
|
|
|
|
it("stops rather than sailing on for ever once the next fix is overdue", () => {
|
|
const atLimit = reckonVessel(A, 900);
|
|
const wayPast = reckonVessel(A, 4_000);
|
|
assert.deepEqual(wayPast, atLimit);
|
|
});
|
|
|
|
it("does not move a hull with no course, whatever its speed says", () => {
|
|
const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 6, course: null }, 600);
|
|
assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
|
|
});
|
|
|
|
it("does not move a stopped hull", () => {
|
|
const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 0, course: 90 }, 600);
|
|
assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
|
|
});
|
|
});
|
|
|
|
// ---- The empty state, designed first ---------------------------------------
|
|
|
|
describe("the harbour with nothing in it says which kind of nothing it is", () => {
|
|
it("distinguishes an unconfigured feed from an empty board", () => {
|
|
const unconfigured = promoteVessels(null, SAN_PEDRO, []);
|
|
assert.equal(unconfigured.source, "none");
|
|
assert.match(vesselSummary(unconfigured), /No vessel feed is configured/i);
|
|
|
|
const answered = promoteVessels(body([]), SAN_PEDRO, []);
|
|
assert.equal(answered.source, "cloud1");
|
|
assert.match(vesselSummary(answered), /answered/i);
|
|
});
|
|
|
|
it("counts what it withheld rather than going blank", () => {
|
|
const promotion = promoteVessels(
|
|
body([
|
|
wire({ id: "off", lat: 30.0, lon: -118.2, heading: 10 }),
|
|
wire({ id: "bad", lat: Number.NaN, heading: 10 }),
|
|
wire({ id: "blind", lat: 33.6, lon: -118.1, heading: null, course: null }),
|
|
]),
|
|
SAN_PEDRO,
|
|
[],
|
|
);
|
|
assert.equal(promotion.drawn.length, 0);
|
|
assert.equal(promotion.offBoard, 1);
|
|
assert.equal(promotion.suppressed, 1);
|
|
assert.equal(promotion.withoutOrientation, 1);
|
|
const summary = vesselSummary(promotion);
|
|
assert.match(summary, /outside the frame/);
|
|
assert.match(summary, /unreadable/);
|
|
});
|
|
|
|
it("never labels a hull laden or in ballast", () => {
|
|
const promotion = promoteVessels(
|
|
body([wire({ lat: BERTH.lat, lon: BERTH.lng })]),
|
|
SAN_PEDRO,
|
|
[BERTH],
|
|
);
|
|
// The owner asked "whether they are empty or full". The honest answer is a
|
|
// port figure — 348,691 of 460,467 boxes left Los Angeles empty in July
|
|
// 2026 — and it is never attached to a hull, because `vessels` carries no
|
|
// draught column and the static AIS message is absent for most ships.
|
|
assert.match(vesselSummary(promotion), /not a ship one/);
|
|
assert.equal("draught" in (promotion.drawn[0] ?? {}), false);
|
|
});
|
|
});
|
|
|
|
// ---- The modelled harbour --------------------------------------------------
|
|
|
|
const PORT: Port = {
|
|
id: "USLAX",
|
|
name: "Port of Los Angeles",
|
|
lat: 33.73,
|
|
lng: -118.26,
|
|
harborType: "CB",
|
|
channel: [
|
|
[33.705, -118.26],
|
|
[33.72, -118.255],
|
|
[33.74, -118.25],
|
|
],
|
|
berths: [
|
|
{ id: "a", lat: 33.735, lng: -118.262, bearing: 118, maxLength: 400 },
|
|
{ id: "b", lat: 33.737, lng: -118.259, bearing: 118, maxLength: 400 },
|
|
{ id: "c", lat: 33.739, lng: -118.256, bearing: 118, maxLength: 340 },
|
|
{ id: "d", lat: 33.741, lng: -118.253, bearing: 296, maxLength: 120 },
|
|
],
|
|
};
|
|
|
|
describe("the modelled harbour, which is what runs this round", () => {
|
|
it("is deterministic — two people see the same ships", () => {
|
|
const a = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
|
|
const b = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
|
|
assert.deepEqual(a, b);
|
|
const other = modelHarbour([PORT], { seed: 116, atMs: 1_000_000 });
|
|
assert.notDeepEqual(other.vessels.map((v) => v.id), []);
|
|
});
|
|
|
|
it("carries no name, no MMSI, no callsign and no destination", () => {
|
|
// The store has CSCL INDIAN OCEAN and EVER LOVELY in it right now, and
|
|
// hardcoding them would be the fire layer's twenty-two orange marks in a
|
|
// nicer costume. Identity arrives with a licence entry or not at all.
|
|
for (const vessel of modelHarbour([PORT], { seed: 115 }).vessels) {
|
|
for (const forbidden of ["name", "mmsi", "callsign", "destination", "draught", "laden"]) {
|
|
assert.equal(forbidden in vessel, false, `a modelled vessel carried ${forbidden}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("says it is modelled, and the panel says so too", () => {
|
|
const modelled = modelHarbour([PORT], { seed: 115 });
|
|
assert.equal(modelled.source, "modelled");
|
|
/**
|
|
* A minute, not the fifteen a real AIS listener declares.
|
|
*
|
|
* `intervalSeconds` is a property of the *source*, and this source is a
|
|
* closed form of the clock that can be asked for any instant — see
|
|
* `MODELLED_INTERVAL_SECONDS`. Fifteen minutes was mimicry, and it drew
|
|
* arriving ships over Terminal Island, because a consumer dead-reckons along
|
|
* a straight course and the Main Channel bends. A real feed still arrives
|
|
* declaring its own 900 and is still dead-reckoned for 900.
|
|
*/
|
|
assert.equal(modelled.intervalSeconds, MODELLED_INTERVAL_SECONDS);
|
|
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
|
|
assert.match(vesselSummary(promotion), /Modelled/);
|
|
assert.match(vesselSummary(promotion), /no names and no MMSIs/);
|
|
});
|
|
|
|
it("volunteers no heading, so the berths have to do the work", () => {
|
|
const modelled = modelHarbour([PORT], { seed: 115 });
|
|
assert.ok(modelled.vessels.length > 0);
|
|
for (const vessel of modelled.vessels) assert.equal(vessel.heading, null);
|
|
|
|
const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
|
|
assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
|
|
for (const drawn of promotion.drawn) {
|
|
if (!drawn.berthId) continue;
|
|
const berth = PORT.berths?.find((b) => b.id === drawn.berthId);
|
|
assert.equal(drawn.bearing, berth?.bearing);
|
|
}
|
|
});
|
|
|
|
it("puts a handful under way on the channel and the rest alongside", () => {
|
|
const promotion = promoteVessels(
|
|
modelHarbour([PORT], { seed: 115, underWayPerPort: 3 }),
|
|
SAN_PEDRO,
|
|
berthAnchors([PORT]),
|
|
);
|
|
/**
|
|
* A band, not a number, and the band is the honest assertion.
|
|
*
|
|
* `underWayPerPort` sizes the berth cycle so that the transits add up to the
|
|
* target, but the legs differ in length and each arriving or departing ship
|
|
* may have a tug attending her, so the count breathes. What must hold is
|
|
* that the channel is neither empty nor a traffic jam.
|
|
*/
|
|
assert.ok(promotion.makingWay >= 2, `only ${promotion.makingWay} under way`);
|
|
assert.ok(promotion.makingWay <= 8, `${promotion.makingWay} under way is a jam`);
|
|
assert.ok(promotion.alongside >= 1, "the quays came out empty");
|
|
// Every moving hull has a course, or the layer could not reckon it and would
|
|
// not draw a wake — which is the one thing that reads at board scale.
|
|
for (const drawn of promotion.drawn) {
|
|
if (drawn.speed > 0) assert.notEqual(drawn.course, null);
|
|
}
|
|
});
|
|
|
|
it("draws nothing at all for a board with no ports", () => {
|
|
const modelled = modelHarbour(undefined, { seed: 115 });
|
|
assert.deepEqual(modelled.vessels, []);
|
|
const promotion = promoteVessels(modelled, SAN_PEDRO, []);
|
|
assert.deepEqual(promotion.drawn, []);
|
|
});
|
|
});
|
|
|
|
// ---- Geometry helper -------------------------------------------------------
|
|
|
|
/** Metres from a point to the segment `a`-`b`, in the flat local approximation. */
|
|
function metresToSegment(
|
|
p: { lat: number; lng: number },
|
|
a: { lat: number; lng: number },
|
|
b: { lat: number; lng: number },
|
|
): number {
|
|
const scale = Math.cos((a.lat * Math.PI) / 180);
|
|
const px = (p.lng - a.lng) * scale;
|
|
const py = p.lat - a.lat;
|
|
const bx = (b.lng - a.lng) * scale;
|
|
const by = b.lat - a.lat;
|
|
const denominator = bx * bx + by * by;
|
|
const t = denominator > 0 ? Math.max(0, Math.min(1, (px * bx + py * by) / denominator)) : 0;
|
|
const dx = px - bx * t;
|
|
const dy = py - by * t;
|
|
return Math.hypot(dx, dy) * 111_320;
|
|
}
|