From acf4d1a51071d9f956783193db389eb68080a569 Mon Sep 17 00:00:00 2001 From: Kartios Date: Sat, 22 Aug 2026 23:35:09 -0700 Subject: [PATCH] feat: the state becomes California, and the port fills with ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Stage 1 of one California.** The owner's complaint had two halves and this is the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N, so the board disagreed with its own minimap about the shape of California in a single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino, the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal. **And it got cheaper.** 391,169 triangles to 375,351, while gaining the North Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame whatever it is given. That argument was already written in the pack's own comment. The second half — three boards becoming one world you zoom through — is NOT here. Merging at Bay density would be 34.04M triangles, 13x the highest budget, and merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m. Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts` are untouched by design. **Aerial perspective, which the state board could not have had before.** The old fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is 820 km — so no pixel could ever be fogged. Fog now responds to camera altitude, clamped to the authored pair as a ceiling. `Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }` and **deliberately no colour**. That is structural, not stylistic: it is why a future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint and start rebuilding the PMREM cubemap on every camera step. Coarsening the fingerprint instead would have hidden one instance and armed the mechanism. A mutation-tested seam guard fails if anyone merges the two paths back together. **The port.** Terminal Island rendered as a bare tan polygon with generic white blocks while the chapter text called it the busiest port complex in the hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five buckets merging ACROSS ports the way airports.ts merges across fields, so a second complex costs no extra draws: +11 draws and +4,377 triangles for all of it. At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m ship's 1.024 long — the crane is the taller object, and it is what makes a port read as a port from altitude. **Ships, and the wake carries the information.** Moored hulls have no foam, verified at three terminals; a tug under way in the Main Channel trails a clean Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean "not available" — with an explicit test that cog 358.7 SURVIVES, because a naive range check on cog eats real headings near north. "Empty or full" is not in AIS position reports and is not invented per ship. The honest answer is at port level and is a better story: 348,691 of 460,467 boxes left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against FBX02 $347 outbound. **Radar and birds ship dark, and say why.** California is 0.47% wet and migration is nocturnal and seasonal, so both layers have nothing to say on most days. The panel reads "No radar feed is configured, so this board draws no weather. That is a fact about this box, not about the sky." Also recorded, and it matters beyond this commit: **the GPU on amd-server never leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load. `bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline, so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry identical to the digit. Every frame-time number measured on this box is a floor. Two investigations reached two different wrong conclusions from single-run comparisons before this was traced. Geometry is the gate; frame time is advisory. No cap was raised. Tests 1,340 -> 1,540, server 280 -> 295. Co-Authored-By: Claude Opus 5 (1M context) --- ARCHITECTURE.md | 259 ++++ TODO.md | 189 ++- index.html | 21 + scripts/performance-budgets.json | 14 +- server/src/app.ts | 12 + server/src/birds/cloud1.ts | 188 +++ server/src/birds/index.ts | 122 ++ server/src/config.ts | 95 ++ server/src/radar/cloud1.ts | 199 +++ server/src/radar/index.ts | 113 ++ server/src/routes/birds.ts | 27 + server/src/routes/health.ts | 8 + server/src/routes/radar.ts | 34 + server/src/test/sky.test.ts | 406 ++++++ src/access.ts | 22 +- src/adapters/http.ts | 40 + src/assets/radarRamp.ts | 141 +++ src/cities/california.ts | 636 ++++++++-- src/cities/socal.ts | 497 +++++++- src/engine/atmosphere.ts | 300 ++++- src/engine/clouds.ts | 17 + src/engine/fireSmoke.ts | 7 + src/engine/fires.ts | 11 + src/engine/migration.ts | 516 ++++++++ src/engine/ports.ts | 1367 +++++++++++++++++++++ src/engine/precip.ts | 508 ++++++++ src/engine/scene.ts | 284 ++++- src/engine/scenekit.ts | 26 + src/engine/types.ts | 432 +++++++ src/engine/vessels.ts | 981 +++++++++++++++ src/main.ts | 450 ++++++- src/server/birds.ts | 400 ++++++ src/server/radar.ts | 735 +++++++++++ src/server/vessels.ts | 973 +++++++++++++++ src/server/wire.ts | 235 ++++ src/test/californiaCity.test.ts | 30 +- src/test/data/birdsGate.test.ts | 253 ++++ src/test/data/radarGate.test.ts | 316 +++++ src/test/data/vesselGate.test.ts | 479 ++++++++ src/test/data/wireContract.test.ts | 182 +++ src/test/integration/barrel.test.ts | 63 +- src/test/integration/layerSeams.test.ts | 241 ++++ src/test/integration/sceneWiring.test.ts | 52 + src/test/packs/californiaBoard.test.ts | 38 +- src/test/packs/californiaExtent.test.ts | 183 +++ src/test/packs/socalPorts.test.ts | 378 ++++++ src/test/render/aerialPerspective.test.ts | 457 +++++++ src/test/render/migration.test.ts | 345 ++++++ src/test/render/ports.test.ts | 365 ++++++ src/test/render/precip.test.ts | 344 ++++++ src/test/render/vessels.test.ts | 410 ++++++ src/test/vehicle/vesselScale.test.ts | 177 +++ 52 files changed, 14436 insertions(+), 142 deletions(-) create mode 100644 server/src/birds/cloud1.ts create mode 100644 server/src/birds/index.ts create mode 100644 server/src/radar/cloud1.ts create mode 100644 server/src/radar/index.ts create mode 100644 server/src/routes/birds.ts create mode 100644 server/src/routes/radar.ts create mode 100644 server/src/test/sky.test.ts create mode 100644 src/assets/radarRamp.ts create mode 100644 src/engine/migration.ts create mode 100644 src/engine/ports.ts create mode 100644 src/engine/precip.ts create mode 100644 src/engine/vessels.ts create mode 100644 src/server/birds.ts create mode 100644 src/server/radar.ts create mode 100644 src/server/vessels.ts create mode 100644 src/test/data/birdsGate.test.ts create mode 100644 src/test/data/radarGate.test.ts create mode 100644 src/test/data/vesselGate.test.ts create mode 100644 src/test/integration/layerSeams.test.ts create mode 100644 src/test/packs/californiaExtent.test.ts create mode 100644 src/test/packs/socalPorts.test.ts create mode 100644 src/test/render/aerialPerspective.test.ts create mode 100644 src/test/render/migration.test.ts create mode 100644 src/test/render/ports.test.ts create mode 100644 src/test/render/precip.test.ts create mode 100644 src/test/render/vessels.test.ts create mode 100644 src/test/vehicle/vesselScale.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 15ee583..01205f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -704,3 +704,262 @@ entered from California than from the Southland, because the two rectangles contain different fires. `smokeCaption`'s sentence is worded for exactly that — "drawn from the fires currently on the board" — and the alternative, a statewide query per building, is a second source of truth about the same sky. + +--- + +## 12. The state board is the state, and what that cost + +The default board — the first frame an anonymous visitor sees — used to stop at +38.05 N. The minimap in the corner of the same frame drew the whole of +California from the same pack, so one screenshot contained a picture of the +state and a picture of two thirds of the state, disagreeing about the shape of +the one silhouette in this product that everybody already knows. + +`bounds` is now 32.50–42.05 N and -124.50 to -114.0 W: the Mexican border, the +Oregon line, Cape Mendocino with eight kilometres of ocean west of it, and the +Colorado past Parker. All four edges are the state's own. + +### 12.1 The price was paid in the cell, not in the bounds + +Extending north and west at the old 0.022° × 0.027° spacing takes the lattice +from **84,924 points to 168,813** — 2.02× — and the terrain mesh with it, +against a mobile budget that had 70,000 triangles spare and two more layers +landing on the same board in the same round. Extending the bounds alone does not +fit and was never going to. + +Coarsening the cell by 1.42× in each axis puts it back. The argument is the one +`california.ts` already made when the board grew east and it is worth stating +once more here, because it is counterintuitive: **what the eye sees is the cell +as a fraction of the board, not the cell in metres**, because the camera +retreats to frame whatever it is given. + +| board | cell | span | cell / span | +| --- | --- | --- | --- | +| the corridor, before the east | 0.020° | 284 u | 0.0041 | +| the southern two thirds | 0.022° | 428 u | 0.0030 | +| the whole state | 0.0312° | 554 u | 0.0033 | + +The ground cell went from 2,449 m to 3,473 m — 42% coarser on the earth — and +*finer* in the frame than the board two revisions ago. Measured after: 85,008 +lattice points against 84,924, and 71,876 terrain triangles against 87,074. + +`verticalExaggeration` went 13 → 15 in the same change, and for the same kind of +reason. Relief in the frame is peak-units over board *span*: at 13 the extended +board is 6.4% where the old one was 8.3%, and 15 puts it at 7.4%, which is +Southern California's 7.5% almost exactly. Buildings share the exaggeration and +get *smaller* in frame, not larger — a 250 m tower is 0.0035 of a span where it +was 0.0040 — so the "bed of nails" objection the old comment raises about +pushing to 20 does not apply at 15 on a board this size. + +### 12.2 Fog is a function of camera altitude. Almost nothing else became metric + +An investigation proposed converting every span-derived constant in the engine +to physical metres, on the grounds that the three boards disagree by 8× once +converted and San Francisco is the one that is right. **The diagnosis is +correct and the prescription is not**, and the arithmetic is worth keeping: +86 km of clear-day visibility is 0.91 spans on the Bay Area's 1,003-unit board +and **0.085 spans on the extended state board**, where the camera orbits out to +1,108 units. Literal metre fog buries California in haze 47 units from the lens +and there is no pose on that board from which the state is visible at all. + +So exactly two things changed, and the rule for deciding which is stated here so +the next person does not have to re-derive it: + +- **Fog near and far** are now a function of camera altitude — + `aerialReach()` in `atmosphere.ts` — clamped so the pair the board was + authored with is the ceiling and is never exceeded. It saturates at any + whole-board pose, which is what leaves every existing marketing still + untouched *by construction rather than by measurement*: the Bay Area's opening + chapter sits 40.6 km up and the curve reaches its ceiling by 6.7 km. +- **Cloud base altitude** was checked and did not move. It was already + `BASE_ALTITUDE_M = 1350` passed through `world.metres()`, which is a physical + metre constant respecting each pack's own exaggeration. Nothing to convert. + +Everything else stays a span, and the distinction is not "physical vs lazy" — it +is **whether the constant describes the air or describes the composition**: + +| constant | stays a span, because | +| --- | --- | +| `clouds.ts` `TILE_SPANS` (2.2) and the 14×14 cell layout | tuned over 190 photographed frames; a cell-ownership chain, not a distance | +| `satellites.ts` dome radius | the file says outright that it is arbitrary — it is where the dome looks right | +| `fires.ts` `MARK_SPAN_FRACTION` | a glyph sized to be legible, like the aeroplane's 0.42-unit clamp | +| `terrain.ts` `SWELL_TILE_SPANS` (1/34) | a physically-scaled sea has no visible surface at all; the file argues it at length | +| camera `far`, `shadowExtent`, orbit limits, minimap layout | properties of a board, not of an atmosphere | + +The one number a physical model got wrong on its own is worth recording, because +it was found by taking a photograph rather than by thinking. A camera parked +223 km from a mountain and 8 km above the ground is, physically, looking at +something it cannot see — the air supports 231 km and the subject is at 223 of +it — and the frame came back as a white rectangle with a faint cone in it. A +board is a **map**, and a map is looked at from outside the atmosphere it +depicts, which is the same argument `minVisibilityM` was already making. So the +fog is floored at **6× the camera's own stand-off** (`AERIAL_SUBJECT_CLEARANCE`), +which with `main.ts`'s 1.15/3.9 ratio puts the near plane at 1.77 stand-offs: +nothing closer than about 1.8× the camera's distance to its subject is fogged at +all, and everything past that hazes out toward six. + +Six rather than four is set by one measurement, not by taste, and it is what +makes "leaves the existing boards untouched by construction" true rather than +approximately true. California's opening pose is 67.6 km up and Southern +California's 17.0 km, so both saturate on the air term alone. The Bay Area's is +11.3 km up against a 12.4 km saturation altitude — 324 km of air against 369 km +of authored reach — and it is the stand-off term that carries it over. At a +clearance of 2.4 that board renders at **88%** of its authored reach and the far +corner picks up haze it has never had, which would have been a silent change to +every still shot from the board that carries most of the product's imagery. +`aerialPerspective.test.ts` asserts all three against the deployed formula, not +against `cityDaylight`'s narrower one, because the narrower one is not what any +board on the page uses. + +### 12.3 What the north is made of + +The added land is authored, not permitted. Hand-traced by house rule (§3.2): the +North Coast from Bodega Head to the Oregon line with Cape Mendocino at -124.41, +the 120th meridian and the corner at Lake Tahoe, the Klamath knot as four chains +at four bearings because the Klamaths genuinely do not run north-south, the +southern Cascades, the northern Sierra tapering out into the Cascade arc, the +Warner Mountains alone in the north-east corner, and the Sacramento Valley as a +chain rather than a circle. + +Two failures were made and photographed on the way, both of them re-runs of +mistakes this pack already documents: + +- **The northern Sierra shipped as a row of separate domes.** Six hand-placed + bells 0.35° apart on a 0.24–0.26 radius is 1.4 radii, and a chain only reads + as a ridge below about 0.9. `ridge()` computes the spacing; the peaks that + are written by hand are the ones somebody has checked. +- **Mount Shasta rendered as a 4,320 m cone of pine.** `groundColor` answers + `inPark` **first**, so a park envelope paints its ground green at any altitude + and the `alpine` stop is unreachable inside one. The Cascade forest is now two + polygons with the mountain in the gap between them — which is also true on the + ground, since the Shasta Valley west of the cone is treeless grassland. + +No new inland water. Clear Lake, Shasta Lake and Lake Tahoe are all legible at +this scale and all three were rejected for the same reason: `createWater` puts +every `inlandWater` polygon at y = 0.05, which is sea level, and those three sit +at 400 m, 320 m and 1,897 m. At an exaggeration of 15 a lake plate at sea level +under ground standing 3 to 15 units above it is a hole, not a lake. The Salton +Sea works because it really is below sea level. Each polygon is also its own +draw call, and the California mobile cell is the tightest in the project. + +## 13. Four layers, one seam, and two gates each + +The port kit, the ships, the reflectivity raster and the migration field arrived +as four independent workstreams and reach the board through one seam: +`SceneOptions` carries four optional factories — `ports`, `vessels`, `precip`, +`migration` — and `createScene` builds, lights, ticks and disposes each of them +exactly as it does `fires`. Every one of those five touch points is a line +somebody can delete without breaking a compile, so +`src/test/integration/layerSeams.test.ts` asserts all five from the source, at +both ends of the seam: the factory is supplied by `main.ts` and it is consumed by +`scene.ts`. + +**A layer that is not supplied costs nothing.** Not "draws nothing" — is never +visited: no group, no material, no geometry, no draw call. That is the whole +reason the slots are optional rather than a `visible` flag, and it is what the +budget shows. The California mobile cell measures **370 draws and 373,825 +triangles** with the two sky layers wired and no feed behind them, which is the +number it measured before they existed. + +### 13.1 The two gates + +Each layer answers two questions, and they are different questions: + +| Layer | Board gate | Deployment gate | +|---|---|---| +| `ports` | the pack declares `city.ports` | none — a quay is authored, not fetched | +| `vessels` | the pack declares `city.ports` | none this round; the hulls are modelled | +| `precip` | `boardCarriesRaster(bounds)` | `/health` `sources.radar` | +| `migration` | `boardCarriesRaster(bounds)` | `/health` `sources.birds` | + +The **board** gate is geometry. `boardCarriesRaster` asks whether a rectangle can +be described by a quarter-degree cell — twelve cells on the short side, which is +three degrees — and it answers true for California (9.55° × 10.50°) and false for +the Southland (1.08° × 1.66°) and the Bay (0.85° × 0.89°). That is the same +answer an `id === "california"` would have given and it is a fact about the map +rather than about a name. It gates the birds as well as the rain because both +feeds are statewide instruments quantised to the same cell: NEXRAD's composite +and BirdCast's fifty-eight counties. A metro board would show three county discs +and part of a fourth, which is a picture of the sampling grid rather than of the +migration. + +The **deployment** gate is `access.feeds`, read once from `/health`'s `sources` +block at boot — the same field `drawsFire` reads. `sources.radar` and +`sources.birds` are published by `server/src/routes/health.ts` off the config, so +"why is there no rain on this board" is answered by the box rather than by its +env file. Both are **optional** on `Feeds`, unlike every other feed there, because +they are newer than some servers this client will meet and `undefined` has to +mean *do not ask*. + +### 13.2 The quiet day is the designed case + +This repo's default is `none` for radar and `none` for birds, and no AIS licence +has been read, so **the empty state is what nearly every visitor sees**. It is +never blank. Each gate owns a sentence, each sentence is always complete, and +each says what is being withheld as well as what is being shown: + +- no feed at all — *"No radar feed is configured, so this board draws no + weather. That is a fact about this box, not about the sky."* +- a feed that answered with nothing — *"Nothing is falling on this board. All 16 + radars reporting. Last scan 5 minutes old."* +- an instrument that does not measure in daylight — *"Nothing is aloft. BirdCast + measures migration only after dark. Last night 393,290 birds crossed + California heading south-east…"* +- a harbour with modelled hulls — *"…19 hulls: 6 making way, 13 at rest, 16 + alongside a berth. No hull is labelled laden or in ballast: that is a port + figure, not a ship one."* + +They are printed into `#sea-section` and `#sky-section`, which are `index.html` +elements written by `main.ts` rather than by `mount.ts` — the arrangement +`#fire-section` and `#presence-host` already have, and for the same reason: the +content is a *board's* instrument reading, it is rebuilt per board, and the +section is hidden outright on a board that has neither a harbour nor a statewide +raster. Both sit below **Go**, so the studio's own call to action stays above the +fold on a phone. + +### 13.3 The harbour is refreshed as a new fix, never nudged + +`modelHarbour` stands in for a feed this deployment does not have, and the feed's +shape sets the cadence. Upstream listens for thirty seconds every fifteen +minutes; a hull under way moves about five kilometres between two reports; +`engine/vessels.ts` dead-reckons along the reported course for exactly 900 +seconds and then stops. `main.ts` rebuilds the modelled body on that same +interval off the once-a-minute clock, which is not a smoothing trick — it is the +feed's own behaviour, and the small jump a moving hull makes when a new body +lands is the jump a real fix makes. Nothing anywhere splines between two fixes, +because the chord between them is not a path anything took. + +Two clocks, deliberately different: + +- the **modelled harbour** is built for `currentInstant()`, so a scrubbed sky and + the ships under it describe the same moment, and `promoteVessels` is handed the + same number so the body's age is what it really is: zero; +- the **sky promotions** take `Date.now()`, because `fetchedAt` is a real + timestamp and "last scan 5 minutes old" has to stay true when somebody drags + the clock to midnight. + +The migration layer is given `env.sun.elevation` — the same number the rig is +lit by — rather than an ephemeris of its own. `LightingState.hemisphere.intensity` +is **not** a day/night signal: it measures 1.33 at 21° below the horizon against +0.95 at noon, because `atmosphere.ts` raises the fill to compensate a moonlit +scene. A layer that read it that way drew nothing at midnight while passing a +test written against invented numbers. + +### 13.4 What the boards cost after wiring + +Measured on this box, against the pre-round baseline: + +| Cell | Before | After | Cap | +|---|---|---|---| +| california · mobile | 368 draws / 389,843 tri | 370 / 373,825 | 415 / 430,000 | +| socal · desktop | 205 / 1,417,648 | 216 / 1,422,025 | 320 / 1,700,000 | +| socal · mobile | 137 / 765,596 | 148 / 771,797 | 170 / 900,000 | +| bay-area · desktop | 206 / 2,264,956 | 209 / 2,264,956 | 320 / 2,600,000 | + +The whole port complex and its fleet — two ports, a thirteen-kilometre +breakwater, six container yards, fifty-six gantries and nineteen hulls with +wakes — costs **eleven draw calls and four to six thousand triangles**, and the +same eleven on both viewports. That is what +instancing across buckets buys, and it is the `airports.ts` argument repeated: +the draw count is a function of how many *kinds* of surface exist, not of how +many terminals. diff --git a/TODO.md b/TODO.md index 21df270..7e21755 100644 --- a/TODO.md +++ b/TODO.md @@ -3,6 +3,137 @@ Short, and only things that are decided but not done. Anything speculative belongs in an issue, not here. +## The state board became the whole state — what this leaves open + +`src/cities/california.ts` now runs 32.50–42.05 N and -124.50 to -114.0 W. Four +things fall out of it that somebody has to pick up. + +- **The marketing stills are frames of a board that no longer exists.** Every + `city: "california"` shot in `scripts/brand-assets/shots.mjs` was photographed + at 428 units across with a ruled crop along 38.05 N. Re-shoot with + `npm run refresh`. The `chapter` indices are all still correct — the north + chapter was added at the **end**, index 5, precisely so nothing re-points — but + `california-relief`'s caption says "428 units across at 1,919 metres to the + unit" and that number is now 554. The caption is the only edit needed. +- **`scripts/performance-budget.mjs`'s header comment is now stale in one row.** + It records `california/mobile` at 389,843 triangles; the extended board + measures 373,825. The caps in `performance-budgets.json` were rewritten + downward to match. Nobody owned that comment this round. +- **`ARCHITECTURE.md` §12.2 lists the span constants that stay spans**, and the + list is the decision, not a survey. If a future round wants physical units for + the satellite dome or the cloud tile, read the reason each one is where it is + before moving it — two of them were tuned by photograph and one says outright + that it is arbitrary. +- **The Bay Area desktop frame-time cell was red on all three runs that + measured this**, at p95 33.3, 33.2 and 33.3 ms against a 16.7 budget. Recorded + here rather than claimed green, and recorded with everything beside it, + because the section below asks for exactly that: + + - geometry is identical to the digit across all three runs — 2,264,928 + triangles, 207 draws of a 320 cap — and unchanged from before this work to + within 52 triangles (2,264,876); + - `src/cities/sf.ts` was not touched and nothing in this change is reachable + from that board's geometry; + - the Bay Area **mobile** cell renders the same scene, with the same + per-camera lighting listener attached, at 16.7-16.8 ms on every run — so a + per-frame CPU regression is ruled out, because it would show on both; + - `pp_dpm_sclk` read `0: 500Mhz *` of an available 2725 throughout, with + `gpu_busy_percent` at 0, which is the state the section below names as the + cause. + + Three draws from a bimodal metric on a card parked at a sixth of its clock is + still not a scene defect. It is now three, though, and the honest state of this + cell is "red on this box, cause identified, not reproduced anywhere else". + + **Re-measured after the layers were wired, and it is now six.** The final run + of this round read p95 33.3 ms on all three attempts again, with the geometry + identical to the digit across every attempt — 2,264,956 triangles and 209 + draws of a 320 cap — and **identical to the pre-round baseline for that board**, + which is the number that matters: the Bay Area gets no port, no fleet and no + sky layer, and its triangle count did not move by one. The card read 500 MHz of + 2725 through that run as well. + +### Aerial perspective is wired, and one seam is still coarse + +Fog now follows camera altitude (`aerialReach` in `engine/atmosphere.ts`, §12.2). +`main.ts` recomputes the rig on the controls' `change` event, throttled at 2% of +the current altitude, which collapses a chapter flight to a few dozen +recomputations instead of sixty a second. + +What that does **not** cover is a camera moved by something other than +`OrbitControls`: the drive, actor and aircraft follow-cameras set the pose +directly each frame and do not fire `change`. Those poses fall back to the +one-hertz clock tick, so a fast descent under a follow camera steps the fog up +to four times instead of easing it. It is not visible on the boards that exist — +the follow cameras sit low and stay there — but a continuous state-to-city +descent would need this on the frame loop rather than on an event. + +### The north is authored, and three things in it are thin + +- The Klamath knot is four chains at four bearings, which reads as "not a + north-south range" and does not yet read as the Trinity Alps in particular. +- The northern Sierra tapers into the Cascade arc through the Diamond Mountains + and Honey Lake, and from the state pose that corner still reads as Basin and + Range domes rather than as the end of a range. It is arguably correct — Honey + Lake really is Basin and Range — but it was not checked against a photograph + of the real ground. +- There is **no Sacramento, Redding, Chico or Santa Rosa** on the board. The + block count is 8,881 against a pack test that caps it at 9,200, and the + Central Valley's new northern half is farmland with nothing built on it. The + state capital being absent is the most visible gap in the extension. Adding it + is a district and about 300 lots; the budget that has to be checked first is + draw calls on the california mobile cell, not triangles. + +## The port, the ships and the sky are wired — what that leaves open + +`main.ts` now supplies all four layer factories and feeds the three that take a +feed. `ARCHITECTURE.md` §13 is the design; these are the things it does not +cover. + +- **A dark navy tower stands in the water off San Pedro, and it is not ours.** + Roughly 1.5 km south-east of the Terminal Island shoreline, a single + building-sized block sits on open water. It is **pre-existing**: it is in + `/tmp/tera-look/socal-check.png`, photographed before the port kit was wired, + when that waterfront was still a bare tan plate. Nothing in the port kit, the + vessel layer or this wiring puts it there. What changed is that the new + Harbour chapter frames it — it was previously only ever seen from the + whole-board pose, where it is two pixels. It belongs to whatever places + anonymous blocks on the Southland board, and it wants a land mask. +- **The radar sheet has never been seen against a real feed.** Both sky + projections answer 404 on cloud-1 today, so the layer was photographed against + a synthetic fixture served to `look.mjs --api`. Two things that showed up + there and want re-checking against a real composite: a 50 dBZ core draws at + `PRECIP_ALPHA_MAX` 0.92, which is opaque enough to hide the terrain under it + (correct for a severe cell, and unreachable on a normal California day at + 0.47% wet and 20-35 dBZ, where the same ramp renders as a translucent smudge); + and `promoteRadar`'s sentence prints `observedAt` as a raw ISO string in the + middle of an otherwise plain-English line. +- **`Services` still does not carry the two sky services.** `server/src/app.ts` + constructs `createRadarService` and `createBirdsService` at the route rather + than on `Services`. `sources.radar` / `sources.birds` are now on the health + body — `routes/health.ts` reads them off the config, which is what every other + line in that route does — but a future route that wants either service cannot + reach it through `Services` the way every other one can. +- **The sky is polled by the clock, not by a watcher.** `askSky` fires once per + board and then only when the body's own `ttlSeconds` has expired, checked on + the once-a-minute tick that already exists. That is deliberate — a fourth + polling ladder in `adapters/http.ts` to re-ask a question whose answer changes + twelve times an hour is machinery bought for nothing — but it does mean the + two sky feeds have neither `watchFires`'s visibility check nor its failure + back-off. A feed configured with a 30-second TTL would be asked once a minute, + and a box answering 500 would be asked again every minute rather than backing + off. Both are fine at 300 s and 600 s; neither is fine at 5 s. +- **Every hull on the board is modelled and the AIS licence is still unread.** + `VesselsSourceId` is `'none' | 'modelled' | 'cloud1'`, the modelled body + carries "not an observation of any vessel", and `vesselSummary` says the live + feed is not configured. When `/api/sea` lands, the only change in `main.ts` is + where `body` comes from — but the aisstream terms decide whether a real + position may be shown to the public at all, and nobody has read them. +- **Nothing photographs the sky layers in CI.** `layerSeams.test.ts` asserts the + wiring from source at both ends, which catches a deleted line; it cannot catch + a layer that builds and draws nothing. The fixture that proved this one works + lives in a scratchpad and is not committed. + ## One command re-shoots the imagery: `npm run refresh` ```sh @@ -196,28 +327,50 @@ Worth knowing before starting (the rest is in `~/.claude/skills/tera-capture`): going — or give one of them `--site`-style isolation first. - Commit tera first, then re-run, so the manifest records a clean sha. -## The Bay Area frame drop was the GPU, not the scene — closed +## The GPU on this box never leaves 500 MHz, and every frame-time number here is a floor -Struck 2026-08-22. This section used to record a p95 of 33.3 ms on `bay-area` -desktop as a real defect and carried a 33.4 ms allowance in -`scripts/performance-budgets.json`. **Both were wrong, and the way they were -wrong is the lesson.** +**This is a machine fault, not a scene fault, and it invalidates frame-time +measurement on amd-server until it is fixed.** -The shadow-map hypothesis is measurably false: 4096, 2048, 1024 and 256 all -render the board in 1.21-1.31 ms. And the frame-time metric is not reproducible -on this box at single-run granularity — two consecutive runs over a -byte-identical `dist` gave 33.4 then 16.7 on the same cell, with triangle and -draw counts identical to the digit. The card was sitting at **500 MHz of a -possible 2725** through every run that reproduced the drop. +Traced 2026-08-23: `/sys/class/drm/card*/device/pp_dpm_sclk` sampled every two +seconds through eighty seconds of sustained rendering — 2,264,956 triangles at +1440x900 — reports `0: 500Mhz *` on all forty samples. It never ramps. +`power_dpm_force_performance_level` is `auto`. The card's own ceiling is +**2725 MHz core and 1000 MHz memory**, so it is running at roughly 18% of core +and as low as 96 MHz of VRAM clock. -So it was GPU power management, and the "it reproduces" conclusion rested on two -samples. Two draws from a bimodal metric is not reproduction. The allowance is -removed and the cell is back to 16.7. +The consequence: `bay-area/desktop` is fragment-bound at exactly this clock and +sits on the vsync deadline. Same build, same geometry, viewport swept — -**Geometry is the gate here; frame time is advisory.** Triangle and draw counts -are deterministic and identical across runs. Treat a single red p95 as noise -until it reproduces across several runs, and record the GPU clock state next to -the renderer string so the next person sees what this one did not. +| viewport | pixels | p95 | +|---|---|---| +| 1440x900 | 1.296 MP | **33.3** | +| 1280x800 | 1.024 MP | 16.7 | +| 1152x720 | 0.829 MP | 16.8 | +| 1024x640 | 0.655 MP | 16.7 | + +The cell is ~27% past what this board sustains at 500 MHz. So a build that adds a +trivial amount of fragment work flips it from 16.8 to 33.3 while its geometry is +identical to the digit, and a build that removes some flips it back. **That is +what makes single-run frame-time comparisons on this box worthless**, and it is +how two separate investigations here reached two different wrong conclusions. + +**Do not "fix" this by raising a cap.** Geometry is the gate — it is deterministic +and identical across runs. Frame time is advisory until the card clocks properly. + +**Two ways this diagnosis was reached wrongly first, both worth remembering.** +One investigation blamed the desktop shadow map: disproved, because 4096, 2048, +1024 and 256 all render the board in 1.21-1.31 ms. Another blamed a camera +`change` listener rebuilding the PMREM environment: disproved three ways — every +field `environmentKey()` fingerprints is bit-identical from 500 m to 67,600 m of +camera altitude; 105 camera-lighting applies produced **0** rebuilds; and the +budget harness runs under `reducedMotion: "reduce"`, so its camera is +**stationary** and fires no `change` events at all during the sample window. +Both wrong conclusions came from comparing a single run against a single run. + +To settle it properly: `power_dpm_force_performance_level` needs root. Forcing it +to `high` and re-running the matrix would give the first trustworthy frame-time +numbers this project has had. ## The aeroplane glyph is still larger than the Golden Gate diff --git a/index.html b/index.html index aaddbdd..e71d34d 100644 --- a/index.html +++ b/index.html @@ -1358,6 +1358,27 @@
+ + + + + +

Chapters

diff --git a/scripts/performance-budgets.json b/scripts/performance-budgets.json index 3336a6b..6273fb2 100644 --- a/scripts/performance-budgets.json +++ b/scripts/performance-budgets.json @@ -4,25 +4,25 @@ "california": { "desktop": { "p95FrameIntervalMs": 16.7, - "maxDrawCalls": 650, - "maxTriangles": 750000 + "maxDrawCalls": 480, + "maxTriangles": 440000 }, "mobile": { "p95FrameIntervalMs": 33.3, - "maxDrawCalls": 420, - "maxTriangles": 460000 + "maxDrawCalls": 415, + "maxTriangles": 430000 } }, "california-drive": { "desktop": { "p95FrameIntervalMs": 16.7, - "maxDrawCalls": 650, - "maxTriangles": 750000 + "maxDrawCalls": 480, + "maxTriangles": 430000 }, "mobile": { "p95FrameIntervalMs": 33.3, "maxDrawCalls": 280, - "maxTriangles": 440000 + "maxTriangles": 420000 } }, "office": { diff --git a/server/src/app.ts b/server/src/app.ts index 3554971..b810683 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -13,8 +13,11 @@ */ import Fastify, { type FastifyError, type FastifyInstance } from "fastify"; +import { createBirdsService } from "./birds/index.ts"; import { registerCachePolicy } from "./cache.ts"; import { loadConfig, type Config } from "./config.ts"; +import { createRadarService } from "./radar/index.ts"; +import { registerBirds } from "./routes/birds.ts"; import { registerDevices } from "./routes/devices.ts"; import { registerFires } from "./routes/fires.ts"; import { registerFlights } from "./routes/flights.ts"; @@ -23,6 +26,7 @@ import { registerMarkers } from "./routes/markers.ts"; import { registerMedia } from "./routes/media.ts"; import { registerOffices } from "./routes/offices.ts"; import { registerPresence } from "./routes/presence.ts"; +import { registerRadar } from "./routes/radar.ts"; import { registerRealtime } from "./routes/realtime.ts"; import { registerSatellites } from "./routes/satellites.ts"; import { registerSession } from "./routes/session.ts"; @@ -52,6 +56,14 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance { registerFlights(app, services); registerSatellites(app, services); registerFires(app, services); + // The two sky feeds are constructed here rather than on `Services`. They own + // their own cache and their own failure behaviour exactly as every other + // service does, and neither can throw at a route. `sources.radar` and + // `sources.birds` are on the health body — `routes/health.ts` reads them off + // the config rather than off a service, which is what every line in that route + // does and is why it still touches no upstream. + registerRadar(app, createRadarService(config, app.log)); + registerBirds(app, createBirdsService(config, app.log)); registerWeather(app, services); registerMarkers(app, services); registerMedia(app, services); diff --git a/server/src/birds/cloud1.ts b/server/src/birds/cloud1.ts new file mode 100644 index 0000000..d8b63f9 --- /dev/null +++ b/server/src/birds/cloud1.ts @@ -0,0 +1,188 @@ +/** + * The migration feed's one upstream: a **projection**, served by the machine + * that owns the database. + * + * The same arrangement as `fires/cloud1.ts` and `radar/cloud1.ts`, and it is + * worth saying why it applies here too even though `birds.sqlite` holds nothing + * private. The value is not only in what this particular store contains: it is + * that **cloud-2 has no database at all**, so no route written on this box next + * year can select a column from any of them. Making that true of every feed is + * what keeps it true of the one that matters. + * + * ### Both halves, or neither + * + * The newest ten-minute granule and last night's state summary are asked for in + * parallel, and `null` comes back if either fails. That matters more here than + * anywhere else in this build, because the two halves answer *different* + * questions and the second one is the whole empty state: without last night's + * figures the daylight sky has nothing to say but "nothing is aloft", which is + * the blank panel this layer was designed to avoid. + * + * ### The state row is dropped on the way in, and again on the way out + * + * `counties` upstream is 59 rows: 58 counties plus `US-CA`, `kind='state'`, with + * NULL coordinates and — in tonight's granule — 793,141 birds aloft against the + * largest county's 82,549. It is refused here by `countyRows`, refused again by + * `promoteBirds` in the browser, and a third time by the layer's own reader. + * Three lines for one row is not paranoia: the failure it prevents is a + * three-quarter-million-bird blob at 0,0, and the reason it would happen is that + * the row looks exactly like the other fifty-eight. + */ + +import { getJson } from "../http.ts"; +import { countyRows } from "../../../src/server/birds.ts"; +import type { WireBirdCounty } from "../../../src/server/wire.ts"; + +/** How long either GET may take. */ +const TIMEOUT_MS = 8_000; + +/** California has 58 counties; the cap is a bound on a bad day, not a fit. */ +const MAX_COUNTIES = 256; + +export interface BirdsSnapshot { + /** Epoch ms at which this box completed the fetch. */ + fetchedAt: number; + /** ISO-8601 of the ten-minute granule, or "" if the upstream sent none. */ + observedAt: string; + counties: WireBirdCounty[]; + /** Last night, from the state row. Never a sum over counties. */ + statewide: { + crossed: number; + peakAloft: number; + peakAt: string; + meanAltitude: number; + heading: string; + } | null; + attribution: string[]; +} + +export interface BirdsLog { + warn(msg: string): void; +} + +interface CountiesResponse { + observedAt?: unknown; + counties?: unknown; + attribution?: unknown; +} + +interface NightResponse { + night?: unknown; + attribution?: unknown; +} + +export async function fetchProjection( + base: string, + key: string, + log: BirdsLog, +): Promise { + if (base === "") return null; + const headers = key === "" ? undefined : { "x-tera-key": key }; + const options = { timeoutMs: TIMEOUT_MS, ...(headers === undefined ? {} : { headers }) }; + + const [countiesBody, nightBody] = await Promise.all([ + getJson(`${base}/counties`, options), + getJson(`${base}/night`, options), + ]); + + if (countiesBody === null || nightBody === null) { + log.warn( + "birds:cloud1: the projection did not answer with both halves " + + `(counties ${countiesBody === null ? "failed" : "ok"}, ` + + `night ${nightBody === null ? "failed" : "ok"}); keeping the last whole body`, + ); + return null; + } + + const rows = readArray(countiesBody.counties, MAX_COUNTIES, readCounty); + + return { + fetchedAt: Date.now(), + observedAt: nonEmptyString(countiesBody.observedAt) ?? "", + // The state row goes here and not downstream. Everything past this point is + // a county with a coordinate and an area. + counties: countyRows(rows), + statewide: readNight(nightBody.night), + attribution: mergeAttribution(countiesBody.attribution, nightBody.attribution), + }; +} + +// ---- Reading somebody else's JSON ----------------------------------------- + +function readCounty(raw: unknown): WireBirdCounty | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const id = nonEmptyString(row.id); + const lat = finite(row.lat); + const lon = finite(row.lon); + const areaKm2 = finite(row.areaKm2); + // No id, no coordinate or no area means nothing downstream can place it or + // scatter it. All three are structural, not cosmetic — and the state row is + // missing two of the three, which is the second reason it never survives. + if (id === null || lat === null || lon === null || areaKm2 === null) return null; + return { + id, + name: nonEmptyString(row.name) ?? "", + lat, + lon, + areaKm2, + aloft: Math.max(0, finite(row.aloft) ?? 0), + altitude: Math.max(0, finite(row.altitude) ?? 0), + direction: finite(row.direction) ?? 0, + speed: Math.max(0, finite(row.speed) ?? 0), + }; +} + +/** + * Last night's state row. + * + * `null` rather than a zeroed object when it is missing, because zero birds + * crossing California is a finding and an absent summary is not one. + */ +function readNight(raw: unknown): BirdsSnapshot["statewide"] { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const crossed = finite(row.crossed); + const peakAloft = finite(row.peakAloft); + if (crossed === null || peakAloft === null) return null; + return { + crossed, + peakAloft, + peakAt: nonEmptyString(row.peakAt) ?? "", + meanAltitude: Math.max(0, finite(row.meanAltitude) ?? 0), + heading: nonEmptyString(row.heading) ?? "", + }; +} + +function readArray(raw: unknown, cap: number, read: (row: unknown) => T | null): T[] { + if (!Array.isArray(raw)) return []; + const out: T[] = []; + for (const row of raw) { + if (out.length >= cap) break; + const parsed = read(row); + if (parsed !== null) out.push(parsed); + } + return out; +} + +function mergeAttribution(a: unknown, b: unknown): string[] { + const out: string[] = []; + for (const source of [a, b]) { + if (!Array.isArray(source)) continue; + for (const line of source) { + if (typeof line !== "string" || line === "" || out.includes(line)) continue; + out.push(line); + } + } + return out; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/server/src/birds/index.ts b/server/src/birds/index.ts new file mode 100644 index 0000000..f395b2a --- /dev/null +++ b/server/src/birds/index.ts @@ -0,0 +1,122 @@ +/** + * Which migration this box serves — California's, or none. + * + * The same shape as `fires/index.ts` and `radar/index.ts`. One body, one cache + * key, no region parameter: the whole state's granule is 58 rows and every board + * is a rectangle inside it, so a server that filtered by board would be doing a + * worse version of a clip the client has to do anyway (`promoteBirds` in + * `src/server/birds.ts`). + * + * ### The empty body carries a reason, and that is the difference from fires + * + * A fire body with nothing in it needs no explanation beyond its own age: + * nothing is burning. An empty *migration* body needs one, because the layer is + * absent about fourteen hours in every twenty-four by construction and a viewer + * arriving at noon would otherwise read a working feed as a broken one. So + * `quiet` is not optional on the wire and is filled in here even for a box with + * no source at all. + * + * ### `none` is a quiet sky, not an invented flock + * + * The same asymmetry `fires/index.ts` argues. An invented aeroplane is a + * plausible aeroplane; three hundred thousand invented birds over a named county + * is a claim about a real place. So the synthetic case is the honest quiet one — + * which is also what keeps `scripts/check-zero-config-boot.mjs` green with + * `TERA_BIRDS_SOURCE` unset. + */ + +import { fetchProjection, type BirdsSnapshot } from "./cloud1.ts"; +import { createUpstream } from "../upstream.ts"; +import type { Config } from "../config.ts"; +import type { BirdsBody } from "../../../src/server/wire.ts"; + +export interface BirdsService { + current(): Promise; +} + +export interface BirdsLog { + warn(msg: string): void; +} + +const CALIFORNIA_KEY = "california"; + +/** + * Five minutes. + * + * Granules are ten minutes apart and the collector reads them in batches about + * twenty minutes behind, so anything faster is two machines' work for identical + * bytes. Floored for the reason every other feed here is floored: + * `TERA_BIRDS_TTL=0` reads like "fresh" and means "one fetch pair per request". + */ +const MIN_TTL_SECONDS = 300; + +/** + * What a box with no source answers with. + * + * Note the `quiet` reason: `no-data`, not `daylight`. Nobody has looked, which + * is a different thing from having looked and found the sun up, and the panel + * says so in words rather than showing the same blank for both. + */ +function emptyBody(ttlSeconds: number): BirdsBody { + return { + source: "none", + fetchedAt: new Date(0).toISOString(), + observedAt: null, + counties: [], + statewide: null, + quiet: { + reason: "no-data", + message: + "No migration feed is configured on this box, so nothing is drawn over " + + "the state. That is a fact about this box, not about the sky.", + }, + ttlSeconds, + }; +} + +export function createBirdsService(config: Config, log: BirdsLog): BirdsService { + const { source, url, key, ttlSeconds } = config.birds; + const ttl = Math.max(MIN_TTL_SECONDS, ttlSeconds); + + const upstream = createUpstream({ + label: "birds:cloud1", + ttlSeconds: ttl, + log, + }); + + return { + async current(): Promise { + if (source === "none") return emptyBody(ttl); + + const snapshot = await upstream.get(CALIFORNIA_KEY, () => fetchProjection(url, key, log)); + if (snapshot === null) return emptyBody(ttl); + + // Whether the sky is quiet is decided in the browser, where the sun's + // elevation is already known to a hundredth of a degree + // (`atmosphere.ts`). The server would have to compute a second opinion + // from a clock the scrubber does not own, and the two would disagree on + // exactly the frames that matter. So `quiet` is `null` here whenever + // anything at all arrived, and `promoteBirds` fills it in. + const quiet: BirdsBody["quiet"] = + snapshot.counties.length > 0 + ? null + : { + reason: "no-data", + message: + "The migration feed answered with no counties in it. " + + "Either the newest granule has not landed yet or nothing was measured.", + }; + + return { + source, + fetchedAt: new Date(snapshot.fetchedAt).toISOString(), + observedAt: snapshot.observedAt === "" ? null : snapshot.observedAt, + counties: snapshot.counties, + statewide: snapshot.statewide, + quiet, + ttlSeconds: ttl, + ...(snapshot.attribution.length > 0 ? { attribution: snapshot.attribution } : {}), + }; + }, + }; +} diff --git a/server/src/config.ts b/server/src/config.ts index bd0327f..c8c1ed5 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -24,10 +24,12 @@ import { isSafeIceUrl } from "../../src/media/iceValidation.ts"; import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts"; import type { AuthMode, + BirdsSourceId, DevicesSourceId, FiresSourceId, FlightsSourceId, MarkersSourceId, + RadarSourceId, SatellitesSourceId, WeatherSourceId, } from "../../src/server/wire.ts"; @@ -124,6 +126,25 @@ export interface FiresConfig { detectionWindowHours: number; } +/** + * One sky feed — radar or birds — pointed at a cloud-1 projection, or at + * nothing. + * + * Identical in shape to `FiresConfig` minus the window, because both feeds have + * the same relationship to their upstream: no database on this box, one base URL + * on somebody else's tailnet, an optional key, and a source that is `none` until + * an operator says otherwise. `source` is kept generic over the two id types so + * that neither feed can be handed the other's. + */ +export interface SkyConfig { + source: RadarSourceId & BirdsSourceId; + /** Base URL, no trailing slash. Empty on every source but `cloud1`. */ + url: string; + /** Sent as `x-tera-key` when set. See `FiresConfig.key`. */ + key: string; + ttlSeconds: number; +} + /** * A first-party hardware bridge — an operator's own studio, over their own * network. @@ -286,6 +307,16 @@ export interface Config { flights: FlightsConfig; satellites: SatellitesConfig; fires: FiresConfig; + /** + * The two sky feeds, both off by default and both harmless when they are. + * + * They are separate sections rather than one `sky` because they are separate + * upstreams with separate cadences — a NEXRAD composite advances every ten + * minutes and a BirdCast granule is written once and never changes — and a + * shared TTL would be wrong for one of them on every request. + */ + radar: SkyConfig; + birds: SkyConfig; markers: MarkersConfig; devices: DevicesConfig; /** The first-party device bridge. Read by `devices/firstParty.ts` only. */ @@ -312,6 +343,8 @@ export function loadConfig(env: Env = process.env): Config { const flights = loadFlights(env, degraded); const satellites = loadSatellites(env, degraded); const fires = loadFires(env, degraded); + const radar = loadSky(env, "RADAR", "radar", 300, degraded); + const birds = loadSky(env, "BIRDS", "migration", 600, degraded); const studio = loadStudio(env); const devices = loadDevices(env, studio, degraded); const auth = loadAuth(env, degraded); @@ -344,6 +377,8 @@ export function loadConfig(env: Env = process.env): Config { flights, satellites, fires, + radar, + birds, markers, devices, studio, @@ -691,6 +726,66 @@ function loadFires(env: Env, degraded: string[]): FiresConfig { }; } +/** + * One sky feed, off by default, for exactly the reason fires are. + * + * A zero-config clone must make no outbound requests at all + * (`scripts/check-zero-config-boot.mjs`), and both of these would be requests to + * a *private* machine on somebody else's tailnet. There is no public default to + * fall back to and there should not be: a stranger's board draws no rain and no + * birds, which is the truth about what that box knows. + * + * `none` serves a real, empty body — never an invented shower and never an + * invented flock. The asymmetry with the simulated flight plan is the one + * `SatellitesBody` states: an invented aeroplane is a plausible aeroplane, and + * an invented thunderstorm is a claim that it is raining on somebody who can + * look out of the window. + * + * Note what is deliberately NOT here: an unset `TERA_RADAR_SOURCE` appends + * nothing to `degraded`. Off by default is a choice this repo made, not a + * default that needs configuring, and `check-zero-config-boot.mjs` refuses to + * pass with *any* demotion on an empty environment. What tells a viewer that a + * quiet board is quiet because nobody asked is `sources.radar` / `sources.birds` + * on the health body and the epoch-zero `fetchedAt` on the body itself. + */ +function loadSky( + env: Env, + prefix: "RADAR" | "BIRDS", + noun: string, + defaultTtl: number, + degraded: string[], +): SkyConfig { + const asked = str(env, `TERA_${prefix}_SOURCE`, "none"); + let source = oneOf(asked, SKY_SOURCES); + if (source === null) { + degraded.push( + `TERA_${prefix}_SOURCE="${asked}" is not one of ${SKY_SOURCES.join(", ")}; ` + + `serving no ${noun}.`, + ); + source = "none"; + } + + const url = str(env, `TERA_${prefix}_URL`, "").replace(/\/+$/, ""); + if (source === "cloud1" && url === "") { + degraded.push( + `TERA_${prefix}_SOURCE=cloud1 needs TERA_${prefix}_URL, the base of the projection ` + + `endpoint. Serving no ${noun}: an empty sky is the honest answer, and the board says ` + + "how old its last answer is.", + ); + source = "none"; + } + + return { + source, + url, + key: str(env, `TERA_${prefix}_KEY`, ""), + ttlSeconds: num(env, `TERA_${prefix}_TTL`, defaultTtl, degraded), + }; +} + +/** Both sky feeds take the same two ids, which is why one list serves both. */ +const SKY_SOURCES: (RadarSourceId & BirdsSourceId)[] = ["none", "cloud1"]; + const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"]; /** diff --git a/server/src/radar/cloud1.ts b/server/src/radar/cloud1.ts new file mode 100644 index 0000000..9e4f975 --- /dev/null +++ b/server/src/radar/cloud1.ts @@ -0,0 +1,199 @@ +/** + * The radar feed's one upstream: a **projection**, served by the machine that + * owns the database. + * + * ### Read this before changing anything here + * + * `echo_cells` carries a column called `distance_km`, and it is measured **from + * a private house**. It is the same leak class `server/src/fires/cloud1.ts` was + * written to make impossible, and the resolution is the same one: this module + * has **no database**. It makes two bounded GETs against an endpoint whose + * SELECT lists are written out by hand on the other machine, and it cannot be + * careless with a column it was never sent. + * + * That ordering is the security property. A route somebody writes next year + * cannot select a column that is not on this box, and no amount of care in this + * file would have been as strong. It is also why there is no sqlite driver in + * this repo's dependency list and why `scripts/check-dependency-licenses.mjs` is + * entitled to keep it that way. + * + * **Nothing here may ever name `distance_km` — not in a query string, not in a + * field list, not in a comment that becomes a query string.** + * `server/src/test/sky.test.ts` asserts the adopted key set *equals* an + * allowlist and that no request path contains the word, which is the second + * line: an `assert.ok(!keys.has("distance_km"))` would pass for every column + * nobody thought to name, and that is precisely the class of column that gets + * added later. + * + * ### Both halves, or neither + * + * Cells and station status are asked for in parallel and `null` is returned if + * **either** fails. A lattice with a silently empty station list is a board that + * has quietly stopped drawing its own coverage holes, and it would draw them as + * clear sky — the strongest possible version of the failure this whole layer + * exists to prevent. One snapshot, one answer; `upstream.ts` above this keeps + * serving the last good whole body until a whole one arrives. + */ + +import { getJson } from "../http.ts"; +import type { RadarCell, RadarStation } from "../../../src/server/radar.ts"; + +/** How long either GET may take. The upstream reads sqlite behind its own cache. */ +const TIMEOUT_MS = 8_000; + +/** + * The most rows this build will adopt from either endpoint. + * + * The statewide lattice is under 1,700 cells and the whole station table is + * sixteen rows, so both of these are two orders of magnitude clear of anything + * real. They are the second of two bounds rather than the only one — a caller + * that trusts an upstream's cap inherits the day the upstream's cap changes. + */ +const MAX_CELLS = 8_000; +const MAX_STATIONS = 128; + +export interface RadarSnapshot { + /** Epoch ms at which this box completed the fetch. */ + fetchedAt: number; + /** ISO-8601 of the volume scan the cells came from. */ + observedAt: string; + /** + * Share of California at or above the rain threshold, 0..1. + * + * Measured upstream over the whole state's pixels, not over our lattice. It is + * what promotion is decided from, and it is scale-free — see + * `RADAR_MIN_WET_FRACTION` in `src/server/radar.ts` for why that matters. + */ + wetFraction: number; + cells: RadarCell[]; + stations: RadarStation[]; + attribution: string[]; +} + +export interface RadarLog { + warn(msg: string): void; +} + +/** What the projection endpoint answers with. Restated, never imported. */ +interface CellsResponse { + observedAt?: unknown; + wetFraction?: unknown; + cells?: unknown; + attribution?: unknown; +} + +interface StationsResponse { + stations?: unknown; + attribution?: unknown; +} + +export async function fetchProjection( + base: string, + key: string, + log: RadarLog, +): Promise { + if (base === "") return null; + const headers = key === "" ? undefined : { "x-tera-key": key }; + const options = { timeoutMs: TIMEOUT_MS, ...(headers === undefined ? {} : { headers }) }; + + const [cellsBody, stationsBody] = await Promise.all([ + getJson(`${base}/cells`, options), + getJson(`${base}/stations`, options), + ]); + + if (cellsBody === null || stationsBody === null) { + log.warn( + "radar:cloud1: the projection did not answer with both halves " + + `(cells ${cellsBody === null ? "failed" : "ok"}, ` + + `stations ${stationsBody === null ? "failed" : "ok"}); keeping the last whole body`, + ); + return null; + } + + return { + fetchedAt: Date.now(), + observedAt: nonEmptyString(cellsBody.observedAt) ?? "", + // Clamped, not trusted. A `wetFraction` served as a percentage rather than a + // fraction would promote every frame for ever, and the number is the only + // thing standing between a quiet day and 1,596 texels of nothing. + wetFraction: clamp01(finite(cellsBody.wetFraction) ?? 0), + cells: readArray(cellsBody.cells, MAX_CELLS, readCell), + stations: readArray(stationsBody.stations, MAX_STATIONS, readStation), + attribution: mergeAttribution(cellsBody.attribution, stationsBody.attribution), + }; +} + +// ---- Reading somebody else's JSON ----------------------------------------- +// +// Field by field, checked rather than cast, and the field list is exhaustive on +// purpose: what is not named here does not enter this process. The upstream is a +// machine on the same tailnet run by the same person, which is exactly the +// relationship that produces "it will always be the right shape" — and then a +// collector gains a column. + +function readCell(raw: unknown): RadarCell | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const lat = finite(row.lat); + const lon = finite(row.lon); + const dbz = finite(row.dbz); + if (lat === null || lon === null || dbz === null) return null; + return { lat, lon, dbz }; +} + +function readStation(raw: unknown): RadarStation | null { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null; + const row = raw as Record; + const lat = finite(row.lat); + const lon = finite(row.lon); + const id = nonEmptyString(row.id); + if (id === null || lat === null || lon === null) return null; + return { + id, + lat, + lon, + // Carried verbatim and judged once, in the gate, where the argument for the + // judgement can be read. Six of sixteen stations read "Maintenance Action + // Mandatory" right now and every one of them is transmitting. + type: nonEmptyString(row.type), + operability: nonEmptyString(row.operability), + }; +} + +function readArray(raw: unknown, cap: number, read: (row: unknown) => T | null): T[] { + if (!Array.isArray(raw)) return []; + const out: T[] = []; + for (const row of raw) { + if (out.length >= cap) break; + const parsed = read(row); + if (parsed !== null) out.push(parsed); + } + return out; +} + +/** Both credit lines, de-duplicated, in the order they arrived. */ +function mergeAttribution(a: unknown, b: unknown): string[] { + const out: string[] = []; + for (const source of [a, b]) { + if (!Array.isArray(source)) continue; + for (const line of source) { + if (typeof line !== "string" || line === "" || out.includes(line)) continue; + out.push(line); + } + } + return out; +} + +function nonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} diff --git a/server/src/radar/index.ts b/server/src/radar/index.ts new file mode 100644 index 0000000..9b88309 --- /dev/null +++ b/server/src/radar/index.ts @@ -0,0 +1,113 @@ +/** + * Which reflectivity this box serves — which is California's, or none. + * + * The same shape as `fires/index.ts`, and it takes the same decisions for the + * same reasons: one body, one cache key, no region parameter, a TTL with a + * floor, and `none` serving a real empty body rather than an invented shower. + * + * ### The lattice is built here, on the server, and that is deliberate + * + * `RadarBody.field` is a lattice rather than a list of cells, so somebody has to + * turn one into the other. Doing it here means the anomalous-propagation rule, + * the coverage-hole rule and the promotion threshold are applied **once, in one + * place, by the module that can be tested without a browser** — and it means a + * quiet day costs the wire an empty body rather than 1,596 texels' worth of JSON + * that the client would then throw away. `src/server/radar.ts` is imported by + * both ends precisely so there is one statement about the data and not two. + * + * The board's own coastline comes from the city pack, which is data and types + * and imports no renderer. That is the same arrangement `media/bindings.ts` + * already uses for the office packs, and it is what keeps the land/sea test from + * being a second, drifting copy of the shoreline. + * + * ### `none` serves an empty sky and never an invented one + * + * The flights service falls back to a simulated plan, because an empty sky over + * a city reads as a bug. Nothing here does. An invented aeroplane is a plausible + * aeroplane; an invented thunderstorm is a claim that it is raining on somebody + * who can look out of the window. So the synthetic case *is* the clear sky — + * `field: null`, `source: "none"`, `fetchedAt` at the Unix epoch — which is + * exactly what `weather/synthetic.ts` does when it answers with + * `precipitation: 0`, and it is what keeps `scripts/check-zero-config-boot.mjs` + * green on a keyless clone. + * + * `fetchedAt` is the epoch and not "now", because "now" would be a claim that + * this was fetched a moment ago. It was never fetched, and the board says so. + */ + +import { fetchProjection, type RadarSnapshot } from "./cloud1.ts"; +import { createUpstream } from "../upstream.ts"; +import { buildRadarField } from "../../../src/server/radar.ts"; +import CALIFORNIA_CITY from "../../../src/cities/california.ts"; +import type { Config } from "../config.ts"; +import type { RadarBody } from "../../../src/server/wire.ts"; + +export interface RadarService { + current(): Promise; +} + +export interface RadarLog { + warn(msg: string): void; +} + +/** One state, one key. See `fires/index.ts` for the argument. */ +const CALIFORNIA_KEY = "california"; + +/** + * Two minutes. + * + * The composite advances every ten minutes and reaches the collector about five + * minutes late, so asking faster than this spends two machines' work to receive + * identical bytes. The floor exists because `TERA_RADAR_TTL=0` reads like "as + * fresh as possible" and means "one fetch pair per inbound request" — the trap + * `TERA_FLIGHTS_TTL=0` and `TERA_FIRES_TTL=0` both were. + */ +const MIN_TTL_SECONDS = 120; + +function emptyBody(ttlSeconds: number): RadarBody { + return { + source: "none", + fetchedAt: new Date(0).toISOString(), + field: null, + ttlSeconds, + }; +} + +export function createRadarService(config: Config, log: RadarLog): RadarService { + const { source, url, key, ttlSeconds } = config.radar; + const ttl = Math.max(MIN_TTL_SECONDS, ttlSeconds); + + const upstream = createUpstream({ + label: "radar:cloud1", + ttlSeconds: ttl, + log, + }); + + return { + async current(): Promise { + if (source === "none") return emptyBody(ttl); + + const snapshot = await upstream.get(CALIFORNIA_KEY, () => fetchProjection(url, key, log)); + if (snapshot === null) return emptyBody(ttl); + + const built = buildRadarField({ + cells: snapshot.cells, + stations: snapshot.stations, + bounds: CALIFORNIA_CITY.bounds, + coast: CALIFORNIA_CITY.landmasses, + observedAt: snapshot.observedAt, + wetFraction: snapshot.wetFraction, + }); + + return { + source, + fetchedAt: new Date(snapshot.fetchedAt).toISOString(), + // `null` on a quiet day, which is nine days in ten and is not a fault. + // The age beside it is what tells a viewer apart from a dead feed. + field: built.field, + ttlSeconds: ttl, + ...(snapshot.attribution.length > 0 ? { attribution: snapshot.attribution } : {}), + }; + }, + }; +} diff --git a/server/src/routes/birds.ts b/server/src/routes/birds.ts new file mode 100644 index 0000000..d51740f --- /dev/null +++ b/server/src/routes/birds.ts @@ -0,0 +1,27 @@ +/** + * `GET /api/v1/birds` — tonight's nocturnal migration, and, far more often, the + * reason there is none. + * + * No query, for the same reason `/radar` takes none: one granule covers the + * whole state and every board is a rectangle inside it. + * + * The body is publicly cacheable and carries nothing personal at all — BirdCast + * is a Cornell Lab forecast product aggregated to counties, and the county + * coordinates are Census internal points. The interesting property is the other + * one: **an empty body is the normal body**, so this route answers 200 with an + * empty `counties` array and a filled-in `quiet` for about fourteen hours of + * every twenty-four. A client that treats an empty array as a fault will be + * wrong most of the day, which is why `quiet` is not optional on the wire. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { BirdsService } from "../birds/index.ts"; + +export function registerBirds(app: FastifyInstance, birds: BirdsService): void { + app.get("/api/v1/birds", async (req, reply) => { + const body = await birds.current(); + publicCache(req, reply, body.ttlSeconds); + return body; + }); +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 8ee5d57..f15d8d2 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -43,6 +43,14 @@ export function registerHealth(app: FastifyInstance, services: Services): void { markers: config.markers.source, devices: config.devices.source, fires: config.fires.source, + // The two sky feeds. Optional on `HealthBody`, so a client reading an + // older box's answer sees `undefined` and correctly concludes it serves + // neither; published here so "why is there no rain on this board" is + // answered by the box rather than by its env file. Read straight off the + // config for the same reason every line above it is: this route touches + // no service and cannot be made to fail by somebody else's outage. + radar: config.radar.source, + birds: config.birds.source, }, auth: { mode: config.auth.mode, diff --git a/server/src/routes/radar.ts b/server/src/routes/radar.ts new file mode 100644 index 0000000..63cb6d0 --- /dev/null +++ b/server/src/routes/radar.ts @@ -0,0 +1,34 @@ +/** + * `GET /api/v1/radar` — the statewide reflectivity lattice, for everybody. + * + * No query, for the reason `/fires` and `/satellites` take none: the answer does + * not vary by who asked or where they are looking. There is one composite over + * California, the boards are rectangles inside it, and with no parameter there + * is no key space and none of the amplification concerns that shape + * `regions.ts`. + * + * ### Publicly cacheable, and the reason is structural + * + * The upstream store carries `echo_cells.distance_km`, measured from a private + * house. It cannot reach a shared cache, and not because this route is careful: + * because **this box has no database**. cloud-1 serves a projection with a + * hand-written column list and there is nothing here to be careless with. What a + * CDN can hold is a grid of NWS reflectivity — a public-domain US government + * product, the same one every weather site in the country redraws. + * + * `publicCache` still applies its own credential check, so a request that + * arrived with a session attached falls back to the fail-closed + * `private, no-store` default. + */ + +import type { FastifyInstance } from "fastify"; +import { publicCache } from "../cache.ts"; +import type { RadarService } from "../radar/index.ts"; + +export function registerRadar(app: FastifyInstance, radar: RadarService): void { + app.get("/api/v1/radar", async (req, reply) => { + const body = await radar.current(); + publicCache(req, reply, body.ttlSeconds); + return body; + }); +} diff --git a/server/src/test/sky.test.ts b/server/src/test/sky.test.ts new file mode 100644 index 0000000..aa3b293 --- /dev/null +++ b/server/src/test/sky.test.ts @@ -0,0 +1,406 @@ +/** + * The two sky feeds, and the one property they exist to guarantee. + * + * Most of this file is about **which keys reach the wire**, which is unusual for + * a feed test and is the whole point of this one. `radar.sqlite`'s `echo_cells` + * table carries a column called `distance_km`, and it is measured from a private + * house — the same leak class `server/src/test/fires.test.ts` was written + * against, in a store nobody would think to check because it is full of weather. + * + * The structural defence is that this box has no database: cloud-1 serves a + * projection with a hand-written column list, so there is nothing here to be + * careless with. The tests below are the second line, and they take the same + * shape the fire test does — the adopted key set **equals** an allowlist, so a + * field added upstream fails here rather than arriving in a browser. An + * `assert.ok(!keys.has("distance_km"))` would have passed for every column + * nobody thought to name, which is precisely the class of column that gets added + * later. + * + * The rest is the ordinary feed contract, plus one thing neither flights nor + * fires has: **an empty body is the normal body**. California is under rain a + * mean 0.596% of the time and BirdCast measures only after dark, so a quiet + * answer here is a finding and not a fault, and `quiet` is not optional on the + * birds body for exactly that reason. + */ + +import assert from "node:assert/strict"; +import { after, beforeEach, describe, it } from "node:test"; +import { buildApp } from "../app.ts"; +import { loadConfig } from "../config.ts"; +import type { BirdsBody, RadarBody } from "../../../src/server/wire.ts"; + +/** + * Every key `RadarBody.field` may carry. The lattice, and the two panel + * counters, and nothing else. + * + * Adding to this list is a **privacy decision**, not a refactor. The question to + * answer first is "can this be inverted, joined or differenced into the distance + * from a rain cell to somebody's front door" — because `echo_cells.distance_km` + * is literally that, already computed, sitting one careless SELECT away on the + * machine at the other end of this fetch. + */ +const FIELD_KEYS = [ + "cellLat", "cellLng", "cols", "dbz", "minLat", "minLng", + "observedAt", "rows", "stations", "stationsDown", "wetFraction", +] as const; + +/** The same, for a radar station. */ +const STATION_KEYS = ["id", "lat", "lon", "operability", "type"] as const; + +/** And for a county. BirdCast is public, but the rule is the rule. */ +const COUNTY_KEYS = [ + "aloft", "altitude", "areaKm2", "direction", "id", "lat", "lon", "name", "speed", +] as const; + +/** Home-relative names, in every spelling the two sides use. None may appear. */ +const FORBIDDEN = ["distance_km", "distanceKm", "bearing_deg", "bearingDeg", "threat", "home", "px"]; + +const realFetch = globalThis.fetch; +let calls: string[] = []; +let cellsBody: unknown = null; +let stationsBody: unknown = null; +let countiesBody: unknown = null; +let nightBody: unknown = null; + +/** + * A projection response shaped as cloud-1 would serve one — including the + * columns this build must never adopt, and one it simply does not read. + */ +function upstreamCells(): unknown { + return { + observedAt: "2026-08-23T03:55Z", + wetFraction: 0.01941, + readAt: "2026-08-23T04:00:21.000Z", + attribution: ["NEXRAD base reflectivity via NOAA/NWS and Iowa State University IEM"], + cells: [ + // Riverside, in the monsoon. Real. + { lat: 33.875, lon: -117.375, dbz: 61.5, px: 302, distance_km: 18.4, provenance: "us-nws/iem-n0q" }, + { lat: 33.625, lon: -117.375, dbz: 44.0, px: 91, distance_km: 26.1 }, + { lat: 34.125, lon: -117.625, dbz: 33.0, px: 40, distance_km: 31.7 }, + ], + }; +} + +function upstreamStations(): unknown { + return { + attribution: ["Radar status from NOAA/NWS"], + stations: [ + { id: "KSOX", lat: 33.81773, lon: -117.63599, type: "WSR-88D", operability: "RDA - Maintenance Action Required", alarms: "Communication", power_w: 620 }, + { id: "KVTX", lat: 34.41166, lon: -119.1786, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" }, + { id: "KHNX", lat: 36.31416, lon: -119.63213, type: "WSR-88D", operability: "RDA - On-line" }, + ], + }; +} + +function upstreamCounties(): unknown { + return { + observedAt: "2026-08-23T03:20:00Z", + attribution: ["Nocturnal migration from BirdCast (Cornell Lab of Ornithology / Colorado State)"], + counties: [ + { id: "US-CA-019", name: "Fresno County", lat: 36.761006, lon: -119.655019, areaKm2: 15569, aloft: 82549, altitude: 333, direction: 140.0, speed: 6.9, mtr: 372.1, vid: 5.3 }, + { id: "US-CA-107", name: "Tulare County", lat: 36.228834, lon: -118.781055, areaKm2: 12531, aloft: 52108, altitude: 446, direction: 130.1, speed: 7.5 }, + // The row that is a state pretending to be a county: no coordinate, no + // area, and 9.6 times the largest county's birds. + { id: "US-CA", name: "California", lat: null, lon: null, areaKm2: null, aloft: 793141, altitude: 605, direction: 140.6, speed: 4.8 }, + ], + }; +} + +function upstreamNight(): unknown { + return { + attribution: ["Nocturnal migration from BirdCast (Cornell Lab of Ornithology / Colorado State)"], + night: { + crossed: 393290.37, + peakAloft: 1501193.14, + peakAt: "2026-08-22T06:20:00Z", + meanAltitude: 726.0, + heading: "south-east", + granules: 65, + }, + }; +} + +globalThis.fetch = (async (input: unknown) => { + const url = String(input); + calls.push(url); + const body = url.includes("/cells") + ? cellsBody + : url.includes("/stations") + ? stationsBody + : url.includes("/counties") + ? countiesBody + : nightBody; + if (body === null) return new Response("nope", { status: 503 }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +}) as unknown as typeof globalThis.fetch; + +after(() => { + globalThis.fetch = realFetch; +}); + +beforeEach(() => { + calls = []; + cellsBody = upstreamCells(); + stationsBody = upstreamStations(); + countiesBody = upstreamCounties(); + nightBody = upstreamNight(); +}); + +function appWith(env: Record) { + const config = loadConfig(env); + config.logLevel = "silent"; + return buildApp(config); +} + +const CLOUD1 = { + TERA_RADAR_SOURCE: "cloud1", + TERA_RADAR_URL: "http://127.0.0.1:9/api/radar", + TERA_BIRDS_SOURCE: "cloud1", + TERA_BIRDS_URL: "http://127.0.0.1:9/api/birds", +}; + +async function radar(app: ReturnType) { + const res = await app.inject({ method: "GET", url: "/api/v1/radar" }); + assert.equal(res.statusCode, 200); + return { body: res.json() as RadarBody, headers: res.headers }; +} + +async function birds(app: ReturnType) { + const res = await app.inject({ method: "GET", url: "/api/v1/birds" }); + assert.equal(res.statusCode, 200); + return { body: res.json() as BirdsBody, headers: res.headers }; +} + +/** Every key anywhere in a body, however deep. */ +function keysOf(value: unknown, into = new Set()): Set { + if (Array.isArray(value)) { + for (const item of value) keysOf(item, into); + } else if (value !== null && typeof value === "object") { + for (const [key, inner] of Object.entries(value)) { + into.add(key); + keysOf(inner, into); + } + } + return into; +} + +// ---- Off by default ------------------------------------------------------- + +describe("a box with no sky source", () => { + it("serves a real empty sky rather than an invented shower", async () => { + const app = appWith({}); + after(() => app.close()); + + const { body } = await radar(app); + assert.equal(body.source, "none"); + assert.equal(body.field, null); + // The epoch, not "now". "Now" would claim this was fetched a moment ago; it + // was never fetched, and a board with nothing on it has to be able to say + // which of those two it is looking at. + assert.equal(Date.parse(body.fetchedAt), 0); + assert.equal(calls.length, 0, "and nothing was asked of anybody"); + }); + + it("serves an empty migration body that says why it is empty", async () => { + const app = appWith({}); + after(() => app.close()); + + const { body } = await birds(app); + assert.equal(body.source, "none"); + assert.deepEqual(body.counties, []); + assert.equal(body.statewide, null); + // The difference from fires: an empty fire body needs no explanation, and an + // empty migration body does, because the layer is absent fourteen hours in + // every twenty-four by construction. + assert.ok(body.quiet, "quiet is not optional"); + assert.equal(body.quiet.reason, "no-data"); + assert.match(body.quiet.message, /fact about this box/); + assert.equal(calls.length, 0); + }); + + it("adds no demotion line, because off is a choice and not a misconfiguration", async () => { + const config = loadConfig({}); + assert.deepEqual(config.degraded, []); + assert.equal(config.radar.source, "none"); + assert.equal(config.birds.source, "none"); + }); + + it("demotes cloud1 with no URL, and says so in one sentence", async () => { + const config = loadConfig({ TERA_RADAR_SOURCE: "cloud1", TERA_BIRDS_SOURCE: "cloud1" }); + assert.equal(config.radar.source, "none"); + assert.equal(config.birds.source, "none"); + assert.equal(config.degraded.length, 2); + assert.match(config.degraded.join(" "), /TERA_RADAR_URL/); + assert.match(config.degraded.join(" "), /TERA_BIRDS_URL/); + }); +}); + +// ---- The leak ------------------------------------------------------------- + +describe("what may cross the wire", () => { + it("adopts exactly the named cell and station keys and nothing else", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + const { body } = await radar(app); + assert.ok(body.field, "the fixture is a real Riverside storm and must promote"); + const keys = keysOf(body); + for (const forbidden of FORBIDDEN) { + assert.ok(!keys.has(forbidden), `${forbidden} reached the wire`); + } + // The lattice is built on the server, so a cell never crosses as a cell: the + // field is one row-major array of numbers with a shape beside it. That is a + // stronger property than filtering a cell's columns — there is no object for + // a new column to attach to — and this is what asserts it stayed true. + assert.deepEqual( + Object.keys(body.field).sort(), + [...FIELD_KEYS].sort(), + "the field carries exactly the keys the wire contract declares", + ); + assert.deepEqual( + [...keys].filter((key) => (STATION_KEYS as readonly string[]).includes(key)), + [], + "a station survives only as a count — `stations` and `stationsDown`", + ); + assert.ok(!keys.has("alarms"), "…and its alarm summary never leaves cloud-1"); + assert.ok(!keys.has("power_w")); + }); + + it("never names distance_km in a request path", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + await radar(app); + await birds(app); + assert.ok(calls.length >= 4); + for (const url of calls) { + for (const forbidden of FORBIDDEN) { + assert.ok(!url.includes(forbidden), `${url} names ${forbidden}`); + } + } + }); + + it("adopts exactly the named county keys", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + const { body } = await birds(app); + assert.equal(body.counties.length, 2, "the state row is dropped on the way in"); + for (const county of body.counties) { + assert.deepEqual([...Object.keys(county)].sort(), [...COUNTY_KEYS].sort()); + } + const keys = keysOf(body.counties); + assert.ok(!keys.has("mtr"), "a column this build does not read must not be adopted"); + assert.ok(!keys.has("vid")); + assert.ok(!body.counties.some((c) => c.id === "US-CA")); + }); + + it("takes last night from the state row and never from a sum", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + const { body } = await birds(app); + assert.ok(body.statewide); + assert.equal(Math.round(body.statewide.crossed), 393290); + // Summing the counties for that night gives 2,360,086. Nothing here does. + assert.notEqual(Math.round(body.statewide.crossed), 2360086); + assert.ok(!("granules" in body.statewide), "and only the five fields the wire declares"); + }); +}); + +// ---- Both halves, or neither --------------------------------------------- + +describe("a projection that half answers", () => { + it("returns null when either half of the radar fetch fails", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + stationsBody = null; + const first = await radar(app); + // A lattice with a silently empty station list would draw every coverage + // hole as clear sky, which is the strongest possible version of the failure + // this whole layer exists to prevent. + assert.equal(first.body.field, null); + assert.equal(Date.parse(first.body.fetchedAt), 0); + }); + + it("returns null when either half of the birds fetch fails", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + nightBody = null; + const { body } = await birds(app); + // Without last night's figures the daylight sky has nothing to say but + // "nothing is aloft", which is the blank panel this layer exists to avoid. + assert.deepEqual(body.counties, []); + assert.equal(body.statewide, null); + assert.ok(body.quiet); + }); + + it("keeps serving the last whole body when the upstream goes away", async () => { + const app = appWith({ ...CLOUD1, TERA_RADAR_TTL: "0" }); + after(() => app.close()); + + const good = await radar(app); + assert.ok(good.body.field); + const stamp = good.body.fetchedAt; + + cellsBody = null; + // Past the floored TTL, so the next request really does try again. + await new Promise((resolve) => setTimeout(resolve, 5)); + const stale = await radar(app); + assert.ok(stale.body.field, "ten-minute-old radar beats no radar, and beats a 503 by more"); + assert.equal(stale.body.fetchedAt, stamp); + }); + + it("floors the TTL, so TERA_RADAR_TTL=0 is not a fetch pair per request", async () => { + const config = loadConfig({ ...CLOUD1, TERA_RADAR_TTL: "0", TERA_BIRDS_TTL: "0" }); + const app = buildApp({ ...config, logLevel: "silent" }); + after(() => app.close()); + + const first = await radar(app); + assert.ok(first.body.ttlSeconds >= 120); + const second = await birds(app); + assert.ok(second.body.ttlSeconds >= 300); + }); +}); + +// ---- The lattice ---------------------------------------------------------- + +describe("the lattice the server builds", () => { + it("is the product's own grid clipped to the state board", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + const { body } = await radar(app); + assert.ok(body.field); + assert.equal(body.field.rows, 38); + assert.equal(body.field.cols, 42); + assert.equal(body.field.dbz.length, 38 * 42); + assert.equal(body.field.cellLat, 0.25); + assert.equal(body.field.stations, 3); + // Two of the three carry a maintenance work order and both are transmitting. + assert.equal(body.field.stationsDown, 0); + assert.ok(JSON.stringify(body).length < 40_000, "the whole body is small enough to cache"); + }); + + it("draws nothing when statewide coverage is under the floor", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + + cellsBody = { ...(upstreamCells() as Record), wetFraction: 0.0001 }; + const { body } = await radar(app); + assert.equal(body.field, null, "promotion is decided from coverage, not from cell count"); + assert.notEqual(Date.parse(body.fetchedAt), 0, "…and the feed is alive, which the age says"); + }); + + it("is publicly cacheable, because a projection cannot leak what it never sent", async () => { + const app = appWith(CLOUD1); + after(() => app.close()); + const { headers } = await radar(app); + assert.match(String(headers["cache-control"]), /public/); + const bird = await birds(app); + assert.match(String(bird.headers["cache-control"]), /public/); + }); +}); diff --git a/src/access.ts b/src/access.ts index 908daad..6bfee41 100644 --- a/src/access.ts +++ b/src/access.ts @@ -189,6 +189,24 @@ export interface Feeds { * place is burning, made to somebody who may live there. */ fires: boolean; + /** + * The two sky projections, from `TERA_RADAR_SOURCE` and `TERA_BIRDS_SOURCE`. + * + * **Optional, unlike every field above**, and that is the one thing to + * understand about them: `sources.radar` and `sources.birds` are newer than + * some servers this client will meet, and `undefined` has to mean the same as + * `false` — do not ask. A required boolean here would have read a body from an + * older box as a definite "no", which is the same answer by luck rather than + * by construction, and would have made every existing `Feeds` literal in the + * tests a compile error for no gain. + * + * Both are off on this repo's default and on every clone, and both stay + * honest when they are: `promoteRadar(null)` and `promoteBirds(null)` write + * "no feed is configured — that is a fact about this box, not about the sky", + * which is the sentence a blank panel could not say. + */ + radar?: boolean; + birds?: boolean; } export interface Access { @@ -383,7 +401,7 @@ function access( } /** - * `/health`'s `sources` block, read as three yes/no answers. + * `/health`'s `sources` block, read as one yes/no answer per feed. * * Defensively, like `admin` above and for the same reason: this field is newer * than some servers this client will meet, and a missing one has to fall the @@ -401,6 +419,8 @@ function feedsFrom(raw: unknown): Feeds { markers: wired("markers"), devices: wired("devices"), fires: wired("fires"), + radar: wired("radar"), + birds: wired("birds"), }; } diff --git a/src/adapters/http.ts b/src/adapters/http.ts index 4f92d64..223c09d 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -50,6 +50,7 @@ import type { SatelliteElements } from "../engine/satellites.ts"; import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts"; import { seededRandom } from "../engine/world.ts"; import type { + BirdsBody, DeviceCommandBody, DeviceCommandResultBody, DevicesBody, @@ -61,6 +62,7 @@ import type { MarkersBody, OfficeDoc, PresenceBody, + RadarBody, SatellitesBody, WeatherBody, WireAircraft, @@ -336,6 +338,30 @@ export interface TeraClient { * of" line without waiting for a fire to move. */ watchFires(onBody: (body: FiresBody | null) => void): FireWatch; + /** + * The statewide reflectivity lattice, once. `null` when nothing answered. + * + * No watcher, and the absence is the same one `satellites` explains rather + * than the one `fires` explains: the composite behind this is a five-minute + * product and the server caches it, so the *cadence* belongs to whoever is + * looking at it. `main.ts` re-asks off the once-a-minute clock when the body's + * own `ttlSeconds` has expired, which is one request per TTL per tab and needs + * no ladder here. + * + * `null` is never an empty sky. `promoteRadar` turns a refusal into "no radar + * feed is configured" and an answered-but-dry body into "nothing is falling on + * this board", and those are different sentences about the same picture. + */ + radar(options?: { signal?: AbortSignal }): Promise; + /** + * Tonight's migration, once, and the quiet reason when there is none. + * + * `BirdsBody.quiet` is not optional, which is the whole design: 168 of 297 + * granules upstream are daylight, so the empty answer is the common one and it + * arrives carrying its own explanation rather than as an empty array a caller + * has to interpret. + */ + birds(options?: { signal?: AbortSignal }): Promise; /** * One office pack. `null` for anything the server will not serve — including * a private one, which answers 404 rather than 403 so the endpoint cannot be @@ -580,6 +606,20 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient { return watchFires(get, onBody); }, + /** + * The two sky feeds, each once. + * + * Deliberately not merged into one call. They are two upstream projections + * with two TTLs and two failure modes, and a box configured for radar and + * not for birds must be able to answer one and refuse the other — which a + * combined body could only express by inventing a shape neither route has. + */ + radar: (opts: { signal?: AbortSignal } = {}) => + get("/radar", { ...(opts.signal ? { signal: opts.signal } : {}) }), + + birds: (opts: { signal?: AbortSignal } = {}) => + get("/birds", { ...(opts.signal ? { signal: opts.signal } : {}) }), + office: (id) => get(`/offices/${encodeURIComponent(id)}`), /** diff --git a/src/assets/radarRamp.ts b/src/assets/radarRamp.ts new file mode 100644 index 0000000..7d67632 --- /dev/null +++ b/src/assets/radarRamp.ts @@ -0,0 +1,141 @@ +/** + * The NWS reflectivity ramp, as numbers. + * + * Two hundred and fifty-six colours, fetched once from the Iowa Environmental + * Mesonet's own N0Q legend and written out here as packed `0xRRGGBB` integers. + * They are not a palette somebody liked the look of: they are *the* ramp the + * product is published in, so a viewer who has seen a weather map before reads + * this board's rain without being taught anything, and the green-to-yellow-to- + * red-to-magenta ladder means what it means everywhere else. + * + * ### Why numbers rather than a PNG + * + * No binary assets under `src/` is a licensing rule, not a stylistic one — see + * ARCHITECTURE.md and `scripts/check-no-binaries.mjs`. A colour ramp is the most + * tempting thing in the repo to ship as a 256x1 image, and it is also the + * cheapest to ship as a list: 256 integers is about 2 KB of source, it diffs, + * and it needs no `PROVENANCE.json` entry because there is no artifact. + * + * ### The index is the dBZ axis, and it is exactly affine + * + * `idx` 1 is -31.5 dBZ and every step is half a dBZ, so `idx` 255 is 95.5. That + * was **checked against all 255 rows of the upstream table**, not assumed: + * `dbz = -32 + idx / 2` reproduces every one of them. So only the colours are + * shipped, and the axis is one line of arithmetic that cannot drift out of step + * with the table it indexes. + * + * `idx` 0 is the product's "no data" entry and is black with no dBZ beside it. + * It is kept in the table so the array index *is* the ramp index, and it is + * never looked up: `radarRampRgb` clamps to 1. + * + * ### What the store actually contains + * + * Every one of the 8,782 `echo_cells` rows in the upstream store is at or above + * **20.0 dBZ** — the collector thresholds at rain before it writes — so in + * practice only indices 104 and up are ever reached. The rest are here because a + * ramp with holes in it is a ramp somebody has to check the bounds of. + * + * Credit: NOAA/NWS via Iowa State University's IEM. US government work. + */ + +import { RADAR_RAIN_DBZ } from "../server/radar.ts"; + +/** dBZ at ramp index 0.5 steps below index 1. See the header: the axis is affine. */ +const RAMP_DBZ_BASE = -32; +const RAMP_DBZ_STEP = 0.5; + +/** Packed `0xRRGGBB`, indexed by the product's own ramp index. */ +export const RADAR_RAMP: readonly number[] = [ + 0x000000, 0x85718f, 0x85728f, 0x86738d, 0x87758b, 0x87768b, 0x887789, 0x897987, + 0x897a87, 0x8a7b85, 0x8b7d84, 0x8b7e84, 0x8c7f82, 0x8d8180, 0x8d8280, 0x8e837e, + 0x8f847c, 0x8f857c, 0x90877b, 0x918879, 0x918979, 0x928b77, 0x938d75, 0x969153, + 0x989457, 0x9b975b, 0x9d9a60, 0xa09d64, 0xa3a068, 0xa5a36d, 0xa8a671, 0xaaa976, + 0xadac7a, 0xb0af7e, 0xb2b283, 0xb7b88c, 0xbabb90, 0xbdbe94, 0xbfc199, 0xc2c49d, + 0xc4c7a2, 0xc7caa6, 0xcacdaa, 0xccd0af, 0xd2d4b4, 0xcfd2b4, 0xc9ccb4, 0xc6c9b4, + 0xc3c7b4, 0xc0c4b4, 0xbdc1b4, 0xb9beb4, 0xb6bbb4, 0xb3b9b4, 0xb0b6b4, 0xadb3b4, + 0xaab0b4, 0xa4abb4, 0xa0a8b4, 0x9da5b4, 0x9aa2b4, 0x97a0b4, 0x949db4, 0x919ab4, + 0x949bb5, 0x9098b4, 0x8c95b3, 0x8892b2, 0x808cb0, 0x7c89af, 0x7886ae, 0x7483ac, + 0x7080ab, 0x6c7daa, 0x6779a9, 0x6376a8, 0x5f73a7, 0x5b70a6, 0x576da4, 0x4f67a2, + 0x4b64a1, 0x4761a0, 0x435e9f, 0x415b9e, 0x4361a2, 0x4568a6, 0x486faa, 0x4a76ae, + 0x4d7db2, 0x4f84b6, 0x518bbb, 0x5699c3, 0x599fc7, 0x5ba6cb, 0x5eadcf, 0x60b4d4, + 0x62bbd8, 0x65c2dc, 0x67c9e0, 0x6ad0e4, 0x6fd6e8, 0x68d6d7, 0x59d6b3, 0x52d6a2, + 0x4bd690, 0x43d67e, 0x3cd66d, 0x35d65b, 0x11d518, 0x11d117, 0x10cd17, 0x10c816, + 0x10c416, 0x0fbc15, 0x0fb714, 0x0eb314, 0x0eaf13, 0x0eab13, 0x0da612, 0x0da212, + 0x0d9e11, 0x0c9911, 0x0c9510, 0x0c9110, 0x0b880f, 0x0b840e, 0x0a800e, 0x0a7c0d, + 0x0a770d, 0x09730c, 0x096f0c, 0x096b0b, 0x08660b, 0x08620a, 0x095e09, 0x327308, + 0x467d08, 0x5b8807, 0x6f9207, 0x849d06, 0x98a806, 0xadb205, 0xc1bd05, 0xd6c704, + 0xead204, 0xffe200, 0xffd800, 0xffd300, 0xffce00, 0xffc900, 0xffc400, 0xffc000, + 0xffbb00, 0xffb600, 0xffb100, 0xffac00, 0xffa700, 0xffa200, 0xff9900, 0xff9400, + 0xff8f00, 0xff8a00, 0xff8500, 0xff8000, 0xff0000, 0xf80000, 0xf10000, 0xea0000, + 0xe30000, 0xd50000, 0xcd0000, 0xc60000, 0xbf0000, 0xb80000, 0xb10000, 0xaa0000, + 0xa30000, 0x9b0000, 0x940000, 0x8d0000, 0x7f0000, 0x780000, 0x710000, 0xffffff, + 0xfff5ff, 0xffeaff, 0xffdfff, 0xffd4ff, 0xffc9ff, 0xffbeff, 0xffb3ff, 0xff9dff, + 0xff92ff, 0xff75ff, 0xfc6bfd, 0xf960fa, 0xf656f7, 0xf34bf4, 0xf040f1, 0xed36ef, + 0xea2bec, 0xe720e9, 0xe10be3, 0xb200ff, 0xac00fc, 0xa400f7, 0x9b00f4, 0x9300ef, + 0x8800ea, 0x8300e8, 0x7900e2, 0x7200dd, 0x6900db, 0x05ecf0, 0x05ebf0, 0x05eaf0, + 0x05dde0, 0x05dce0, 0x05dbe0, 0x05cdd0, 0x05ccd0, 0x04bdc0, 0x04bcc0, 0x04bbc0, + 0x04aeb0, 0x04adb0, 0x049ea0, 0x049da0, 0x049ca0, 0x038e90, 0x038d90, 0x038c90, + 0x037e80, 0x037d80, 0x036f70, 0x036e70, 0x036d70, 0x025f60, 0x025e60, 0x024f50, + 0x024e50, 0x024d50, 0x023f40, 0x023e40, 0x023d40, 0x013030, 0x012f30, 0x012020, + 0x011f20, 0x011e20, 0x3a67b5, 0x3a66b5, 0x3a65b5, 0x3a64b5, 0x3a63b5, 0x3a62b5, +]; + +/** The dBZ this ramp index stands for. `idx` 0 has none and is not addressable. */ +export function dbzForRampIndex(index: number): number { + return RAMP_DBZ_BASE + index * RAMP_DBZ_STEP; +} + +/** The ramp index for a reflectivity, clamped into the addressable range. */ +export function rampIndexForDbz(dbz: number): number { + if (!Number.isFinite(dbz)) return 1; + const raw = Math.round((dbz - RAMP_DBZ_BASE) / RAMP_DBZ_STEP); + return Math.min(255, Math.max(1, raw)); +} + +/** + * One reflectivity as three 0-255 channels. + * + * Returned as a tuple rather than a `THREE.Color` on purpose: this module is + * imported by the layer that fills a `DataTexture` byte by byte, and a colour + * object would mean an sRGB-to-linear conversion happening in the wrong place. + * The bytes written into an `RGBAFormat` / `UnsignedByteType` texture are sRGB, + * and the texture is told so. + */ +export function radarRampRgb(dbz: number): [number, number, number] { + const packed = RADAR_RAMP[rampIndexForDbz(dbz)] ?? 0; + return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff]; +} + +/** + * The part of the ramp anyone will ever see, painted into a canvas. + * + * A thirteen-stop colour scale that nobody can read is decoration, so the layer + * ships its own key rather than assuming the reader already has one. Drawn at + * runtime, which is the same reason the table above is numbers: there is no + * image file anywhere in this. + * + * `null` with no DOM, which is the posture every canvas builder in this repo + * takes (`airports.ts`, `assets/fire.ts`, `clouds.ts`) — a headless test builds + * the layer and gets no key, rather than throwing on `document`. + */ +export function radarRampStrip( + width = 256, + height = 12, + minDbz = RADAR_RAIN_DBZ, + maxDbz = 75, +): HTMLCanvasElement | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const context = canvas.getContext("2d"); + if (!context) return null; + + for (let x = 0; x < canvas.width; x++) { + const dbz = minDbz + ((maxDbz - minDbz) * x) / Math.max(1, canvas.width - 1); + const [r, g, b] = radarRampRgb(dbz); + context.fillStyle = `rgb(${r},${g},${b})`; + context.fillRect(x, 0, 1, canvas.height); + } + return canvas; +} diff --git a/src/cities/california.ts b/src/cities/california.ts index 92c8c45..f466ce0 100644 --- a/src/cities/california.ts +++ b/src/cities/california.ts @@ -82,14 +82,15 @@ export const CALIFORNIA_I_5 = routePath("la-sf-i-5"); /** * Longitude's foreshortening at this board's centre latitude. * - * `World` computes exactly this from `city.center.lat` and uses it inside + * `World` computes exactly this from `city.center.lat` — 37.3 now that the + * board is the whole state — and uses it inside * `elevationAt`, so any spacing measured here has to be measured the same way * or a chain running east-west comes out with its peaks 22% further apart than * one running north-south. Repeated rather than imported because `cities` must * not import `engine` — see ARCHITECTURE.md §2 — and kept next to the one * function that needs it. */ -const LNG_SQUASH = Math.cos((35.3 * Math.PI) / 180); +const LNG_SQUASH = Math.cos((37.3 * Math.PI) / 180); /** * A range as a chain of peaks along a line, at the spacing that makes a chain @@ -176,20 +177,85 @@ function ridge( * Winding stays consistent — land is always on the left — which is what * `ShapeGeometry` needs to triangulate the shore plate. * - * **Three of the four sides are the state's own edges, not the board's.** The - * Pacific is a coast, the south is the Mexican border and the east is the - * Colorado River and the Nevada line. Only the top of the board is a crop, and - * its closure runs along 38.13–38.20 N — a sixth of a degree past - * `bounds.maxLat`, far enough out that `coastalFalloff` (which ramps relief to - * zero within `coastFalloff` of *any* polygon edge) never sees it and never - * flattens the Sierra against the top of the frame. The Mexican border is the - * one deliberate exception: it sits *two hundredths* below `bounds.minLat`, so - * that the state's edge and the board's crop are the same line and there is no - * strip of sea along the bottom of the frame. See the note there. + * **All four sides are now the state's own edges, and none of them is a crop.** + * The Pacific is a coast, the south is the Mexican border, the east is the + * Colorado River and the Nevada line, and the north is the 42nd parallel. That + * last one is what this trace was extended for and it is the whole of the + * owner's first complaint: the board used to close along 38.13 N with a ruled + * segment, so it drew the southern two thirds of California while the minimap + * beside it drew the whole state, and the two disagreed about the shape of the + * place in a single frame. A ruled line across the top of a silhouette is not a + * subtle defect — it is the one edge of the state everybody can name after the + * coast. + * + * The three artificial closures follow one rule, which is that they sit just + * *outside* `bounds` so that the land covers the outermost row or column of the + * lattice and no strip of sea is left along the edge of the frame. The Mexican + * border is two hundredths below `bounds.minLat`; the Oregon line is two + * hundredths above `bounds.maxLat`. Neither is a crop and neither leaves a + * moat. (`coastalFalloff` ramps relief to zero within `coastFalloff` of any + * polygon edge, so the bottom and top rows of ground are flattened — which is + * what a state edge should look like, and is what the Nevada line has always + * looked like.) */ export const CORRIDOR_LAND: LatLng[] = [ - // North closure, just off the top of the board. - [38.13, -123.05], + // ---- The Oregon line, where it meets the sea --------------------------- + // + // 42.07 N and not 42.05, and the two hundredths are the same trick the + // Mexican border plays at the other end of the state: the closure sits just + // *outside* `bounds.maxLat`, so the land covers the top row of the lattice + // and no strip of sea is left along the top of the frame pretending to be + // Oregon. The real parallel is 42.00, which is 7.8 km south of this and 4 + // units on a 554-unit board — a rounding error at 1,919 m to the unit, and + // the honest direction to round in, because the alternative is a visible + // moat between California and a state that is not on this board. + [42.07, -124.23], + + // ---- The North Coast, Oregon to Point Reyes ---------------------------- + // + // Two hundred and fifty kilometres of coast that used to be open ocean, and + // the reason the board disagreed with its own minimap about the shape of + // California. The three things worth getting right are all in the first + // eighty kilometres of it. + // + // **Cape Mendocino is the westernmost ground in the state**, at -124.41, and + // it is the corner that makes the northern silhouette read as California + // rather than as a coastline in general — the coast runs almost due south + // from the Oregon line, turns hard west at Trinidad, and then bends back + // south-east for four hundred kilometres to the Golden Gate. Miss the cape + // and the whole North Coast is a straight line. + // + // **Humboldt Bay is not traced**, for the reason San Diego Bay is not: it is + // thirty-five kilometres long and two wide, which is a scratch a pixel across + // here, and every extra inlet is a self-intersection waiting to happen. The + // spit stands in for both shores. + // + // **The Lost Coast is drawn as a coast and not as a bay.** Between Punta + // Gorda and Shelter Cove the King Range rises 1,246 m out of the surf in five + // kilometres; there is no road along it in life and there is no indentation + // in it here. + [41.99, -124.21], // Smith River + [41.87, -124.2], + [41.76, -124.21], // Point St George and Crescent City + [41.55, -124.08], // the mouth of the Klamath + [41.3, -124.1], + [41.05, -124.15], // Trinidad Head + [40.87, -124.18], + [40.77, -124.24], // the Humboldt spit, off Eureka + [40.62, -124.35], // False Cape + [40.44, -124.41], // Cape Mendocino — the westernmost ground in California + [40.28, -124.36], // Punta Gorda + [40.1, -124.13], // Shelter Cove, the foot of the King Range + [39.9, -123.95], + [39.72, -123.83], // Cape Vizcaino + [39.5, -123.79], // Fort Bragg + [39.3, -123.8], // Mendocino + [39.1, -123.72], + [38.95, -123.73], // Point Arena + [38.75, -123.53], + [38.55, -123.32], // Fort Ross + [38.35, -123.07], // Bodega Head + [38.2, -122.99], // ---- Pacific coast, north to south ------------------------------------- [38.05, -122.98], @@ -360,22 +426,50 @@ export const CORRIDOR_LAND: LatLng[] = [ [34.88, -114.63], [35.0, -114.63], // the Arizona / Nevada / California tri-point - // ---- The Nevada line, and the north closure ------------------------------ + // ---- The Nevada line, both halves of it --------------------------------- // - // One ruled segment from the tri-point to the corner at Lake Tahoe — 39.0 N, - // -120.0 — of which this board draws the part below its own top edge. The - // gradient is -1.342° of longitude per degree of latitude, which is worth - // writing down because it is what sites the two peaks in the White Mountains: - // the line is at -118.17 where White Mountain Peak stands and the peak is - // nine hundredths of a degree inside it, so both of those are placed against - // the line rather than against a round number. Anything east of it is Nevada, - // and Nevada is not on this board. + // The oblique first: one ruled segment from the tri-point to the corner at + // Lake Tahoe — 39.0 N, -120.0 — at a gradient of -1.342° of longitude per + // degree of latitude. That number is worth writing down because it is what + // sites the two peaks in the White Mountains: the line is at -118.17 where + // White Mountain Peak stands and the peak is nine hundredths of a degree + // inside it, so both are placed against the line rather than against a round + // number. // - // Extended to 38.2 N, a sixth of a degree past `bounds.maxLat`, for the same - // reason every closure sits outside the bounds: `coastalFalloff` ramps relief - // to zero within `coastFalloff` of *any* polygon edge, and the board's crop - // must not flatten the Sierra against the top of the frame. - [38.2, -118.93], + // It used to stop at 38.2, a sixth of a degree past the old crop. It now runs + // to the corner and then turns, because the corner is the point: above 39.0 + // the boundary is the **120th meridian**, dead straight for three degrees of + // latitude, and the right angle where the oblique meets it is the second most + // recognisable thing about this state's outline after Point Conception. A + // board that stopped before the corner had the diagonal and not the notch. + [35.5, -115.3], + [36.0, -115.97], + [36.5, -116.64], + [37.0, -117.31], + [37.5, -117.98], + [38.0, -118.65], + [38.5, -119.32], + [39.0, -120.0], // the corner, in Lake Tahoe + + // ---- Up the 120th meridian ---------------------------------------------- + [39.5, -120.0], + [40.0, -120.0], + [40.5, -120.0], + [41.0, -120.0], + [41.5, -120.0], + [42.07, -120.0], // the north-east corner + + // ---- West along the Oregon line ------------------------------------------ + // + // The 42nd parallel, ruled in life as well as here — it was drawn by treaty in + // 1819 and has not moved since. Carried as six vertices rather than one + // segment so that `coastalFalloff`'s edge search has something to find at a + // sensible spacing along four hundred kilometres of straight boundary. + [42.07, -120.8], + [42.07, -121.6], + [42.07, -122.4], + [42.07, -123.2], + [42.07, -123.8], ]; /** @@ -449,6 +543,115 @@ const HILLS: Hill[] = [ { name: "Vaca Mountains", lat: 38.04, lng: -122.06, elevation: 700, radius: 0.14 }, { name: "Berkeley Hills", lat: 37.87, lng: -122.19, elevation: 450, radius: 0.09 }, + /** + * ---- The northern Coast Ranges ---- + * + * Bolinas Ridge to the Oregon line: the Mayacamas behind the Napa Valley, the + * Mendocino Range behind Fort Bragg, the Yolla Bolly at the Klamath's shoulder + * and — the one that matters — the King Range. + * + * The King Range is 1,246 m and it stands **five kilometres from the surf**, + * which is why eighty kilometres of California shoreline has no road along it + * and is called the Lost Coast. A 0.1 radius is what keeps it that steep here: + * anything wider puts a shelf between the summit and the water and the whole + * point of the landform is that there is not one. `coastFalloff` still flattens + * the last 0.025° of it, which is about two units, and that is a beach at this + * scale rather than a plain. + */ + ...ridge("Sonoma Mountains", [38.25, -122.55], [38.55, -122.85], 800, 0.12), + ...ridge("Mayacamas Mountains", [38.5, -122.6], [39.0, -122.95], 1_300, 0.14), + { name: "Mount St Helena", lat: 38.67, lng: -122.63, elevation: 1_320, radius: 0.1 }, + ...ridge("Mendocino Range", [39.05, -123.3], [39.95, -123.55], 1_200, 0.16), + ...ridge("Snow Mountain", [39.1, -122.75], [39.7, -122.9], 1_800, 0.15), + ...ridge("Yolla Bolly Mountains", [39.85, -122.85], [40.45, -123.15], 2_300, 0.18), + ...ridge("King Range", [39.98, -124.02], [40.32, -124.18], 1_250, 0.1), + ...ridge("North Coast Front", [40.55, -123.85], [41.55, -123.85], 1_200, 0.16), + { name: "Round Valley Rim", lat: 39.78, lng: -123.32, elevation: 900, radius: 0.12 }, + + /** + * ---- The Klamath Mountains ---- + * + * The most confusing terrain in the state and the reason the north-west corner + * cannot be drawn as a chain: the Klamaths do not run north-south the way + * everything else in California does. They are a knot — the Trinity Alps, the + * Salmons, the Marbles and the Siskiyous crossing each other at every angle — + * and four chains laid at four different bearings is the cheapest honest way + * to say so with the tools this pack has. A single north-south ridge here + * would make the corner read as more Coast Range, which is exactly what it is + * not. + * + * The Trinity Alps at 2,700 m are the high ground; the Siskiyous carry the + * Oregon line itself, which is why that chain runs east-west along 41.8. + */ + ...ridge("Trinity Alps", [40.72, -123.15], [41.15, -122.7], 2_700, 0.16), + ...ridge("Salmon Mountains", [40.95, -123.45], [41.4, -123.05], 2_200, 0.15), + ...ridge("Marble Mountains", [41.35, -123.4], [41.72, -123.05], 2_300, 0.14), + ...ridge("Siskiyou Mountains", [41.8, -123.75], [41.95, -122.65], 2_000, 0.14), + ...ridge("Scott Bar Mountains", [41.55, -122.95], [41.85, -122.65], 1_700, 0.13), + ...ridge("South Fork Mountain", [40.2, -123.45], [40.75, -123.5], 1_700, 0.14), + { name: "Mount Eddy", lat: 41.32, lng: -122.48, elevation: 2_750, radius: 0.12 }, + + /** + * ---- The southern Cascades ---- + * + * **Shasta and Lassen are the two objects that make the north read as the + * north**, and they are the only free-standing stratovolcanoes on this board. + * Everything else in California is a range; these are cones, and a cone + * standing alone on a plateau is a silhouette nothing else here produces. + * + * Shasta is 4,322 m and rises about 3,000 m from the ground around it in + * seventeen kilometres, so the radius is 0.24 — deliberately tighter than the + * Sierra crest's 0.30 on a peak a fifth taller again, because the whole + * character of the mountain is that it is steep and by itself. Shastina is its + * satellite cone on the west shoulder and costs one record; without it the + * summit is a perfect circle, which no volcano is. + * + * Lassen is 3,187 m, the southern end of the Cascade arc, and the last + * mountain in the contiguous United States to erupt before St Helens. It sits + * a hundred kilometres south of Shasta with the Hat Creek country between + * them, and the gap is as much the picture as the peaks are. + */ + { name: "Mount Shasta", lat: 41.409, lng: -122.194, elevation: 4_320, radius: 0.24 }, + { name: "Shastina", lat: 41.409, lng: -122.27, elevation: 3_700, radius: 0.09 }, + { name: "Lassen Peak", lat: 40.488, lng: -121.505, elevation: 3_190, radius: 0.17 }, + { name: "Brokeoff Mountain", lat: 40.43, lng: -121.58, elevation: 2_800, radius: 0.1 }, + ...ridge("Cascade Front", [40.25, -121.62], [41.05, -121.95], 2_000, 0.18), + ...ridge("Trinity Divide", [40.9, -122.42], [41.25, -122.4], 2_100, 0.14), + { name: "Medicine Lake Highland", lat: 41.58, lng: -121.55, elevation: 2_350, radius: 0.24 }, + + /** + * ---- The northern Sierra, and the Warners ---- + * + * The crest above carries the range from Piute Peak to Sonora Pass. North of + * that it keeps going for another three degrees of latitude and it does one + * thing the southern half never does: it **dies out**. Sonora is 3,150 m, + * Donner is 2,600, Grizzly Ridge is 2,100, and by Lassen there is no Sierra + * left at all — the arc hands over to the Cascades. That taper is drawn here + * rather than stopped abruptly, because a 3,000 m wall ending in mid-air at + * 39 N was what the old crop actually looked like. + * + * The Warner Mountains are the odd one out and are worth their eight records: + * they are a Basin-and-Range block stranded in the far north-east corner, a + * hundred kilometres east of the Cascades with nothing but plateau between, + * and they are the only relief in the top right of the frame. + */ + // + // Written as chains and not as a row of peaks, and that is not a style + // choice — it is the mistake this board has now made twice. The crest above + // is thirteen hand-placed bells at 0.25° on a 0.28-0.30 radius, which is 0.89 + // radii apart and is a ridge. The first draft of the northern half was six + // bells at 0.35° on a 0.24-0.26 radius: 1.4 radii apart, which is a row of + // separate domes, and a photograph of it looked exactly like the bubble wrap + // the desert shipped as once before. `ridge` computes the 0.9 spacing rather + // than trusting anybody to type it. + ...ridge("Ebbetts Crest", [38.2, -119.7], [38.72, -120.05], 3_000, 0.26), + ...ridge("Carson Range", [38.72, -119.98], [39.15, -120.05], 3_000, 0.24), + ...ridge("Donner Crest", [39.15, -120.2], [39.55, -120.45], 2_600, 0.22), + ...ridge("Sierra Buttes", [39.5, -120.55], [39.95, -120.8], 2_500, 0.2), + ...ridge("Grizzly Ridge", [39.9, -120.85], [40.35, -121.0], 2_100, 0.2), + ...ridge("Diamond Mountains", [40.1, -120.5], [40.5, -120.35], 1_900, 0.16), + ...ridge("Warner Mountains", [41.0, -120.2], [41.9, -120.3], 2_600, 0.13), + // ---- Sierra Nevada: the crest ------------------------------------------ // Thirteen peaks at ~0.25° spacing on a ~0.29° radius. This is the board's // one genuinely big landform and the reason the exaggeration is 13. @@ -478,6 +681,9 @@ const HILLS: Hill[] = [ { name: "Mother Lode", lat: 37.35, lng: -119.65, elevation: 1_000, radius: 0.3 }, { name: "Tuolumne Foothills", lat: 37.65, lng: -119.9, elevation: 950, radius: 0.32 }, { name: "Stanislaus Foothills", lat: 37.95, lng: -120.2, elevation: 900, radius: 0.32 }, + // The northern half of the ramp, as one chain rather than six bells, for the + // spacing reason the crest above gives. + ...ridge("Mother Lode North", [38.2, -120.35], [40.3, -121.85], 850, 0.3), // ---- White and Inyo Mountains, the far wall of the Owens Valley --------- { name: "Inyo Mountains", lat: 36.45, lng: -117.85, elevation: 2_500, radius: 0.2 }, @@ -717,6 +923,50 @@ const HILLS: Hill[] = [ { name: "Oxnard Plain", lat: 34.22, lng: -119.12, elevation: 18, radius: 0.22 }, { name: "Antelope Valley floor", lat: 34.75, lng: -118.2, elevation: 700, radius: 0.42 }, + /* + * The northern floors, and the one that carries a third of the new board. + * + * **The Sacramento Valley is the same object as the San Joaquin and has to + * overlap it**, for the reason the dry floors below give at length: a radial + * bell falls to zero with zero gradient at its own edge, two floors that + * merely touch leave a seam at exactly 0 m between them, and 0 m is the beach + * colour. A pale sand stripe drawn across the middle of the Central Valley at + * the latitude of Stockton is the exact failure this avoids. The San Joaquin + * floor is centred at 36.6 on a radius of 1.7, so it reaches 38.3; this one + * is centred at 39.4 on 1.3 and reaches 38.1, which puts two tenths of a + * degree of overlap under the join. + * + * The plateau floors are the opposite case and are meant to be high. The + * Modoc Plateau really does sit at 1,400 m, the Klamath Basin at 1,260 and + * Surprise Valley at 1,500, so they come out as `upland` rock rather than as + * `flats` gold — which is right: the top right of this board is high desert, + * not farmland, and it is the same country as the Great Basin over the line. + */ + // A chain along the valley's own axis rather than one bell, because the + // Sacramento Valley is 250 km long and 80 km wide and a single radial bell + // that covers its length is a 290 km circle — which is what the first draft + // drew, and it came out as a smooth gold bowl swallowing the Coast Ranges on + // one side and the Sierra foothills on the other. The San Joaquin gets away + // with one bell because it really is nearly as wide as it is long. + ...ridge("Sacramento Valley floor", [38.5, -121.6], [40.35, -122.2], 34, 0.62), + // 631 m of andesite standing alone in the middle of a dead-flat valley — the + // smallest mountain range in the world by the sign at the county line, and + // one of the very few things on this board that is legible *because* nothing + // else is near it. A 0.08 radius is nine kilometres, which is what it is. + { name: "Sutter Buttes", lat: 39.21, lng: -121.82, elevation: 631, radius: 0.08 }, + // One wide bell and not a chain, which is the opposite call from the + // Sacramento Valley two lines up and is the same reasoning applied to a + // different shape. The Modoc is a *plateau*: 20,000 km² of basalt at a fairly + // even 1,400 m, as round as it is long. Drawn as a chain of 0.42 bells it came + // out as three separate domes in a photograph — the bubble-wrap failure again, + // and this time in a place that has no ranges in it at all. The Mojave floor + // takes the same treatment at 1.1 for the same reason. + { name: "Modoc Plateau floor", lat: 41.32, lng: -120.95, elevation: 1_400, radius: 0.78 }, + { name: "Surprise Valley floor", lat: 41.4, lng: -120.06, elevation: 1_500, radius: 0.16 }, + { name: "Honey Lake floor", lat: 40.3, lng: -120.4, elevation: 1_220, radius: 0.42 }, + { name: "Shasta Valley floor", lat: 41.65, lng: -122.5, elevation: 800, radius: 0.24 }, + { name: "Scott Valley floor", lat: 41.5, lng: -122.9, elevation: 850, radius: 0.14 }, + /* * The dry floors, east of the Transverse Ranges. * @@ -817,6 +1067,138 @@ const RANGES: LatLng[][] = [ [37.95, -122.82], [38.05, -122.9], ], + /* + * The North Coast forest, Sonoma to the Oregon line. + * + * The largest single wildland envelope on the board and the one that changes + * the frame most, because the ground it covers used to be ocean. North of the + * Bay this coast is continuous forest for four hundred kilometres — redwood on + * the seaward slope, Douglas fir behind it — and it is genuinely darker and + * greener than anything else in California. Get it wrong and the top left of + * the board is gold grassland running to the Oregon line, which is a picture + * of a state four hundred kilometres inland from this one. + * + * Its western edge follows the coast at about a tenth of a degree inland + * rather than sitting on it, so `coastFalloff`'s flattened rim stays outside + * the park and the shore reads as a shore. Its eastern edge is the inner Coast + * Range crest, which is where the fog stops reaching and the country turns to + * oak and grass — a real line, and the one the eye reads as the edge of the + * north coast. + */ + [ + [38.3, -122.95], + [38.7, -123.15], + [39.1, -123.35], + [39.5, -123.4], + [39.9, -123.5], + [40.3, -123.6], + [40.7, -123.7], + [41.1, -123.75], + [41.5, -123.85], + [41.9, -123.9], + [41.95, -124.15], + [41.5, -124.1], + [41.0, -124.05], + [40.6, -124.2], + [40.3, -124.3], + [40.0, -124.05], + [39.6, -123.72], + [39.2, -123.66], + [38.8, -123.42], + [38.4, -123.1], + ], + /* + * The Klamath and the Trinity Alps: the interior knot. + * + * Drawn separately from the coast belt above rather than merged with it, + * because the ground between them is the Trinity River country and is not the + * same landscape — and because one envelope covering both would swallow the + * Shasta and Scott valleys, which are farmed, pale and the only two flat + * things in the north-west corner. Two greens with a gold gap in them is the + * shape of that corner. + */ + [ + [40.2, -123.4], + [40.6, -123.3], + [41.0, -123.1], + [41.4, -123.0], + [41.8, -122.9], + [41.95, -123.2], + [41.6, -123.5], + [41.2, -123.6], + [40.8, -123.5], + [40.4, -123.55], + ], + /* + * The Cascade belt: Shasta, Lassen, and the conifer between them. + * + * The envelope stops short of Shasta's summit cone on purpose, the same way + * the Sierra one stops half a degree west of the crest. Above about 2,700 m + * the mountain is scree, glacier and old snow, and leaving it out of the park + * is the only way this two-colour ramp can draw a tree line — the cone comes + * out `alpine` grey standing on a green skirt, which is what it looks like + * from a hundred kilometres away on any clear day in the valley. + */ + [ + [40.2, -121.9], + [40.6, -121.75], + [41.0, -121.85], + [41.18, -121.95], + [41.2, -122.3], + [41.05, -122.45], + [40.6, -122.25], + [40.25, -122.2], + ], + /* + * The Shasta-Trinity forest north and west of the mountain — and the gap + * between this polygon and the one above it is the point of both. + * + * `groundColor` answers `inPark` **first**, so a park polygon paints its + * ground green at any altitude and the `alpine` stop is unreachable inside + * one. That is correct almost everywhere and catastrophic on a stratovolcano: + * the first draft ran one envelope over the whole Cascade belt and Mount + * Shasta rendered as a 4,320 m cone of pine, which is the single most + * recognisable landform in northern California drawn wrong. + * + * So the two belts stop either side of 41.2–41.55 and the mountain sits in the + * gap, painted by the height ramp as the rock and old snow it is. The gap is + * also honest on its own terms: the Shasta Valley immediately west of the cone + * is open grassland at 800 m with no trees on it at all, which is why the + * mountain reads as free-standing from the interstate. + */ + [ + [41.55, -122.05], + [41.9, -122.15], + [41.95, -122.55], + [41.6, -122.7], + [41.35, -122.6], + [41.4, -122.25], + ], + /* + * The northern Sierra forest belt, carrying the range on from Sonora Pass. + * + * Same two edges as its southern half and for the same two reasons: the + * western one sits a little up the range front because the foothills really + * are gold grassland and the conifers really do start around 600 m, and the + * eastern one stops short of the crest so the granite above the tree line is + * left as rock. It narrows as it goes north because the range does. + */ + [ + [38.05, -119.84], + [38.35, -120.0], + [38.7, -120.25], + [39.05, -120.5], + [39.4, -120.75], + [39.75, -121.0], + [40.05, -121.25], + [40.2, -121.5], + [39.9, -121.4], + [39.55, -121.25], + [39.2, -121.05], + [38.85, -120.8], + [38.5, -120.55], + [38.15, -120.35], + ], // Inner Coast Range: the Temblor and the Diablo. [ [35.05, -119.715], @@ -1309,74 +1691,119 @@ export const CALIFORNIA_CITY: City = { id: "california", name: "California", /** - * The middle of the board, not the middle of the corridor. + * The middle of the board, not the middle of the corridor and not the middle + * of the state's population. * - * Scene space is centred here, and the board reaches from -123.05 to -114.0 - * now that the state's own eastern edge is on it. Left at the corridor's - * midpoint the origin sat 1.6° — seventy-five scene units — west of the - * middle of the bounds, which puts the satellite dome and the shadow box off - * to one side of the thing they are meant to cover. + * Scene space is centred here, and everything sized off the origin — the + * satellite dome, the shadow box, the star field — is centred with it, so a + * centre off to one side makes those covers too small on the far side by + * exactly the offset. The board now runs 32.50 to 42.05 and -124.50 to + * -114.0, whose midpoint is 37.275, -119.25. Rounded to 37.3, which is also + * the latitude `LNG_SQUASH` above is taken at; the two have to agree or a + * chain running east-west comes out with its peaks in the wrong places. + * + * It moved two degrees north with the bounds and that is the whole reason it + * is written down: 35.3 was the middle of a board that stopped at 38.05, and + * left where it was it would now sit ninety scene units south of the middle + * of the frame. */ - center: { lat: 35.3, lng: -118.55 }, + center: { lat: 37.3, lng: -119.25 }, /** - * The board is the southern two thirds of California, and it stops where the - * state stops on three sides out of four. + * The board is California. All of it, and it stops where the state stops on + * every side. * - * It used to stop at -117.55, which is a line through Temecula chosen for no - * reason except that the corridor did not need anything east of it. Half the - * Mojave, the whole Colorado Desert, the Salton basin, Death Valley and every - * one of the Peninsular Ranges were off the board, and — worse for the first - * frame anybody sees — the land ended in a ruled north-south line with open - * ocean beyond it. `maxLng` is now past Parker, so the eastern edge is the - * Colorado River and the Nevada line, which is a silhouette rather than a - * crop. `minLat` is the Mexican border for the same reason. + * The last version of this comment argued that `maxLat` could stay a crop — + * "a crop along the top of the frame, far from the camera and half in the + * fog, is the cheap direction to be wrong in". That was wrong for a reason + * the argument could not see from inside itself: the minimap draws the whole + * state from the same pack and the board drew two thirds of it, so the frame + * contained a picture of California and a picture of a piece of California at + * the same time, and the eye goes straight to the disagreement. There is no + * cheap direction to be wrong about a silhouette this well known. * - * `maxLat` is still a crop, and stays one: north of 38.05 is the Sacramento - * Valley and the Klamaths, which are another two hundred kilometres of board - * for a corridor that ends at San Francisco. A crop along the top of the - * frame, far from the camera and half in the fog, is the cheap direction to - * be wrong in; a crop down the side of the frame at the closest point to the - * camera was the expensive one. + * 32.50 to 42.05 N and -124.50 to -114.0 W: the Mexican border, the Oregon + * line, Cape Mendocino with eight kilometres of ocean west of it, and the + * Colorado past Parker. Nothing here is chosen for the corridor's + * convenience. + * + * **The price was paid in the cell, not in the bounds, and that is the whole + * arithmetic of this change.** Extending north and west at the old 0.022 by + * 0.027 spacing takes the lattice from 84,924 points to 168,813 — 2.02x — and + * the terrain with it, against a mobile budget with 70,000 triangles spare. + * Coarsening the cell by 1.42x in each axis puts it back. What matters on + * screen is not the cell in metres but the cell as a *fraction of the board*, + * because the camera retreats to frame whatever it is given; see `cellLat`. */ - bounds: { minLat: 32.55, maxLat: 38.05, minLng: -123.05, maxLng: -114.0 }, + bounds: { minLat: 32.5, maxLat: 42.05, minLng: -124.5, maxLng: -114.0 }, latScale: 58, /** - * 13, against 2.25. See the header table: this is the number that decides - * whether the state has mountains on it, and 2.25 put the Sierra 1.6 units - * off a board 284 units tall. + * 15, against 13, against the 2.25 this board shipped with. See the header + * table: this is the number that decides whether the state has mountains on + * it, and 2.25 put the Sierra 1.6 units off a board 284 units tall. * - * It is shared with buildings, which is the reason it is not higher still. At - * 13 a 250 m downtown tower is 1.7 units — about a third of the height of the - * Santa Monica Mountains behind it, which is roughly the relationship a - * photograph would show. Push the exaggeration to 20 to get an even more - * dramatic Sierra and downtown Los Angeles becomes a bed of nails. + * The two points went on when the board became the whole state, and they are + * not a taste change — they are what keeps the relief where it already was + * once the camera stood further back. Relief in the frame is `peak units over + * board span`, because `scene.ts` retreats the camera to frame the span: + * + * | board | exaggeration | peak | span | relief | + * | --- | --- | --- | --- | --- | + * | the southern two thirds | 13 | 35.7 u | 428 u | 8.3% | + * | the whole state, at 13 | 13 | 35.7 u | 554 u | 6.4% | + * | the whole state, at 15 | 15 | 41.2 u | 554 u | 7.4% | + * | Southern California | 3.4 | 29.4 u | 393 u | 7.5% | + * | the Bay Area | 3.6 | 48.7 u | 1,003 u | 4.9% | + * + * So 15 puts this board on Southern California's number exactly, which is the + * calibration that matters — the two are meant to read as the same landscape + * at two zooms. + * + * **The bed-of-nails objection does not survive the arithmetic here**, and the + * old comment's warning about pushing to 20 is still correct for the board it + * was written about. A 250 m downtown tower goes from 1.69 units to 1.95, on a + * board whose span went from 428 to 554 — so as a fraction of the frame it is + * 0.0035 where it was 0.0040. The towers are *smaller* in this frame than they + * were in the last one, not larger, and the mountains merely caught up. */ - verticalExaggeration: 13, + verticalExaggeration: 15, /** - * About 2.4 km, coarsened by a tenth when the board grew east. + * About 3.5 km, coarsened by 1.42x when the board became the whole state. * - * The instinct when a board looks flat is that the lattice is too fine. It was - * not: the Sierra is 80 km wide, which is 33 cells at this spacing, and + * This is the same trade the board made once before when it grew east, made + * again and larger, and it is worth restating rather than referring to, + * because it is the only reason the extension fits. + * + * The instinct when a board looks flat is that the lattice is too fine. It is + * not: the Sierra is 80 km wide, which is 23 cells even at this spacing, and * Southern California renders the San Gabriels — the range that board is - * famous for — across 37 coarse cells. Halving the cell here would have - * quadrupled the terrain and bought nothing the eye can find, because the - * missing structure was vertical, not horizontal. + * famous for — across 37 coarse cells. The structure a state board is missing + * is vertical, never horizontal. * - * The tenth is the price of the eastern half of the state, and it is a price - * paid in a currency nobody can see. Extending `bounds` to the Colorado grew - * the land under the lattice by 60%, which at the old spacing put the terrain - * at 130,622 triangles against 81,546 — half the board's whole spare budget on - * one mesh. What matters on screen is not the cell in metres but the cell as a - * fraction of the board, because the camera retreats to frame whatever it is - * given: 0.022° is 1.27 units on a board now 428 across, where 0.02° was 1.16 - * units on a board 284 across. The cell is 27% bigger on the ground and 27% - * *smaller* in the frame, and the terrain costs 105,762 triangles instead. + * **The cell as a fraction of the board is what the eye sees, and it did not + * move.** The camera retreats to frame whatever it is given, so the number + * that matters is cell over span, not cell in metres: + * + * | board | cell | span | fraction | + * | --- | --- | --- | --- | + * | corridor, before the east | 0.020° | 284 u | 0.0041 | + * | southern two thirds | 0.022° | 428 u | 0.0030 | + * | the whole state | 0.0312° | 554 u | 0.0033 | + * + * So the ground cell went from 2,449 m to 3,473 m — 42% coarser on the earth + * — and *finer* in the frame than the board two revisions ago. The lattice + * holds at 85,008 points against 84,924, and the terrain triangle count with + * it. + * + * `cellLng` is 0.0383 rather than 0.0312 for the same reason it was 0.027 + * rather than 0.022: a degree of longitude at this latitude is `cos(37.3)` of + * a degree of latitude, so an equal-area cell has to be 1/0.795 as wide as it + * is tall. Both were multiplied by the same 1.42. */ - cellLat: 0.022, - cellLng: 0.027, + cellLat: 0.0312, + cellLng: 0.0383, /** * ~2.8 km, or a little over one cell. @@ -1422,15 +1849,28 @@ export const CALIFORNIA_CITY: City = { number: "01", description: "Los Angeles and San Francisco joined as one living route board.", /* - * Framed on the middle of the bounds, not on the middle of the corridor, - * and pulled back with the board: `scene.ts` sizes the fog, the far plane - * and the orbit limits from `boardSpan`, but the opening pose is authored - * here and does not scale itself. The board went from 284 units across to - * 428 when the state's own eastern edge arrived, and the old 370/320 - * stand-off framed the Central Valley with San Diego and the Mojave off - * the side of the screen. + * Framed on the middle of the bounds, and pulled back with the board: + * `scene.ts` sizes the fog, the far plane and the orbit limits from + * `boardSpan`, but the opening pose is authored here and does not scale + * itself. This is the third time it has had to move — 284 units across, + * then 428 when the state's own eastern edge arrived, now 554 with the + * north on — and each time the failure of not moving it was the same one: + * a shot that framed the Central Valley with a third of the state off the + * side of the screen. + * + * 612/528 holds the *angle* the board is seen from — 40.8° above the + * horizontal, the same as every version of this pose — and changes only + * the stand-off, which is 1.39 board spans against the old 1.26. The + * extra tenth is the board's aspect and not its size: at 428 x 319 it was + * a landscape rectangle that filled a 16:10 frame, and at 554 x 484 it is + * nearly square and arrives as a diamond, which needs more room in the + * axis it is turned into. Shot at 580 the Mexican border sat seventy + * pixels off the bottom edge. + * + * `chapterFraming` then widens it further on a narrow window, which is a + * different correction for a different reason and composes with this one. */ - focus: { lat: 35.15, lng: -118.35, distance: 408, height: 352, rotation: 0.5 }, + focus: { lat: 37.15, lng: -119.35, distance: 612, height: 528, rotation: 0.42 }, }, { id: "la-sf-us-101", @@ -1464,6 +1904,30 @@ export const CALIFORNIA_CITY: City = { description: "The northern door into the detailed Bay Area board and Lumbridge HQ.", focus: { lat: 37.7749, lng: -122.4194, distance: 38, height: 26, rotation: 0.8 }, }, + /** + * The sixth chapter, and the reason it exists is that the tour used to end + * where the board used to end. + * + * Two hundred kilometres of state came onto this board and the chapter list + * had no way to look at any of it: every authored pose sat below 38 N, so a + * visitor who never dragged the camera would never see the Klamaths, the + * Cascades or four hundred kilometres of North Coast. A board with land on + * it that the tour cannot reach is a board that is half decoration. + * + * Sited between Shasta and Lassen rather than on either, because the gap is + * as much of the picture as the peaks are — a hundred kilometres of Hat + * Creek country with a 4,320 m cone at one end and a 3,190 m one at the + * other, and nothing else in California looks like that. + */ + { + id: "shasta-cascades", + label: "Shasta & the north", + shortLabel: "North", + number: "06", + description: + "Two volcanoes, the Klamath knot and four hundred kilometres of coast that used to be ocean.", + focus: { lat: 40.6, lng: -122.4, distance: 212, height: 152, rotation: 0.5 }, + }, ], /** diff --git a/src/cities/socal.ts b/src/cities/socal.ts index 3e9289e..db98b18 100644 --- a/src/cities/socal.ts +++ b/src/cities/socal.ts @@ -39,6 +39,7 @@ import type { Hill, Landmark, LatLng, + Port, } from "../engine/types.ts"; // ---- Coastlines ----------------------------------------------------------- @@ -176,16 +177,89 @@ export const SOUTHLAND: LatLng[] = [ /** * Terminal Island: dredge spoil, container cranes and a federal prison, sitting - * between San Pedro and Long Beach. It is here because the harbour reads as a - * harbour only if there is something in it for the two bridges to land on. + * between San Pedro and Long Beach. + * + * ### Why this outline is thirty-one points and not six + * + * It used to be six, and its own comment said what those six were for: "the + * harbour reads as a harbour only if there is something in it for the two + * bridges to land on." A hexagon is enough to land a bridge on and it is not + * enough to be a port, because **the recognisable thing about this island is not + * its outline, it is the comb cut into it** — the West Basin and the East Basin + * opening north off the Cerritos Channel, Fish Harbor opening south, the Pier + * 400 fill hung off a causeway in the south-east, and a quay along the wall of + * every one of them. With six points there is nowhere for a quay to be that is + * both on land and on the water, which is the one thing a quay has to be. + * + * Traced by hand, like every other coordinate in this file, and per + * ARCHITECTURE §3.2 that means it is eyeball-accurate original expression rather + * than a copy of anybody's dataset. `ports.sqlite` cannot help: its seven rows + * all sit on an exact arc-minute grid, up to 1.3 km — 3.3 scene units here — + * from the water they claim to be on. See `engine/ports.ts`. + * + * ### What the shape has to satisfy + * + * - **The two bridges still land on it.** The Vincent Thomas's east abutment + * `[33.7535, -118.258]` and the Long Beach Gateway's west abutment + * `[33.752, -118.232]` are both inside this ring, and both of their mainland + * ends are still outside it. `socalPorts.test.ts` asserts all four. + * - **It does not touch the mainland.** The narrowest water left between the two + * is 425 m of Main Channel at the Vincent Thomas and about 500 m of Back + * Channel off Pier T, which is what those channels really are. + * - **Every authored quay vertex lies inside it.** That is the assertion that + * stops a quay floating on open water or being buried in the fill behind it, + * and it is why the basins are cut 900-1,000 m deep rather than token + * notches: a quay wall wants a kilometre of straight water frontage. + * + * Five and a half kilometres east to west, six north to south, which at 391 m to + * the scene unit is about 14 by 15 units on a 393-unit board. */ export const TERMINAL_ISLAND: LatLng[] = [ - [33.769, -118.264], - [33.77, -118.242], - [33.76, -118.227], - [33.742, -118.228], - [33.735, -118.258], - [33.746, -118.268], + // The north shore, along the Cerritos (Back) Channel, west to east, with the + // two basins cut southward into it. + [33.7672, -118.2665], // the north-west corner, at the mouth of the West Basin + [33.759, -118.2618], // the West Basin, west wall + [33.7602, -118.2555], // the head of the West Basin + [33.769, -118.2582], // back out to the Cerritos Channel + [33.7688, -118.247], // the Badger Avenue rail bridge + [33.758, -118.2452], // the East Basin, west wall + [33.7588, -118.2382], // the head of the East Basin + [33.7672, -118.2378], // back out + [33.7635, -118.2295], // under the Long Beach International Gateway + [33.7595, -118.221], // Pier T, north-west + [33.7548, -118.2125], // Pier T, north-east, at the Middle Harbor slip + + // The east end, on Queensway Bay. + [33.747, -118.2105], + [33.743, -118.216], + + // The south shore, east to west, on the outer harbour. + [33.7405, -118.2245], + [33.7398, -118.233], + + // The causeway down to the Pier 400 fill, and the fill itself. The neck is + // 556 m across, which is a road, two rail tracks and their shoulders. + [33.7345, -118.2352], // the causeway, east side + [33.729, -118.2372], + [33.7285, -118.23], // Pier 400, north-east + [33.7155, -118.233], // Pier 400, south-east + [33.715, -118.2495], // Pier 400, south-west + [33.729, -118.252], // Pier 400, north-west + [33.7288, -118.2432], + [33.7343, -118.2412], // the causeway, west side + [33.738, -118.244], + + // Fish Harbor, cut in from the south. The cannery basin, and the one part of + // this island that predates the container. + [33.7378, -118.256], + [33.735, -118.2572], + [33.7352, -118.264], + [33.7398, -118.2652], + [33.742, -118.2698], // the south-west corner, at Reservation Point + + // The west shore, up the Main Channel. + [33.7505, -118.2708], // under the Vincent Thomas + [33.759, -118.2692], ]; /** @@ -1687,6 +1761,385 @@ export const LONG_BEACH_GATEWAY: Bridge = { export const BRIDGES = [VINCENT_THOMAS, LONG_BEACH_GATEWAY]; +// ---- Ports ---------------------------------------------------------------- + +/** + * San Pedro Bay: Los Angeles and Long Beach, one basin, two ports. + * + * Together they are the busiest container complex in the western hemisphere, and + * until this block existed the Harbour chapter said so over a picture of nothing + * at all. `engine/ports.ts` draws all of it in four draw calls; this is the data + * it draws. + * + * ### Everything below is hand-traced, and the store cannot help + * + * ARCHITECTURE §3.2 applies here exactly as it does to the coastline. Beyond + * that, the upstream `ports` table is unusable for placement even if the licence + * allowed it: all seven of its rows sit on an exact arc-minute grid — `lat*60` + * and `lon*60` are whole integers for every one — and one arc-minute at this + * latitude is 1,852 m of latitude and 1,540 m of longitude, so a quay placed + * from those columns lands as much as 3.3 scene units from the water. Its + * `channel_depth_ft` is worse: a binned WPI code that reports Los Angeles as + * fourteen feet deep against a real dredged channel near fifty-three. + * `socalPorts.test.ts` asserts that neither anchor below is on an arc-minute. + * + * The one column of that table which *is* load-bearing is `harbor_type`: USLAX + * and USLGB are both `CB`, coastal breakwater, and they share the federal + * breakwater declared on `LOS_ANGELES` below. Oakland is `CN`, coastal natural, + * and when the Bay board gets this kit it must not be given one. + * + * ### The empty boxes are the story, and they are a fact about a port + * + * The owner asked whether the ships are empty or full. They cannot be: there is + * no draught column in the vessel store, the AIS static message that would carry + * one is absent for most hulls most of the time, and a per-hull laden state + * would be an invention. The same question answered at the level where the data + * is real is far better anyway — in July 2026, **348,691 of the 460,467 + * containers that left Los Angeles left empty**, which is 75.7 per cent, and a + * second collector explains it from the other side: a box costs $7,491 to bring + * east across the Pacific and $347 to send back. Nobody pays to repatriate an + * empty, so three quarters of them go home with nothing in them. + * + * That number is drawn, not just written: `Yard.emptyShare` is the proportion of + * stacks the yard atlas paints in the empty palette. **It is the empty share of + * the month's outbound boxes, not a census of what is standing in the yard + * today** — nobody counts that — and it is applied here because the export + * stacks are the large majority of what an American west-coast yard holds. + * + * ### Why Long Beach carries no `throughput` record + * + * Its export split is known and is nearly identical to LA's: 341,806 empty + * against 104,843 loaded, 76.5 per cent. Its *import* halves were never read. + * `PortThroughput` requires all four counts, and a record with two real numbers + * and two plausible ones is precisely the failure the fire layer nearly shipped, + * so Long Beach gets no record rather than half of one. Its yards still carry + * the 0.765 that is measured. + */ + +/** + * The federal breakwater — three arms, two gates, 13.06 km of rubble. + * + * San Pedro (3.46 km) from the shore below Cabrillo out to the Angels Gate + * light; the Middle Breakwater (5.61 km); and the Long Beach arm (3.99 km) + * running east toward Alamitos Bay. The 589 m gap between the first two is + * Angels Gate, the main entrance, and the 703 m between the second and third is + * Queens Gate. + * + * This is the object that makes the whole board's south-west corner read as a + * harbour rather than a bay: 13.06 km is 33.4 scene units on a 393-unit board, + * about nine per cent of its width, and it is the only part of this port that is + * legible from the whole-board pose. It is drawn wider than life — see + * `BREAKWATER_BASE_M` and its floor in `engine/ports.ts` — for the reason the + * LA River polygon is drawn four times its true width a few hundred lines above: + * a feature narrower than a lattice cell does not render as a thin line, it + * renders as a dotted one. + */ +const SAN_PEDRO_BREAKWATER: LatLng[][] = [ + [ + [33.7065, -118.2885], // rooted off Cabrillo Beach + [33.7048, -118.274], + [33.707, -118.262], + [33.7083, -118.2517], // the Angels Gate light + ], + [ + [33.7095, -118.2455], // the Middle Breakwater, west end + [33.712, -118.227], + [33.7145, -118.206], + [33.7165, -118.1855], // Queens Gate, west side + ], + [ + [33.7175, -118.178], // the Long Beach arm, west end + [33.719, -118.156], + [33.72, -118.135], + ], +]; + +/** + * The Port of Los Angeles. + * + * Four container terminals on the west and south of Terminal Island, forty + * gantries between them, and the Main Channel running up from Angels Gate past + * the Vincent Thomas to the turning basin at Wilmington. + * + * The crane bearings are the whole of the recognition and they are all + * different: Pier 400 leans west over the outer harbour, Pier 300 south, the + * East Basin west into its slip and the West Basin east into its own. A port + * where every boom points the same way reads as a fence. + */ +export const LOS_ANGELES: Port = { + id: "USLAX", + name: "Port of Los Angeles", + // The middle of Terminal Island, hand-placed. Deliberately not + // `ports.sqlite`'s 33.75 / -118.25, which is an arc-minute rounding. + lat: 33.7508, + lng: -118.2604, + harborType: "CB", + breakwater: SAN_PEDRO_BREAKWATER, + channel: [ + [33.698, -118.247], // the approach, outside the breakwater + [33.709, -118.2486], // through Angels Gate + [33.72, -118.258], + [33.732, -118.267], + [33.743, -118.2731], + [33.751, -118.2732], // under the Vincent Thomas + [33.762, -118.2711], + [33.7688, -118.269], // the turning basin at the head + ], + /** + * Quay polygons, **water edge first**. + * + * That ordering is a contract with `engine/ports.ts`, which takes the first + * two vertices as the wall and drops the skirt and the revetment toe from + * them. A polygon traced the other way round hangs its wall off the back of + * the terminal, where nothing can see it and the water edge is a bare cut. + */ + quays: [ + { + id: "pier-400", + deckHeight: 4, + polygon: [ + [33.7284, -118.2516], + [33.7157, -118.2493], + [33.7158, -118.2481], + [33.7286, -118.2503], + ], + }, + { + id: "pier-300", + deckHeight: 4, + polygon: [ + [33.7382, -118.2448], + [33.738, -118.2552], + [33.7391, -118.2553], + [33.7393, -118.2448], + ], + }, + { + id: "east-basin", + deckHeight: 4, + polygon: [ + [33.7594, -118.2379], + [33.7666, -118.2376], + [33.7665, -118.2363], + [33.7594, -118.2366], + ], + }, + { + id: "west-basin", + deckHeight: 4, + polygon: [ + [33.7603, -118.2629], + [33.7655, -118.2658], + [33.7651, -118.267], + [33.7598, -118.264], + ], + }, + ], + /** + * Berths, for the vessel layer that will lie hulls along them. + * + * `bearing` is the bow of a ship alongside, and it is authored here rather + * than read off the wire because the wire cannot answer it: of 150 vessels in + * the store sitting under half a knot, only 75 report a usable heading and 21 + * report neither heading nor course. A berth has one answer and it never + * changes. + */ + berths: [ + { id: "lax-401", lat: 33.7268, lng: -118.2518, bearing: 171.6, maxLength: 400, quayId: "pier-400" }, + { id: "lax-402", lat: 33.7236, lng: -118.2512, bearing: 171.6, maxLength: 400, quayId: "pier-400" }, + { id: "lax-403", lat: 33.7204, lng: -118.2507, bearing: 171.6, maxLength: 400, quayId: "pier-400" }, + { id: "lax-404", lat: 33.7172, lng: -118.2501, bearing: 171.6, maxLength: 400, quayId: "pier-400" }, + { id: "lax-306", lat: 33.7378, lng: -118.2474, bearing: 268.9, maxLength: 366, quayId: "pier-300" }, + { id: "lax-305", lat: 33.7377, lng: -118.2526, bearing: 268.9, maxLength: 366, quayId: "pier-300" }, + { id: "lax-226", lat: 33.7612, lng: -118.2383, bearing: 2.3, maxLength: 335, quayId: "east-basin" }, + { id: "lax-232", lat: 33.7648, lng: -118.2381, bearing: 2.3, maxLength: 335, quayId: "east-basin" }, + { id: "lax-136", lat: 33.7618, lng: -118.2632, bearing: 334.9, maxLength: 366, quayId: "west-basin" }, + { id: "lax-142", lat: 33.7644, lng: -118.2646, bearing: 334.9, maxLength: 366, quayId: "west-basin" }, + ], + /** + * Crane rows. Forty gantries in four records. + * + * `height` is rail to the top of the portal beam; the raised boom tip stands + * about half as high again, which at this board's 3.4x exaggeration is 1.13 + * scene units — taller than a 400 m ship is long. `idleFraction` is taken from + * the far end of each rail by `craneStations`, so a partly-worked berth looks + * like a block of booms down over a ship and the rest stood off, which is what + * one looks like. + */ + cranes: [ + { + id: "pier-400", + from: [33.7284, -118.2514], + to: [33.7157, -118.2492], + count: 18, + bearing: 261.6, + height: 82, + outreach: 72, + idleFraction: 0.17, + }, + { + id: "pier-300", + from: [33.7384, -118.2448], + to: [33.7382, -118.2553], + count: 8, + bearing: 178.9, + height: 78, + outreach: 68, + idleFraction: 0.38, + }, + { + id: "east-basin", + from: [33.7594, -118.2377], + to: [33.7666, -118.2374], + count: 6, + bearing: 272.3, + height: 74, + outreach: 61, + idleFraction: 0.5, + }, + { + id: "west-basin", + from: [33.7602, -118.263], + to: [33.7655, -118.266], + count: 8, + bearing: 64.9, + height: 78, + outreach: 68, + idleFraction: 0.25, + }, + ], + /** + * The yards. `emptyShare` is 0.757 on every one of them — 348,691 of 460,467 + * — and it is the number the atlas paints, not a number in a caption. + */ + yards: [ + { id: "pier-400", lat: 33.7223, lng: -118.2433, length: 1420, width: 1130, bearing: 172, emptyShare: 0.757 }, + { id: "pier-300", lat: 33.7412, lng: -118.252, length: 1210, width: 600, bearing: 90, emptyShare: 0.757 }, + { id: "east-basin", lat: 33.761, lng: -118.2351, length: 820, width: 480, bearing: 2, emptyShare: 0.757 }, + { id: "west-basin", lat: 33.753, lng: -118.2649, length: 1260, width: 570, bearing: 350, emptyShare: 0.757 }, + /** + * The on-dock rail yard in the middle of the island, and **the one rectangle + * here with no `emptyShare` on it**. + * + * Nobody publishes what is standing in it. `emptyShare` absent is what the + * type means by unknown, and `yardAtlas` draws an unknown yard in one flat + * colour rather than in a plausible-looking mix — so the board carries a + * visible difference between a number that was measured and a number that + * was not, which is the whole discipline this product is trying to keep. + */ + { id: "rail-yard", lat: 33.7498, lng: -118.2502, length: 1150, width: 470, bearing: 350 }, + ], + /** July 2026. `asOf` is the month these counts describe and is never "now". */ + throughput: { + asOf: "2026-07", + loadedExport: 111_776, + emptyExport: 348_691, + loadedImport: 499_552, + emptyImport: 446, + }, + /** + * The explanation, from a different collector entirely. + * + * No timestamp, deliberately: the store's `observed_at` is our own read clock, + * Freightos publishes none, and a card that renders it as "as of" is lying + * about a precision nobody has. + */ + rates: [ + { id: "FBX01", lane: "China / East Asia to North America West Coast", usdPerFeu: 7_491 }, + { id: "FBX02", lane: "North America West Coast to China / East Asia", usdPerFeu: 347 }, + ], +}; + +/** + * The Port of Long Beach — Pier T, on the east end of Terminal Island. + * + * The former Naval Station, and the half of the island that is in a different + * city. It is here as much for what it proves as for what it shows: it shares + * every mesh with Los Angeles, so the second port on this board costs no extra + * draw call at all. See `createPorts`. + * + * No `breakwater` of its own — the arms on `LOS_ANGELES` are one federal + * structure across the whole bay and drawing them twice would double the + * geometry for an identical picture. No `throughput`, for the reason given at + * the head of this block. + */ +export const LONG_BEACH: Port = { + id: "USLGB", + name: "Port of Long Beach", + lat: 33.7529, + lng: -118.2181, + harborType: "CB", + channel: [ + [33.7235, -118.1885], // in through Queens Gate + [33.736, -118.1955], + [33.746, -118.203], + [33.753, -118.206], + [33.758, -118.214], + [33.7637, -118.223], // under the Long Beach International Gateway + [33.7709, -118.233], // and away west up the Back Channel + ], + quays: [ + { + id: "pier-t", + deckHeight: 4, + polygon: [ + [33.759, -118.2205], + [33.755, -118.2133], + [33.7541, -118.214], + [33.7581, -118.2212], + ], + }, + { + id: "pier-t-east", + deckHeight: 4, + polygon: [ + [33.7541, -118.2126], + [33.7476, -118.2109], + [33.7473, -118.2122], + [33.7539, -118.2139], + ], + }, + ], + berths: [ + { id: "lgb-t132", lat: 33.7586, lng: -118.219, bearing: 123.6, maxLength: 400, quayId: "pier-t" }, + { id: "lgb-t136", lat: 33.7573, lng: -118.2166, bearing: 123.6, maxLength: 400, quayId: "pier-t" }, + { id: "lgb-t140", lat: 33.756, lng: -118.2142, bearing: 123.6, maxLength: 400, quayId: "pier-t" }, + { id: "lgb-s101", lat: 33.7526, lng: -118.2117, bearing: 168, maxLength: 335, quayId: "pier-t-east" }, + { id: "lgb-s105", lat: 33.7493, lng: -118.2109, bearing: 168, maxLength: 335, quayId: "pier-t-east" }, + ], + cranes: [ + { + id: "pier-t", + from: [33.7588, -118.2206], + to: [33.7548, -118.2134], + count: 10, + bearing: 33.6, + height: 84, + outreach: 74, + idleFraction: 0.2, + }, + { + id: "pier-t-east", + from: [33.7541, -118.2128], + to: [33.7475, -118.2111], + count: 6, + bearing: 78, + height: 76, + outreach: 64, + // The quiet frontage. Five of six booms up is what an east-facing berth + // with nothing alongside looks like, and it is the cheapest way this board + // has of saying that a port is not uniformly busy. + idleFraction: 0.83, + }, + ], + yards: [ + { id: "pier-t", lat: 33.753, lng: -118.2214, length: 1210, width: 710, bearing: 124, emptyShare: 0.765 }, + { id: "pier-t-east", lat: 33.7484, lng: -118.2132, length: 480, width: 240, bearing: 168, emptyShare: 0.765 }, + ], +}; + +export const PORTS = [LOS_ANGELES, LONG_BEACH]; + // ---- Airports ------------------------------------------------------------- /** @@ -3129,11 +3582,21 @@ export const DISTRICTS: District[] = [ }, { id: "san-pedro", + // The eastern edge is the Main Channel, and it is drawn that way on purpose. + // This polygon used to be a plain quad reaching to -118.235, which put the + // whole of Terminal Island inside it — so the busiest container terminal in + // the hemisphere came out as three dozen generic industrial blocks, + // indistinguishable from the housing across the water. `engine/ports.ts` + // draws what is actually there. Vertices 3 and 4 run the boundary down the + // middle of the channel: San Pedro's own waterfront at about -118.2755 stays + // inside it, Terminal Island's west shore at -118.2708 stays outside. name: "San Pedro & Wilmington", polygon: [ [33.795, -118.305], [33.79, -118.235], - [33.708, -118.246], + [33.7745, -118.238], + [33.7745, -118.272], + [33.708, -118.276], [33.713, -118.31], ], minHeight: 11, @@ -3169,6 +3632,11 @@ export const DISTRICTS: District[] = [ // any case. Vertices 2-9 walk down the corridor, round the field, and back // up it. name: "Long Beach", + // The last vertex moved north from 33.758 to 33.7695 for the reason the San + // Pedro polygon's east edge moved: at -118.238 the old south edge sat at + // 33.7579, which is below Terminal Island's East Basin and put lots on the + // Everport quay. Everything between the old line and the new one is water — + // the Long Beach shoreline there is at 33.766 — so nothing real was lost. polygon: [ [33.868, -118.23], [33.8653, -118.166], @@ -3181,7 +3649,7 @@ export const DISTRICTS: District[] = [ [33.865, -118.161], [33.862, -118.09], [33.752, -118.102], - [33.758, -118.24], + [33.7695, -118.24], ], minHeight: 12, maxHeight: 124, @@ -3570,7 +4038,7 @@ export const CHAPTERS: City["chapters"] = [ rotation: -0.9, }, description: - "San Pedro, Terminal Island and Long Beach around one basin, with the peninsula rising behind them. The busiest port complex in the hemisphere, and the only two bridges on this map that cross water.", + "Three of every four boxes that left Los Angeles in July 2026 left empty \u2014 348,691 of 460,467 \u2014 because a container is worth $7,491 coming east across the Pacific and $347 going back. The pale stacks in these yards are that number. San Pedro, Terminal Island and Long Beach around one basin, behind thirteen kilometres of federal breakwater.", }, { id: "orange-county", @@ -3676,6 +4144,13 @@ export const SOCAL_CITY: City = { * floats a dark stripe over every runway the kit lays flush. */ airports: AIRPORTS, + + /** + * San Pedro Bay, drawn by `engine/ports.ts`. Two ports, one federal + * breakwater, fifty-six gantries and six container yards — in four draw calls, + * because every bucket merges across both ports. + */ + ports: PORTS, landmarks: LANDMARKS, bridges: BRIDGES, roads: ROADS, diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts index 74b6133..0a27ede 100644 --- a/src/engine/atmosphere.ts +++ b/src/engine/atmosphere.ts @@ -470,9 +470,104 @@ export interface AtmosphereOptions { moonlight?: MoonlightOptions | null; } +/** + * Where the camera is, for the one term in the rig that depends on it. + * + * Deliberately not part of `Environment`: an `Environment` is what was + * *observed* about the world, and a camera position is not an observation of + * anything. Deliberately not a field on `Atmosphere` either — that would make + * `apply` depend on a mutable it does not own, and `apply` being pure is what + * CONTRACT §4's one-way rule rests on. + */ +export interface AerialView { + /** + * Camera height above the ground it is looking at, in metres. `null` when + * nobody knows, which yields exactly the rig this function produced before + * altitude was a term in it. + */ + altitudeMetres: number | null; + /** + * How far the camera is from what it is looking at, in metres. + * + * Optional, and its absence is a *stricter* answer rather than a missing one: + * without it the fog is whatever the air at this altitude supports, which on a + * board 1,063 km across can be nearer than the subject of the shot. See + * `AERIAL_SUBJECT_CLEARANCE`. + */ + standoffMetres?: number | null; +} + +/** + * How far you can see, and nothing else about the light. + * + * The **entire** camera-dependent surface of this module, stated as a type so + * that it is a fact somebody has to edit rather than a habit. `aerialReach` + * scales three fog distances and touches nothing else in a `LightingState`, so + * two views of the same `Environment` differ in exactly these two numbers — + * `aerialPerspective.test.ts` asserts that field by field. + * + * Deliberately **not** a colour. The haze colour is the horizon's, and the + * horizon is a fact about the sky and the sun rather than about where the + * camera is standing. That is not a stylistic preference: `environmentRig.ts` + * fingerprints `sky.horizon` in order to decide whether to re-render and + * re-convolve the PMREM cubemap, so the day a camera-dependent term reaches a + * colour is the day every orbit step rebuilds the environment map. Keeping this + * type to two distances is what makes that impossible by construction, and + * widening it is a decision to be taken in front of that sentence. + */ +export interface AerialFog { + near: number; + far: number; +} + export interface Atmosphere { - /** The rig this observation implies. Pure; the caller applies the result. */ - apply(env: Environment): LightingState; + /** + * The rig this observation implies. Pure; the caller applies the result. + * + * `view` is optional and omitting it is not a degraded mode: the fog is then + * the clear-day pair the board was authored with, which is what every caller + * got before aerial perspective existed. See `aerialReach`. + */ + apply(env: Environment, view?: AerialView): LightingState; + /** + * The half of the rig that follows the camera, alone. Pure, like `apply`. + * + * ## Why this exists at all + * + * Because the sun and the camera move at completely different rates and were + * being served by one call. `main.ts` recomputes on the clock **once a + * minute**; it recomputes on the orbit controls' `change` event, throttled to + * 2% of altitude, which is a few dozen times during a single drag. Routing the + * second of those through `apply` + `Scene.setLighting` fans a camera gesture + * out across six layer setters, a material `needsUpdate`, an instanced-mesh + * rebuild and the environment rig's fingerprint — none of which have anything + * to do with the camera having moved, because none of them read a distance. + * + * ## Why it returns two numbers and not a `LightingState` + * + * So that the narrow path *cannot* carry a sky. Fog distances are screen-space + * and per-frame; the sky, the sun and the environment map are the light, they + * are decided on the clock, and CONTRACT.md §4 keeps `Atmosphere` their sole + * owner. A setter that took a whole rig would let the camera path quietly + * become a second way to change the light, which is the exact merge this split + * exists to prevent. See `AerialFog`. + * + * ## Why it recomputes the rig rather than a cheaper fog of its own + * + * Measured at 0.019 ms a call, against 0.14-0.19 ms for the full apply-and- + * light path it replaces, so the saving is already in the fan-out and a second + * cheaper formula would buy a rounding error. What it would cost is the + * property that matters: the fog on the camera path is *the same number* + * `apply` would have written, bit for bit, because it **is** that number. Two + * derivations of one fog is how a drag and a clock tick start disagreeing + * about the weather, and it is the same argument this file already makes for + * `cloudCover` being a method rather than a second return. + * + * `view` is required here, unlike on `apply`. Asking for the fog without + * saying where the camera is has no answer worth having — it is the authored + * ceiling, which is what `apply` already gives you. + */ + aerial(env: Environment, view: AerialView): AerialFog; /** * How much of the sky has cloud in it, 0..1 — observed if anyone observed it, * modelled from this place and this instant if nobody did. Pure, like `apply`. @@ -622,6 +717,155 @@ const VISIBILITY_HAZY_KM = 8; /** Visibility assumed when a source says "fog" and reports no number. */ const FOG_CONDITION_VISIBILITY_KM = 1.5; +// ---- Aerial perspective --------------------------------------------------- + +/** + * The density scale height of the atmosphere, in metres. + * + * Air thins as `exp(-h/H)` and H is about 8.5 km, so a camera at 8.5 km looks + * through `1/e` of the air a camera at sea level does and sees `e` times as + * far. This is the only physical constant in the whole of the change below and + * it is the one that is not negotiable. + */ +const AERIAL_SCALE_HEIGHT_M = 8_500; + +/** + * How far you can see horizontally at sea level on a clear day, in metres. + * + * 86 km, which is not a round number and is not a guess: it is what San + * Francisco's fog *already is* once its `boardSpan * 0.91` is converted through + * 94.34 m per unit, and San Francisco is the one board of the three whose fog + * is physically correct. Converted to metres the three boards disagree by 8x — + * SF at 86 km, Southern California at 140, the state board at 748 — so this is + * the number the other two are being measured against rather than a new one. + */ +const AERIAL_CLEAR_VISIBILITY_M = 86_000; + +/** + * Never let the fog collapse entirely, however low the camera gets. + * + * Deliberately below where the curve actually lands, so that it is a safety net + * and not a term in the answer: at zero altitude every board comes out at 86 km + * of visibility, which is 0.040 of the state board's authored reach, 0.28 of + * Southern California's and 0.45 of the Bay Area's. Two per cent sits under all + * three. Its only job is that a camera put at a nonsense altitude by a + * controller bug renders haze rather than a wall — the failure mode of an + * aerial-perspective term has to be a board you can still read. + */ +const AERIAL_MIN_REACH = 0.02; + +/** + * How far the fog must reach past whatever the camera is looking at, as a + * multiple of the camera's own stand-off. + * + * **The correction that turns a physical model into a picture, and it was found + * by taking one.** A camera parked 223 km from a mountain on the state board and + * 8 km above the ground is, physically, looking at something it cannot see: the + * air alone supports 231 km of visibility and the subject is at 223 of it, so + * the frame came back as a white rectangle with a faint cone in it. That is a + * correct rendering of an incorrect question. A board is a **map**, and a map is + * looked at from outside the atmosphere it depicts — which is the same argument + * `minVisibilityM` already makes and the reason it exists at all. + * + * So the floor generalises from "1.6 board spans" to "six times however far the + * camera actually is", which is the same idea sized by the shot rather than by + * the pack. With `main.ts`'s 1.15/3.9 near-to-far ratio that puts the near plane + * at 1.77 stand-offs: **nothing closer to the camera than about 1.8 times its + * own distance to its subject is fogged at all**, and everything past that hazes + * out toward six. + * + * Six and not four, and the number is set by one measurement rather than by + * taste. It is what makes every board's whole-board pose saturate at the ceiling + * — the guarantee this whole change rests on, since those are the frames every + * marketing still is shot from. California's opening pose is 67.6 km up and + * Southern California's 17.0 km, so both clear it on the air term alone; the Bay + * Area's is 11.3 km up against a 12.4 km saturation altitude, and it is the one + * board that needs the stand-off term to get there. At six it clears with 16% + * to spare; at four it renders at 88% of its authored reach and the far corner + * of the board picks up haze it has never had. `aerialPerspective.test.ts` + * asserts all three against the deployed formula so this cannot drift. + * + * `null` turns it off, which is what the pure tests pass: the altitude curve on + * its own is the thing being asserted there, and a clearance term would make + * those numbers a fact about a camera rather than about the air. + */ +const AERIAL_SUBJECT_CLEARANCE = 6; + +/** + * How much of a board's authored visibility the air at this altitude supports, + * 0..1. + * + * ## Why this is a fraction and not a distance + * + * The obvious change — the one the investigation actually proposed — is to + * state fog in physical metres and be done with it. It does not survive + * arithmetic. 86 km of clear-day visibility is 0.91 spans on San Francisco's + * 1,003-unit board, which is right, and **0.085 spans on the extended state + * board**, where the camera orbits out to 1,108 units. A literal metre fog + * would bury California in haze from 47 units out and there would be no pose on + * that board from which the state was visible at all. + * + * So the invariant is not a distance. It is *visibility through the atmospheric + * column as the camera climbs*, expressed as a fraction of the reach the board + * was authored for. At the wide pose it saturates at 1 and every board renders + * exactly as it did before this function existed — San Francisco is left + * untouched by construction rather than by measurement, because its resting + * chapter sits 40 km up and the fraction reached 1 by 6.7 km. Low down, on the + * board where a chase camera is two kilometres off the ground and the Sierra is + * eighty kilometres away, it is a fifth, and the range hazes the way a range + * eighty kilometres away does. + * + * `null` means nobody said where the camera is, and the answer is 1: today's + * behaviour, unchanged, which is what every caller that has not been taught + * about altitude must keep getting. + */ +export function aerialReach( + altitudeMetres: number | null, + authoredReachM: number, + standoffM: number | null = null, +): number { + if (altitudeMetres === null || !Number.isFinite(altitudeMetres)) return 1; + if (!Number.isFinite(authoredReachM) || authoredReachM <= 0) return 1; + const h = Math.max(0, altitudeMetres); + const air = AERIAL_CLEAR_VISIBILITY_M * Math.exp(h / AERIAL_SCALE_HEIGHT_M); + const clears = + standoffM !== null && Number.isFinite(standoffM) && standoffM > 0 + ? standoffM * AERIAL_SUBJECT_CLEARANCE + : 0; + return clamp(Math.max(air, clears) / authoredReachM, AERIAL_MIN_REACH, 1); +} + +/** + * The clear-day fog pair a camera at this altitude earns, in scene units. + * + * The ceiling is the pair the board was authored with and is never exceeded — + * `boardSpan * 0.91` and `boardSpan * 2` are `cityDaylight`'s, and `main.ts` + * hands in its own slightly wider pair for a board with a satellite dome over + * it. Either way this only ever pulls the fog *in*, so no board can be made to + * render further than it was tuned to. + * + * Exported because it is the whole of the change and it is testable with no GL + * context, no board and no camera: three numbers in, two numbers out. + */ +export function aerialFog(options: { + /** The pair this board would use with no altitude term at all. Scene units. */ + ceiling: { near: number; far: number }; + /** Metres in one scene unit. */ + metresPerUnit: number; + /** Camera height above the ground it is looking at, metres. `null` = unknown. */ + altitudeMetres: number | null; + /** How far the camera is from what it is looking at, metres. See the clearance. */ + standoffMetres?: number | null; +}): { near: number; far: number } { + const { ceiling, metresPerUnit, altitudeMetres } = options; + const reach = aerialReach( + altitudeMetres, + ceiling.far * metresPerUnit, + options.standoffMetres ?? null, + ); + return { near: ceiling.near * reach, far: ceiling.far * reach }; +} + // ---- The daylight table --------------------------------------------------- interface Rig { @@ -869,8 +1113,32 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { const marineOptions = options.marineLayer ?? null; const moonOptions = options.moonlight === undefined ? DEFAULT_MOONLIGHT : options.moonlight; - function apply(env: Environment): LightingState { + function apply(env: Environment, view?: AerialView): LightingState { const elevation = env.sun.elevation; + /** + * Aerial perspective, as one scalar applied to all three fog distances. + * + * A second argument rather than a field on `Environment`, and the + * distinction is CONTRACT §4's: an `Environment` is what the *world* is + * doing, and where the camera happens to be is not a fact about the world. + * `apply` stays pure — it is now a pure function of two arguments — and a + * caller that passes nothing gets exactly the rig it got before this + * existed, because `aerialReach(null, …)` is 1. + * + * The floor moves with the other two on purpose. `minVisibilityM` exists + * because a map is looked at from outside the atmosphere it depicts, so it + * has to be big; left fixed while the clear pair shrank, it would clamp the + * haze straight back out again and this whole change would render as + * nothing at all. + */ + const reach = aerialReach( + view?.altitudeMetres ?? null, + clearFar * metresPerUnit, + view?.standoffMetres ?? null, + ); + const nearAt = clearNear * reach; + const farAt = clearFar * reach; + const floorAt = floorFar * reach; // Copied, because `sample` hands back a table row unchanged when the // elevation is off either end of it and everything below mutates in place. const rig = { ...sample(table, elevation) }; @@ -911,7 +1179,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { applyCloud(rig, cloud, day); applyPrecipitation(rig, precipitation, condition, day); - let fogFar = visibilityFar(weather, condition, clearFar, metresPerUnit); + let fogFar = visibilityFar(weather, condition, farAt, metresPerUnit); if (weather === null || weather.visibilityKm === null) { // Rain shortens the view; a source that measured visibility has already // said so, and applying both would count it twice. @@ -921,14 +1189,14 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { const inside = marineOptions.visibilityM / metresPerUnit; fogFar = Math.min(fogFar, lerp(fogFar, inside, obscuration)); } - fogFar = Math.max(fogFar, floorFar); + fogFar = Math.max(fogFar, floorAt); const fogColor = applyObscuration(rig, obscuration, day, night, condition); // A clear day keeps the near plane it was given; anything shorter holds the // ratio instead, because fog that starts where the clear day's did and ends // sixty units out is not fog, it is a wall. - const near = clamp(fogFar >= clearFar ? clearNear : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9); + const near = clamp(fogFar >= farAt ? nearAt : fogFar * FOG_NEAR_RATIO, 2, fogFar * 0.9); return { sun: combineKey(lightDirection(env.sun, shadowFloor), rig.sunColor, rig.sunIntensity, moon), @@ -944,6 +1212,24 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { }; } + /** + * `apply`'s own fog distances, taken off `apply`'s own answer. + * + * The one-line body is the point and is load-bearing: there is no second + * formula here to keep in step, so the number a camera step writes and the + * number the next clock tick writes cannot drift apart. The interface note + * above carries the measurement that says this is affordable. + * + * The `null` branch is unreachable from a city rig — `apply` always returns a + * fog — and is written rather than asserted because a `null` fog is a real + * `LightingState` (it is what an office gets, CONTRACT.md §4), and the honest + * answer for a rig with no fog in it is the pair the board was authored with. + */ + function aerial(env: Environment, view: AerialView): AerialFog { + const fog = apply(env, view).fog; + return fog === null ? { near: clearNear, far: clearFar } : { near: fog.near, far: fog.far }; + } + /** * The sky's own cover, 0..1, for whatever wants to draw it. * @@ -977,7 +1263,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere { return modelledCloudCover(marineOptions, env, lng); } - return { apply, cloudCover }; + return { apply, aerial, cloudCover }; } // ---- The sun's direction, and the shadow camera --------------------------- diff --git a/src/engine/clouds.ts b/src/engine/clouds.ts index 1f36349..a8a800c 100644 --- a/src/engine/clouds.ts +++ b/src/engine/clouds.ts @@ -575,6 +575,18 @@ export interface CloudLayer { setCover(fraction: number): void; /** Applies a rig computed elsewhere. This layer never works one out itself. */ setLighting(state: LightingState): void; + /** + * The two fog uniforms alone, for a camera that moved under a sky that did + * not. See `SceneHandle.setAerialFog`. + * + * The colour is deliberately absent and its absence is the whole seam: this + * layer folds `fog.color` into `uBase` as well as into `uFogColor` — the + * underside of a deck is lit by the haze under it — so a colour arriving here + * without the rig that produced it would leave those two disagreeing. The + * distances feed one uniform each and nothing else, which is exactly why they + * are the pair that can travel alone. + */ + setFogDistances(near: number, far: number): void; /** * The wind, as `WeatherObservation` reports it: speed in km/h and the bearing * it blows *from*, degrees clockwise from true north. Either may be `null` — @@ -958,6 +970,11 @@ export function createCloudLayer(world: World, options: CloudLayerOptions = {}): targetCover = clamp(fraction, 0, 1); }, + setFogDistances(near, far) { + uniforms.uFogNear!.value = near; + uniforms.uFogFar!.value = far; + }, + setLighting(state) { const sun = new THREE.Color().setHex(state.sun.color); const sky = new THREE.Color().setHex(state.hemisphere.sky); diff --git a/src/engine/fireSmoke.ts b/src/engine/fireSmoke.ts index afed654..1df2955 100644 --- a/src/engine/fireSmoke.ts +++ b/src/engine/fireSmoke.ts @@ -136,6 +136,8 @@ export interface FireSmokeLayer { setWind(kph: number | null, fromDeg: number | null): void; /** Applies a rig computed elsewhere. This layer never works one out itself. */ setLighting(state: LightingState): void; + /** The two fog uniforms alone. See `SceneHandle.setAerialFog`. */ + setFogDistances(near: number, far: number): void; /** * 0..1 night, from the same `nightFactor` seam `nightlights.ts` uses. * @@ -331,6 +333,11 @@ export function createFireSmoke(options: FireSmokeOptions): FireSmokeLayer { setWind: applyWind, + setFogDistances(near, far) { + if (uniforms.uFogNear) uniforms.uFogNear.value = near; + if (uniforms.uFogFar) uniforms.uFogFar.value = far; + }, + setLighting(state) { const key = clamp(state.sun.intensity / 3, 0, 1); if (uniforms.uKey) uniforms.uKey.value = key; diff --git a/src/engine/fires.ts b/src/engine/fires.ts index 378ef1d..e2f4ab5 100644 --- a/src/engine/fires.ts +++ b/src/engine/fires.ts @@ -161,6 +161,11 @@ export interface FireLayer { setFires(view: FireView | null): void; setSmokeVisible(visible: boolean): void; setLighting(state: LightingState): void; + /** + * The fog distances alone, for both the marks and the plumes. See + * `SceneHandle.setAerialFog`. + */ + setFogDistances(near: number, far: number): void; setSolarElevation(degrees: number): void; setWind(kph: number | null, fromDeg: number | null): void; tick(dt: number): void; @@ -557,6 +562,12 @@ export function createFireLayer(world: World, options: FireLayerOptions): FireLa smoke.setLighting(state); }, + setFogDistances(near, far) { + if (markUniforms.uFogNear) markUniforms.uFogNear.value = near; + if (markUniforms.uFogFar) markUniforms.uFogFar.value = far; + smoke.setFogDistances(near, far); + }, + setSolarElevation(degrees) { if (!Number.isFinite(degrees)) return; const night = nightFactor(degrees); diff --git a/src/engine/migration.ts b/src/engine/migration.ts new file mode 100644 index 0000000..483cd2d --- /dev/null +++ b/src/engine/migration.ts @@ -0,0 +1,516 @@ +/** + * Nocturnal migration over California, as one drift field of light. + * + * One `THREE.Points`, one draw call, zero triangles — the `nightlights.ts` + * pattern, which puts San Francisco's 12,038 street lamps on the board for the + * cost of a single cloud. Seven hundred motes is nothing beside that. + * + * ### What the data is, and therefore what this may claim + * + * BirdCast is a **forecast raster aggregated to county-nights**. The store holds + * 7,575 rows at ten-minute resolution across 58 counties, each carrying birds + * aloft, a mean altitude, a direction and a ground speed — and *nothing finer*. + * There is no track, no individual, no species and no position. So: + * + * - Motes scatter inside a **disc** of the county's true `areaKm2` about its + * Census internal point. A disc and not the county's outline, because an + * outline would claim a spatial structure the data does not have. San + * Bernardino's 52,073 km² is a 129 km disc; San Francisco's 601 km² is a + * 14 km one, and the difference between them is the whole picture. + * - A mote is **never placed by interpolating between two granules**. It is + * spawned once, given its county's reported direction and ground speed at + * that moment, and then integrated forward on its own. When its life runs + * out it is spawned again from whatever the newest granule says. Joining two + * consecutive samples into a trajectory would be inventing the ten minutes + * in between, which is exactly what `Vessel` refuses to do between AIS + * fixes. + * - There is no hover, no label and no identity, because there is nothing to + * identify. + * + * ### Speed is real and therefore slow, and that is left alone + * + * At 8.2 m/s — the state's mean tonight — and 1,919 metres to the scene unit, a + * mote covers 3.8 units in its fifteen-minute life on a 554-unit board. That is + * a slow drift you notice by looking away and back, like cloud. It is not + * exaggerated. Altitude *is* exaggerated, through the same + * `altitudeSceneUnitsPerMetre` seam the aircraft use (0.01, `main.ts`), and the + * two are different decisions: without the altitude exaggeration a bird at 726 m + * would sit 0.38 units above the ground and be inside the terrain, so that + * exaggeration buys visibility. Exaggerating the speed would only buy drama. + * + * ### The empty sky is the layer, most of the time + * + * 176 of 297 granules in the store are daytime and hold 104 rows between them, + * against 7,719 at night. BirdCast measures only after dark, so this layer is + * absent about fourteen hours in every twenty-four **by construction** — before + * any question of season. `setSolarElevation` is therefore not a visibility + * preference, it is the layer's own subject: the sun is up, so nothing is aloft, + * and `src/server/birds.ts` owns the sentence that says so and what last night + * did instead. + * + * ### The crow is not this + * + * `src/assets/actors/crow.ts` is 4,390 triangles across **33 meshes** — 33 draw + * calls per bird. It is the player's avatar and a hero actor; forty of them + * would be 1,320 draw calls against a whole-board budget of 650. And a 1.02 m + * wingspan is 0.0005 scene units at this scale. On a board a bird can only ever + * be a point of light. + * + * **This layer owns no light.** CONTRACT.md §4: nothing here constructs a + * `THREE.Light`, and the motes are additive sprites, which is emission and not + * illumination — the same distinction `nightlights.ts` turns on. + */ + +import * as THREE from "three"; +import { seededRandom, type World } from "./world.ts"; +import type { LightingState, MigrationCounty, MigrationField } from "./types.ts"; + +// ---- Constants ------------------------------------------------------------ + +/** + * The most motes one county contributes, and the ceiling for the whole board. + * + * Twelve times fifty-eight is 696, which is the number the budget was costed + * against. The buffer is allocated once at `MIGRATION_MAX_POINTS` and never + * grows, so a granule that arrives with sixty counties in it draws 696 motes and + * not a reallocation. + */ +export const MIGRATION_MOTES_PER_COUNTY = 12; +export const MIGRATION_MAX_POINTS = 700; + +/** + * Birds aloft at which a county is drawn at full strength. + * + * Twenty-five thousand, on a square-root scale — **a judgement, stated as one**, + * in the manner of `FIRE_TIER_MIN_ACRES`. It was set by looking at the frame. + * + * The first attempt was sixty thousand and linear-ish, which allocated 300 motes + * across the state on the granule of 2026-08-23T03:20Z and drew a board that was + * empty except for the San Joaquin Valley — when the truth that night was that + * every one of the 58 counties had birds over it. At twenty-five thousand the + * same granule allocates 444, running 12 down to 1, and the state reads as a + * state. The square root is a perceptual scale and is not a claim about numbers: + * the count that *is* a claim is the sentence in the panel, and it comes from + * the state row and never from these. + */ +export const MIGRATION_FULL_ALOFT = 25_000; + +/** + * How long a mote lives, in seconds, and how much that varies. + * + * Longer than the ten-minute granule on purpose. If every mote were reseeded the + * instant a new granule arrived, the whole field would blink over at once every + * ten minutes — a stutter far more noticeable than the change it was trying to + * show. With a life longer than the step and a random phase at birth the + * population turns over *continuously*: at any moment a few motes are fading in + * with the newest numbers and a few are fading out with the last ones. + */ +const MOTE_LIFE_SECONDS = 900; +const MOTE_LIFE_JITTER = 0.35; + +/** Fraction of a life spent fading in, and the same again fading out. */ +const MOTE_FADE = 0.18; + +/** + * Solar elevation, in degrees, over which the field is gone. + * + * Full strength at or below civil twilight, nothing at all once the sun is up. + * Not a preference: BirdCast does not measure by day, so a mote in a daylight + * frame would be an invention. 104 daytime rows do exist in the store across 176 + * granules, and this is what refuses to draw them. + */ +const MIGRATION_DARK_DEG = -6; +const MIGRATION_LIGHT_DEG = 0; + +/** Scene units per metre of altitude. `main.ts`'s aircraft seam; see the header. */ +export const MIGRATION_ALTITUDE_UNITS_PER_METRE = 0.01; + +/** A cool white with a little warmth in it, so the field is not a screen artefact. */ +const MOTE_COLOR = { r: 0.82, g: 0.86, b: 1.0 }; + +/** + * How big a mote is, as a fraction of the board span. + * + * Sized against the board rather than in metres for the reason the crow cannot + * be drawn at all: a 1.02 m wingspan is 0.0005 scene units here, so a mote is + * not a bird at scale, it is a mark standing for a few thousand of them. 0.9% of + * the span is about five units on the state board — a soft point a couple of + * pixels across from the opening pose, which is what the first shot said was + * needed: at 0.4% the whole field was invisible against a lit Central Valley. + */ +const MOTE_SIZE_SPANS = 0.009; + +/** Sprite edge, in texels. Alpha only; the colour comes from the vertex. */ +const MOTE_TEXTURE_SIZE = 64; + +// ---- The handle ----------------------------------------------------------- + +export interface MigrationLayerOptions { + /** Board span in scene units, as `scene.ts` computes it. Sets the mote size. */ + span: number; + /** Scene units per metre of altitude. Defaults to the aircraft seam's 0.01. */ + altitudeSceneUnitsPerMetre?: number; + /** Mote size as a fraction of the board span. See `MOTE_SIZE_SPANS`. */ + moteSpans?: number; + /** A reload must produce the same field. See `seededRandom`. */ + seed?: number; +} + +export interface MigrationLayer { + group: THREE.Object3D; + setField(field: MigrationField | null): void; + setLighting(state: LightingState): void; + setSolarElevation(degrees: number): void; + tick(dt: number): void; + dispose(): void; + /** Motes currently carrying birds. Zero on a quiet sky. */ + activeCount(): number; + /** One mote, for a test that has to prove nothing was interpolated. */ + mote(index: number): MoteReading | null; +} + +/** What a mote is, read from outside. Positions are scene units. */ +export interface MoteReading { + x: number; + y: number; + z: number; + /** Scene units per second. Constant for a mote's whole life, by construction. */ + vx: number; + vz: number; + alpha: number; + /** Index into the field's `counties` this mote was spawned from, or -1. */ + county: number; +} + +// ---- Allocation, as a pure function -------------------------------------- + +/** + * How many motes each county gets, and never more than the buffer holds. + * + * Pure and exported because it is the layer's one claim about the numbers, and a + * claim about numbers that lives inside a mesh builder is a claim nobody can + * test without a GL context. Counties arrive worst-first so that a board with + * more than 58 of them drops the quietest rather than the last. + */ +export function allocateMotes( + counties: readonly MigrationCounty[], + cap = MIGRATION_MAX_POINTS, +): number[] { + const order = counties + .map((county, index) => ({ index, aloft: finite(county?.aloft) ?? 0 })) + .sort((a, b) => b.aloft - a.aloft); + + const counts = new Array(counties.length).fill(0); + let total = 0; + for (const { index, aloft } of order) { + if (aloft <= 0) continue; + const share = Math.min(1, aloft / MIGRATION_FULL_ALOFT); + const want = Math.max(1, Math.round(MIGRATION_MOTES_PER_COUNTY * Math.sqrt(share))); + const take = Math.min(want, cap - total); + if (take <= 0) break; + counts[index] = take; + total += take; + } + return counts; +} + +/** Radius, in kilometres, of a disc with this area. See the header: a disc. */ +export function discRadiusKm(areaKm2: number): number { + if (!Number.isFinite(areaKm2) || areaKm2 <= 0) return 0; + return Math.sqrt(areaKm2 / Math.PI); +} + +// ---- The layer ------------------------------------------------------------ + +export function createMigrationLayer( + world: World, + options: MigrationLayerOptions, +): MigrationLayer { + const group = new THREE.Group(); + group.name = "migration"; + + const perMetre = options.altitudeSceneUnitsPerMetre ?? MIGRATION_ALTITUDE_UNITS_PER_METRE; + const random = seededRandom(options.seed ?? 0x5b_1d_c0de); + + const positions = new Float32Array(MIGRATION_MAX_POINTS * 3); + const colors = new Float32Array(MIGRATION_MAX_POINTS * 4); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + // itemSize 4, which is what makes three define `USE_COLOR_ALPHA` and give the + // cloud a per-mote alpha. A `PointsMaterial` has one opacity for the whole + // buffer, so without this a mote could not fade in or out and the field would + // have to blink — see `MOTE_LIFE_SECONDS`. + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4)); + geometry.setDrawRange(0, 0); + + const material = new THREE.PointsMaterial({ + size: Math.max(0.4, options.span * (options.moteSpans ?? MOTE_SIZE_SPANS)), + sizeAttenuation: true, + vertexColors: true, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + opacity: 0, + }); + const map = moteTexture(); + if (map !== null) material.map = map; + + const points = new THREE.Points(geometry, material); + points.name = "migration-motes"; + points.frustumCulled = false; + points.visible = false; + group.add(points); + + // Per-mote state. Parallel arrays rather than objects: 700 of them, touched + // every frame, and the whole point of the layer is that it costs nothing. + const vx = new Float32Array(MIGRATION_MAX_POINTS); + const vz = new Float32Array(MIGRATION_MAX_POINTS); + const age = new Float32Array(MIGRATION_MAX_POINTS); + const life = new Float32Array(MIGRATION_MAX_POINTS); + const county = new Int16Array(MIGRATION_MAX_POINTS).fill(-1); + + let field: MigrationField | null = null; + let counts: number[] = []; + let slots: number[] = []; + let active = 0; + let night = 0; + const tint = { ...MOTE_COLOR }; + + /** + * Which county a mote slot belongs to, flattened once per granule. + * + * A slot keeps its index for the layer's whole life and simply re-reads this + * table when it respawns, which is what lets a mote outlive the granule it was + * born from without ever mixing two of them. + */ + function reslot(): void { + slots = []; + for (let i = 0; i < counts.length; i++) { + for (let n = 0; n < (counts[i] as number); n++) slots.push(i); + } + active = Math.min(slots.length, MIGRATION_MAX_POINTS); + geometry.setDrawRange(0, active); + } + + /** Spawn one mote from the newest granule. Never from two. */ + function spawn(index: number, phase: number): void { + const which = slots[index]; + const source = which === undefined ? undefined : field?.counties[which]; + if (source === undefined) { + county[index] = -1; + colors[index * 4 + 3] = 0; + return; + } + + const radiusKm = discRadiusKm(finite(source.areaKm2) ?? 0); + // Uniform over the disc, which needs the square root — without it every + // county would be a dot with a halo instead of a spread. + const r = radiusKm * Math.sqrt(random()); + const theta = random() * Math.PI * 2; + const dLat = (r * Math.cos(theta)) / 111.32; + const dLng = (r * Math.sin(theta)) / (111.32 * Math.cos((source.lat * Math.PI) / 180)); + const lat = source.lat + dLat; + const lng = source.lng + dLng; + + const [x, z] = world.project(lat, lng); + // Ground is sampled once, at birth, and carried for the mote's whole life. + // `height_mean_m` is above ground level, so a bird over Inyo County is above + // the White Mountains and not inside them. + const y = world.groundAt(lat, lng) + (finite(source.altitude) ?? 0) * perMetre; + + const speed = Math.max(0, finite(source.speed) ?? 0) / world.metresPerUnit; + const heading = ((finite(source.direction) ?? 0) * Math.PI) / 180; + positions[index * 3] = x; + positions[index * 3 + 1] = y; + positions[index * 3 + 2] = z; + // North is -Z: `World.project` negates latitude. A field that drifts + // north-west when the feed says south-east is the one bug here nobody would + // see, because a cloud of dots has no other way to be wrong. + vx[index] = speed * Math.sin(heading); + vz[index] = -speed * Math.cos(heading); + age[index] = phase * MOTE_LIFE_SECONDS; + life[index] = MOTE_LIFE_SECONDS * (1 - MOTE_LIFE_JITTER + random() * MOTE_LIFE_JITTER * 2); + county[index] = which as number; + colors[index * 4] = tint.r; + colors[index * 4 + 1] = tint.g; + colors[index * 4 + 2] = tint.b; + colors[index * 4 + 3] = 0; + } + + function fadeOf(index: number): number { + const t = life[index] === 0 ? 1 : (age[index] as number) / (life[index] as number); + if (t <= 0 || t >= 1) return 0; + if (t < MOTE_FADE) return t / MOTE_FADE; + if (t > 1 - MOTE_FADE) return (1 - t) / MOTE_FADE; + return 1; + } + + function refreshVisibility(): void { + material.opacity = night; + points.visible = active > 0 && night > 0.01; + } + + return { + group, + + setField(next: MigrationField | null): void { + const read = readable(next); + field = read; + counts = read === null ? [] : allocateMotes(read.counties); + reslot(); + if (read === null) { + // `null` is "nothing has answered", so every mote in the air is + // retired rather than left drifting on a granule that is no longer + // being claimed. Without this, a feed that dropped out and came back + // would put the old night's motes over the new night's counties. + county.fill(-1); + refreshVisibility(); + return; + } + // Only slots with nothing in them are spawned here. A mote already in the + // air keeps its own velocity and its own remaining life, and will pick the + // new granule up when it dies — which is the whole reason two granules + // never meet inside one position. + for (let i = 0; i < active; i++) { + if (county[i] === -1 || (age[i] as number) >= (life[i] as number)) spawn(i, random()); + } + geometry.attributes.position!.needsUpdate = true; + geometry.attributes.color!.needsUpdate = true; + refreshVisibility(); + }, + + setLighting(state: LightingState): void { + /** + * Colour only. Whether the field is drawn at all is `setSolarElevation`'s + * business, because the layer's subject *is* the night. + * + * And explicitly **not** `hemisphere.intensity`: measured on the shipped + * rig it is 1.33 at 21 degrees below the horizon and 0.95 at noon, because + * `atmosphere.ts` raises the fill to compensate a moonlit scene. The first + * draft of this layer read it as a day/night signal, computed zero + * strength at midnight, and drew nothing at all on the one board it was + * built for — while passing a test written against invented numbers. The + * picture is the instrument that found it. + */ + const packed = state.hemisphere?.sky ?? 0xffffff; + const mix = 0.25; + tint.r = MOTE_COLOR.r * (1 - mix) + (((packed >> 16) & 0xff) / 255) * mix; + tint.g = MOTE_COLOR.g * (1 - mix) + (((packed >> 8) & 0xff) / 255) * mix; + tint.b = MOTE_COLOR.b * (1 - mix) + ((packed & 0xff) / 255) * mix; + }, + + setSolarElevation(degrees: number): void { + night = 1 - smoothstep(MIGRATION_DARK_DEG, MIGRATION_LIGHT_DEG, degrees); + refreshVisibility(); + }, + + tick(dt: number): void { + if (!Number.isFinite(dt) || dt <= 0 || active === 0 || field === null) return; + for (let i = 0; i < active; i++) { + if (county[i] === -1) { + spawn(i, 0); + continue; + } + age[i] = (age[i] as number) + dt; + if ((age[i] as number) >= (life[i] as number)) { + spawn(i, 0); + continue; + } + positions[i * 3] = (positions[i * 3] as number) + (vx[i] as number) * dt; + positions[i * 3 + 2] = (positions[i * 3 + 2] as number) + (vz[i] as number) * dt; + colors[i * 4 + 3] = fadeOf(i); + } + geometry.attributes.position!.needsUpdate = true; + geometry.attributes.color!.needsUpdate = true; + }, + + dispose(): void { + geometry.dispose(); + material.dispose(); + material.map?.dispose(); + }, + + activeCount: () => active, + + mote(index: number): MoteReading | null { + if (!Number.isInteger(index) || index < 0 || index >= active) return null; + return { + x: positions[index * 3] as number, + y: positions[index * 3 + 1] as number, + z: positions[index * 3 + 2] as number, + vx: vx[index] as number, + vz: vz[index] as number, + alpha: colors[index * 4 + 3] as number, + county: county[index] as number, + }; + }, + }; +} + +// ---- Bits ----------------------------------------------------------------- + +/** + * The mote's glow, drawn on a canvas rather than shipped as a file. + * + * `null` with no DOM, which is what lets a headless test build the whole layer. + * Without a sprite a `Points` cloud is a field of hard squares, so the fallback + * is deliberately a missing map and not a substitute one. + */ +function moteTexture(): THREE.Texture | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = MOTE_TEXTURE_SIZE; + canvas.height = MOTE_TEXTURE_SIZE; + const context = canvas.getContext("2d"); + if (!context) return null; + + const half = MOTE_TEXTURE_SIZE / 2; + const gradient = context.createRadialGradient(half, half, 0, half, half, half); + gradient.addColorStop(0, "rgba(255,255,255,1)"); + gradient.addColorStop(0.3, "rgba(255,255,255,0.5)"); + gradient.addColorStop(0.7, "rgba(255,255,255,0.09)"); + gradient.addColorStop(1, "rgba(255,255,255,0)"); + context.fillStyle = gradient; + context.fillRect(0, 0, MOTE_TEXTURE_SIZE, MOTE_TEXTURE_SIZE); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +/** + * A field worth drawing, or `null`. + * + * Total: the consumer is a render loop. A county with no coordinate is dropped + * rather than placed at the origin — which is what the `US-CA` state row would + * do, and it carries 793,141 birds aloft tonight against the largest county's + * 82,549. `src/server/birds.ts` takes it out first; this is the second line. + */ +function readable(field: MigrationField | null | undefined): MigrationField | null { + if (field === null || field === undefined || typeof field !== "object") return null; + if (!Array.isArray(field.counties)) return null; + const counties = field.counties.filter( + (county) => + county !== null && + typeof county === "object" && + finite(county.lat) !== null && + finite(county.lng) !== null && + (finite(county.aloft) ?? 0) > 0, + ); + if (counties.length === 0) return null; + return { ...field, counties }; +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + if (edge1 === edge0) return x < edge0 ? 0 : 1; + const t = clamp01((x - edge0) / (edge1 - edge0)); + return t * t * (3 - 2 * t); +} + +function clamp01(value: number): number { + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; +} + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/src/engine/ports.ts b/src/engine/ports.ts new file mode 100644 index 0000000..3834428 --- /dev/null +++ b/src/engine/ports.ts @@ -0,0 +1,1367 @@ +/** + * Ports — the busiest thing on any coast, and the one piece of it you can read + * from a hundred kilometres up. + * + * `socal.ts`'s Harbour chapter has said "the busiest port complex in the + * hemisphere" since the pack was written, and until this module existed the + * picture underneath that sentence was a tan hexagon carrying the same generic + * white blocks as the housing beside it. Nothing in frame said *port*. This is + * the kit that says it. + * + * ### What makes a port read as a port + * + * Not the buildings — there are hardly any. Four things, in this order: + * + * 1. **The breakwater.** Thirteen kilometres of rubble across the mouth of a + * bight is the object that turns a bay into a harbour, and it is the only + * thing here that is legible at the whole-board pose: 13.06 km is 33.4 scene + * units on a 393-unit board, about nine per cent of the frame's width. + * 2. **The cranes**, and they are the hero. See the arithmetic below. + * 3. **The comb of slips** — the shape of the reclaimed land itself, basins cut + * into it and quays along their walls. That is authored in the city pack; + * this module only needs the land to already have the right outline. + * 4. **The yards** — enormous flat rectangles of stacked steel, in a colour + * nothing else on a map is. + * + * ### THE CRANE IS THE HERO, AND VERTICAL EXAGGERATION IS WHY + * + * `world.metres()` carries the board's vertical exaggeration; horizontal + * distances do not. On Southern California that is 3.4x at 390.6 m to the unit, + * so a 130 m gantry crane stands **1.13 scene units tall** while an entire + * 400 m container ship is **1.02 units long**. The crane is taller than the ship + * is long. There is no other object in this feature with anything like that + * legibility per triangle — sixty triangles buys a silhouette you cannot mistake + * for anything else — which is why the crane row is authored data with a count + * and a bearing, and why it went in before a single hull did. + * + * Boom angle is free storytelling on top of that. A boom lowered over a berth + * reads as working and a boom raised to near-vertical reads as idle, and the + * difference is a per-instance rotation costing nothing. `Crane.idleFraction` + * is what a pack sets to say how much of a rail is stood down. + * + * ### A CONTAINER YARD IS PAINT. IT IS NOT INSTANCES. HERE IS THE ARITHMETIC + * + * This is the counterintuitive call in the kit and it will be "improved" into + * instances by a later reader unless the numbers sit here in the module comment. + * + * A forty-foot container is **12.2 m** long. Southern California is **390.6 m to + * the scene unit**, so one box is **0.0312 units** — and the closest pose this + * board allows is the Harbour chapter at 40 units of camera distance, where a + * unit is roughly fifty pixels. A container is **one and a half pixels**, from + * the closest the camera ever gets, and the whole of San Pedro holds tens of + * thousands of them. Instanced, that is tens of thousands of matrices to draw a + * texture; painted, it is one quad per terminal indexing into one canvas, and it + * reads *better* because the canvas can band the stacks at exactly the + * proportion the data says rather than at whatever a random roll produced. + * + * So `yardAtlas` bands a canvas per yard exactly the way `airports.ts`'s + * `markingsAtlas` bands runway paint, and `Yard.emptyShare` is drawn into those + * pixels. On the Bay board (94.3 m/unit) a container is 0.129 units and the same + * call still holds; anyone reaching for instances should re-run this paragraph's + * arithmetic for their own board first. + * + * ### FIVE BUCKETS, AND THE NUMBER IS THE DESIGN + * + * The binding constraint on this feature is **draw calls on the mobile cell**, + * not triangles. `scripts/performance-budget.mjs` measures socal mobile at 140 + * draws against a cap of 170: thirty spare for this feature and every future + * one. (Desktop has 112 spare, which is why costing this against desktop makes + * it look three times cheaper than it is.) So the kit has exactly four meshes, + * with a fifth bucket left for the hulls that will lie alongside these quays: + * + * 1. `ports:stone` — breakwater berms, quay decks, quay skirts, revetment + * toes and terminal sheds. One Lambert material. + * 2. `ports:yard` — one ground quad per yard, all indexing one canvas atlas. + * 3. `ports:channel` — the dredged water, a darker and smoother strip. + * 4. `ports:cranes` — every gantry on the board in ONE `InstancedMesh`. + * 5. reserved for `vessels.ts`. + * + * and every one of them **merges across ports**, the way `airports.ts` merges + * across airfields. A second port complex on the same board costs no extra draw + * call at all; it costs a few hundred triangles. That property is asserted in + * `src/test/render/ports.test.ts` rather than assumed, because this repo has + * been bitten twice by the opposite — a suspension bridge at ~34 draw calls, and + * twelve identical asphalt freeways that could never merge because a fresh + * material was allocated per ribbon. + * + * The corollary is `airports.ts:43`'s scar and it applies here unchanged: every + * geometry below carries position, normal **and uv**, indexed, whether or not it + * has a texture, because `mergeGeometries` silently drops a bucket whose + * attribute sets disagree. + * + * ### TWO COLUMNS OF `ports.sqlite` THAT MUST NEVER BE USED, AND WHY + * + * The upstream store has a `ports` table with seven rows. Two of its columns + * look usable and are not: + * + * - **`lat`/`lon` cannot place anything.** All seven rows sit on an exact + * arc-minute grid — `lat*60` and `lon*60` are whole integers for every one of + * them. One arc-minute at 33.75 N is 1,852 m of latitude and 1,540 m of + * longitude, so a quay placed from those columns lands as much as 1.3 km — + * **3.3 SoCal scene units** — from the water it is supposed to edge. They are + * card anchors, not survey points. Every coordinate this kit draws is + * hand-traced in the city pack, per ARCHITECTURE §3.2. + * - **`channel_depth_ft` is a binned WPI code.** It says Los Angeles is 14 ft + * deep, Long Beach 19 ft and Oakland 8 ft, against real dredged channels near + * 50 ft. Any "may this ship enter" rule built on it excludes every container + * ship from the busiest port in the hemisphere, and it would look like a + * working feature the whole time. + * + * `harbor_type` **is** usable and is load-bearing: `CB` is a coastal breakwater + * harbour and `CN` a coastal natural one. San Pedro and Long Beach are `CB` and + * share the federal breakwater; Oakland is `CN` and must never be given one. + * This module draws a breakwater only where a pack authored one, and a pack + * should only author one where `harborType` says `CB`. + */ + +import * as THREE from "three"; +import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; +import { LOD_HEIGHT_TOLERANCE } from "./terrain.ts"; +import type { Crane, LatLng, LightingState, Port, Quay, Yard } from "./types.ts"; +import type { World } from "./world.ts"; + +// ---- The authored contract ------------------------------------------------ +// +// Re-exported so the kit reads as one import, and *declared* in `types.ts` for +// the reason `airports.ts:74-82` gives: `City` has to name `Port`, +// `src/index.ts` reaches `City`, and `src/test/integration/barrel.test.ts` reads +// the import graph as source and cannot tell an erased type-only edge from a +// real one. Plain data lives where plain data lives. + +export type { Berth, Crane, Port, PortFreightRate, PortThroughput, Quay, Yard } from "./types.ts"; + +/** + * What a renderer needs from this module, declared here rather than imported + * from `scene.ts`. + * + * `fires.ts` makes exactly this move and for exactly this reason: the engine + * must not take a dependency on the scene assembler, and neither side has to + * exist for the other to compile. The two copies are checked against each other + * at `SceneOptions.ports` and in `ports.test.ts`. + * + * **There is no `tick`.** Stone does not move. A layer that offered one would + * invite a caller to spend a frame budget on a breakwater. + */ +export interface PortLayer { + group: THREE.Object3D; + setLighting(state: LightingState): void; + dispose(): void; +} + +// ---- Palette -------------------------------------------------------------- + +/** + * Exported for the same reason `AIRPORT_PALETTE` is: a pack in a different + * landscape may need to move these, and the contrasts between them are design + * decisions worth seeing in one place. + * + * **The constraint that shaped this table is the Vincent Thomas Bridge.** It is + * `0x3f7d55`, and in the Harbour frame it is already by far the most saturated + * object on the water. Real container colours — postbox red, safety orange, + * Maersk blue — put a second and third loud hue two hundred metres from it, and + * the whole harbour reads as a toy. So the boxes here are a *muted* rust and a + * *muted* steel blue at roughly half the chroma of the real thing, and the empty + * stacks, which are the majority, are almost neutral. `AIRPORT_PALETTE.field` + * records the same lesson from the other side: an olive green made six real + * airfields read as golf courses across two boards. + */ +export const PORT_PALETTE = { + /** + * Rubble mound, quay concrete and revetment, all one swatch because they are + * one bucket. Deliberately greyer and darker than `socal.ts`'s `shore` + * (`0xc2b393`): a quay has to separate from the beach it is next to, and + * value is what does that at distance. + */ + stone: 0x8d8880, + /** Yard asphalt, under the stacks. The darkest large surface on the coast. */ + yardBase: 0x54565b, + /** + * An empty box. Weathered, nearly neutral, and the commonest colour in the + * yard — which is the whole point: three of every four boxes leaving Los + * Angeles have nothing in them. + */ + boxEmpty: 0xb2ada2, + /** A loaded box. Muted rust; see the note above about the bridge. */ + boxLoadedA: 0x8d6053, + /** A loaded box. Muted steel blue. */ + boxLoadedB: 0x5e6c7b, + /** + * What a stack is drawn as when nobody knows what is in it. One colour, never + * a guessed mix — `Yard.emptyShare` absent means unknown, and unknown drawn as + * a plausible-looking blend is the failure the fire layer nearly shipped. + */ + boxUnknown: 0x7f7d77, + /** Yard roadways and the block alleys, painted into the atlas. */ + yardLane: 0x6a6c70, + /** + * Gantry cream. Ship-to-shore cranes are painted white or off-white almost + * everywhere, and pale is also what stands out against both the blue water + * they lean over and the dark yard behind them. + */ + crane: 0xdcd7cb, + /** Dredged water: darker than `socal.ts`'s sea (`0x3f7391`) and smoother. */ + channel: 0x2e5a74, +} as const; + +// ---- Authoring helpers ---------------------------------------------------- + +const DEG = Math.PI / 180; +const METRES_PER_DEGREE_LAT = 111_320; + +function metresPerDegreeLng(lat: number): number { + return METRES_PER_DEGREE_LAT * Math.cos(lat * DEG); +} + +/** `[east, north]` unit vector for a true bearing. */ +function bearingVector(heading: number): [number, number] { + return [Math.sin(heading * DEG), Math.cos(heading * DEG)]; +} + +/** Move from a coordinate by metres east and metres north. */ +export function offsetLatLng( + origin: { lat: number; lng: number }, + east: number, + north: number, +): LatLng { + return [ + origin.lat + north / METRES_PER_DEGREE_LAT, + origin.lng + east / metresPerDegreeLng(origin.lat), + ]; +} + +/** Great-circle-ish metres between two coordinates. Flat earth is fine at this size. */ +export function metresBetween(a: LatLng, b: LatLng): number { + const north = (b[0] - a[0]) * METRES_PER_DEGREE_LAT; + const east = (b[1] - a[1]) * metresPerDegreeLng((a[0] + b[0]) / 2); + return Math.hypot(north, east); +} + +/** Total length of a path in metres. What a breakwater is measured in. */ +export function pathLengthMetres(path: readonly LatLng[]): number { + let total = 0; + for (let i = 1; i < path.length; i += 1) { + const from = path[i - 1]; + const to = path[i]; + if (from && to) total += metresBetween(from, to); + } + return total; +} + +/** + * The four corners of a yard, from its centre, size and bearing. + * + * Exported because a pack that wants to check its own rectangle is on land — as + * `socalPorts.test.ts` does — must derive the corners exactly the way the + * renderer does, or the test is checking a different rectangle. + */ +export function yardCorners(yard: Yard): LatLng[] { + const [alongE, alongN] = bearingVector(yard.bearing); + // Right of the bearing is the bearing turned a quarter clockwise. + const rightE = alongN; + const rightN = -alongE; + const halfLength = yard.length / 2; + const halfWidth = yard.width / 2; + return ([ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ] as const).map(([s, t]) => + offsetLatLng( + yard, + alongE * s * halfLength + rightE * t * halfWidth, + alongN * s * halfLength + rightN * t * halfWidth, + ), + ); +} + +/** + * Where each gantry on a rail stands, and whether its boom is up. + * + * `count` gantries spread evenly from `from` to `to`, half a pitch in from each + * end — the same placement rule `airports.ts` uses for aircraft on stand, so a + * row of eighteen never has one crane hanging off the end of its rail. + * + * The idle ones are taken from the **far end of the rail**, deterministically, + * rather than rolled per crane. A partly-worked berth looks like a block of + * gantries down over a ship and the rest stood off to one side; a random + * scatter of raised booms looks like a fault. It is also the only way the count + * can be asserted exactly. + */ +export function craneStations(crane: Crane): { at: LatLng; idle: boolean }[] { + const count = Math.max(0, Math.floor(crane.count)); + if (count === 0) return []; + const idleCount = Math.min(count, Math.round(count * (crane.idleFraction ?? 0))); + const stations: { at: LatLng; idle: boolean }[] = []; + for (let i = 0; i < count; i += 1) { + const t = (i + 0.5) / count; + stations.push({ + at: [ + crane.from[0] + (crane.to[0] - crane.from[0]) * t, + crane.from[1] + (crane.to[1] - crane.from[1]) * t, + ], + idle: i >= count - idleCount, + }); + } + return stations; +} + +// ---- Batching ------------------------------------------------------------- +// +// A private copy of `airports.ts`'s `Batch`, and copied rather than imported for +// the reason that file gives at lines 31-41: the cache must not be module-level, +// because `createScene().dispose()` walks the scene disposing every material it +// finds, and a cache that outlived one build would hand the next board a +// disposed material and render the whole port black. + +interface Bucket { + readonly name: string; + readonly material: THREE.Material; + readonly castShadow: boolean; + readonly receiveShadow: boolean; + readonly parts: THREE.BufferGeometry[]; +} + +/** One build's worth of materials and geometry, merged on the way out. */ +class Batch { + private readonly buckets = new Map(); + + add( + name: string, + geometry: THREE.BufferGeometry | null, + material: THREE.Material, + shadows: { cast?: boolean; receive?: boolean } = {}, + ): void { + if (!geometry) return; + const key = `${material.uuid}|${name}`; + const bucket = this.buckets.get(key); + if (bucket) { + bucket.parts.push(geometry); + return; + } + this.buckets.set(key, { + name, + material, + castShadow: shadows.cast ?? false, + receiveShadow: shadows.receive ?? true, + parts: [geometry], + }); + } + + flush(into: THREE.Object3D): void { + for (const bucket of this.buckets.values()) { + const merged = + bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false); + if (!merged) { + // Losing a bucket in silence is the failure the module comment warns + // about, so say which one rather than rendering a port with no quay. + console.warn(`ports: "${bucket.name}" has mismatched attributes and was not merged`); + continue; + } + if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose(); + const mesh = new THREE.Mesh(merged, bucket.material); + mesh.name = bucket.name; + mesh.castShadow = bucket.castShadow; + mesh.receiveShadow = bucket.receiveShadow; + into.add(mesh); + } + this.buckets.clear(); + } +} + +// ---- Geometry ------------------------------------------------------------- +// +// `groundQuad`, `stripGeometry`, `slabGeometry` and `massGeometry` are copies of +// `airports.ts`'s, kept here for the same dispose reason the `Batch` is. Their +// scars are copied with them: the ground quad's winding, the strip's left rail +// first, the slab's ring orientation and the mass's un-negated `atan2`. + +/** + * A flat, axis-free quad in the ground plane. + * + * `(alongX, alongZ)` is a unit vector in scene space; the across direction is + * its right-hand perpendicular. UVs run `u` along and `v` across, which is what + * lets one yard index into a horizontal band of the atlas. + */ +function groundQuad( + cx: number, + y: number, + cz: number, + alongX: number, + alongZ: number, + halfLength: number, + halfWidth: number, + uv: { u0: number; u1: number; v0: number; v1: number } = { u0: 0, u1: 1, v0: 0, v1: 1 }, +): THREE.BufferGeometry { + const rightX = -alongZ; + const rightZ = alongX; + const positions: number[] = []; + const uvs: number[] = []; + for (const [s, t, u, v] of [ + [-halfLength, -halfWidth, uv.u0, uv.v0], + [halfLength, -halfWidth, uv.u1, uv.v0], + [-halfLength, halfWidth, uv.u0, uv.v1], + [halfLength, halfWidth, uv.u1, uv.v1], + ] as const) { + positions.push(cx + alongX * s + rightX * t, y, cz + alongZ * s + rightZ * t); + uvs.push(u, v); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute( + "normal", + new THREE.Float32BufferAttribute([0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0], 3), + ); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + // Wound so the face normal comes out +Y; the other order renders the yard as a + // hole in the ground under a one-sided material. + geometry.setIndex([0, 2, 1, 1, 2, 3]); + return geometry; +} + +/** + * A flat strip along a polyline — the dredged channel. + * + * The **left rail is emitted first**, which is not a style choice: the other way + * round reverses the winding, `computeVertexNormals` hands every triangle a + * normal pointing at the seabed, and a one-sided material draws nothing at all. + * `airports.ts` and `structures.ts` both carry this scar. + */ +function stripGeometry(points: readonly THREE.Vector3[], width: number): THREE.BufferGeometry { + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + const half = width / 2; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + const previous = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + if (!point || !previous || !next) continue; + const dx = next.x - previous.x; + const dz = next.z - previous.z; + const length = Math.hypot(dx, dz) || 1; + const leftX = -(dz / length); + const leftZ = dx / length; + positions.push( + point.x + leftX * half, + point.y, + point.z + leftZ * half, + point.x - leftX * half, + point.y, + point.z - leftZ * half, + ); + const v = index / Math.max(1, points.length - 1); + uvs.push(0, v, 1, v); + if (index < points.length - 1) { + const a = index * 2; + indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** A flat polygon in the ground plane, triangulated. A quay deck. */ +function slabGeometry( + points: readonly [number, number][], + y: number, +): THREE.BufferGeometry | null { + if (points.length < 3) return null; + // `ShapeGeometry` builds in XY and faces +Z. Feeding it `(x, -z)` and turning + // it a quarter about X puts north back at -z and the normal at +Y. + let ring = points.map(([x, z]) => new THREE.Vector2(x, -z)); + // A clockwise ring comes out facing away from the camera and is invisible + // under a one-sided material, so orient it here rather than asking every pack + // to trace its quays in one direction. + if (THREE.ShapeUtils.area(ring) < 0) ring = ring.reverse(); + const geometry = new THREE.ShapeGeometry(new THREE.Shape(ring)); + geometry.rotateX(-Math.PI / 2); + geometry.translate(0, y, 0); + return geometry; +} + +/** + * A box standing on the ground, turned to a bearing. Terminal sheds. + * + * The box's own length axis is +Z, and `rotateY(phi)` sends +Z to + * `(sin phi, cos phi)` in (x, z) — so phi is `atan2(alongX, alongZ)` and **not** + * `atan2(alongX, -alongZ)`. `airports.ts` records at length how the negation + * fails: it does not rotate a building by a wrong angle, it *mirrors* it about + * the east-west line. + */ +function massGeometry( + cx: number, + groundY: number, + cz: number, + alongX: number, + alongZ: number, + length: number, + width: number, + height: number, +): THREE.BufferGeometry { + const geometry = new THREE.BoxGeometry(width, height, length); + geometry.rotateY(Math.atan2(alongX, alongZ)); + geometry.translate(cx, groundY + height / 2, cz); + return geometry; +} + +/** + * A trapezoid mound along a path — the breakwater. + * + * Three quad strips: a crest and two slopes down to the seabed. Emitted as one + * indexed buffer with real normals, because a berm lit only on its crest is a + * flat grey line and a berm lit on its three faces is unmistakably a heap of + * rock. Left rail first, for `stripGeometry`'s reason. + */ +function bermGeometry( + points: readonly THREE.Vector3[], + crestHalf: number, + baseHalf: number, + crestY: number, + baseY: number, +): THREE.BufferGeometry | null { + if (points.length < 2) return null; + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + // Four rails across the section: left toe, left crest, right crest, right toe. + const offsets: [number, number][] = [ + [baseHalf, baseY], + [crestHalf, crestY], + [-crestHalf, crestY], + [-baseHalf, baseY], + ]; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + const previous = points[Math.max(0, index - 1)]; + const next = points[Math.min(points.length - 1, index + 1)]; + if (!point || !previous || !next) continue; + const dx = next.x - previous.x; + const dz = next.z - previous.z; + const length = Math.hypot(dx, dz) || 1; + const leftX = -(dz / length); + const leftZ = dx / length; + const v = index / Math.max(1, points.length - 1); + offsets.forEach(([across, y], rail) => { + positions.push(point.x + leftX * across, y, point.z + leftZ * across); + uvs.push(rail / (offsets.length - 1), v); + }); + if (index < points.length - 1) { + const a = index * offsets.length; + const b = a + offsets.length; + for (let rail = 0; rail < offsets.length - 1; rail += 1) { + indices.push(a + rail, b + rail, a + rail + 1, a + rail + 1, b + rail, b + rail + 1); + } + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** + * A vertical ribbon hanging under a polyline — the quay face, and the revetment + * toe under it. + * + * Its winding is the mirror of `stripGeometry`'s, because the two rails here are + * a top and a bottom rather than a left and a right, and a wall wound the other + * way faces into the land it is holding back. + */ +function wallGeometry( + points: readonly [number, number][], + topY: number, + bottomY: number, + outward: [number, number], +): THREE.BufferGeometry | null { + if (points.length < 2) return null; + const positions: number[] = []; + const uvs: number[] = []; + const indices: number[] = []; + for (let index = 0; index < points.length; index += 1) { + const point = points[index]; + if (!point) continue; + positions.push(point[0], topY, point[1], point[0], bottomY, point[1]); + const u = index / Math.max(1, points.length - 1); + uvs.push(u, 1, u, 0); + if (index < points.length - 1) { + const a = index * 2; + indices.push(a, a + 1, a + 2, a + 2, a + 1, a + 3); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + // A vertical ribbon's computed normals can come out either way depending on + // which direction the ring was traced. Force them to face the water, so the + // sun on a quay wall is the sun on the side you can see. + const normal = geometry.getAttribute("normal"); + const first = normal.getX(0) * outward[0] + normal.getZ(0) * outward[1]; + if (first < 0) { + for (let i = 0; i < normal.count; i += 1) { + normal.setXYZ(i, -normal.getX(i), -normal.getY(i), -normal.getZ(i)); + } + const index = geometry.getIndex(); + if (index) { + for (let i = 0; i < index.count; i += 3) { + const b = index.getX(i + 1); + index.setX(i + 1, index.getX(i + 2)); + index.setX(i + 2, b); + } + index.needsUpdate = true; + } + normal.needsUpdate = true; + } + return geometry; +} + +// ---- The yard atlas ------------------------------------------------------- + +const YARD_ATLAS_WIDTH = 1024; +const YARD_BAND = 256; + +/** Next power of two at or above `n`; keeps the atlas mipmappable everywhere. */ +function powerOfTwo(n: number): number { + let size = 1; + while (size < n) size *= 2; + return size; +} + +/** A small deterministic PRNG, so a yard looks the same on every load. */ +function seeded(seed: number): () => number { + let state = (seed | 0) || 1; + return () => { + state = (state * 1_664_525 + 1_013_904_223) | 0; + return ((state >>> 8) & 0xff_ffff) / 0x100_0000; + }; +} + +/** + * Every container yard on one board, painted into one texture. + * + * Each yard gets a horizontal band `YARD_BAND` pixels tall: x runs along the + * yard's `length` from its low end, y across its `width`. Drawn in **metres and + * mapped through** — `alongPx` and `acrossPx` are the only two places the + * resolution appears — so a block stays the right size when a yard of a + * different size shares the atlas. + * + * The unit drawn is a **stacking block**, not a container: roughly 250 m by + * 32 m, which is what a straddle-carrier yard is actually laid out in and which + * at this resolution is 135 px by 8 px. A container would be 13 px by 0.6, and + * 0.6 of a pixel is not a thing a canvas can draw. + * + * ### `emptyShare` is drawn exactly, not rolled + * + * The empty blocks are chosen by an exact count — `round(blocks * emptyShare)` — + * and then interleaved through the yard rather than sampled per block. That is + * the difference between a picture that *is* 75.7 per cent empty and one that is + * 75.7 per cent empty on average, and it is the whole reason the yard is paint: + * a canvas can hold a proportion, a random scatter of instances cannot. + * + * With `emptyShare` absent every block is `boxUnknown`. One colour, never a + * guessed mix. + */ +export function yardAtlas(yards: readonly Yard[]): THREE.Texture | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = YARD_ATLAS_WIDTH; + canvas.height = powerOfTwo(YARD_BAND * Math.max(1, yards.length)); + const context = canvas.getContext("2d"); + if (!context) return null; + + const hex = (color: number) => `#${color.toString(16).padStart(6, "0")}`; + + // The whole sheet, including any unused tail band, is asphalt. A transparent + // tail would show the sea through the last yard's mipmaps. + context.fillStyle = hex(PORT_PALETTE.yardBase); + context.fillRect(0, 0, canvas.width, canvas.height); + + /** A stacking block, metres. */ + const BLOCK_LENGTH = 250; + const BLOCK_WIDTH = 32; + const BLOCK_GAP_ALONG = 30; + const BLOCK_GAP_ACROSS = 16; + /** The perimeter road every terminal has, metres. */ + const PERIMETER = 40; + + yards.forEach((yard, index) => { + const top = index * YARD_BAND; + const alongPx = (metres: number) => (metres / yard.length) * YARD_ATLAS_WIDTH; + const acrossPx = (metres: number) => (metres / yard.width) * YARD_BAND; + const random = seeded(1 + index * 7919); + + context.save(); + context.beginPath(); + context.rect(0, top, YARD_ATLAS_WIDTH, YARD_BAND); + context.clip(); + + // The perimeter road, and the lanes between the block groups. + context.fillStyle = hex(PORT_PALETTE.yardLane); + context.fillRect(0, top, YARD_ATLAS_WIDTH, acrossPx(PERIMETER)); + context.fillRect(0, top + YARD_BAND - acrossPx(PERIMETER), YARD_ATLAS_WIDTH, acrossPx(PERIMETER)); + + const columns = Math.max(1, Math.floor(yard.length / (BLOCK_LENGTH + BLOCK_GAP_ALONG))); + const rows = Math.max( + 1, + Math.floor((yard.width - 2 * PERIMETER) / (BLOCK_WIDTH + BLOCK_GAP_ACROSS)), + ); + const total = columns * rows; + const empties = yard.emptyShare === undefined ? 0 : Math.round(total * yard.emptyShare); + + /** + * Which blocks are empties, spread through the yard rather than clumped. + * + * A Bresenham walk: block `k` is an empty when the running count crosses + * another `empties/total`. Exactly `empties` blocks come out empty and they + * are evenly distributed, which is what a real yard looks like — the empty + * stacks are not in one corner, they are between the loaded ones. + */ + const isEmpty = (k: number) => + yard.emptyShare !== undefined && + Math.floor(((k + 1) * empties) / total) > Math.floor((k * empties) / total); + + // Centred, so the slack left over by the integer block counts is split + // between the two ends rather than dumped at one of them — an unpaved strip + // down one side of a terminal reads as an unfinished texture. + const alongMargin = (yard.length - columns * (BLOCK_LENGTH + BLOCK_GAP_ALONG)) / 2; + const acrossMargin = + (yard.width - 2 * PERIMETER - rows * (BLOCK_WIDTH + BLOCK_GAP_ACROSS)) / 2; + + for (let column = 0; column < columns; column += 1) { + for (let row = 0; row < rows; row += 1) { + const k = column * rows + row; + const x = alongPx(alongMargin + column * (BLOCK_LENGTH + BLOCK_GAP_ALONG)); + const y = top + acrossPx(PERIMETER + acrossMargin + row * (BLOCK_WIDTH + BLOCK_GAP_ACROSS)); + const width = alongPx(BLOCK_LENGTH); + const height = Math.max(1, acrossPx(BLOCK_WIDTH)); + const base = + yard.emptyShare === undefined + ? PORT_PALETTE.boxUnknown + : isEmpty(k) + ? PORT_PALETTE.boxEmpty + : random() < 0.55 + ? PORT_PALETTE.boxLoadedA + : PORT_PALETTE.boxLoadedB; + // A stack is many boxes and no two weather alike; a flat fill reads as + // a painted rectangle, which is what it is. +-6% of value is enough. + const shade = 0.94 + random() * 0.12; + const colour = new THREE.Color(base).multiplyScalar(shade); + context.fillStyle = `#${colour.getHexString()}`; + context.fillRect(x, y, width, height); + // A block is never full to its end. Eat a random tail out of it, which + // is what stops the yard reading as graph paper. + if (random() < 0.35) { + context.fillStyle = hex(PORT_PALETTE.yardBase); + const eaten = width * (0.1 + random() * 0.3); + context.fillRect(x + width - eaten, y, eaten, height); + } + } + } + context.restore(); + }); + + const texture = new THREE.CanvasTexture(canvas); + // Straight through: the atlas is authored top-down and the v coordinates below + // are canvas rows over canvas height, so flipping it would put one yard's + // stacks on another yard. + texture.flipY = false; + texture.colorSpace = THREE.SRGBColorSpace; + texture.needsUpdate = true; + return texture; +} + +// ---- The build ------------------------------------------------------------ + +/** + * The stack, in scene units. Same reasoning as `airports.ts`'s: these have to be + * far enough apart to survive the depth buffer, and a hundredth of a unit is the + * step that works — at 391 m per unit and 3.4x exaggeration the whole stack is + * about a metre of real height, over ground that is genuinely built in layers. + * + * `PLATE_LIFT` has the same hard floor under it and it is not the ground: + * `terrain.ts` builds its relief at `world.metres(e) + 0.012`, and the terrain + * LOD may collapse a near-flat patch to a quad sitting up to + * `LOD_HEIGHT_TOLERANCE` above the lattice points it replaced. A plate cleared + * of neither is pierced by the ground it stands on — which is how Van Nuys grew + * a tan wedge through the middle of its airfield. Two constants that must not + * drift apart are one constant, so it is derived rather than typed. + */ +const PLATE_LIFT = LOD_HEIGHT_TOLERANCE + 0.04; +const YARD_LIFT = 0.01; +const QUAY_LIFT = 0.02; + +/** + * Where the sea is. + * + * `terrain.ts` puts the sea plane at `y = -0.06` and the shore plates at `y = 0` + * — six hundredths of a unit, which at 391 m per unit is about seven metres of + * step. A quay skirt has to reach the first number and a channel has to float a + * hair above it, so both are derived from this rather than typed twice. + */ +const SEA_Y = -0.06; +const CHANNEL_LIFT = 0.01; + +/** + * How wide a breakwater is drawn, in metres, before the legibility floor. + * + * The federal breakwater at San Pedro is about ninety metres across its base, + * which is 0.23 scene units — under half a pixel at the whole-board pose, where + * this object has to do its most important work. So it carries a floor in scene + * units the way `bridges.ts`'s members do, and for the same stated reason: an + * invisible breakwater is a worse answer than a chunky one. `socal.ts`'s LA + * River sets the same precedent from the other direction, drawn at four times + * its true width because anything narrower than a terrain cell renders as a + * dotted line rather than a thin one. + */ +const BREAKWATER_CREST_M = 44; +const BREAKWATER_BASE_M = 140; +const BREAKWATER_CREST_FLOOR = 0.11; +const BREAKWATER_BASE_FLOOR = 0.3; +/** Crest height above the waterline, metres. Exaggerated by the board like any height. */ +const BREAKWATER_CREST_HEIGHT_M = 7; + +/** + * Dredged channel width, metres, and its floor. + * + * The Main Channel at San Pedro really is about 340 m between the shoal edges, + * which is 0.87 units — legible at the Harbour pose and marginal at the whole + * board, hence the floor. It is one number for every channel on the board + * rather than a field on `Port`, because a channel drawn wider than the water it + * lies in laps onto the quay beside it, and 340 m is what the narrowest reach on + * this board can take. + */ +const CHANNEL_WIDTH_M = 340; +const CHANNEL_FLOOR = 0.55; + +/** A gantry's dimensions, metres, and the floors that keep the members visible. */ +const CRANE_RAIL_GAUGE_M = 30; +const CRANE_MEMBER_M = 5; +const CRANE_GAUGE_FLOOR = 0.115; +const CRANE_MEMBER_FLOOR = 0.032; +const CRANE_OUTREACH_FLOOR = 0.26; +/** Degrees the boom stands at when it is raised. A stowed gantry is near vertical. */ +const CRANE_BOOM_IDLE_DEG = 74; + +/** Every coordinate a port's graded plate has to be at least as high as. */ +function plateSamples(port: Port): LatLng[] { + const samples: LatLng[] = []; + for (const quay of port.quays ?? []) for (const point of quay.polygon) samples.push(point); + for (const yard of port.yards ?? []) samples.push(...yardCorners(yard)); + for (const berth of port.berths ?? []) samples.push([berth.lat, berth.lng]); + if (samples.length === 0) samples.push([port.lat, port.lng]); + return samples; +} + +/** + * One graded height for the whole port, for the reason `airports.ts` gives about + * an airfield: a container terminal is reclaimed land, and what reclamation *is* + * is somebody spending a great deal of money making the ground one plane. + */ +function plateHeight(world: World, port: Port): number { + let highest = -Infinity; + for (const [lat, lng] of plateSamples(port)) { + const ground = world.groundAt(lat, lng); + if (ground > highest) highest = ground; + } + return (Number.isFinite(highest) ? highest : 0) + PLATE_LIFT; +} + +interface Surfaces { + stone: THREE.Material; + yard: THREE.Material; + channel: THREE.Material; + crane: THREE.Material; +} + +function surfaces(yards: readonly Yard[]): { surfaces: Surfaces; atlas: THREE.Texture | null } { + const atlas = yardAtlas(yards); + return { + atlas, + surfaces: { + stone: new THREE.MeshLambertMaterial({ color: PORT_PALETTE.stone }), + yard: new THREE.MeshLambertMaterial({ + ...(atlas ? { map: atlas } : { color: PORT_PALETTE.yardBase }), + }), + /** + * Standard rather than Lambert, and the only one in the kit that is: the + * whole job of this surface is to be *smoother* than the sea beside it, + * and roughness is not a thing a Lambert has. Dredged water is calmer and + * darker than the shoal beside it, which is exactly the cue that makes a + * channel legible from the air without drawing a line on the map. + */ + channel: new THREE.MeshStandardMaterial({ + color: PORT_PALETTE.channel, + roughness: 0.16, + metalness: 0, + }), + crane: new THREE.MeshLambertMaterial({ color: PORT_PALETTE.crane }), + }, + }; +} + +/** The outward (waterward) horizontal direction of a quay, in scene space. */ +function quayOutward(world: World, quay: Quay): [number, number] { + // The first two vertices are the water edge by authoring convention, so the + // outward normal is the side of that edge the rest of the polygon is NOT on. + const projected = quay.polygon.map(([lat, lng]) => world.project(lat, lng)); + const a = projected[0]; + const b = projected[1]; + if (!a || !b) return [0, -1]; + const dx = b[0] - a[0]; + const dz = b[1] - a[1]; + const length = Math.hypot(dx, dz) || 1; + const leftX = -(dz / length); + const leftZ = dx / length; + // Centroid of the whole ring tells us where the land is. + let cx = 0; + let cz = 0; + for (const [x, z] of projected) { + cx += x; + cz += z; + } + cx /= projected.length; + cz /= projected.length; + const towardLand = (cx - a[0]) * leftX + (cz - a[1]) * leftZ; + return towardLand > 0 ? [-leftX, -leftZ] : [leftX, leftZ]; +} + +/** One port's stone, yards and channel, dropped into a batch that may hold others. */ +function buildPort( + world: World, + port: Port, + batch: Batch, + paints: Surfaces, + cranes: THREE.Matrix4[], + bandOffset: number, + bandTotal: number, +): void { + const unit = 1 / world.metresPerUnit; + const size = (metres: number, floor: number) => Math.max(metres * unit, floor); + const plate = plateHeight(world, port); + + // ---- The breakwater ----------------------------------------------------- + // + // Drawn only where a pack authored one. `harborType` is the reason a pack + // authors one: `CB` harbours have them and `CN` harbours must not be given + // one, because giving Oakland a breakwater would be inventing the single + // largest object on its waterfront. + + for (const arm of port.breakwater ?? []) { + const points = arm.map(([lat, lng]) => { + const [x, z] = world.project(lat, lng); + return new THREE.Vector3(x, 0, z); + }); + batch.add( + "ports:stone", + bermGeometry( + points, + size(BREAKWATER_CREST_M / 2, BREAKWATER_CREST_FLOOR), + size(BREAKWATER_BASE_M / 2, BREAKWATER_BASE_FLOOR), + world.metres(BREAKWATER_CREST_HEIGHT_M), + SEA_Y - 0.02, + ), + paints.stone, + { cast: true }, + ); + } + + // ---- The dredged channel ------------------------------------------------ + + if (port.channel && port.channel.length >= 2) { + const points = port.channel.map(([lat, lng]) => { + const [x, z] = world.project(lat, lng); + return new THREE.Vector3(x, SEA_Y + CHANNEL_LIFT, z); + }); + batch.add( + "ports:channel", + stripGeometry(points, size(CHANNEL_WIDTH_M, CHANNEL_FLOOR)), + paints.channel, + { receive: false }, + ); + } + + // ---- Quays: a deck, a face down to the water, and a toe under it --------- + + for (const quay of port.quays ?? []) { + const projected = quay.polygon.map(([lat, lng]) => world.project(lat, lng)); + const deckY = plate + QUAY_LIFT + world.metres(quay.deckHeight); + batch.add("ports:stone", slabGeometry(projected, deckY), paints.stone); + + const a = projected[0]; + const b = projected[1]; + if (!a || !b) continue; + const outward = quayOutward(world, quay); + // The face: a vertical ribbon from the deck down past the waterline, so + // there is no gap for the sea's swell to show through at a low sun. + batch.add("ports:stone", wallGeometry([a, b], deckY, SEA_Y - 0.03, outward), paints.stone, { + cast: true, + }); + /** + * The revetment toe: a short sloped apron of rock at the foot of the wall. + * + * Every quay in a working harbour has one, and at this scale it is what + * stops the wall reading as a razor-thin line where the concrete meets the + * water. Two quads, and it is the third thing the STONE bucket is for. + */ + const toe = size(26, 0.07); + const toeA: [number, number] = [a[0] + outward[0] * toe, a[1] + outward[1] * toe]; + const toeB: [number, number] = [b[0] + outward[0] * toe, b[1] + outward[1] * toe]; + batch.add( + "ports:stone", + bermGeometry( + [new THREE.Vector3(toeA[0], 0, toeA[1]), new THREE.Vector3(toeB[0], 0, toeB[1])], + toe / 2, + toe, + SEA_Y + 0.02, + SEA_Y - 0.03, + ), + paints.stone, + ); + } + + // ---- Yards: one quad each, all indexing one atlas ------------------------ + + (port.yards ?? []).forEach((yard, index) => { + const [x, z] = world.project(yard.lat, yard.lng); + const [east, north] = bearingVector(yard.bearing); + // Scene space runs x east and z south, so a bearing's north component is a + // negative z. + const alongX = east; + const alongZ = -north; + const band = bandOffset + index; + batch.add( + "ports:yard", + groundQuad( + x, + plate + YARD_LIFT, + z, + alongX, + alongZ, + (yard.length / 2) * unit, + (yard.width / 2) * unit, + { + u0: 0, + u1: 1, + v0: (band * YARD_BAND) / bandTotal, + v1: ((band + 1) * YARD_BAND) / bandTotal, + }, + ), + paints.yard, + ); + + /** + * One shed at the landward end of the yard. + * + * A terminal is almost all flat ground, and a yard with nothing standing on + * it anywhere reads as a car park from a low camera. One transit shed per + * yard — twelve triangles — is the whole of the relief this kit spends on + * buildings, and it is where the reefer racks and the maintenance shop + * genuinely are. + */ + const shedAlong = Math.min(yard.length * 0.16, 260); + const shedAcross = Math.min(yard.width * 0.3, 120); + const [shedLat, shedLng] = offsetLatLng( + yard, + -east * (yard.length / 2 - shedAlong * 0.75), + -north * (yard.length / 2 - shedAlong * 0.75), + ); + const [sx, sz] = world.project(shedLat, shedLng); + batch.add( + "ports:stone", + massGeometry( + sx, + plate + YARD_LIFT, + sz, + alongX, + alongZ, + shedAlong * unit, + shedAcross * unit, + world.metres(16), + ), + paints.stone, + { cast: true }, + ); + }); + + // ---- Cranes: matrices only; the mesh is built once for the whole board --- + + for (const crane of port.cranes ?? []) { + pushCraneMatrices(world, crane, plate, cranes); + } +} + +/** + * Every box of every gantry on one rail, as instance matrices. + * + * **The instanced geometry is a unit box and each crane contributes five of + * them.** That is what makes a per-instance boom angle possible inside a single + * `InstancedMesh`: an instance matrix can rotate a boom about its pivot, but it + * cannot rotate one limb of a rigid crane geometry relative to the rest. Five + * boxes, sixty triangles, one draw call for every gantry on the board, and the + * boom angle is free. A `Group` per crane — the obvious shape — would be five + * draws times fifty-six gantries against a mobile cap with thirty spare, which + * is the ~34-draw suspension bridge and the twelve unmergeable freeways all over + * again. `ports.test.ts` asserts no crane is a `Group`. + * + * ### The board stretches height and not width, so the boom is composed + * + * `world.metres()` applies vertical exaggeration and horizontal distance does + * not, so a 70 m boom is 0.18 units lying flat and 0.61 units standing up. Its + * apparent length and apparent angle are therefore computed from the two + * components separately — never from one length and a rotation — or a raised + * boom would be three times too short. + */ +function pushCraneMatrices( + world: World, + crane: Crane, + plate: number, + out: THREE.Matrix4[], +): void { + const unit = 1 / world.metresPerUnit; + const size = (metres: number, floor: number) => Math.max(metres * unit, floor); + const gauge = size(CRANE_RAIL_GAUGE_M, CRANE_GAUGE_FLOOR); + const member = size(CRANE_MEMBER_M, CRANE_MEMBER_FLOOR); + const portal = world.metres(crane.height); + const outreach = size(crane.outreach, CRANE_OUTREACH_FLOOR); + + // The boom's horizontal direction in scene space: x east, z south. + const [east, north] = bearingVector(crane.bearing); + const boomX = east; + const boomZ = -north; + // `rotateY(phi)` sends local +X to `(cos phi, -sin phi)` in (x, z), so aiming + // +X at `(boomX, boomZ)` wants `phi = atan2(-boomZ, boomX)`. + const yaw = Math.atan2(-boomZ, boomX); + + const dummy = new THREE.Object3D(); + const push = ( + x: number, + y: number, + z: number, + sx: number, + sy: number, + sz: number, + tilt = 0, + ) => { + dummy.position.set(x, y, z); + dummy.rotation.set(0, 0, 0); + dummy.quaternion.setFromEuler(new THREE.Euler(0, yaw, tilt, "YZX")); + dummy.scale.set(sx, sy, sz); + dummy.updateMatrix(); + out.push(dummy.matrix.clone()); + }; + + for (const station of craneStations(crane)) { + const [rx, rz] = world.project(station.at[0], station.at[1]); + const base = plate; + const top = base + portal; + + // 1 & 2: the legs. The waterside pair stands on the rail; the landside pair + // is a gauge back from it, away from the water. + push(rx, base + portal / 2, rz, member, portal, member); + push( + rx - boomX * gauge, + base + portal / 2, + rz - boomZ * gauge, + member, + portal, + member, + ); + + // 3: the portal beam and machinery house, spanning the gauge at the top. + push( + rx - boomX * gauge * 0.5, + top + member * 0.7, + rz - boomZ * gauge * 0.5, + gauge + member, + member * 1.4, + member * 1.15, + ); + + // 4: the boom, pivoting at the top of the waterside leg. + const angle = station.idle ? CRANE_BOOM_IDLE_DEG * DEG : 0; + const reachX = outreach * Math.cos(angle); + // Vertical is the exaggerated axis; horizontal is not. Composed, not rotated. + const reachY = world.metres(crane.outreach * Math.sin(angle)); + const boomLength = Math.hypot(reachX, reachY); + const boomTilt = Math.atan2(reachY, reachX); + push( + rx + boomX * (reachX / 2), + top + member * 1.4 + reachY / 2, + rz + boomZ * (reachX / 2), + boomLength, + member * 0.85, + member * 0.85, + boomTilt, + ); + + // 5: the backreach, always down over the yard. It is what stops a raised + // boom reading as a flagpole and it is where the boxes land. + const backLength = outreach * 0.55; + push( + rx - boomX * (gauge + backLength / 2), + top + member * 1.4, + rz - boomZ * (gauge + backLength / 2), + backLength, + member * 0.75, + member * 0.75, + ); + } +} + +/** + * Every port on a board, as one small set of merged meshes. + * + * **Merged across ports, not per port**, which is `airports.ts`'s decision and + * the same argument: sharing the buckets makes the draw count a function of how + * many *kinds* of surface a port has rather than of how many ports a board + * declares. San Pedro and Long Beach share one stone mesh, one yard atlas, one + * channel mesh and one crane `InstancedMesh` between them, and a third complex + * would add no draw call at all. `ports.test.ts` asserts that property directly + * — four ports must return the same mesh count as one. + * + * Returns an empty group when a pack declares none, so a scene can add it + * unconditionally and a city without a port costs one `Group` and no draw call. + */ +export function createPorts(world: World, ports: readonly Port[]): THREE.Group { + const group = new THREE.Group(); + group.name = "ports"; + group.userData.portIds = ports.map((port) => port.id); + if (ports.length === 0) return group; + + const yards = ports.flatMap((port) => port.yards ?? []); + const bandTotal = powerOfTwo(YARD_BAND * Math.max(1, yards.length)); + const { surfaces: paints, atlas } = surfaces(yards); + const batch = new Batch(); + const cranes: THREE.Matrix4[] = []; + + let bandOffset = 0; + for (const port of ports) { + buildPort(world, port, batch, paints, cranes, bandOffset, bandTotal); + bandOffset += (port.yards ?? []).length; + } + batch.flush(group); + + if (cranes.length > 0) { + const box = new THREE.BoxGeometry(1, 1, 1); + // A merged bucket carries uv whether or not it is textured, and so does + // this: `BoxGeometry` already does, but the assertion in `ports.test.ts` + // walks every geometry in the group and does not care where it came from. + const gantries = new THREE.InstancedMesh(box, paints.crane, cranes.length); + gantries.name = "ports:cranes"; + gantries.castShadow = true; + gantries.receiveShadow = false; + cranes.forEach((matrix, index) => gantries.setMatrixAt(index, matrix)); + gantries.instanceMatrix.needsUpdate = true; + gantries.computeBoundingSphere(); + group.add(gantries); + } + + group.userData.atlas = atlas; + return group; +} + +/** + * The port kit as a layer, which is what `scene.ts` constructs through. + * + * `span` is accepted and deliberately unused. Every other injected layer is + * sized from the board — a fire mark, a rain sheet and a mote cloud are all map + * symbols whose size is a fraction of the frame — but a breakwater is thirteen + * kilometres of rock and it is thirteen kilometres of rock at every camera + * distance. Taking the argument and ignoring it keeps all four factories one + * shape; inventing a use for it would make the port grow when the camera pulled + * back. + */ +export function createPortLayer( + world: World, + ports: readonly Port[], + _options: { span: number } = { span: 0 }, +): PortLayer { + const group = createPorts(world, ports); + + return { + group, + + /** + * Nothing here is a light, and nothing here casts one — CONTRACT §4 gives + * `Atmosphere` sole ownership of the rig, and a lit container terminal is + * one of the more tempting exceptions in the product. + * + * What this does instead is the same move `nightlights.ts` makes: a yard + * under high-mast floods is genuinely one of the brightest surfaces on any + * coast at three in the morning, so the yard material carries an emissive + * that comes up as the sun goes down, modulated by the atlas so the stacks + * catch the floodlights and the asphalt between them does not. Emission is a + * property of a material, not a light in the scene, and it costs no draw + * call and no shadow map. + * + * ### How dark it is comes from the SKY, and `sun.direction` is a trap + * + * The obvious signal is the y component of `sun.direction` — the sine of the + * key light's elevation — and it is wrong twice over. `LightingState.sun` is + * the *key light*, and `atmosphere.ts`'s `combineKey` hands that over to the + * moon after dark: on a moonlit night the "sun" is thirty degrees **up**. + * And the direction is deliberately floored so the shadow camera stays + * usable, so it never reads as far below the horizon even when it is. A port + * lit off that number stays dark on exactly the nights it should be brightest. + * + * `sky.top` has neither problem. It is a pure function of the true solar + * elevation in `atmosphere.ts`'s keyframe table — `0x05070f` at -18 degrees, + * `0x2a4275` at the horizon, daylight above — it is displayed unmodified + * because three marks the background unlit, and it is the same number the + * viewer is looking at. `null` means a rig with no sky at all, which is an + * office, and an office has no container yard in it. + */ + setLighting(state) { + const top = state.sky?.top; + // Relative luminance of the display value; the keyframes are authored in + // sRGB and shown unmodified, so the bytes are the right thing to weigh. + const luminance = + top === undefined + ? 1 + : (0.2126 * ((top >> 16) & 0xff) + + 0.7152 * ((top >> 8) & 0xff) + + 0.0722 * (top & 0xff)) / + 255; + // Full at astronomical night, about half at nautical twilight, off by the + // time the sky has any blue in it. + const night = Math.min(1, Math.max(0, (0.18 - luminance) / 0.15)); + for (const child of group.children) { + if (!(child instanceof THREE.Mesh)) continue; + const material = child.material; + if (Array.isArray(material)) continue; + if (child.name === "ports:yard" && material instanceof THREE.MeshLambertMaterial) { + material.emissive.setHex(0xffe9c4); + material.emissiveIntensity = 0.62 * night; + if (material.map) material.emissiveMap = material.map; + material.needsUpdate = true; + } + if (child.name === "ports:cranes" && material instanceof THREE.MeshLambertMaterial) { + material.emissive.setHex(0xfff2d8); + material.emissiveIntensity = 0.4 * night; + } + } + }, + + dispose() { + group.traverse((object) => { + if (object instanceof THREE.Mesh) { + object.geometry.dispose(); + const material = object.material; + if (Array.isArray(material)) for (const one of material) one.dispose(); + else material.dispose(); + } + }); + const atlas = group.userData.atlas as THREE.Texture | null | undefined; + atlas?.dispose(); + group.clear(); + }, + }; +} diff --git a/src/engine/precip.ts b/src/engine/precip.ts new file mode 100644 index 0000000..8c9d294 --- /dev/null +++ b/src/engine/precip.ts @@ -0,0 +1,508 @@ +/** + * Rain over California, as one quad and one texture. + * + * The whole layer is **two triangles and one draw call**. That is not thrift for + * its own sake: `echo_cells` turned out to be a regular quarter-degree lattice + * rather than a list of extracted features, and once the data is a raster the + * question "geometry or texture" has already been answered by the data. Clipped + * to the extended state board the entire statewide field is 38 x 42 = 1,596 + * texels — 6.2 KB as RGBA, rebuilt only when a scan arrives. + * + * ### Only on the state board, and this is a fact about the cell + * + * A 0.25-degree cell is **27.8 km across**. The SoCal board spans 1.08 degrees + * of latitude, so it is 4.3 cells tall; the Bay Area board is 3.4. A per-cell + * raster there is four enormous squares over a city — a lie about resolution, + * told in a medium that reads as truthful. `precipFactoryFor` refuses to build + * the layer on a board that cannot carry the cell, and returns `null` rather + * than a layer that draws nothing, so a fine board pays no import, no material + * and no draw call at all. + * + * ### The empty sky is the normal sky + * + * California is under rain a mean 0.596% of the time, and across the twenty-six + * frames in the upstream store tonight the range is 0.472% to 1.95%. So the + * ordinary state of this layer is **nothing at all**, and "nothing at all" here + * means the mesh is not in the group: `group.children` is empty, not one + * invisible mesh and not 1,596 transparent texels. The gate upstairs + * (`src/server/radar.ts`) decides that from statewide coverage, and when it says + * no this object holds a geometry and a material and contributes nothing to any + * frame. + * + * ### Three values a texel can hold, and they are three different claims + * + * - **Rain**: at or above `RADAR_RAIN_DBZ`, coloured from the product's own + * NWS ramp so anyone who has seen a weather map reads it without being + * taught, alpha climbing steeply off the threshold. + * - **Dry**: a working radar looked and saw less than 20 dBZ. Fully + * transparent. + * - **Unknown**: `null` — a radar that should be looking there is off the + * air and nothing else covers the hole. Drawn as a faint neutral wash, + * because a hole drawn as clear sky is a claim nobody made. It is claimed + * narrowly: on a night with all sixteen stations transmitting, which is + * tonight, there is not one such cell. `src/server/radar.ts` has the + * argument for why the wider version of that rule was wrong, and the frame + * that showed it. + * + * ### Altitude: the cloud base, and the height was chosen with a picture + * + * A reflectivity composite is a plan view and has no altitude of its own, so + * where the sheet goes is a rendering decision and not a meteorological one. It + * was made twice. + * + * The first answer was six kilometres — above every point of California's land + * in real metres, so the sheet cleared the Sierra crest, which at this board's + * `verticalExaggeration: 15` stands at **39.5 scene units**. It looked wrong, + * and the reason is parallax: 47 units of lift under a camera looking down at + * fifty degrees shifts the sheet about forty units across the frame, which is + * *seventy-five kilometres* on a board at 1,919 m to the unit. A weather map + * whose rain is drawn seventy-five kilometres from where it fell is worse than + * one whose mountains poke through it. + * + * So the sheet sits at the cloud base — 1,350 m, `clouds.ts`'s own + * `BASE_ALTITUDE_M`, 10.6 units here — and the Sierra rises through it and + * occludes it, which is what a low rain layer under a high range actually looks + * like. Parallax drops to about twenty kilometres and the rain reads as being on + * the land it is on. Both frames are in `/tmp/tera-look`; the second is the one + * that is right. + * + * **This layer owns no light.** CONTRACT.md §4. `setLighting` reads the sky + * colour and dims the sheet after dark; it constructs nothing. It reads the + * *sky colour* and not `hemisphere.intensity`, which is the trap here — see + * `skyBrightness`. + */ + +import * as THREE from "three"; +import { RADAR_RAIN_DBZ } from "../server/radar.ts"; +import { radarRampRgb } from "../assets/radarRamp.ts"; +import type { RadarField } from "./types.ts"; +import type { LightingState } from "./types.ts"; +import type { World } from "./world.ts"; + +// ---- Constants ------------------------------------------------------------ + +/** + * Where the sheet sits, in metres above sea level, before the board's own + * vertical exaggeration. + * + * The cloud base — `BASE_ALTITUDE_M` in `clouds.ts`, to the metre, so the rain + * falls out of the bottom of the deck rather than out of a number of its own. + * See the header for why the first attempt at 6,000 m was wrong. + */ +export const PRECIP_ALTITUDE_M = 1_350; + +/** + * Alpha at the rain threshold, and at `PRECIP_ALPHA_FULL_DBZ` and above. + * + * Steep off the threshold on purpose. The sheet is transparent over about 99% of + * its texels on an ordinary day, so the failure mode to design against is not + * "too loud" — it is a whole layer nobody notices is there. A cell that has just + * crossed into rain is already clearly a mark on the board; a 50 dBZ core is + * nearly opaque. + */ +const PRECIP_ALPHA_MIN = 0.3; +const PRECIP_ALPHA_MAX = 0.92; +const PRECIP_ALPHA_FULL_DBZ = 50; +/** Exponent on the alpha ramp. Below 1, so the rise is fastest at the threshold. */ +const PRECIP_ALPHA_CURVE = 0.7; + +/** + * How a cell nobody is looking at is drawn: a faint neutral wash. + * + * Deliberately weak. It has to be visible enough that the Pacific corner reads + * as "not measured" when somebody looks for it, and weak enough that a fifth of + * the board carrying it does not read as weather. Neutral rather than tinted, + * because every colour on the ramp already means a reflectivity. + */ +const PRECIP_UNKNOWN_RGB: readonly [number, number, number] = [150, 155, 165]; +const PRECIP_UNKNOWN_ALPHA = 0.07; + +/** + * How long a new scan takes to replace the one before it, in seconds. + * + * Scans are ten minutes apart, and the two obvious answers are both wrong. A + * hard cut is a whole state's weather changing between two frames, which reads + * as a glitch. A dissolve stretched across the full ten-minute step means that + * for nine and a half of every ten minutes the board is showing a blend of two + * observations and not either of them — the same objection that forbids splining + * a vessel between fixes, in a gentler costume. Thirty seconds hides the seam + * and leaves 95% of every step showing one real scan. + */ +const PRECIP_CROSSFADE_SECONDS = 30; + +/** + * The fewest lattice cells a board must span before a raster is honest on it. + * + * Twelve, in the shorter axis. California spans 38 x 42 and passes; SoCal spans + * 4.3 x 6.6 and the Bay Area 3.4 x 3.6, and both are refused. There is no + * threshold that makes four texels over Los Angeles into a picture of rain. + */ +export const PRECIP_MIN_CELLS = 12; + +/** + * How opaque the sheet is at midnight, as a fraction of its daytime self. + * + * Rain at night is dark. But this is a data overlay before it is weather, and an + * overlay that fades out at sunset is a defect rather than a mood — the boards + * this repo photographs at night are the ones with the most going on. + */ +const PRECIP_NIGHT_OPACITY = 0.55; + +/** Above the terrain and the smoke, below the cloud deck's own sort. */ +const PRECIP_RENDER_ORDER = 1; + +// ---- The handle ----------------------------------------------------------- + +export interface PrecipLayerOptions { + /** Board span in scene units, as `scene.ts` computes it. */ + span: number; + /** Metres above sea level. Defaults to `PRECIP_ALTITUDE_M`; see the header. */ + altitudeM?: number; + /** Seconds a new scan takes to replace the last. Defaults to 30. */ + crossfadeSeconds?: number; +} + +export interface PrecipLayer { + group: THREE.Object3D; + setField(field: RadarField | null): void; + setLighting(state: LightingState): void; + tick(dt: number): void; + dispose(): void; + /** Texels at or above the rain threshold in the field being drawn. Zero when empty. */ + wetTexels(): number; + /** Whether the sheet is currently contributing anything to the frame. */ + drawing(): boolean; +} + +/** A board rectangle, in degrees. Restated; see `src/server/radar.ts`. */ +export interface PrecipBounds { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +/** + * Whether this board's bounds can carry a quarter-degree cell. + * + * The whole "which boards get a raster" decision, in a pure function a test can + * hold, rather than an `if (city.id === "california")` buried in the wiring. + * `cellDeg` is a parameter so that the day the composite changes resolution this + * answers the new question rather than the old one. + */ +export function boardCarriesRaster(bounds: PrecipBounds, cellDeg = 0.25): boolean { + if (!Number.isFinite(cellDeg) || cellDeg <= 0) return false; + const rows = (bounds.maxLat - bounds.minLat) / cellDeg; + const cols = (bounds.maxLng - bounds.minLng) / cellDeg; + return Math.min(rows, cols) >= PRECIP_MIN_CELLS; +} + +/** + * The factory for this board, or `null` for a board too fine to carry the cell. + * + * `null` and not a layer that draws nothing: a board that refuses the raster + * must pay no material, no geometry and no import, and the difference is + * visible in `scripts/performance-budget.mjs` rather than only in principle. + */ +export function precipFactoryFor( + bounds: PrecipBounds, +): ((world: World, options: { span: number }) => PrecipLayer) | null { + if (!boardCarriesRaster(bounds)) return null; + return (world, options) => createPrecipLayer(world, options); +} + +// ---- The layer ------------------------------------------------------------ + +export function createPrecipLayer(world: World, options: PrecipLayerOptions): PrecipLayer { + const group = new THREE.Group(); + group.name = "precip"; + + const altitudeM = options.altitudeM ?? PRECIP_ALTITUDE_M; + const crossfade = Math.max(0, options.crossfadeSeconds ?? PRECIP_CROSSFADE_SECONDS); + + // One unit quad, scaled and placed per field. Two triangles, for ever. + const geometry = new THREE.PlaneGeometry(1, 1, 1, 1); + geometry.rotateX(-Math.PI / 2); + + const material = new THREE.MeshBasicMaterial({ + transparent: true, + // Premultiplied, and it is not a detail. The raster is 1,596 texels blown + // up across a 554-unit board with linear filtering, so every rain patch's + // edge is a long interpolation between a coloured texel and a transparent + // one. Straight alpha interpolates the *colour* toward black on the way, and + // the first shot of this layer had a dark fringe round every echo and a hard + // dark line along the board edge where the lattice is clipped. See `paint`. + premultipliedAlpha: true, + depthWrite: false, + // Visible from underneath as well as from above — the drive and actor + // cameras sit below the cloud base and look up through it. + side: THREE.DoubleSide, + /** + * **One draw call, and without this it is two.** + * + * three.js renders a `transparent` + `DoubleSide` material in two passes by + * default — back faces, then front — so that a closed transparent solid + * composites correctly. Measured on the shipped board: the sheet cost 2 draw + * calls of the 45 this round has to spend, for a single flat quad that + * cannot overlap itself. `forceSinglePass` is the switch that says so, and + * the number came out of `renderer.info.render.calls` and not out of + * reasoning about it. + */ + forceSinglePass: true, + // Not `AdditiveBlending`: rain is not light, and additive over a pale + // Central Valley would turn a green echo white. + blending: THREE.NormalBlending, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.name = "precip-sheet"; + mesh.renderOrder = PRECIP_RENDER_ORDER; + mesh.frustumCulled = false; + + /** The scan being faded out, the scan being faded in, and how far along. */ + let previous: Uint8Array | null = null; + let current: Uint8Array | null = null; + let texture: THREE.DataTexture | null = null; + let blend = 1; + let held: RadarField | null = null; + let wet = 0; + let daylight = 1; + + function releaseTexture(): void { + if (texture === null) return; + texture.dispose(); + if (material.map === texture) material.map = null; + texture = null; + } + + function detach(): void { + if (mesh.parent === group) group.remove(mesh); + held = null; + previous = null; + current = null; + wet = 0; + blend = 1; + releaseTexture(); + material.needsUpdate = true; + } + + /** Write `blend` of `current` over `previous` into the texture and upload it. */ + function compose(): void { + if (texture === null || current === null) return; + const data = texture.image.data as Uint8Array; + if (previous === null || blend >= 1 || previous.length !== current.length) { + data.set(current); + } else { + const t = blend; + for (let i = 0; i < data.length; i++) { + // `| 0` rather than Math.round: this runs over 6,384 bytes and the + // half-step it gives away is a fortieth of one alpha level. + data[i] = ((previous[i] as number) + ((current[i] as number) - (previous[i] as number)) * t) | 0; + } + } + texture.needsUpdate = true; + } + + function setField(field: RadarField | null): void { + const read = readable(field); + if (read === null) { + detach(); + return; + } + + const next = paint(read); + const sameShape = + held !== null && held.rows === read.rows && held.cols === read.cols; + + previous = sameShape && current !== null ? current : null; + current = next.bytes; + wet = next.wet; + blend = previous === null || crossfade === 0 ? 1 : 0; + held = read; + + if (texture === null || !sameShape) { + releaseTexture(); + texture = new THREE.DataTexture( + new Uint8Array(read.rows * read.cols * 4), + read.cols, + read.rows, + THREE.RGBAFormat, + THREE.UnsignedByteType, + ); + // Linear, deliberately. A 27.8 km cell drawn with hard texel edges claims + // a precision the product does not have; a soft one claims none. + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearFilter; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + texture.colorSpace = THREE.SRGBColorSpace; + texture.generateMipmaps = false; + material.map = texture; + material.needsUpdate = true; + } + + place(read); + compose(); + if (mesh.parent !== group) group.add(mesh); + } + + /** + * Sit the quad over the lattice's own footprint, not over the board's bounds. + * + * The lattice is the product's grid clipped to the board, so its edges are a + * half-cell outside the outermost cell CENTRES. Getting this wrong by a half + * cell puts every echo 14 km from where it was measured, which is invisible + * and wrong. + */ + function place(field: RadarField): void { + const south = field.minLat - field.cellLat / 2; + const north = field.minLat + (field.rows - 0.5) * field.cellLat; + const west = field.minLng - field.cellLng / 2; + const east = field.minLng + (field.cols - 0.5) * field.cellLng; + const [westX, southZ] = world.project(south, west); + const [eastX, northZ] = world.project(north, east); + mesh.scale.set(Math.abs(eastX - westX), 1, Math.abs(southZ - northZ)); + mesh.position.set((westX + eastX) / 2, world.metres(altitudeM), (southZ + northZ) / 2); + } + + return { + group, + + setField, + + setLighting(state: LightingState): void { + // Rain at night is dark, but a data overlay that disappears after sunset + // is a defect rather than a mood. Floored well short of zero. + daylight = PRECIP_NIGHT_OPACITY + (1 - PRECIP_NIGHT_OPACITY) * skyBrightness(state); + material.opacity = daylight; + }, + + tick(dt: number): void { + if (current === null || blend >= 1 || crossfade === 0) return; + if (!Number.isFinite(dt) || dt <= 0) return; + blend = Math.min(1, blend + dt / crossfade); + compose(); + }, + + dispose(): void { + detach(); + geometry.dispose(); + material.dispose(); + }, + + wetTexels: () => wet, + drawing: () => mesh.parent === group, + }; +} + +// ---- Painting ------------------------------------------------------------- + +/** + * One field as RGBA bytes, row-major from the south-west cell centre. + * + * Row 0 is the southern row and lands at `v = 0`, which is the bottom edge of + * the plane; after the geometry's `rotateX(-PI/2)` the plane's `-Y` edge is the + * one at `+Z`, and `+Z` is south because `World.project` negates latitude. The + * mapping is therefore the identity and needs no flip — which is worth stating, + * because a texture that is upside down over a state this shape looks plausible. + */ +export function paint(field: RadarField): { bytes: Uint8Array; wet: number } { + const bytes = new Uint8Array(field.rows * field.cols * 4); + let wet = 0; + for (let i = 0; i < field.dbz.length; i++) { + const value = field.dbz[i]; + const at = i * 4; + if (value === null || value === undefined || !Number.isFinite(value)) { + write(bytes, at, PRECIP_UNKNOWN_RGB, PRECIP_UNKNOWN_ALPHA); + continue; + } + if (value < RADAR_RAIN_DBZ) continue; // zeroed already: dry is nothing at all + write(bytes, at, radarRampRgb(value), alphaForDbz(value)); + wet += 1; + } + return { bytes, wet }; +} + +/** + * One texel, **premultiplied**. + * + * The colour is scaled by its own alpha before it is written, which is what the + * material is told to expect. Without it, linear filtering across the long + * interpolation between a 50 dBZ texel and a transparent one walks the colour + * down to black rather than fading it out, and every echo on the board gets a + * dark halo and the clipped edge of the lattice gets a hard dark line. That was + * visible in the first frame this layer ever produced and invisible in every + * test. + */ +function write( + bytes: Uint8Array, + at: number, + rgb: readonly [number, number, number], + alpha: number, +): void { + const a = clamp01(alpha); + bytes[at] = Math.round(rgb[0] * a); + bytes[at + 1] = Math.round(rgb[1] * a); + bytes[at + 2] = Math.round(rgb[2] * a); + bytes[at + 3] = Math.round(a * 255); +} + +/** The alpha ramp, exported so a test can argue with the curve rather than the pixels. */ +export function alphaForDbz(dbz: number): number { + if (!Number.isFinite(dbz) || dbz < RADAR_RAIN_DBZ) return 0; + const t = clamp01((dbz - RADAR_RAIN_DBZ) / (PRECIP_ALPHA_FULL_DBZ - RADAR_RAIN_DBZ)); + return PRECIP_ALPHA_MIN + (PRECIP_ALPHA_MAX - PRECIP_ALPHA_MIN) * Math.pow(t, PRECIP_ALPHA_CURVE); +} + +/** + * A field this layer will draw, or `null`. + * + * Total by design: the consumer is a render loop, so a body from a server one + * version ahead, a `dbz` array of the wrong length, or a field with nothing wet + * in it all produce an empty sky rather than a throw or a rotated raster. + */ +function readable(field: RadarField | null | undefined): RadarField | null { + if (field === null || field === undefined || typeof field !== "object") return null; + const { rows, cols, dbz } = field; + if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) return null; + if (!Number.isFinite(field.cellLat) || !Number.isFinite(field.cellLng)) return null; + if (field.cellLat <= 0 || field.cellLng <= 0) return null; + if (!Number.isFinite(field.minLat) || !Number.isFinite(field.minLng)) return null; + if (!Array.isArray(dbz) || dbz.length !== rows * cols) return null; + for (const value of dbz) { + if (typeof value === "number" && Number.isFinite(value) && value >= RADAR_RAIN_DBZ) return field; + } + // Nothing wet. Not an error and not a fault — it is nine days in ten. + return null; +} + +/** + * How bright the sky is, 0 at midnight and 1 in daylight, from the rig alone. + * + * **Not `hemisphere.intensity`, which is the trap here.** It measures 1.33 at + * 21 degrees below the horizon and 0.95 at noon — it goes *up* at night, because + * `atmosphere.ts` compensates a moonlit scene by raising the fill. A layer that + * read it as a day/night signal would run at full strength in the dark and dim + * at midday, and it would pass a test written against invented numbers. Measured + * on the real rig before this was written: `sky.top` is 0x0d1730 at 04:35Z and + * 0x77a1cb at 20:00Z, which is the honest signal and the one the eye reads too. + * + * `PrecipLayer` has no `setSolarElevation` — unlike the migration field, whose + * subject *is* the night — so the rig is the only clock it gets. + */ +function skyBrightness(state: LightingState): number { + const packed = state.sky?.top ?? state.hemisphere?.sky ?? 0xffffff; + const r = ((packed >> 16) & 0xff) / 255; + const g = ((packed >> 8) & 0xff) / 255; + const b = (packed & 0xff) / 255; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return clamp01((luminance - SKY_NIGHT_LUMINANCE) / (SKY_DAY_LUMINANCE - SKY_NIGHT_LUMINANCE)); +} + +/** Measured off the shipped rig: 0x0d1730 at 04:35Z, 0x77a1cb at 20:00Z. */ +const SKY_NIGHT_LUMINANCE = 0.09; +const SKY_DAY_LUMINANCE = 0.58; + +function clamp01(value: number): number { + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; +} diff --git a/src/engine/scene.ts b/src/engine/scene.ts index c71fa8a..c514522 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -34,7 +34,7 @@ import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { solarPosition, sunDirection } from "./solar.ts"; -import { nightFactor } from "./atmosphere.ts"; +import { nightFactor, type AerialFog } from "./atmosphere.ts"; import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts"; import { createSatelliteLayer, @@ -63,9 +63,12 @@ import type { City, FlightSource, LightingState, + MigrationField, Marker, MarkerPalette, + RadarField, ScenePalette, + Vessel, } from "./types.ts"; import { World, type FieldProgress } from "./world.ts"; import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts"; @@ -194,6 +197,8 @@ export interface FireLayer { /** Draw the plumes, or do not. The marks stay either way. */ setSmokeVisible(visible: boolean): void; setLighting(state: LightingState): void; + /** The fog distances alone. See `SceneHandle.setAerialFog`. */ + setFogDistances(near: number, far: number): void; setSolarElevation(degrees: number): void; /** Wind as the observation reports it: km/h, and the bearing it blows *from*. */ setWind(kph: number | null, fromDeg: number | null): void; @@ -212,6 +217,107 @@ export type FireLayerFactory = ( options: { span: number }, ) => FireLayer; +/** + * The four layers this build added, and the one shape they all share. + * + * Every one of them is a **factory in `SceneOptions`, not a layer**, and every + * factory has exactly `FireLayerFactory`'s signature — `(world, { span })`. That + * is deliberate to the point of being copied rather than generalised. The layer + * needs the `World` that `createScene` is in the middle of building, so it cannot + * be handed in already made; and it is sized from the board rather than from a + * constant, so it needs the span. Absent costs exactly nothing: no geometry, no + * material, no draw call, and the corresponding setter becomes a no-op. That is + * not an optimisation, it is the empty state — a quiet day is the commonest + * correct answer all four of these will ever give, and a layer that is not + * visited at all is cheaper and more honest than one drawing nothing. + * + * `scene.ts` declares the minimum a *renderer* needs and imports none of the + * modules that implement them, exactly as it does for `FireLayer`. The engine + * never takes a dependency on a wire module, and neither side has to exist for + * the other to compile. + */ +export interface PortLayer { + group: THREE.Object3D; + setLighting(state: LightingState): void; + dispose(): void; +} + +export type PortLayerFactory = ( + world: World, + options: { span: number }, +) => PortLayer; + +/** + * Hulls and their wakes. + * + * `setVessels(null)` and `setVessels([])` are the same picture and a different + * sentence, the same distinction `setFires` draws: `null` is "nothing has + * answered", an empty array is "the feed answered and nothing is on this board". + * + * `tick` exists because a ship under way is dead-reckoned **along its reported + * course at its reported speed** between fixes, and its wake is advanced with + * it. It is never a spline between two fixes: upstream listens for thirty + * seconds every fifteen minutes, and the chord between two samples is not a path + * anything took. + */ +export interface VesselLayer { + group: THREE.Object3D; + setVessels(vessels: readonly Vessel[] | null): void; + setLighting(state: LightingState): void; + tick(dt: number): void; + dispose(): void; +} + +export type VesselLayerFactory = ( + world: World, + options: { span: number }, +) => VesselLayer; + +/** + * The reflectivity sheet: one quad at cloud base, one `DataTexture`. + * + * Built only on a board coarse enough to carry a 0.25-degree cell. 27.8 km is a + * quarter of the SoCal board and a third of the Bay Area board, so on the fine + * boards the honest answer is to build no layer at all rather than to draw four + * texels over a city. + */ +export interface PrecipLayer { + group: THREE.Object3D; + /** Replace the raster. `null` clears it — nothing has answered yet. */ + setField(field: RadarField | null): void; + setLighting(state: LightingState): void; + /** Crossfades between the two newest frames across the ten-minute step. */ + tick(dt: number): void; + dispose(): void; +} + +export type PrecipLayerFactory = ( + world: World, + options: { span: number }, +) => PrecipLayer; + +/** + * Nocturnal migration as one `THREE.Points` drift field. + * + * `setSolarElevation` rather than a visibility flag, because the layer's own + * subject is nocturnal: BirdCast measures only after dark, and whether anything + * is aloft is a fact about the sun, not a preference. The same seam + * `nightlights.ts` and `FireLayer` already take. + */ +export interface MigrationLayer { + group: THREE.Object3D; + setField(field: MigrationField | null): void; + setLighting(state: LightingState): void; + setSolarElevation(degrees: number): void; + tick(dt: number): void; + dispose(): void; +} + +export type MigrationLayerFactory = ( + world: World, + options: { span: number }, +) => MigrationLayer; + /** * Whether this visitor has asked the platform for less movement. * @@ -272,6 +378,21 @@ export interface SceneOptions { * no material, no draw call, and `setFires` becomes a no-op. */ fires?: FireLayerFactory; + /** + * How to build this board's port kit, or nothing at all. + * + * The ports themselves are `city.ports` and are plain authored data; this is + * only the renderer for them, kept optional and injected for the same reason + * `fires` is — so a board with no port allocates nothing and so `engine/ports.ts` + * is never in the import graph of a build that does not draw one. + */ + ports?: PortLayerFactory; + /** How to build this board's vessel layer, or nothing at all. */ + vessels?: VesselLayerFactory; + /** How to build this board's reflectivity sheet, or nothing at all. */ + precip?: PrecipLayerFactory; + /** How to build this board's migration field, or nothing at all. */ + migration?: MigrationLayerFactory; /** Fires on hover/click of a marker head. */ onMarkerPick?: (marker: Marker | null) => void; /** @@ -356,6 +477,43 @@ export interface SceneHandle { arrive(): void; /** Applies a rig computed elsewhere. The scene never works one out itself. */ setLighting(state: LightingState): void; + /** + * Move the fog planes because the **camera** moved, without touching the light. + * + * ## Why this is a second setter and must stay one + * + * Aerial perspective is the one term in the rig that depends on where the + * camera is standing, and the camera moves in a completely different tempo + * from the sky: `main.ts` recomputes the sun once a minute and recomputes the + * fog on every orbit step past a 2% altitude threshold — a few dozen times in + * one drag. Before this seam existed both went through `setLighting`, so a + * gesture that changed two floats also re-pushed the sun, the hemisphere, the + * ambient, the sky dome, the moon and the fog colour into six layers, dirtied + * a `MeshLambertMaterial` in `ports.ts`, rebuilt the vessel wake instances, + * and re-fingerprinted the PMREM environment in `environmentRig.ts`. None of + * those read a distance. Measured over a dolly plus a drag, that path costs + * 0.14-0.19 ms a step against 0.02 ms for this one. + * + * ## Why it carries no colour, which is the load-bearing half + * + * `environmentRig.ts` decides whether to re-render and re-convolve the sky + * cubemap by fingerprinting the rig's **colours**, `sky.horizon` among them, + * and `interiors/daylight.ts` pins that horizon stop to the fog colour on + * purpose — it is what hides the seam where the sky dome meets the haze. So a + * camera-dependent *colour* would put a PMREM rebuild on every orbit step, + * and the fix for that would not be to coarsen the fingerprint: quantising + * harder hides one instance and leaves the mechanism armed for the next + * feature that varies a colour. Keeping this setter to two distances is the + * structural answer instead, and `atmosphere.ts`'s `AerialFog` is the type + * that states it. Anything that genuinely changes the light — the sun, the + * sky, the weather, the hour — goes through `setLighting` on the clock, and + * `Atmosphere` stays the sole owner of both (CONTRACT.md §4). + * + * Reaches exactly the three things that draw fog: `scene.fog`, the cloud + * deck's own uniforms, and the fire marks and plumes. Everything else in the + * board is a stock material and reads `scene.fog` for free. + */ + setAerialFog(fog: AerialFog): void; /** * Solar elevation in degrees, for the layers that need the sun's position * rather than the rig it implies. `LightingState` deliberately carries no @@ -388,6 +546,49 @@ export interface SceneHandle { setFires(view: FireView | null): void; /** Draw the smoke plumes, or do not. The marks are unaffected. */ setFireSmoke(visible: boolean): void; + /** + * The ships this board should be drawing, or `null` for none. + * + * The same two-valued emptiness `setFires` has: `null` is "nothing has + * answered", `[]` is "the feed answered and this board is empty". A no-op on a + * build with no vessel layer. + */ + setVessels(vessels: readonly Vessel[] | null): void; + /** + * How high the camera is above the ground it is looking at, in metres. + * + * The one number the light rig needs that a clock cannot supply. + * `Atmosphere.apply` takes it as an optional second argument and turns it into + * aerial perspective; see `aerialReach` there for why the invariant is a + * fraction of the board's authored reach rather than a distance in metres. + * + * Metres and not scene units, deliberately, and it is the *scene* that + * converts because the scene is the only thing that knows this board's + * `metresPerUnit` and its vertical exaggeration. A caller handed units would + * have to know both, which is how the engine's scale would leak into the app + * for the third time. + * + * Measured to the controls' target rather than to the terrain directly under + * the camera: the target is what the shot is *of*, and a camera looking across + * a valley from over a ridge is not two kilometres up merely because there + * happens to be a mountain beneath it. + */ + cameraAltitudeMetres(): number; + /** + * How far the camera is from what it is looking at, in metres. + * + * The other half of what aerial perspective needs, and the half a physical + * model does not think it needs. A map is looked at from outside the + * atmosphere it depicts — the state board's chapters sit two hundred + * kilometres from their own subjects — so the fog has to clear the shot as + * well as follow the air. `atmosphere.ts` floors one against the other; see + * `AERIAL_SUBJECT_CLEARANCE`. + */ + cameraStandoffMetres(): number; + /** The reflectivity raster, or `null`. A no-op without a precipitation layer. */ + setPrecip(field: RadarField | null): void; + /** Tonight's migration, or `null`. A no-op without a migration layer. */ + setMigration(field: MigrationField | null): void; /** * 0..1 smoke load at a coordinate — how much of the drawn fire set is * upwind of it and near enough to matter. Zero with no fire layer. @@ -642,6 +843,48 @@ export async function createScene( scene.add(fireLayer.group); } + /** + * The port kit, the ships, the rain and the birds — each one built exactly the + * way the fire layer above it is, and each one absent by default. + * + * Order is not arbitrary. Ports are ground and go under everything; hulls sit + * on the sea beside the quays the ports just drew; the reflectivity sheet + * hangs at cloud base and must sort against the clouds built above it; the + * migration motes are the last thing in and the highest, so they draw over the + * sheet rather than through it. + */ + const portLayer: PortLayer | null = options.ports + ? options.ports(world, { span: boardSpan }) + : null; + if (portLayer) { + portLayer.setLighting(opening); + scene.add(portLayer.group); + } + + const vesselLayer: VesselLayer | null = options.vessels + ? options.vessels(world, { span: boardSpan }) + : null; + if (vesselLayer) { + vesselLayer.setLighting(opening); + scene.add(vesselLayer.group); + } + + const precipLayer: PrecipLayer | null = options.precip + ? options.precip(world, { span: boardSpan }) + : null; + if (precipLayer) { + precipLayer.setLighting(opening); + scene.add(precipLayer.group); + } + + const migrationLayer: MigrationLayer | null = options.migration + ? options.migration(world, { span: boardSpan }) + : null; + if (migrationLayer) { + migrationLayer.setLighting(opening); + scene.add(migrationLayer.group); + } + const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {}); markerLayer.setMarkers(options.markers ?? []); scene.add(markerLayer.group); @@ -954,6 +1197,9 @@ export async function createScene( roadTraffic?.tick(dt); clouds.tick(dt); fireLayer?.tick(dt); + vesselLayer?.tick(dt); + precipLayer?.tick(dt); + migrationLayer?.tick(dt); /** * The one number the aeroplane glyph clamp cannot reach on its own. * @@ -1044,6 +1290,10 @@ export async function createScene( // drew and only it can free. clouds.dispose(); fireLayer?.dispose(); + portLayer?.dispose(); + vesselLayer?.dispose(); + precipLayer?.dispose(); + migrationLayer?.dispose(); nightLights.dispose(); markerLayer.dispose(); roadTraffic?.dispose(); @@ -1081,6 +1331,10 @@ export async function createScene( kit.applyLighting(state); clouds.setLighting(state); fireLayer?.setLighting(state); + portLayer?.setLighting(state); + vesselLayer?.setLighting(state); + precipLayer?.setLighting(state); + migrationLayer?.setLighting(state); /** * Every lighting change, and it is cheap to do it every one. * @@ -1093,6 +1347,14 @@ export async function createScene( */ options.environment?.apply(scene, state, "city"); }, + setAerialFog: ({ near, far }) => { + // The three fog owners on a city board and no more. `ports`, `vessels`, + // `precip`, `migration` and every building on the board are stock + // materials lit by `scene.fog`, which `kit` has just moved for them. + kit.setFogDistances(near, far); + clouds.setFogDistances(near, far); + fireLayer?.setFogDistances(near, far); + }, setCloudCover: (fraction) => clouds.setCover(fraction), setWind: (kph, fromDeg) => { clouds.setWind(kph, fromDeg); @@ -1104,9 +1366,29 @@ export async function createScene( setSolarElevation: (degrees) => { nightLights.setSolarElevation(degrees); fireLayer?.setSolarElevation(degrees); + migrationLayer?.setSolarElevation(degrees); }, setFires: (view) => fireLayer?.setFires(view), setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible), + cameraStandoffMetres() { + // `metresPerUnit` and NOT `unitsToMetres`, which is the vertical + // conversion and divides the exaggeration back out. A stand-off is a + // distance across the board, and the board's horizontal scale is honest — + // only its height is stretched. Dividing by 15 here would have reported + // every pose as fifteen times closer than it is. + return Math.max(0, kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit); + }, + cameraAltitudeMetres() { + // Vertical exaggeration divides back out: the ground under the target is + // drawn `exaggeration` times too high, so a camera fifty units above it is + // not fifty units of real air above it. `world.unitsToMetres` is the inverse of + // the same conversion `world.metres` applied on the way in. + const above = kit.camera.position.y - kit.controls.target.y; + return Math.max(0, world.unitsToMetres(above)); + }, + setVessels: (vessels) => vesselLayer?.setVessels(vessels), + setPrecip: (field) => precipLayer?.setField(field), + setMigration: (field) => migrationLayer?.setField(field), fireSmokeLoadAt: (lat, lng) => fireLayer?.smokeLoadAt(lat, lng) ?? 0, setSkyInstant: (when) => { skyOverride = when; diff --git a/src/engine/scenekit.ts b/src/engine/scenekit.ts index 341a48e..52e1cb4 100644 --- a/src/engine/scenekit.ts +++ b/src/engine/scenekit.ts @@ -150,6 +150,16 @@ export interface SceneKit { hemisphere: THREE.HemisphereLight; ambient: THREE.AmbientLight; applyLighting(state: LightingState): void; + /** + * Move the fog planes and nothing else. See `SceneHandle.setAerialFog`. + * + * A no-op before the first `applyLighting`, deliberately: the fog's *colour* + * is the rig's and this call does not carry one, so inventing a `THREE.Fog` + * here would have to invent a colour to put in it. The rig arrives within the + * frame either way — `createScene` applies one while it is still building — + * and it brings the real distances with it. + */ + setFogDistances(near: number, far: number): void; /** Jump. Used for the opening pose, where a flight from nowhere is nonsense. */ setPose(pose: Pose): void; flyTo(pose: Pose): void; @@ -430,6 +440,21 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { } } + /** + * The camera-dependent half of `applyLighting`, on its own. + * + * Two assignments, no colour, no lights, no background. Everything else in + * `applyLighting` is a consequence of the sun and the sky, which do not change + * because somebody dragged the board — see `SceneHandle.setAerialFog` for the + * argument and `atmosphere.ts`'s `AerialFog` for why this may never grow a + * third parameter. + */ + function setFogDistances(near: number, far: number) { + if (!(scene.fog instanceof THREE.Fog)) return; + scene.fog.near = near; + scene.fog.far = far; + } + // ---- Camera flights ----------------------------------------------------- const from: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() }; @@ -619,6 +644,7 @@ export function createSceneKit(options: SceneKitOptions): SceneKit { hemisphere, ambient, applyLighting, + setFogDistances, setPose, flyTo, flying: () => flying, diff --git a/src/engine/types.ts b/src/engine/types.ts index cdeffde..c600d0a 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -213,6 +213,208 @@ export interface Airport { tower?: Tower; } +// ---- Ports ---------------------------------------------------------------- +// +// Plain data, JSON-serialisable, no functions and no THREE types, for exactly +// the reason the airport block above gives: a city pack is posted to the +// terrain worker as a structured clone, and `barrel.test.ts` reads the import +// graph as source. A port record that acquired a method would stop the pack +// being sendable; a port type that lived in `engine/ports.ts` would put three.js +// on the package surface for anyone who wanted to name a berth. +// +// The whole of this block is authored by hand. `ports.sqlite` has a `ports` +// table with seven rows and it is not usable for placement: every row sits on an +// exact arc-minute grid, up to 1,852 m from the water it claims to be, and +// `channel_depth_ft` is a binned WPI code that says Los Angeles is fourteen feet +// deep. The figures on `PortThroughput` are the half of that store that *is* +// worth having. + +/** + * The quay wall itself: a slab of stone with a vertical face down to the water. + * + * A polygon rather than a centreline and a width, which is the opposite of how + * `Road` and `Bridge` are authored, and the reason is that a container terminal + * is not a ribbon — it is a corner of reclaimed land with slips cut into it, and + * the shape of that corner is what the eye reads as a port from a hundred + * kilometres up. `deckHeight` is metres above chart datum, so the kit can drop a + * skirt from the deck to the waterline rather than floating the slab. + */ +export interface Quay { + id: string; + polygon: LatLng[]; + /** Metres above the waterline. Around 4 m on a modern container quay. */ + deckHeight: number; +} + +/** + * One place a ship ties up, and the only thing that knows which way it faces. + * + * **`bearing` is authored here rather than read off the wire, and that is not a + * shortcut.** Of 150 vessels in the store whose latest fix is under half a knot, + * only 75 report a usable heading and 21 report neither heading nor course; a + * layer that oriented moored hulls from AIS would leave a third of a harbour + * pointing due north in a row. A berth, by contrast, has one answer and it never + * changes: a hull alongside is parallel to the wall it is alongside. + * + * `maxLength` is the length class, in metres, and it does two jobs — it is what + * lets a berth refuse a hull it could not physically take, and it is the scale + * the kit uses when it has to draw a berth with nothing in it. + */ +export interface Berth { + id: string; + lat: number; + lng: number; + /** TRUE bearing of the bow of a hull lying alongside, degrees clockwise from north. */ + bearing: number; + /** Longest vessel this berth takes, metres. */ + maxLength: number; + /** The quay this berth is cut into, when it is on one. */ + quayId?: string; +} + +/** + * A row of ship-to-shore gantries, as a count and a line. + * + * A row and not a crane, because that is how they are built and because it is + * what keeps the instancing honest: eighteen gantries on Pier 400 are one record + * and eighteen matrices in the board's single crane `InstancedMesh`, never + * eighteen `Group`s. Vertical exaggeration is what makes this the highest + * legibility-per-triangle object in the whole feature — at SoCal's 3.4x a 130 m + * gantry stands 1.13 scene units while a 400 m ship is 1.02 units long, so the + * crane is taller than the ship is long. + * + * `idle` is free storytelling and costs one number: a boom down over a berth + * reads as working and a boom raised to the vertical reads as idle, and it is a + * per-instance rotation either way. + */ +export interface Crane { + id?: string; + /** Start of the rail. */ + from: LatLng; + /** End of the rail. Gantries are spread evenly between the two. */ + to: LatLng; + /** How many gantries stand on this rail. */ + count: number; + /** TRUE bearing the boom points along when it is down, degrees from north. */ + bearing: number; + /** Rail to the top of the portal beam, metres. ~82 m; ~130 m to the raised boom tip. */ + height: number; + /** How far the boom reaches over the water, metres. ~70 m for a post-panamax gantry. */ + outreach: number; + /** + * Fraction of this row with the boom raised, 0..1. Absent means none of it — + * every crane working, which is the picture a port at rest does *not* have. + */ + idleFraction?: number; +} + +/** + * A container yard: a rectangle of stacked boxes, drawn as **pixels**. + * + * This is the counterintuitive call in the whole kit and the arithmetic belongs + * next to the type or it will be "improved" into instances by the next reader. A + * 40-foot container is 12.2 m long, which at SoCal's 390.6 m per scene unit is + * **0.0312 units** — sub-pixel from every camera pose the board allows, and + * there are tens of thousands of them. A yard is therefore one ground quad + * textured from a canvas atlas banded per terminal, exactly the way + * `markingsAtlas` bands runway paint, and the loaded/empty proportion is drawn + * into those pixels rather than counted in geometry. + */ +export interface Yard { + id?: string; + lat: number; + lng: number; + /** Along `bearing`, metres. */ + length: number; + /** Across `bearing`, metres. */ + width: number; + /** TRUE bearing of the long axis — the direction the container rows run. */ + bearing: number; + /** + * Share of the boxes stacked here that are empties, 0..1, for the atlas to + * draw in the empty palette at its true proportion. Absent means unknown, and + * unknown is drawn as one colour rather than as a guessed mix. + */ + emptyShare?: number; +} + +/** + * What went across a quay in a month, and how much it cost to send it. + * + * The honest answer to "are they empty or full", and it is a fact about a + * **port**, not about a ship. There is no draught column in `vessels` and the + * AIS static message that would carry one is absent for most hulls most of the + * time, so a per-hull laden state would be an invention of exactly the kind the + * fire layer nearly shipped. This is the same question answered from data that + * exists: 348,691 of 460,467 boxes left Los Angeles **empty** in July 2026. + * + * `asOf` is a month, `YYYY-MM`, and it is required. A throughput figure with no + * month on it is a claim about now, and this one is never about now. + */ +export interface PortThroughput { + /** The month these counts describe, `YYYY-MM`. Never "now", never a fetch time. */ + asOf: string; + loadedExport: number; + emptyExport: number; + loadedImport: number; + emptyImport: number; +} + +/** + * A freight index on one lane, in dollars per forty-foot equivalent. + * + * Carried beside the throughput because it is the *explanation* for it: FBX01 + * China-to-US-West-Coast at $7,491 against FBX02 back the other way at $347 is + * why three quarters of the boxes leaving Los Angeles have nothing in them. + * + * There is deliberately no observation timestamp. The store's `observed_at` is + * our own read clock — Freightos publishes none — and a card that renders it as + * "as of" is lying about precision. + */ +export interface PortFreightRate { + /** The published index code: `FBX01`. An id, not a label. */ + id: string; + /** What the lane is, in words: "China / East Asia to North America West Coast". */ + lane: string; + usdPerFeu: number; +} + +/** + * A working port, as the board draws it. + * + * `harborType` decides the single biggest object on the plate and is therefore + * not decoration: `CB` is a coastal breakwater harbour and `CN` is a coastal + * natural one. San Pedro and Long Beach are `CB` and share the thirteen and a + * half kilometres of federal breakwater that turns a bight into a harbour from a + * hundred kilometres up; Oakland is `CN` and must never be given one. + */ +export interface Port { + /** UN/LOCODE where there is one — `"USLAX"`. Only ever an id. */ + id: string; + name: string; + /** + * A hand-traced anchor for the plate and the card, **not** a row out of + * `ports.sqlite`. See the note at the head of this block. + */ + lat: number; + lng: number; + /** `CB` coastal breakwater, `CN` coastal natural, `RN` river natural. */ + harborType: "CB" | "CN" | "RN"; + /** + * The breakwater, as one continuous path per arm. Absent on a natural + * harbour, and absence is the correct picture there rather than a gap. + */ + breakwater?: LatLng[][]; + /** The dredged channel centreline, drawn as a darker strip on the sea. */ + channel?: LatLng[]; + quays?: Quay[]; + berths?: Berth[]; + cranes?: Crane[]; + yards?: Yard[]; + throughput?: PortThroughput; + rates?: PortFreightRate[]; +} + export interface View { id: string; label: string; @@ -302,6 +504,13 @@ export interface City { */ airports?: Airport[]; + /** + * Working ports, drawn by `engine/ports.ts` as quays, breakwaters, yards and + * crane rows. Optional for the same reason `airports` is: a pack with no port + * should not have to say so, and every pack predates it. + */ + ports?: Port[]; + /** Palette overrides; every field is optional. */ palette?: Partial; } @@ -599,3 +808,226 @@ export type SatelliteGroup = | "station" | "weather" | "other"; + +// ---- Vessels -------------------------------------------------------------- + +/** + * What kind of hull this is, as far as anyone looking down at it cares. + * + * A **display bucket and not a taxonomy**, the same argument `SatelliteGroup` + * makes: the geometry factory switches on this and nothing else reads it. It is + * an enumerated union rather than a hardcoded builder for a specific reason — + * better hull geometry is being modelled separately, and a new case here is how + * it arrives, rather than a rewrite of the instancing path. + */ +export type VesselKind = + | "container" + | "tanker" + | "bulk" + | "vehicle-carrier" + | "tug" + | "ferry" + | "fishing" + | "other"; + +/** + * What the hull is doing, from AIS `nav_status`. + * + * **This labels a card and never gates motion.** 83 of 197 vessels claiming + * "under way using engine" in the store are sitting still; speed over ground is + * the only thing that knows whether something is moving. Kept as a small union + * rather than the raw 0-15 code because fifteen of those sixteen values are + * distinctions no renderer can draw. + */ +export type VesselStatus = "under-way" | "at-anchor" | "moored" | "unknown"; + +/** + * One hull on the board. + * + * ## What is deliberately absent + * + * **There is no name and no MMSI**, and their absence is the contract rather + * than an omission. A layer that hardcoded the hulls sitting in the store right + * now would be the fire layer's twenty-two orange marks in a nicer costume: + * plausible, specific, and a claim about a named commercial vessel that this + * deployment has no licensed feed behind. Identity arrives with a licensed live + * feed and a written licence entry, or it does not arrive. + * + * **There is no laden state.** `vessels` carries no draught column, the AIS + * static message that would carry one is absent for most hulls most of the time, + * and "empty or full" is answered at the port instead — see `PortThroughput`. + * `draught` below is the *hull's* depth as a scale for the geometry, authored + * with the vessel, and it is never an observation and never a cargo claim. + * + * ## Scale + * + * Sizes are true metres and are drawn true. That **inverts** the deliberate + * oversizing in `aircraftGeometry.ts`, where a map symbol is held at 0.42 units + * whatever the board, and the reason for the inversion is that a ship is almost + * always alongside a quay drawn at true scale while an aeroplane is alone in the + * sky with nothing to be wrong against. 400 m is 1.024 units on SoCal and 4.24 + * on the Bay. + */ +export interface Vessel { + /** Stable for the life of a fix set. An opaque token, never an MMSI. */ + id: string; + kind: VesselKind; + lat: number; + lng: number; + /** + * Which way the bow points, degrees clockwise from TRUE north. + * + * Already resolved by the gate, and that is the point of it being non-null: a + * moored hull takes its berth's authored bearing, a moving one takes its + * course over ground, and the caller never has to know which. See + * `Berth.bearing`. + */ + bearing: number; + /** Overall, metres. */ + length: number; + /** Metres. */ + beam: number; + /** Hull depth for the geometry, metres. Authored, never observed, never cargo. */ + draught?: number; + /** + * Metres per second over the ground. + * + * Past the gate this is a real speed or zero, never a sentinel: AIS reports + * 102.3 knots for "not available", which dead-reckons a hull 3.2 km — eight + * SoCal units — in sixty seconds. The sentinel is stripped upstream; it is + * never NULL, so a null-check catches nothing. + */ + speed: number; + /** + * Course over ground, degrees clockwise from TRUE north, or `null` when the + * source did not report a usable one. + * + * **`cog % 360` is a booby trap and it will look correct.** Real course + * reaches 358.7 and the "not available" sentinel is exactly 360.0, so the + * obvious normalisation turns every unknown course into due north and the + * whole fleet quietly faces the same way with no test that notices. + */ + course: number | null; + /** What it says it is doing. Labels the card; never gates the motion. */ + status: VesselStatus; + /** The berth it is lying alongside, when it is lying alongside one. */ + berthId?: string; + /** + * How old the fix already was when the source handed it over, in seconds — + * the same field and the same reading as `Aircraft.ageSeconds`. + * + * It matters more here. `vessel_pos` is a thirty-second listen every fifteen + * minutes, so a hull under way has moved about five kilometres between + * samples. Dead-reckoning along the reported course from a fix whose age is + * known is honest; splining between two fixes is not, and no code path may + * produce a position on the chord between them. + */ + ageSeconds?: number; +} + +// ---- The sky: radar and migration ----------------------------------------- + +/** + * One statewide reflectivity raster, as a regular lattice. + * + * A lattice rather than a list of cells, because `echo_cells` turns out to be a + * regular 0.25-degree grid and saying so in the type is what makes the layer one + * quad and one `DataTexture` instead of geometry. On the extended state board + * the whole of California is about 41x40 = 1,640 texels, roughly 6.5 KB as + * RGBA. + * + * **`null` in `dbz` is not zero.** A cell inside the coverage radius of a + * station whose RDA is inoperable is *unknown*, and a hole drawn as a hole is + * the difference between "nothing is falling there" and "nobody is looking + * there". That distinction is the whole reason this array is nullable rather + * than a `Float32Array`, and it is worth the boxing. + */ +export interface RadarField { + /** Centre of the south-west cell. */ + minLat: number; + minLng: number; + /** Cell pitch in degrees. 0.25 for the NEXRAD composite — about 27.8 km. */ + cellLat: number; + cellLng: number; + /** Lattice size. `dbz.length` must equal `rows * cols`. */ + rows: number; + cols: number; + /** Reflectivity in dBZ, row-major from the south-west. `null` where unknown. */ + dbz: (number | null)[]; + /** ISO-8601 of the volume scan these cells came from. */ + observedAt: string; + /** Fraction of the lattice at or above the draw threshold, 0..1. */ + wetFraction: number; + /** Radars that answered, and radars whose RDA is down. For the panel. */ + stations: number; + stationsDown: number; +} + +/** Nocturnal migration over one county, for one ten-minute granule. */ +export interface MigrationCounty { + /** FIPS or slug. An id; the layer never renders it. */ + id: string; + name: string; + /** The county's internal point — a point known to be inside it. */ + lat: number; + lng: number; + /** + * True county area, km². The motes scatter in a **disc** of this area, and a + * disc rather than a polygon because the data has no structure finer than a + * county and an outline would claim one it does not have. + */ + areaKm2: number; + /** Birds aloft over this county at `MigrationField.observedAt`. */ + aloft: number; + /** Mean flight altitude above ground, metres. Statewide mean is about 726. */ + altitude: number; + /** Direction of travel — the bearing they are heading TOWARD, degrees true. */ + direction: number; + /** Ground speed, metres per second. */ + speed: number; +} + +/** + * Last night, as one sentence's worth of numbers. + * + * `crossed` and `peakAloft` come from the **state row** and never from a sum + * over counties. Summing all 58 gives 2,360,086 against an authoritative + * 393,290 — a 6x error waiting for the first person who writes `SELECT SUM`, + * because a bird crossing four counties is counted four times. + */ +export interface MigrationNight { + crossed: number; + peakAloft: number; + /** ISO-8601 of the peak. */ + peakAt: string; + /** Metres above ground. */ + meanAltitude: number; + /** Where they were going, in words: "south-east". */ + heading: string; +} + +/** Why the sky is empty, when it is — which is most of the time. */ +export interface MigrationQuiet { + reason: "daylight" | "off-season" | "no-data"; + /** One true, specific sentence. Never a blank panel and never "no data". */ + message: string; +} + +/** + * The migration layer's whole input, including an empty one. + * + * **The empty state is the layer**, for most visitors: 168 of 297 granules are + * daytime, BirdCast measures only after dark, and the layer is therefore absent + * about fourteen hours in every twenty-four by construction. `quiet` is + * non-optional for the same reason `FiresBody.fetchedAt` is — an empty + * `counties` array with nothing beside it is indistinguishable from a dead feed. + */ +export interface MigrationField { + counties: MigrationCounty[]; + /** ISO-8601 of the ten-minute granule. */ + observedAt: string; + /** The statewide headline, from the state row. `null` when there is no night yet. */ + statewide: MigrationNight | null; + /** `null` when something is genuinely aloft. Never `null` beside an empty set. */ + quiet: MigrationQuiet | null; +} diff --git a/src/engine/vessels.ts b/src/engine/vessels.ts new file mode 100644 index 0000000..4e87f51 --- /dev/null +++ b/src/engine/vessels.ts @@ -0,0 +1,981 @@ +/** + * Ships, and the wakes that are what you actually see. + * + * ### The wake is the ship at board scale + * + * That sentence is the whole design and it is arithmetic rather than taste. On + * the Southern California board one scene unit is 390.6 m, so a 400 m ultra- + * large container vessel — the biggest thing that comes to Los Angeles — is + * **1.024 units long**. Photographed at the whole-board pose that is about four + * pixels: a hull is not a picture of a ship, it is a fleck. Its wake, at + * 1.5 km, is **3.84 units** and about fourteen pixels, and it reads at once. + * + * So the wake is not decoration on top of the ships. It is the primary object, + * the hull is the thing at the sharp end of it, and the two are budgeted the + * other way round from how they look in a list of features: one `InstancedMesh` + * for every hull on the board, one `LineSegments` for every wake on the board, + * two draw calls total, and the wake gets the vertices. + * + * A moored ship has **no wake at all**, and that absence is information: a wake + * is a function of speed through water, so a quay lined with wakeless hulls and + * one long V curving in past the breakwater is a picture of a working harbour + * on a normal day, drawn from numbers rather than staged. + * + * ### Ships are drawn at TRUE size, which inverts the aircraft rule + * + * `aircraftGeometry.ts` deliberately oversizes: an aeroplane in flight is held + * at 0.42 units on every board, which over SoCal is about four times life size, + * and nobody has ever noticed because an aeroplane is alone in the sky with + * nothing to be wrong against. **A ship is never alone.** It is alongside a quay, + * under a gantry crane and beside a breakwater, every one of them drawn at true + * scale by `ports.ts`, so a hull scaled to be legible would be a hull visibly + * longer than the berth it is lying in. The legibility that oversizing would buy + * is bought by the wake instead, which is free to be as long as the water it + * disturbed. + * + * `vesselScale.test.ts` asserts the truthful numbers — 400 m is 1.024 units on + * SoCal and 4.24 on the Bay — precisely because "make the ships a bit bigger so + * you can see them" is a reasonable-sounding change that would quietly break the + * relationship this layer exists inside. + * + * ### One geometry, one mesh, and the seam for better hulls + * + * Every ship on a board is the same ~40-triangle solid — slab, raked bow, aft + * house, funnel — with **length, beam and depth as per-instance scale**. A 30 m + * tug and a 400 m box ship are the same forty triangles at thirteen times the + * size. Deck detail is a band of a canvas atlas rather than geometry, so a hatch + * run and a pipe rack are the same six triangles with different pixels. + * + * `hullShape(kind)` is the seam for the better geometry the owner is modelling + * separately: it maps a `VesselKind` to a shape id, every kind currently maps to + * `"generic"`, and a real container-ship mesh arrives as a new shape id plus a + * case in `hullGeometry`. The layer builds one `InstancedMesh` per shape that + * has hulls in it, so the draw count is a function of **how many kinds of hull + * geometry exist**, never of how many ships are on the board — the same property + * `airports.ts` gets by merging buckets across airports rather than per airport. + * + * ### What this layer will not do + * + * It will not put a hull anywhere between two observed fixes. Upstream listens + * for thirty seconds every fifteen minutes and a ship makes about five + * kilometres in between, so the chord between two samples is not a path anything + * took. Motion comes from `reckonVessel` in `server/vessels.ts`, which advances + * one fix along its own reported course at its own reported speed and has no + * second fix to reach toward. That is a property of the function's signature + * rather than of this file's discipline. + * + * It also owns no light. `CONTRACT.md` §4 gives the rig to `atmosphere.ts`, and + * a harbour is exactly the tempting exception — floodlights over the yard, deck + * lights, red and green at the breakwater entrance. Those belong on + * `nightlights.ts`'s existing `THREE.Points` cloud, which puts twelve thousand + * street lamps in one draw call; there is no `THREE.Light` anywhere below. + */ + +import * as THREE from "three"; +import type { LightingState, Vessel, VesselKind } from "./types.ts"; +import type { World } from "./world.ts"; + +// ---- Capacities ----------------------------------------------------------- + +/** + * How many hulls the instance buffer holds. + * + * Matches `VESSEL_DRAW_LIMIT` in `src/server/vessels.ts`, which is the cap the + * gate already applies — this is the buffer that assumes it. Not a data claim: + * the busiest box in the store, LA/Long Beach, holds 81 vessels on a typical + * latest fix and San Francisco Bay 82. + */ +export const VESSEL_HULL_CAPACITY = 192; + +/** + * How many wakes are drawn at once. + * + * Lower than the hull capacity on purpose, and the ratio is measured rather than + * guessed: of the 81 hulls in the LA/LB box, 31 are moving and 22 are making way + * at five knots or more; SF Bay is 31 and 27. Sixty-four is comfortably above + * both and is what the buffer below is sized from. + */ +export const WAKE_CAPACITY = 64; + +/** + * Points down each rail of one wake. + * + * The rails are straight, so this buys smoothness of the *fade* rather than of + * the line: sixteen steps is enough that the taper from foam to nothing has no + * visible banding at the closest pose. Sixty-four wakes of five rails of fifteen + * segments is 9,600 vertices, which is 268 KB of position and colour — the whole + * buffer, allocated once, for every wake on the board. + */ +export const WAKE_POINTS = 16; + +/** + * How many rails one wake is drawn from. + * + * WebGL ignores `gl.lineWidth` — every line is one pixel, on every driver — so a + * wake's *width* has to be geometry. Five rails at fractions of the Kelvin + * spread give the wedge a fill rather than an outline, and three of the five are + * real features rather than padding: the two cusp lines at the full angle, which + * are the brightest part of a real wake, and the turbulent centreline the hull + * drags directly astern. The two between them are what stop the V reading as a + * pair of scratches at close range. + */ +export const WAKE_RAILS: readonly number[] = [-1, -0.5, 0, 0.5, 1]; + +/** + * The Kelvin half-angle, degrees. + * + * 19.47° — arcsin(1/3) — and it is a real physical constant rather than a tuned + * one: the wedge of a displacement hull's wake is that angle **regardless of + * speed**, for any hull, in deep water. Speed changes how long the wake is and + * how bright it is; it does not open or close the V. Using the true number costs + * nothing and means the picture is right for a reason. + */ +export const KELVIN_HALF_ANGLE_DEG = 19.47; + +/** + * How many hull lengths of wake a vessel at full speed leaves behind it. + * + * Calibrated against the one number the design work photographed: a 1.5 km wake + * behind a 400 m ship, which is 3.84 units on SoCal and the length at which a + * moving vessel becomes legible from the whole-board pose. Expressed in hull + * lengths so that a 30 m tug gets a 112 m wake instead of a kilometre and a half + * of foam behind something the size of a bus. + */ +export const WAKE_HULL_LENGTHS = 3.75; + +/** The speed at which a hull leaves its full-length wake, m/s. About 12 knots. */ +export const WAKE_FULL_SPEED_MPS = 6.17; + +/** + * The speed below which a hull leaves no wake at all, m/s. Half a knot. + * + * The same threshold `server/vessels.ts` gates motion on, restated here in the + * units the renderer holds, because a hull that is stopped for the purposes of + * dead reckoning and a hull that is stopped for the purposes of foam must be the + * same hull — otherwise a berthed ship trails a stub of wake for ever. + */ +export const WAKE_MIN_SPEED_MPS = 0.257; + +/** Head-of-wake opacity. Foam is bright, and it is still not the brightest thing. */ +const WAKE_ALPHA = 0.62; + +/** + * The sea's surface, in scene units. + * + * `terrain.ts` sets its water plane to `y = -0.06` and does not export the + * number. It is restated here rather than imported because `terrain.ts` is not + * this workstream's file to change, and a hull floating a hair above its own + * reflection is a defect a picture finds instantly: on SoCal 0.06 units is 23 m, + * which at the harbour pose is a ship hovering a building's height over the + * water. If the sea ever moves, this moves with it — and the two are worth + * unifying behind an export the next time anyone owns both files. + */ +const SEA_SURFACE_Y = -0.06; + +/** + * How far above the sea the wake is drawn, in scene units. + * + * Small enough to read as being *on* the water and large enough to stay out of + * the depth buffer's argument with the sea plane, which is the failure mode a + * line lying exactly on a plane always has. 0.04 units is 15.6 m on SoCal and + * 3.8 m on the Bay board — sub-pixel from every pose either board is looked at + * from — and lines take no polygon offset, so height is the only dial there is. + */ +const WAKE_LIFT = 0.04; + +const DEG = Math.PI / 180; + +// ---- Palette -------------------------------------------------------------- + +/** + * Hull colours, per kind, applied as a per-instance tint over one shared atlas. + * + * A tint rather than a material, because a material per kind is a draw call per + * kind and the whole argument of this file is that hulls cost one. `instanceColor` + * multiplies the atlas, so value contrast — a pale deck against a dark side — + * lives in the texture and hue lives here. + * + * These are display buckets and not liveries. There is no attempt to render a + * particular operator's colours, for the same reason there is no name on the + * card: this deployment has no licensed feed behind a claim about a specific + * ship. + */ +export const VESSEL_PALETTE: Readonly> = { + /** + * Box ships are the blue-hulled majority of anything at San Pedro. + * + * Lighter than a real hull, and photographed into being so — twice. A true + * dark navy, multiplied through the atlas and then shaded by a Lambert term + * whose key is a high sun on a vertical topside, came out near-black; at board + * scale a black fleck on blue water reads as a rendering fault rather than as + * a ship. These sit in the same value register as everything else on these + * boards — `AIRPORT_PALETTE.aircraft` is 0xe2e6e9, the block scatter is pale + * grey — because a board is a legibility problem before it is a colour one. + * Value buys legibility; hue keeps the kinds apart. + */ + container: 0x93aac4, + tanker: 0x949da4, + bulk: 0xa39d92, + /** A car carrier is a floating white shoebox and is unmistakable for it. */ + "vehicle-carrier": 0xdadee2, + /** Harbour tugs are the one genuinely bright thing on the water. */ + tug: 0xc2705c, + ferry: 0xe0e5e9, + fishing: 0x9dabb4, + other: 0x8d949b, +}; + +/** Foam, in daylight. Not pure white: pure white has nowhere left to go. */ +const WAKE_COLOR_DAY = 0xe8f0f4; + +// ---- Hull geometry -------------------------------------------------------- + +/** + * Which solid a kind is drawn from. + * + * The seam for the hull assets being modelled separately. Every kind currently + * answers `"generic"`, so a board builds exactly one `InstancedMesh` however + * many kinds are on it; a real container-ship mesh arrives as a new shape id + * here plus a case in `hullGeometry`, and the layer grows a second instanced + * mesh without anything else in this file changing. That is the difference + * between an enum the factory switches on and a hardcoded builder, and it is why + * this function exists despite currently having one answer. + */ +export type HullShape = "generic"; + +export function hullShape(kind: VesselKind): HullShape { + // Every arm answers the same thing today and the switch is still written out, + // because the arms are where the better geometry lands one kind at a time. An + // `if` here would have to be replaced; these are added to. + switch (kind) { + case "container": + case "tanker": + case "bulk": + case "vehicle-carrier": + case "tug": + case "ferry": + case "fishing": + case "other": + return "generic"; + } +} + +/** Every shape a board may need a mesh for. One, today. */ +export const HULL_SHAPES: readonly HullShape[] = ["generic"]; + +/** + * Draught as a fraction of length, per kind. + * + * **Authored, never observed, and never a cargo claim.** `vessels` in the store + * has no draught column at all, the AIS static message that would carry one is + * absent for most hulls most of the time, and "empty or full" is answered at the + * port — see `PortThroughput` and the 75.7% of boxes that leave Los Angeles + * empty. This is a hull dimension used to decide how much of a ship is under the + * water, and nothing downstream may present it as a measurement. + * + * A 400 m box ship at 0.036 is 14.4 m deep, which is what they draw fully laden; + * a tug at 0.13 is 3.9 m, which is what a harbour tug draws. Both are hull + * facts. + */ +const DRAUGHT_RATIO: Readonly> = { + container: 0.036, + tanker: 0.045, + bulk: 0.05, + "vehicle-carrier": 0.045, + tug: 0.13, + ferry: 0.045, + fishing: 0.1, + other: 0.05, +}; + +/** + * Freeboard — waterline to main deck — as a fraction of length. + * + * Bigger than draught for a box ship, which surprises people and is correct: a + * loaded ULCV stands about thirty metres out of the water before you count the + * container stack, and the stack is what makes the silhouette. A car carrier is + * the extreme case and is nearly all freeboard. + */ +const FREEBOARD_RATIO: Readonly> = { + container: 0.075, + tanker: 0.05, + bulk: 0.055, + "vehicle-carrier": 0.14, + tug: 0.17, + ferry: 0.11, + fishing: 0.16, + other: 0.07, +}; + +/** Metres of hull under the waterline. Authored from the length, never observed. */ +export function hullDraughtMetres(vessel: Pick): number { + const authored = vessel.draught; + if (typeof authored === "number" && Number.isFinite(authored) && authored > 0) return authored; + return vessel.length * (DRAUGHT_RATIO[vessel.kind] ?? DRAUGHT_RATIO.other); +} + +/** Metres of hull above the waterline, to the main deck. */ +export function hullFreeboardMetres(vessel: Pick): number { + return vessel.length * (FREEBOARD_RATIO[vessel.kind] ?? FREEBOARD_RATIO.other); +} + +/** + * The unit hull: 1 long, 1 wide, 1 deep from keel to main deck, bow to the + * north at a bearing of zero. + * + * Built as one non-indexed buffer rather than merged from boxes, which is + * deliberate. `mergeGeometries` silently drops a bucket whose attribute sets + * disagree — `airports.ts:43` records that exact scar — and the only way to be + * certain a hull carries position, normal *and* uv is to write all three. + * Non-indexed gives flat facets for free, which is what a slab-sided ship is. + * + * The superstructure deliberately stands **above** y = 1. The unit is the hull + * depth, not the overall height, so scaling by keel-to-deck metres gives a tug a + * tug-sized wheelhouse and a box ship a box ship's, with no second scale to keep + * in step. + * + * Forty triangles: ten for the body, six for the bow wedge, twelve for the aft + * house and twelve for the funnel. + */ +export function hullGeometry(shape: HullShape = "generic"): THREE.BufferGeometry { + switch (shape) { + case "generic": + return genericHull(); + } +} + +/** The one solid this build has. See `hullGeometry` for what replaces it. */ +function genericHull(): THREE.BufferGeometry { + const positions: number[] = []; + const uvs: number[] = []; + + /** + * Atlas bands. `v` runs 0 at the top of the canvas to 1 at the bottom, which + * is only true because `deckAtlas` turns `flipY` off. + */ + const SIDE: [number, number] = [0.02, 0.31]; + const DECK: [number, number] = [0.35, 0.64]; + const HOUSE: [number, number] = [0.69, 0.98]; + + type P = readonly [number, number, number]; + const quad = (a: P, b: P, c: P, d: P, band: [number, number]) => { + const [v0, v1] = band; + tri(a, b, c, [0, v0], [1, v0], [1, v1]); + tri(a, c, d, [0, v0], [1, v1], [0, v1]); + }; + const tri = ( + a: P, + b: P, + c: P, + uvA: [number, number], + uvB: [number, number], + uvC: [number, number], + ) => { + positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]); + uvs.push(uvA[0], uvA[1], uvB[0], uvB[1], uvC[0], uvC[1]); + }; + + // Bow at -z, stern at +z, so a bearing of zero points the ship north — the + // board's north is -z, and `bearingRotation` below is the only place that + // convention is turned into a rotation. + const bx = 0.5; // half beam + const zBow = -0.5; + const zShoulder = -0.28; + const zStern = 0.5; + const stem = 0.34; // how high up the stem the forefoot starts + + /** + * Every face below is wound counter-clockwise **seen from outside**, and that + * is not pedantry: `computeVertexNormals` reads the winding, so a face wound + * the other way is both back-face culled and lit from inside. The first + * photograph of this hull had a deck wound downward — you saw straight through + * it to the inside of the bottom plating, and it read merely as "the ships are + * a bit dark" rather than as a hole. It took a picture to find and one sign to + * fix. + */ + // ---- Body: five quads, ten triangles. + quad([-bx, 0, zShoulder], [-bx, 0, zStern], [-bx, 1, zStern], [-bx, 1, zShoulder], SIDE); + quad([bx, 0, zStern], [bx, 0, zShoulder], [bx, 1, zShoulder], [bx, 1, zStern], SIDE); + quad([-bx, 0, zStern], [bx, 0, zStern], [bx, 1, zStern], [-bx, 1, zStern], SIDE); + quad([-bx, 0, zShoulder], [bx, 0, zShoulder], [bx, 0, zStern], [-bx, 0, zStern], SIDE); + quad([-bx, 1, zStern], [bx, 1, zStern], [bx, 1, zShoulder], [-bx, 1, zShoulder], DECK); + + // ---- Bow: two side quads and two triangles, six triangles. + quad([-bx, 0, zShoulder], [-bx, 1, zShoulder], [0, 1, zBow], [0, stem, zBow], SIDE); + quad([bx, 1, zShoulder], [bx, 0, zShoulder], [0, stem, zBow], [0, 1, zBow], SIDE); + tri([-bx, 1, zShoulder], [bx, 1, zShoulder], [0, 1, zBow], [0, 0.35], [1, 0.35], [0.5, 0.64]); + tri([-bx, 0, zShoulder], [0, stem, zBow], [bx, 0, zShoulder], [0, 0.02], [0.5, 0.31], [1, 0.02]); + + // ---- Aft house: a box on the quarterdeck, twelve triangles. + box(quad, -0.34, 0.34, 1, 1.55, 0.26, 0.46, HOUSE); + + // ---- Funnel: twelve triangles, and the reason a ship reads as a ship. + box(quad, -0.12, 0.12, 1.55, 1.86, 0.32, 0.42, HOUSE); + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + geometry.name = "vessel-hull"; + return geometry; +} + +function box( + quad: ( + a: readonly [number, number, number], + b: readonly [number, number, number], + c: readonly [number, number, number], + d: readonly [number, number, number], + band: [number, number], + ) => void, + x0: number, + x1: number, + y0: number, + y1: number, + z0: number, + z1: number, + band: [number, number], +): void { + // Reversed against the obvious ordering, for the winding reason `hullGeometry` + // gives above: written the natural way, every one of these six faces points + // into the box. + quad([x0, y1, z0], [x1, y1, z0], [x1, y0, z0], [x0, y0, z0], band); + quad([x1, y1, z1], [x0, y1, z1], [x0, y0, z1], [x1, y0, z1], band); + quad([x0, y1, z1], [x0, y1, z0], [x0, y0, z0], [x0, y0, z1], band); + quad([x1, y1, z0], [x1, y1, z1], [x1, y0, z1], [x1, y0, z0], band); + quad([x0, y1, z1], [x1, y1, z1], [x1, y1, z0], [x0, y1, z0], band); + quad([x0, y0, z0], [x1, y0, z0], [x1, y0, z1], [x0, y0, z1], band); +} + +/** + * The deck atlas: three horizontal bands, drawn on a canvas at runtime. + * + * No binary asset, by house rule and by `scripts/check-no-binaries.mjs`. Three + * bands rather than one per kind, because a per-kind band needs a per-instance + * uv offset, which needs an instanced attribute and a shader patch — and the + * thing a per-kind band would buy (a container stack that is visibly containers) + * is 0.0312 units per box on SoCal, which is sub-pixel from every pose this + * board is ever looked at from. Hue comes from `VESSEL_PALETTE` as a per-instance + * tint instead, and the atlas carries the *relative* value that makes a hull + * read as a hull from above: a pale deck against a slightly darker side. + * + * Painted near white on purpose. `instanceColor` **multiplies** this texture, and + * two mid-greys multiplied are a dark grey: the first pass of this file drew the + * bands at a plausible ship's own values, and the fleet photographed as a row of + * black slabs on blue water. A modulation map has to sit near 1.0 and let the + * tint be the colour. + * + * Returns `null` under Node, where there is no `document`. Every caller treats a + * missing atlas as a flat material rather than as an error, exactly as + * `airports.ts` does with its runway markings. + */ +export function deckAtlas(): THREE.Texture | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + // Side: flat, with a darker boot-top along the waterline. The band's v runs + // 0.02..0.31 and the geometry maps y=0 (keel) to the bottom of it — which is + // only true because `flipY` is turned off below. + ctx.fillStyle = "#f4f6f7"; + ctx.fillRect(0, 0, 64, 21); + ctx.fillStyle = "#9aa0a6"; + ctx.fillRect(0, 0, 64, 5); + + // Deck: bright, with hatch coamings across it. Cross-ship stripes, because + // that is the way hatches and container rows run and it is the one detail that + // says "this is the top of a ship" from directly above. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 22, 64, 20); + ctx.fillStyle = "#c8ced4"; + for (let x = 3; x < 62; x += 6) ctx.fillRect(x, 23, 3, 18); + ctx.fillStyle = "#e4e9ed"; + ctx.fillRect(0, 30, 64, 2); + + // Superstructure: pale, with two rows of windows. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 44, 64, 20); + ctx.fillStyle = "#7d858c"; + ctx.fillRect(4, 49, 56, 3); + ctx.fillRect(4, 55, 56, 2); + + const texture = new THREE.CanvasTexture(canvas); + texture.name = "vessel-deck-atlas"; + texture.colorSpace = THREE.SRGBColorSpace; + /** + * `flipY = false`, and it is not a detail. + * + * three.js flips an image on upload so that `v = 0` is the *bottom* of the + * picture, which silently inverts a banded atlas: the first photograph of this + * layer had every hull wearing the superstructure's window rows down its side + * and the boot-top across its wheelhouse, and it looked merely dark rather + * than wrong. Drawing the bands top-down and turning the flip off keeps the + * canvas's y and the geometry's v the same axis. + */ + texture.flipY = false; + texture.wrapS = THREE.ClampToEdgeWrapping; + texture.wrapT = THREE.ClampToEdgeWrapping; + return texture; +} + +// ---- Scale ---------------------------------------------------------------- + +/** + * Metres to scene units **horizontally**, with no vertical exaggeration. + * + * The distinction matters and is the one thing easy to get wrong here. + * `world.metres()` applies the board's `verticalExaggeration` — 3.4 on SoCal — + * because it is for heights, and it is what makes a 130 m gantry crane stand + * 1.13 units tall. A ship's *length* is a plan measurement lying flat next to a + * quay that was projected, not exaggerated, so it must not go through the same + * function: a 400 m hull run through `world.metres` would be 3.5 units long and + * would overhang its berth by two ship-lengths. + * + * Exported because `vesselScale.test.ts` asserts the two boards' answers + * directly, and because it is the number anybody reasoning about this layer + * needs first. + */ +export function metresAcross(world: World, metres: number): number { + return metres / world.metresPerUnit; +} + +/** Length overall, in scene units, on this board. 1.024 for 400 m on SoCal. */ +export function hullLengthUnits(world: World, vessel: Pick): number { + return metresAcross(world, vessel.length); +} + +/** + * How long a wake is, in metres. + * + * Speed through water and hull length, which is what a wake is a function of. + * Below `WAKE_MIN_SPEED_MPS` it is zero and the ship has none — a moored hull + * with a stub of foam behind it would be the layer quietly claiming motion it + * has no evidence for. + */ +export function wakeLengthMetres(speedMps: number, lengthMetres: number): number { + if (!Number.isFinite(speedMps) || speedMps < WAKE_MIN_SPEED_MPS) return 0; + if (!Number.isFinite(lengthMetres) || lengthMetres <= 0) return 0; + const fraction = Math.min(1, speedMps / WAKE_FULL_SPEED_MPS); + return lengthMetres * WAKE_HULL_LENGTHS * fraction; +} + +/** + * A true bearing as a rotation about the board's vertical. + * + * North is `-z` and east is `+x`, and the unit hull is built with its bow at + * `-z`, so a bearing of B is a yaw of `-B`. One line, in one place, because + * getting it wrong by a sign sails the entire fleet backwards and looks + * completely plausible in a still frame. + */ +export function bearingRotation(bearingDegrees: number): number { + return -bearingDegrees * DEG; +} + +// ---- The layer ------------------------------------------------------------ + +/** + * What a vessel layer can be asked, beyond the seam `scene.ts` declares. + * + * The three readbacks are for tests and for a panel: everything else about this + * layer is observed through the scene graph, which is the rule `flights.test.ts` + * set and the reason there is no private state exposed here. + */ +export interface VesselLayerHandle { + group: THREE.Object3D; + setVessels(vessels: readonly Vessel[] | null): void; + setLighting(state: LightingState): void; + tick(dt: number): void; + dispose(): void; + /** How many hulls are drawn right now. */ + hullCount(): number; + /** How many wakes are drawn right now. Never more than `hullCount`. */ + wakeCount(): number; + /** Where a hull is being drawn, in scene units, or `null`. */ + positionOf(id: string): THREE.Vector3 | null; +} + +/** + * Build the layer. + * + * The signature is `SceneOptions.vessels`'s exactly — `(world, { span })` — and + * `span` is deliberately unused: every size in here is a true metre converted + * through the board's own scale, which is the whole argument above. It is taken + * anyway so the four new layer factories are one shape, and so that a future + * fade-with-distance has the number it needs without a seam change. + * + * **Nothing is added to the group until a vessel arrives.** An empty board costs + * one `THREE.Group` and no draw call, which is the same posture `scene.ts` takes + * toward a layer that is not configured at all: a layer that is not visited is + * cheaper and more honest than one drawing nothing. + */ +export function createVesselLayer( + world: World, + options: { span: number } = { span: 0 }, +): VesselLayerHandle { + void options; + const group = new THREE.Group(); + group.name = "vessels"; + + const atlas = deckAtlas(); + const hullMaterial = new THREE.MeshLambertMaterial({ + color: 0xffffff, + ...(atlas ? { map: atlas } : {}), + }); + hullMaterial.name = "vessel-hull"; + + const geometry = hullGeometry("generic"); + + /** + * One `InstancedMesh` per hull shape, built up front and added to the group + * only when it has hulls in it. + * + * Built up front rather than per update because an `InstancedMesh` owns a + * `Float32Array` of sixteen floats per instance and rebuilding it whenever the + * feed answers would allocate and discard 12 KB every fifteen minutes for the + * life of the page. `count` is the dial instead, which is what it is for. + */ + const hullMeshes = new Map(); + for (const shape of HULL_SHAPES) { + const mesh = new THREE.InstancedMesh(geometry, hullMaterial, VESSEL_HULL_CAPACITY); + mesh.name = `vessels:${shape}`; + mesh.count = 0; + // No shadow, and it is a budget decision with a number behind it. The SoCal + // mobile cell has thirty draw calls spare for the whole of ports and ships, + // a shadow pass costs another draw per mesh, and a 400 m hull's shadow on + // water at 390 m to the unit is under three pixels from any pose this board + // is looked at from. The cranes are what cast the shadows that matter. + mesh.castShadow = false; + mesh.receiveShadow = false; + // The instance matrices are rewritten from scene-space every time the feed + // answers and every frame a hull is under way, so the bounding sphere three + // computes at construction is permanently wrong and culling on it would cull + // the fleet. + mesh.frustumCulled = false; + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); + hullMeshes.set(shape, mesh); + } + + /** + * One `LineSegments` for every wake on the board. + * + * The same object `flights.ts` builds for aircraft trails and for the same + * reasons: the vertex count is trivial either way, and a single preallocated + * buffer means traffic changing does not allocate and dispose geometry. Per- + * vertex alpha does the fade, which needs a four-component colour attribute — + * three.js reads the item size and switches the shader on it. + * + * Allocated once, here, and never resized. `wakes.test.ts` asserts the array's + * length across a hundred ticks precisely because "just push a few more + * points" is the change that would undo it. + */ + const maxWakeVertices = WAKE_CAPACITY * WAKE_RAILS.length * (WAKE_POINTS - 1) * 2; + const wakePositions = new Float32Array(maxWakeVertices * 3); + const wakeColors = new Float32Array(maxWakeVertices * 4); + const wakeGeometry = new THREE.BufferGeometry(); + wakeGeometry.setAttribute("position", new THREE.BufferAttribute(wakePositions, 3)); + wakeGeometry.setAttribute("color", new THREE.BufferAttribute(wakeColors, 4)); + wakeGeometry.setDrawRange(0, 0); + const wakeMaterial = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + // Wakes cross each other in a busy channel and are the faintest thing on the + // water; letting them write depth makes whichever drew first punch a hole in + // every one behind it. `flights.ts` learned this on contrails. + depthWrite: false, + }); + const wakeLine = new THREE.LineSegments(wakeGeometry, wakeMaterial); + wakeLine.name = "vessel-wakes"; + wakeLine.frustumCulled = false; + + const foam = new THREE.Color(WAKE_COLOR_DAY); + let foamAlpha = WAKE_ALPHA; + + /** What the feed last said, and how long ago in layer time. */ + let fixes: Vessel[] = []; + /** Seconds since `fixes` were handed over. Reset by `setVessels`, grown by `tick`. */ + let sinceFix = 0; + /** Where each hull is being drawn, so a caller can ask without a raycast. */ + const drawn = new Map(); + let wakes = 0; + + const matrix = new THREE.Matrix4(); + const quaternion = new THREE.Quaternion(); + const position = new THREE.Vector3(); + const scale = new THREE.Vector3(); + const axis = new THREE.Vector3(0, 1, 0); + const instanceColor = new THREE.Color(); + + function rebuild(): void { + let vertex = 0; + wakes = 0; + drawn.clear(); + + for (const mesh of hullMeshes.values()) mesh.count = 0; + + const counts = new Map(); + for (const vessel of fixes) { + const shape = hullShape(vessel.kind); + const mesh = hullMeshes.get(shape); + if (!mesh) continue; + const index = counts.get(shape) ?? 0; + if (index >= VESSEL_HULL_CAPACITY) continue; + + // Motion, and the only motion there is: one fix advanced along its own + // reported course. `reckonVessel` takes a single fix and therefore has no + // second point to interpolate toward — see `server/vessels.ts`. + const here = reckon(vessel, sinceFix + (vessel.ageSeconds ?? 0)); + const [x, z] = world.project(here.lat, here.lng); + + const draught = hullDraughtMetres(vessel); + const depth = draught + hullFreeboardMetres(vessel); + // Keel below the waterline, waterline on the sea. `world.metres` for the + // vertical because that is the axis the board exaggerates; `metresAcross` + // for the other two because they lie in the plan the quay was projected + // into. + position.set(x, SEA_SURFACE_Y - world.metres(draught), z); + quaternion.setFromAxisAngle(axis, bearingRotation(vessel.bearing)); + scale.set(metresAcross(world, vessel.beam), world.metres(depth), metresAcross(world, vessel.length)); + matrix.compose(position, quaternion, scale); + mesh.setMatrixAt(index, matrix); + instanceColor.setHex(VESSEL_PALETTE[vessel.kind] ?? VESSEL_PALETTE.other); + mesh.setColorAt(index, instanceColor); + counts.set(shape, index + 1); + drawn.set(vessel.id, new THREE.Vector3(x, position.y, z)); + + if (wakes < WAKE_CAPACITY) { + const written = writeWake(vessel, x, z, vertex); + if (written > vertex) { + vertex = written; + wakes += 1; + } + } + } + + for (const [shape, mesh] of hullMeshes) { + mesh.count = counts.get(shape) ?? 0; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + const wanted = mesh.count > 0; + const present = mesh.parent === group; + if (wanted && !present) group.add(mesh); + else if (!wanted && present) group.remove(mesh); + } + + wakeGeometry.setDrawRange(0, vertex); + /** + * Upload only what was written. + * + * `needsUpdate` alone re-sends the whole `Float32Array` — three.js reads an + * empty update range as "all of it" — which is 1.4 MB a frame for a buffer a + * quiet harbour writes two percent of. `clearUpdateRanges` first or the + * ranges accumulate and the saving is gone inside a second. Straight out of + * `flights.ts`, which measured it. + */ + const positionAttr = wakeGeometry.attributes.position as THREE.BufferAttribute; + const colorAttr = wakeGeometry.attributes.color as THREE.BufferAttribute; + positionAttr.clearUpdateRanges(); + colorAttr.clearUpdateRanges(); + if (vertex > 0) { + positionAttr.addUpdateRange(0, vertex * 3); + colorAttr.addUpdateRange(0, vertex * 4); + positionAttr.needsUpdate = true; + colorAttr.needsUpdate = true; + } + + const wantWake = vertex > 0; + const haveWake = wakeLine.parent === group; + if (wantWake && !haveWake) group.add(wakeLine); + else if (!wantWake && haveWake) group.remove(wakeLine); + } + + /** + * One vessel's wake, as two straight rails diverging astern. + * + * The rails leave the ship at its quarters — half a beam either side of the + * centreline at the stern — and open at the Kelvin half-angle, which is a + * constant. Nothing here is a history buffer: the wake is drawn along the + * course the hull was reckoned along, which is the same line, so a stored + * track would be a second copy of the same claim with an opportunity to + * disagree with it. + * + * The honest limitation, stated because it is visible: when a new fix arrives + * with a different course the whole V swings to the new one rather than + * bending. A bend would have to be drawn through positions between two fixes, + * and there are none — that is the rule this layer is built around. + */ + function writeWake(vessel: Vessel, x: number, z: number, start: number): number { + const lengthMetres = wakeLengthMetres(vessel.speed, vessel.length); + if (lengthMetres <= 0) return start; + const course = vessel.course ?? vessel.bearing; + const length = metresAcross(world, lengthMetres); + const half = metresAcross(world, vessel.length) / 2; + const beam = metresAcross(world, vessel.beam) / 2; + const spread = Math.tan(KELVIN_HALF_ANGLE_DEG * DEG); + + const radians = course * DEG; + // Course over ground as a direction on the board: north is -z, east is +x. + const forwardX = Math.sin(radians); + const forwardZ = -Math.cos(radians); + // Starboard is the course turned a quarter clockwise. + const rightX = Math.cos(radians); + const rightZ = Math.sin(radians); + + // The stern, which is where a wake starts and the hull's centre is not. + const sternX = x - forwardX * half; + const sternZ = z - forwardZ * half; + + let vertex = start; + for (const side of WAKE_RAILS) { + let previousX = sternX + rightX * beam * side; + let previousZ = sternZ + rightZ * beam * side; + let previousFade = 1; + for (let i = 1; i < WAKE_POINTS; i++) { + if (vertex + 2 > maxWakeVertices) return vertex; + const t = i / (WAKE_POINTS - 1); + const back = t * length; + const across = beam + back * spread; + const px = sternX - forwardX * back + rightX * across * side; + const pz = sternZ - forwardZ * back + rightZ * across * side; + // Foam dies away from the ship. The exponent puts most of the fade in + // the older half so the segment right behind the stern stays bright, + // which is both what foam does and what makes the ship findable. + const fade = (1 - t) ** 1.6; + writeWakeVertex(vertex++, previousX, previousZ, previousFade); + writeWakeVertex(vertex++, px, pz, fade); + previousX = px; + previousZ = pz; + previousFade = fade; + } + } + return vertex; + } + + function writeWakeVertex(index: number, x: number, z: number, fade: number): void { + const p = index * 3; + wakePositions[p] = x; + wakePositions[p + 1] = SEA_SURFACE_Y + WAKE_LIFT; + wakePositions[p + 2] = z; + const c = index * 4; + wakeColors[c] = foam.r; + wakeColors[c + 1] = foam.g; + wakeColors[c + 2] = foam.b; + wakeColors[c + 3] = fade * foamAlpha; + } + + function reckon(vessel: Vessel, seconds: number): { lat: number; lng: number } { + // Inlined rather than imported so that `engine/` keeps taking no dependency + // on `server/`, which is the direction of the arrow everywhere else in this + // repo. The arithmetic is `reckonVessel`'s and the two are asserted against + // each other in `vesselGate.test.ts`. + if (!Number.isFinite(seconds) || seconds <= 0) return vessel; + if (vessel.course === null || !Number.isFinite(vessel.course)) return vessel; + if (!Number.isFinite(vessel.speed) || vessel.speed < WAKE_MIN_SPEED_MPS) return vessel; + const dt = Math.min(seconds, RECKON_CEILING_SECONDS); + const distance = vessel.speed * dt; + const radians = vessel.course * DEG; + const lat = vessel.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT; + const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(vessel.lat * DEG); + const lng = + metresPerDegreeLng > 1 + ? vessel.lng + (Math.sin(radians) * distance) / metresPerDegreeLng + : vessel.lng; + return { lat, lng }; + } + + return { + group, + + setVessels(vessels) { + // `null` and `[]` are the same picture and a different sentence — nothing + // has answered, against the feed answered and this board is empty. The + // distinction is the panel's to draw, from `VesselPromotion`; here they + // are both an empty fleet and both cost zero draw calls. + fixes = vessels ? vessels.filter((v) => v && Number.isFinite(v.lat) && Number.isFinite(v.lng)) : []; + sinceFix = 0; + rebuild(); + }, + + setLighting(state) { + /** + * The hulls need nothing here: they are `MeshLambertMaterial` and are lit + * by the one rig, which is the entire point of CONTRACT §4. Only the foam + * is unlit, and only because a line has no normal to light. + * + * So the foam takes the key light's colour and a share of its intensity: a + * wake under a low orange sun goes warm, and at night it dims to something + * the harbour's own lights can sit above rather than glowing white on + * black water. `sun.intensity` is the proxy rather than a solar elevation + * because `VesselLayer` carries no elevation setter — the migration layer + * is the one that needed one, and adding it here to tint foam would be a + * seam widened for a tint. + */ + const key = Number.isFinite(state.sun.intensity) ? Math.max(0, state.sun.intensity) : 1; + const daylight = Math.min(1, key / DAYLIGHT_KEY_INTENSITY); + foam.setHex(WAKE_COLOR_DAY).lerp(new THREE.Color(state.sun.color), 0.25 * (1 - daylight)); + foam.multiplyScalar(0.45 + 0.55 * daylight); + foamAlpha = WAKE_ALPHA * (0.55 + 0.45 * daylight); + rebuild(); + }, + + tick(dt) { + if (!Number.isFinite(dt) || dt <= 0) return; + // Only a board with something making way pays for a rebuild. A harbour + // full of moored hulls is static geometry and is left alone, which is the + // common case: 50 of the 81 vessels in the LA/LB box are stopped. + let moving = false; + for (const vessel of fixes) { + if (vessel.course !== null && vessel.speed >= WAKE_MIN_SPEED_MPS) { + moving = true; + break; + } + } + sinceFix += dt; + if (!moving) return; + rebuild(); + }, + + dispose() { + geometry.dispose(); + hullMaterial.dispose(); + atlas?.dispose(); + wakeGeometry.dispose(); + wakeMaterial.dispose(); + for (const mesh of hullMeshes.values()) mesh.dispose(); + hullMeshes.clear(); + drawn.clear(); + group.clear(); + }, + + hullCount() { + let total = 0; + for (const mesh of hullMeshes.values()) total += mesh.count; + return total; + }, + + wakeCount() { + return wakes; + }, + + positionOf(id) { + const at = drawn.get(id); + return at ? at.clone() : null; + }, + }; +} + +/** + * A noon sun's key intensity, from `atmosphere.ts`'s own table. + * + * Used only to turn the key into a 0..1 daylight fraction for the foam. Restated + * rather than imported because importing it would give this layer an opinion + * about the rig, and the number it wants is a ratio rather than a value. + */ +const DAYLIGHT_KEY_INTENSITY = 2.1; + +const METRES_PER_DEGREE_LAT = 111_320; + +/** The dead-reckoning ceiling, in seconds. Fifteen minutes: the sample interval. */ +const RECKON_CEILING_SECONDS = 900; diff --git a/src/main.ts b/src/main.ts index c41e045..c3594e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,7 +50,7 @@ import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites. import type { Pose } from "./engine/scenekit.ts"; import { createStage, deviceProfile } from "./engine/stage.ts"; import { daylightPhase } from "./engine/solar.ts"; -import type { Aircraft, City, Marker, MarkerPalette, View } from "./engine/types.ts"; +import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts"; import CALIFORNIA from "./cities/california.ts"; import SAN_FRANCISCO from "./cities/sf.ts"; import SOCAL from "./cities/socal.ts"; @@ -73,8 +73,22 @@ import { type WeatherWatch, } from "./adapters/http.ts"; import { promote, type FireBounds } from "./server/fires.ts"; -import type { FiresBody } from "./server/wire.ts"; +import type { BirdsBody, FiresBody, RadarBody, VesselsBody } from "./server/wire.ts"; import { createFireLayer } from "./engine/fires.ts"; +import { createPortLayer } from "./engine/ports.ts"; +import { createVesselLayer } from "./engine/vessels.ts"; +import { boardCarriesRaster, precipFactoryFor } from "./engine/precip.ts"; +import { createMigrationLayer } from "./engine/migration.ts"; +import { + berthAnchors, + modelHarbour, + promoteVessels, + vesselSummary, + type BerthAnchor, + type VesselBounds, +} from "./server/vessels.ts"; +import { promoteRadar } from "./server/radar.ts"; +import { promoteBirds } from "./server/birds.ts"; import { mountFirePanel, type FirePanelHandle } from "./ui/firePanel.ts"; import { SAMPLE_MARKERS, @@ -469,6 +483,18 @@ let cityFlights: TrafficSource | null = null; * `null` on the Bay Area board and on every keyless clone. See `firesFor`. */ let fireWatch: FireWatch | null = null; +/** + * Stops this board's camera listening to the fog, or `null` between boards. + * + * The rig follows the clock at about one hertz, which was fine while every term + * in it was a function of time. Aerial perspective is a function of where the + * camera is, and a camera can cross a board in a second — so a fog recomputed + * only on the clock steps four times during a chapter flight and pops each + * time. `OrbitControls` fires `change` on every update that actually moved + * something, including damping and including `setPose`, which is exactly the + * event this needs and is already the event the minimap redraws on. + */ +let cameraFogWatch: (() => void) | null = null; /** The panel that says what the board is showing, and what it is not. */ let firePanel: FirePanelHandle | null = null; /** The board's own rectangle, held so a poll landing later can be clipped to it. */ @@ -485,6 +511,45 @@ let fireBounds: FireBounds | null = null; * settled; `null` means one settled and the feed refused. */ let firesBody: FiresBody | null | undefined; +/** + * The harbour on the board, and the three sentences beside the three feeds. + * + * All of it is per-board and all of it is cleared on the way out of `mountCity`, + * because — unlike `firesBody`, which is one statewide answer for every board — + * a harbour is a fact about a rectangle and a raster is clipped to one. + * + * `vesselsBody` is **modelled**, not observed: `modelHarbour` builds it from the + * board's own authored berths and channels, and `promoteVessels` reads it + * through exactly the gate a live AIS body would go through. The day + * `GET /vessels` exists, the only line that changes is where this comes from. + * `harbourAtMs` is the clock that body was built for, so the next refresh is a + * new *fix* rather than a nudge — see `refreshHarbour`. + */ +let vesselsBody: VesselsBody | null = null; +let vesselBounds: VesselBounds | null = null; +let vesselBerths: readonly BerthAnchor[] = []; +let vesselPorts: readonly Port[] = []; +let harbourAtMs = 0; +/** Whether the board on screen is one a statewide instrument can describe. */ +let boardCarriesSky = false; +/** + * The two sky bodies, and the one request that fetches them. + * + * `undefined` is "not asked yet", `null` is "asked and the box refused", and a + * body carrying `source: "none"` is "asked and the box serves no such feed" — + * three states with three different sentences, which is the whole reason the + * gates take a nullable body rather than a field. `skyAskedAtMs` throttles the + * refetch to the body's own TTL off the once-a-minute clock, so there is no + * second poller in this file. + */ +let radarBody: RadarBody | null | undefined; +let birdsBody: BirdsBody | null | undefined; +let skyAskedAtMs = 0; +let skyInFlight = false; +/** The three board notes, written by the gates and drawn by `showBoardNotes`. */ +let seaNote = ""; +let radarNote = ""; +let birdsNote = ""; /** * The satellite element sets, fetched once for the page rather than once per city. * @@ -886,6 +951,54 @@ function houseLevelFor(solarElevationDeg: number): number { return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6)); } +/** + * The fog alone, for the camera's clock rather than the sun's. + * + * ## The split, and why it is a split rather than a cheaper `setLighting` + * + * Two things move at completely different rates and were being served by one + * call. The **sun** moves on the wall clock, which this file steps once a + * minute. The **camera** moves on a gesture: `change` fires every frame under + * damping, and even throttled to 2% of altitude a single drag gets through a few + * dozen times. This function is the second of those, and it now sends the only + * thing that is actually a fact about the camera — how far you can see from + * where it is standing. + * + * What it used to send was the whole rig, and the whole rig is a fan-out: six + * layer setters, a dirtied `MeshLambertMaterial` in the port yard, a rebuild of + * the vessel wake instances, and the PMREM environment's fingerprint in + * `environmentRig.ts`. Not one of those reads a fog distance. Measured over a + * dolly and a drag on both metro boards, the old path cost 0.14-0.19 ms a step + * and this one costs about 0.02. + * + * ## The trap it also disarms, which is the better reason + * + * `environmentRig.ts` decides whether to re-render and re-convolve the sky + * cubemap by fingerprinting the rig's colours — `sky.horizon` among them — and + * `interiors/daylight.ts` pins that horizon stop to the fog colour so the sky + * dome and the haze meet without a seam. Aerial perspective moves distances and + * no colours, so it does not trip that today; the point of routing the camera + * through a setter that *cannot carry a colour* is that the next altitude-driven + * term cannot trip it either. The alternative fix — coarsening the fingerprint + * until the rebuild stops — hides one instance and leaves the mechanism armed. + * + * `updateSun` still passes the camera's view to `apply`, so a clock tick on a + * board nobody is touching still gets the right fog. `Atmosphere` remains the + * sole owner of both numbers (CONTRACT §4); this file only chooses which of them + * a gesture is allowed to move. + */ +function applyCameraFog(): void { + if (!city || !atmosphere) return; + const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; + const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); + city.setAerialFog( + atmosphere.aerial(env, { + altitudeMetres: city.cameraAltitudeMetres(), + standoffMetres: city.cameraStandoffMetres(), + }), + ); +} + function updateSun() { const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; @@ -903,8 +1016,39 @@ function updateSun() { if (!city || !atmosphere) return; const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather()); - city.setLighting(atmosphere.apply(env)); + /** + * The camera's height is the second argument to the rig now, and the reason + * it comes from the scene rather than from here is that only the scene knows + * this board's metres per unit and its vertical exaggeration. + * + * `atmosphere.ts` turns it into aerial perspective — how far you can see + * through the air you are above — clamped so the clear-day pair below is the + * ceiling and never exceeded. At the whole-board pose it saturates and every + * board renders exactly as it did before, which is why the Bay Area's stills + * are unaffected by construction. Low down it is what puts haze on a range + * eighty kilometres up the valley. + */ + const view = { + altitudeMetres: city.cameraAltitudeMetres(), + standoffMetres: city.cameraStandoffMetres(), + }; + city.setLighting(atmosphere.apply(env, view)); city.setSolarElevation(env.sun.elevation); + /** + * The board's other three feeds, on the same clock and from the same sun. + * + * `env.sun.elevation` is handed to the migration gate rather than letting it + * ask an ephemeris of its own. A second opinion about where the sun is, taken + * from a clock the scrubber does not own, is how a night board and a night + * layer end up disagreeing — and `LightingState.hemisphere` is not the signal + * either, because `atmosphere.ts` raises the fill after dark and it reads + * *higher* at midnight than at noon. + * + * Both are no-ops on a board without them: `tickHarbour` returns on a board + * with no port, and `setPrecip`/`setMigration` are no-ops without a layer. + */ + tickHarbour(currentInstant().getTime()); + if (boardCarriesSky) applySky(env.sun.elevation, Date.now()); /** * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`. * @@ -1055,6 +1199,168 @@ function showFireSection(): void { section.hidden = !wanted; } +// ---- The sea, and the sky over it ----------------------------------------- + +/** + * The seed the harbour is modelled from. + * + * Fixed, and that is the whole point: two visitors on two continents see the + * same ships in the same berths, and a capture script that shoots the Southland + * twice gets the same photograph. Everything downstream of it is a hash of this + * and a stable string — a berth id, a port id — and never `Math.random`. + */ +const HARBOUR_SEED = 115; + +/** + * The harbour, rebuilt as a **new fix** rather than nudged along. + * + * `modelHarbour` stands in for a feed this deployment does not have, and the + * feed's shape is what sets the cadence here. Upstream listens for thirty + * seconds every fifteen minutes, so a hull under way has moved about five + * kilometres between two reports; `engine/vessels.ts` is licensed to dead-reckon + * along the reported course for exactly that long and then stops. Rebuilding on + * the same interval is therefore not a smoothing trick — it is the feed's own + * behaviour — and the small jump a moving hull makes when a new body lands is + * the jump a real fix makes. Splining it away would be inventing the positions + * in between, which is the one thing this layer refuses to do. + * + * The clock is the app's instant rather than the wall clock, so a scrubbed sky + * and the harbour under it describe the same moment, and `promoteVessels` is + * handed the same number so the body's age is what it actually is: zero. + */ +function refreshHarbour(atMs: number): void { + if (vesselBounds === null) return; + vesselsBody = modelHarbour(vesselPorts, { seed: HARBOUR_SEED, atMs }); + harbourAtMs = atMs; + /** + * Through the gate, never around it. + * + * The modelled body is a `VesselsBody` and it goes through the same + * `promoteVessels` a live AIS body would: the three sentinels are re-checked, + * the hulls are clipped to this board, a moored one takes its bearing from the + * berth it is lying on, and the four suppression counters are what + * `vesselSummary` writes its sentence from. The day `/vessels` exists, the + * only line in this file that changes is the one above. + */ + const promotion = promoteVessels(vesselsBody, vesselBounds, vesselBerths, atMs); + city?.setVessels(promotion.drawn); + seaNote = vesselSummary(promotion); +} + +/** A new fix when the declared interval has passed, in either direction. */ +function tickHarbour(atMs: number): void { + if (vesselBounds === null) return; + const intervalMs = Math.max(60, vesselsBody?.intervalSeconds ?? 900) * 1000; + // Absolute, because the clock on this page can be dragged backwards. + if (Math.abs(atMs - harbourAtMs) < intervalMs) return; + refreshHarbour(atMs); +} + +/** + * Ask for the rain and the birds, at most one request each per TTL. + * + * Gated on `/health`'s `sources`, exactly as the fire watch is: on a deployment + * that serves neither — which is this repo's default and every clone — nothing + * is requested at all and the two gates still have something true to say, so the + * panel reads "no radar feed is configured" rather than an ambiguous silence. + * + * There is no watcher class behind this on purpose. Radar is a five-minute + * composite and BirdCast a ten-minute one; the page already has a once-a-minute + * clock, and a fourth polling ladder in `adapters/http.ts` to re-ask a question + * whose answer changes at most twelve times an hour would be machinery bought + * for nothing. + */ +async function askSky(): Promise { + if (skyInFlight) return; + const wantsRadar = access.feeds?.radar === true; + const wantsBirds = access.feeds?.birds === true; + if (!wantsRadar && !wantsBirds) return; + skyInFlight = true; + skyAskedAtMs = Date.now(); + try { + const [radar, birds] = await Promise.all([ + wantsRadar ? tera.radar() : Promise.resolve(null), + wantsBirds ? tera.birds() : Promise.resolve(null), + ]); + if (wantsRadar) radarBody = radar; + if (wantsBirds) birdsBody = birds; + } finally { + skyInFlight = false; + } + // Straight onto the board rather than at the next minute: a scan that landed + // is the only thing on this page that can change what the sky looks like + // without the clock moving. + updateSun(); +} + +/** + * Both sky gates, run against whatever has answered. + * + * `nowMs` is the **wall** clock and not the app's instant, because `fetchedAt` + * is a real timestamp and "last scan 5 minutes old" has to stay true when + * somebody scrubs the sky to midnight. `solarElevationDeg` is the opposite: it + * is the elevation the board is *lit* by, because the migration gate is a + * daylight gate and a layer that disagreed with the sky about whether it is + * night is precisely the failure that made the first version of it draw nothing + * at 04:35 while passing its own tests. + * + * Both messages are always full sentences, including — especially — on the day + * nothing is falling and nothing is aloft, which is most days. + */ +function applySky(solarElevationDeg: number, nowMs: number): void { + const radar = promoteRadar(radarBody, nowMs); + const birds = promoteBirds(birdsBody, { nowMs, solarElevationDeg }); + city?.setPrecip(radar.field); + city?.setMigration(birds.field); + radarNote = radar.message; + birdsNote = birds.message; + // The next scan, when this one has expired. `askSky` stamps its own clock + // before the request, so this cannot re-enter while one is in the air. + const ttlMs = Math.max(60, radarBody?.ttlSeconds ?? 0, birdsBody?.ttlSeconds ?? 0) * 1000; + if (nowMs - skyAskedAtMs >= ttlMs) void askSky(); +} + +/** + * The two board notes, drawn the way `showFireSection` draws the fire panel. + * + * `#sea-section` and `#sky-section` are this file's elements rather than + * `mount.ts`'s, the same arrangement `#fire-section` and `#presence-host` have: + * their content is a board's own instrument reading, they are rebuilt per board, + * and they are hidden outright on a board that has no harbour and no statewide + * raster. Hidden inside a building for the reason the fire list is: a room's + * panel belongs to the room. + */ +function showBoardNotes(): void { + const sea = !inside && vesselBounds !== null ? seaNote : ""; + writeBoardNote("sea-section", [["sea-note", sea]]); + const sky = !inside && boardCarriesSky; + writeBoardNote("sky-section", [ + ["radar-note", sky ? radarNote : ""], + ["birds-note", sky ? birdsNote : ""], + ]); +} + +/** One section, its paragraphs, and the rule that an empty one is not shown. */ +function writeBoardNote( + sectionId: string, + notes: readonly (readonly [string, string])[], +): void { + const section = document.querySelector(`#${sectionId}`); + if (section === null) return; + let any = false; + for (const [id, text] of notes) { + if (text !== "") any = true; + const line = document.querySelector(`#${id}`); + if (line === null) continue; + // Compared before it is written, like `mount.ts`'s applier: this runs on + // every `renderChrome` and a `textContent` write is a layout invalidation. + if (line.textContent !== text) line.textContent = text; + if (line.hidden === (text !== "")) line.hidden = text === ""; + } + if (section.hidden === !any) return; + section.hidden = !any; +} + // ---- Cities --------------------------------------------------------------- /** @@ -1093,6 +1399,19 @@ async function mountCity(id: string) { firePanel?.dispose(); firePanel = null; fireBounds = null; + // The harbour is a fact about a rectangle, so none of it survives the board. + // The two sky bodies deliberately do — they are statewide, like `firesBody`, + // and a visitor coming back to California should see the last scan rather + // than "nothing has answered" for as long as a request takes. + vesselsBody = null; + vesselBounds = null; + vesselBerths = []; + vesselPorts = []; + harbourAtMs = 0; + boardCarriesSky = false; + seaNote = ""; + radarNote = ""; + birdsNote = ""; // `firesBody` deliberately survives: it is one statewide answer for every // board, so the next one can be drawn correctly in the frame it is built in. poseEditor?.destroy(); @@ -1101,6 +1420,8 @@ async function mountCity(id: string) { controlModeState = createControlModeState(); disposeLoadedOffice(); inside = false; + cameraFogWatch?.(); + cameraFogWatch = null; minimap?.dispose(); minimap = null; city?.dispose(); @@ -1198,6 +1519,31 @@ async function mountCity(id: string) { ); const initialMarkers = id === "sf" ? [...markers, ...doors] : doors; + /** + * The two sky layers, decided here so the options object below reads as four + * sentences rather than as four nested conditionals. + * + * Two gates each, and they are the pair `drawsFire` uses. The **board** gate + * is `boardCarriesRaster`: both feeds are statewide instruments quantised to a + * quarter of a degree — sixteen WSR-88Ds and fifty-eight counties — and a + * rectangle ninety kilometres across cannot be described by a cell twenty-seven + * kilometres wide. It answers true for California and false for the Southland + * and the Bay, which is the same answer an `id === "california"` would have + * given and is a fact about the geometry rather than about a name. + * + * The **deployment** gate is `access.feeds`, read from `/health`'s `sources`. + * With no projection configured there is no body, no honest caption beyond the + * one the gate already writes, and nothing to draw — so the layer is withheld + * entirely rather than built and left empty, exactly as `fires` is. This repo's + * own default is `none` for both, which is why the panel sentence matters more + * than the layer. + */ + const carriesSky = boardCarriesRaster(entry.city.bounds); + const precipFactory = carriesSky && access.feeds?.radar === true + ? precipFactoryFor(entry.city.bounds) + : null; + const drawsMigration = carriesSky && access.feeds?.birds === true; + const handle = await createScene(stage, { city: entry.city, // The page's one rig, shared with the office. Handed in rather than built @@ -1266,6 +1612,34 @@ async function mountCity(id: string) { * `SceneOptions.fires`. */ ...(drawsFire(id) ? { fires: createFireLayer } : {}), + /** + * The port kit, on the one board that declares a port. + * + * The ports are `city.ports` — plain authored data, closed over here rather + * than passed through `SceneOptions`, because `scene.ts` must not have to + * know what a quay is. Withheld entirely on a board with none, so + * `engine/ports.ts` never enters the graph of a build that draws no port and + * a portless board costs no group, no material and no draw call. + */ + ...(entry.city.ports?.length + ? { ports: (world, options) => createPortLayer(world, entry.city.ports ?? [], options) } + : {}), + /** + * The hulls, on the same boards as the quays and for the same reason. + * + * Gated on the *ports*, not on a feed: a ship is drawn against a berth, a + * channel and a breakwater, and a hull on a board with none of them is a + * white shape on open water with nothing to say what it is doing. Withheld + * rather than passed-and-emptied, like every layer above it. + * + * What fills it is `refreshHarbour`, below — a modelled body through the + * live gate, never a hull invented at the renderer. + */ + ...(entry.city.ports?.length ? { vessels: createVesselLayer } : {}), + /** Reflectivity, on a board a quarter-degree cell can describe. */ + ...(precipFactory ? { precip: precipFactory } : {}), + /** Tonight's migration, on the same board and under the same argument. */ + ...(drawsMigration ? { migration: createMigrationLayer } : {}), /** * A pin is a hover *and* a click, and an office pin is a door. * @@ -1370,6 +1744,28 @@ async function mountCity(id: string) { } showFireSection(); + /** + * The sea and the sky, in the frame the board is built in. + * + * Both are set up here rather than waiting for the first clock tick, for the + * reason the fire panel is mounted before its watch settles: a board that + * draws no ships and says nothing about it is indistinguishable from a broken + * one, and the sentence is the layer on most days. `refreshHarbour` is + * synchronous — the modelled body is arithmetic over fifteen berths — so the + * first frame of the Southland already has its hulls on it. + */ + if (entry.city.ports?.length) { + vesselPorts = entry.city.ports; + vesselBounds = entry.city.bounds; + vesselBerths = berthAnchors(entry.city.ports); + refreshHarbour(currentInstant().getTime()); + } + // Asked once per board and then only when the body's own TTL has expired; the + // promotion that draws it runs on the clock, in `updateSun`, which is also + // where the sun's elevation comes from. Nothing here blocks the build. + boardCarriesSky = carriesSky; + if (carriesSky) void askSky(); + // Fog distances are scene units, so they have to follow the board — 210/460 // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans @@ -1411,6 +1807,38 @@ async function mountCity(id: string) { // not a decoration. LA gets its own weather, not San Francisco's fog. marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null, }); + /** + * Recompute the **fog** when the camera moves, not only when the clock does. + * + * Throttled on the altitude itself rather than on time: `change` fires every + * frame under damping and building a rig walks a keyframe table and a lunar + * ephemeris, which is not work to do sixty times a second for a number that + * has not moved. Two per cent of the current altitude is under the threshold + * at which the fog planes visibly shift, and it collapses a whole chapter + * flight to a few dozen recomputations. + * + * `applyCameraFog` and not `updateSun`, and not `setLighting` either. The + * clock tick ends in `renderChrome()`, a DOM pass with no business running + * because somebody dragged the board; and the full rig ends in six layer + * setters and the environment rig's fingerprint, none of which read a + * distance. See `applyCameraFog` for the whole argument. + */ + { + const controls = city.stageScene.controls; + let lastAltitude = -1; + const onCameraMoved = () => { + const altitude = city?.cameraAltitudeMetres() ?? 0; + // Two per cent of the current altitude, floored at a metre so a camera + // resting exactly on the ground does not recompute on every event. + const moved = Math.abs(altitude - lastAltitude); + if (lastAltitude >= 0 && moved < Math.max(1, lastAltitude * 0.02)) return; + lastAltitude = altitude; + applyCameraFog(); + }; + controls.addEventListener("change", onCameraMoved); + cameraFogWatch = () => controls.removeEventListener("change", onCameraMoved); + } + city.onChapterChange(() => renderChrome()); city.onControlModeChange((mode) => adoptCityControlMode(mode)); @@ -2686,6 +3114,21 @@ function creditLines(): string[] { const lines: string[] = []; if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? [])); lines.push(...(cityFlights?.attribution() ?? [])); + /** + * The three newest feeds, each named by whatever answered for it. + * + * The modelled harbour is in this list for the opposite of the usual reason: + * its attribution line exists to say that nothing out there is being claimed — + * "not an observation of any vessel" — and the sheet that prints licence + * obligations is the right place for a disclaimer of one. Radar and birds + * carry NWS and BirdCast the day a projection is configured, and carry nothing + * before that, which is why this reads the body rather than a constant. + */ + if (vesselBounds !== null) lines.push(...(vesselsBody?.attribution ?? [])); + if (boardCarriesSky) { + lines.push(...(radarBody?.attribution ?? [])); + lines.push(...(birdsBody?.attribution ?? [])); + } return lines.filter((line) => line.trim() !== ""); } @@ -2810,6 +3253,7 @@ function renderChrome(): void { } chrome?.apply(chromeState(chromeInputs())); showFireSection(); + showBoardNotes(); // Each plan rings the entry its own legend is showing as current. Both are // updated whichever one is mounted, so the hidden one is already right when it // comes back rather than correcting itself a frame after it appears. diff --git a/src/server/birds.ts b/src/server/birds.ts new file mode 100644 index 0000000..f2ec49a --- /dev/null +++ b/src/server/birds.ts @@ -0,0 +1,400 @@ +/** + * Which birds may be drawn, what the sky says when none may, and the one number + * that is wrong by six times if you compute it the obvious way. + * + * ### The empty state ships first, because it is what most people see + * + * BirdCast measures nocturnal migration and measures it **only after dark**. Of + * the 297 granules in the upstream store, 176 are daytime and hold 104 rows + * between them, against 7,719 at night. The layer is therefore absent about + * fourteen hours in every twenty-four *by construction*, before any question of + * season, and every default frame `scripts/look.mjs` takes is a daylight frame. + * A layer that only looks right during the small fraction of frames where it has + * something to say is a layer that looks broken the rest of the time. + * + * So `quiet` is not optional and is never blank. It carries a reason and a + * sentence, and the sentence is the layer: + * + * Nothing is aloft. BirdCast measures migration only after dark. Last night + * 393,290 birds crossed California heading south-east, peaking at 1,501,193 + * aloft at 23:20 PDT, at a mean 726 metres. + * + * ### The six-times error + * + * `SELECT SUM(birds_crossed)` over the 58 county rows for the night of + * 2026-08-21 gives **2,360,086**. The authoritative figure, on the `US-CA` state + * row for the same night, is **393,290**. Both are correct: a bird that crosses + * four counties is counted in four of them, so the county rows are a spatial + * distribution and the state row is the crossing count. Nothing in this file + * ever sums counties into a headline, and `statewideHeadline` reads the state + * row or returns `null`. + * + * ### The state row has no coordinate, and it is enormous + * + * `counties` in the store is 59 rows, not 58: fifty-eight counties plus `US-CA`, + * `kind='state'`, with **NULL lat and lon** and, in tonight's granule, 793,141 + * birds aloft against the largest county's 82,549. A per-county path that does + * not take it out places a 793,141-bird blob at 0,0 in the Gulf of Guinea — or, + * worse, at whatever a `?? 0` turns it into. It is excluded here on three + * independent grounds, because one of them is the one that will be missed. + * + * Pure: this imports nothing but types, touches no DOM and constructs no mesh. + * The gate is a statement about data, and a statement about data that lives + * inside a mesh builder is a statement nobody can test without a WebGL context. + */ + +import type { MigrationCounty, MigrationField, MigrationNight, MigrationQuiet } from "../engine/types.ts"; +import type { BirdsBody, BirdsSourceId, WireBirdCounty } from "./wire.ts"; + +// ---- The ladder ----------------------------------------------------------- + +/** + * The id of the row that is a state and not a county. + * + * Named rather than inlined because it is checked in more than one place and + * because the *reason* it is checked has to travel with it. See the header. + */ +export const BIRDS_STATE_ROW_ID = "US-CA"; + +/** + * Solar elevation above which nothing may be drawn, in degrees. + * + * Civil twilight. BirdCast's product is nocturnal, so a daytime row is not a + * small measurement — it is a measurement of something the instrument does not + * measure, and 104 of them exist in the store. This is what refuses them, and it + * refuses them whatever the feed says, because being wrong about the sun is not + * a thing this build is prepared to be. + */ +export const BIRDS_MAX_SOLAR_ELEVATION_DEG = -6; + +/** + * Birds aloft below which a county is not drawn at all. + * + * A judgement, and a small one: BirdCast reports continuous fields, so a county + * with four birds over it is a rounding artefact of a forecast raster rather + * than four birds. Set low enough that a genuinely quiet county still shows. + */ +export const BIRDS_MIN_ALOFT = 25; + +/** The most counties one board will draw. Fifty-eight exist; this bounds a bad day. */ +export const BIRDS_COUNTY_LIMIT = 64; + +// ---- Shapes --------------------------------------------------------------- + +/** Everything a board needs to draw migration, and to explain an empty sky. */ +export interface BirdsPromotion { + source: BirdsSourceId; + /** ISO-8601 of the last successful upstream fetch. Epoch zero when never. */ + fetchedAt: string; + /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */ + ageMs: number | null; + /** Handed straight to `MigrationLayer.setField`. `null` only when nothing answered. */ + field: MigrationField | null; + /** Rows the gate refused — the state row, and any county under the floor. */ + suppressed: number; + /** The panel's sentence. Never blank, and never "no data". */ + message: string; +} + +export interface BirdsPromoteOptions { + nowMs?: number; + /** + * The sun's elevation over California, degrees. The same number `Atmosphere` + * is working from, injected rather than recomputed — a second opinion about + * where the sun is, taken from a clock the scrubber does not own, is how a + * night board and a night layer end up disagreeing. + */ + solarElevationDeg?: number; +} + +/** The answer for a board with no feed behind it at all. */ +export function emptyBirdsPromotion(): BirdsPromotion { + return { + source: "none", + fetchedAt: new Date(0).toISOString(), + ageMs: null, + field: null, + suppressed: 0, + message: + "No migration feed is configured, so nothing is drawn over this board. " + + "That is a fact about this box, not about the sky.", + }; +} + +// ---- Exclusion ------------------------------------------------------------ + +/** + * Is this row a county, or is it the state pretending to be one? + * + * Three independent tests, and they are three rather than one because the one + * that gets missed is never the one you thought of. The `US-CA` row fails all + * three today; a future `US-CA-REGION-N` row with real coordinates would fail + * only the first, and a county whose Census join silently produced nulls would + * fail only the second. + */ +export function isCountyRow(row: WireBirdCounty | null | undefined): boolean { + if (row === null || row === undefined || typeof row !== "object") return false; + if (typeof row.id === "string" && row.id.trim().toUpperCase() === BIRDS_STATE_ROW_ID) return false; + if (finite(row.lat) === null || finite(row.lon) === null) return false; + if ((finite(row.areaKm2) ?? 0) <= 0) return false; + return true; +} + +/** + * Every county row, in the order they arrived, with the state row and anything + * unplaceable taken out. + * + * Exported so that `birdsGate.test.ts` can assert the exclusion directly rather + * than inferring it from a rendered field, and so `server/src/birds/index.ts` + * can apply the same rule on the way out. + */ +export function countyRows(rows: readonly WireBirdCounty[]): WireBirdCounty[] { + return rows.filter((row) => isCountyRow(row)); +} + +// ---- The headline --------------------------------------------------------- + +/** + * Last night, from the state row and from nowhere else. + * + * `null` when the body carries no state row, which is the honest answer for a + * box that has been up for less than one night. It is never reconstructed by + * summing counties; see the header. + */ +export function statewideHeadline(body: BirdsBody | null | undefined): MigrationNight | null { + const raw = body?.statewide; + if (raw === null || raw === undefined || typeof raw !== "object") return null; + const crossed = finite(raw.crossed); + const peakAloft = finite(raw.peakAloft); + if (crossed === null || peakAloft === null) return null; + return { + crossed, + peakAloft, + peakAt: typeof raw.peakAt === "string" ? raw.peakAt : "", + meanAltitude: finite(raw.meanAltitude) ?? 0, + heading: typeof raw.heading === "string" && raw.heading !== "" ? raw.heading : "", + }; +} + +/** + * A bearing as the words a caption uses. `130.3` is "south-east". + * + * Sixteen points would be more precise than the number deserves: the state's own + * mean direction is a circular mean over thousands of ten-minute county rows, and + * "east-south-east" claims a resolution that mean does not carry. + */ +export function headingWords(degrees: number): string { + if (!Number.isFinite(degrees)) return ""; + const points = [ + "north", "north-east", "east", "south-east", + "south", "south-west", "west", "north-west", + ]; + const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8; + return points[index] as string; +} + +/** + * An ISO instant in California's own clock, as `23:20 PDT`. + * + * `Intl` rather than a fixed offset, because California is UTC-7 for most of the + * year and UTC-8 for the rest, and a caption that says PDT in January is the + * kind of wrong nobody notices for months. Falls back to the UTC form where + * `Intl` has no time-zone data at all, which is a real configuration of Node. + */ +export function californiaClock(iso: string): string { + const ms = Date.parse(iso); + if (!Number.isFinite(ms)) return ""; + try { + return new Intl.DateTimeFormat("en-GB", { + timeZone: "America/Los_Angeles", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZoneName: "short", + }).format(new Date(ms)); + } catch { + return `${new Date(ms).toISOString().slice(11, 16)} UTC`; + } +} + +/** + * The empty sky's sentence. + * + * Always says three things: that nothing is aloft, *why* — which is a fact about + * the instrument and not about the birds — and what last night did, so that a + * viewer who arrives at noon still learns something true. Only the third part is + * conditional, and it is missing only on a box that has not yet seen a night. + */ +export function quietMessage( + reason: MigrationQuiet["reason"], + statewide: MigrationNight | null, +): string { + const why = + reason === "daylight" + ? "Nothing is aloft. BirdCast measures migration only after dark." + : reason === "off-season" + ? "Nothing is aloft over California tonight. The radars are reporting and every county is quiet." + : "Nothing has answered for the sky over California yet."; + + if (statewide === null) return why; + + const heading = statewide.heading === "" ? "" : ` heading ${statewide.heading}`; + const peak = statewide.peakAt === "" ? "" : ` at ${californiaClock(statewide.peakAt)}`; + const altitude = + statewide.meanAltitude > 0 ? `, at a mean ${Math.round(statewide.meanAltitude)} metres` : ""; + return ( + `${why} Last night ${count(statewide.crossed)} birds crossed California${heading}, ` + + `peaking at ${count(statewide.peakAloft)} aloft${peak}${altitude}.` + ); +} + +// ---- The gate ------------------------------------------------------------- + +/** + * Apply the ladder to one body. + * + * Pure and total: `null`, a malformed body, or a body from a server one version + * behind all produce an honest empty sky rather than an exception. The consumer + * is a render loop. + * + * **Daylight wins over the feed.** If the sun is up the answer is `daylight` + * whatever arrived, because a daytime BirdCast row is a measurement of something + * the instrument does not measure. + */ +export function promoteBirds( + body: BirdsBody | null | undefined, + options: BirdsPromoteOptions = {}, +): BirdsPromotion { + const nowMs = options.nowMs ?? Date.now(); + const empty = emptyBirdsPromotion(); + if (body === null || body === undefined || typeof body !== "object") return empty; + + const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt; + const fetchedMs = Date.parse(fetchedAt); + const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null; + const source: BirdsSourceId = body.source === "cloud1" ? "cloud1" : "none"; + const observedAt = typeof body.observedAt === "string" ? body.observedAt : ""; + const statewide = statewideHeadline(body); + + if (ageMs === null) { + // Nothing has ever answered. Not "the sky is empty" — "nobody has spoken". + return { ...empty, source, fetchedAt }; + } + + const rows = Array.isArray(body.counties) ? body.counties : []; + const placed = countyRows(rows); + const counties: MigrationCounty[] = []; + for (const row of placed) { + const aloft = finite(row.aloft) ?? 0; + if (aloft < BIRDS_MIN_ALOFT) continue; + counties.push({ + id: typeof row.id === "string" ? row.id : "", + name: typeof row.name === "string" ? row.name : "", + lat: finite(row.lat) as number, + lng: finite(row.lon) as number, + areaKm2: finite(row.areaKm2) as number, + aloft, + altitude: Math.max(0, finite(row.altitude) ?? 0), + direction: finite(row.direction) ?? 0, + speed: Math.max(0, finite(row.speed) ?? 0), + }); + } + counties.sort((a, b) => b.aloft - a.aloft); + const drawn = counties.slice(0, BIRDS_COUNTY_LIMIT); + const suppressed = rows.length - drawn.length; + + const sun = options.solarElevationDeg; + const daylight = typeof sun === "number" && Number.isFinite(sun) && sun > BIRDS_MAX_SOLAR_ELEVATION_DEG; + + let quiet: MigrationQuiet | null = null; + if (daylight) { + quiet = { reason: "daylight", message: quietMessage("daylight", statewide) }; + } else if (drawn.length === 0) { + // Dark, the feed answered, and every county is quiet. Reported as the + // seasonal case rather than as a fault, because that is what it is: the + // store holds two nights and no year of history, so "off-season" is the + // honest name for "dark and nothing flying" until there is one. + const reason: MigrationQuiet["reason"] = body.quiet?.reason === "no-data" ? "no-data" : "off-season"; + quiet = { reason, message: quietMessage(reason, statewide) }; + } + + const field: MigrationField = { + counties: quiet === null ? drawn : [], + observedAt, + statewide, + quiet, + }; + + return { + source, + fetchedAt, + ageMs, + field, + suppressed: quiet === null ? Math.max(0, suppressed) : rows.length, + message: quiet !== null ? quiet.message : busyMessage(drawn, statewide, observedAt), + }; +} + +/** + * The sentence for a night with something in it. + * + * Note what it does **not** say: how many birds are over California right now. + * The only way to get that from what crosses the wire is to add the counties up, + * and that is the six-times error. The state row's instantaneous figure is not on + * `MigrationNight`, so the honest headline available here is last night's + * crossing count, and the present tense is spent on where and how high instead. + */ +function busyMessage( + counties: readonly MigrationCounty[], + statewide: MigrationNight | null, + observedAt: string, +): string { + const mean = + counties.length === 0 + ? 0 + : counties.reduce((sum, c) => sum + c.altitude * c.aloft, 0) / + Math.max(1, counties.reduce((sum, c) => sum + c.aloft, 0)); + const bearing = + counties.length === 0 ? "" : headingWords(circularMean(counties)); + const when = observedAt === "" ? "" : ` As of ${californiaClock(observedAt)}.`; + const last = + statewide === null ? "" : ` Last night ${count(statewide.crossed)} crossed the state.`; + return ( + `Birds are aloft over ${counties.length} ` + + `${counties.length === 1 ? "county" : "counties"}` + + `${bearing === "" ? "" : `, drifting ${bearing}`}` + + `${mean > 0 ? `, a mean ${Math.round(mean)} metres up` : ""}.` + + `${last}${when}` + ); +} + +/** + * The mean direction of travel, weighted by birds aloft — **circularly**. + * + * An arithmetic mean of bearings is wrong at the wrap: 350 and 10 average to 180 + * and point the whole state due south when it is flying due north. Tonight's + * rows sit in the 120-150 degree bucket and would survive a naive mean, which is + * exactly why this would ship broken and stay broken. + */ +function circularMean(counties: readonly MigrationCounty[]): number { + let x = 0; + let y = 0; + for (const county of counties) { + const weight = Math.max(0, county.aloft); + const radians = (county.direction * Math.PI) / 180; + x += weight * Math.cos(radians); + y += weight * Math.sin(radians); + } + if (x === 0 && y === 0) return 0; + return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360; +} + +// ---- Small helpers -------------------------------------------------------- + +function count(value: number): string { + return Math.round(value).toLocaleString("en-US"); +} + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/src/server/radar.ts b/src/server/radar.ts new file mode 100644 index 0000000..8b0e7f9 --- /dev/null +++ b/src/server/radar.ts @@ -0,0 +1,735 @@ +/** + * Which reflectivity may be drawn — and, on nine days in ten, that none may. + * + * The radar layer has the fire layer's failure mode waiting for it, and the + * evidence is already in the repo: DATA-CATALOG.md:84 records a **54.5 dBZ cell + * between Long Beach and Catalina on a clear day**. Tonight's own frame carries + * 70.5 dBZ over the Catalina channel and thirteen cells at or above 40 dBZ + * strung down the Pacific between 32.6 N and 35.6 N, none of them touching land. + * A thunderstorm painted over the ocean on a blue afternoon spends the board's + * credibility exactly the way twenty-two orange marks over Los Angeles would + * have, and there is no polish that buys it back. + * + * So this file decides three things before any mesh builder sees a cell, and it + * decides them where they can be tested without a GL context — the argument + * `src/engine/fires.ts:16-23` makes, applied to the sky. + * + * ### 1. Anomalous propagation is dropped + * + * A superrefracting marine inversion bends the beam down into the sea surface + * and the radar reports the swell as rain. It is the classic Southern + * California artefact, the collector applies no filter to it, and it shows up + * as *strong* returns — which is what makes it dangerous rather than merely + * untidy. + * + * The rule is deliberately narrow, and it works on **connected blobs rather than + * on single cells**, which is the one thing about it worth arguing over. An + * eight-connected run of echo is refused only when *no cell in the whole run* + * sits over land, its strongest cell reaches `AP_MIN_DBZ`, and it is no larger + * than `AP_MAX_CELLS`. Rain coming ashore is one blob with cells on both sides + * of the shoreline and survives entire; a 47.5 dBZ patch sitting alone off Big + * Sur does not. Weak marine echo is left alone completely, because drizzle under + * a stratus deck offshore is real and is the commonest thing the Pacific has to + * say. + * + * **A per-cell version of this rule was written first and measured wrong.** On + * tonight's frame it refused twelve cells, and three of them — 34.375,-121.125 + * at 55 dBZ among them — were the seaward fringe of a fifty-four-cell system + * whose other end is over Santa Barbara. Deleting them punches holes in the + * middle of a real storm, which is a different lie from the one this file + * exists to prevent but is still a lie. Decomposed into blobs, tonight's frame + * has twenty-three of them and exactly **one** is offshore-only: nine cells + * peaking at 38.5 dBZ, which is under the threshold and is therefore kept, and + * which is the right answer for marine drizzle west of Point Arguello. + * + * ### 2. "Not California" is not "the sea", and this nearly went wrong + * + * The obvious land test is "inside the pack's coastline polygon". Measured + * against tonight's frame on the **extended** board, that test calls + * `41.375,-117.125` at 53 dBZ sea clutter. It is in Nevada. The board now + * reaches -114.0 and 42.05 N, so a third of it is land the California polygon + * has never traced, and a rule written for the cropped board would have deleted + * a Great Basin thunderstorm as an ocean artefact. + * + * Hence three classes, not two: `land` (inside the trace), `sea` (outside it + * *and west of the coast at that latitude*), and `unmapped` — Nevada, Oregon, + * Arizona, and the inside of San Francisco Bay. Only `sea` is ever refused. + * + * ### 3. A radar that is down leaves a hole, and a hole is not dry weather + * + * `null` in `RadarField.dbz` means *the radar that should be looking there is + * off the air*. A cell inside a dead station's ring that no working station + * covers is `null`; everything else is `RADAR_DRY_DBZ`. Those are different + * claims and the type is nullable so they can stay different. + * + * It is claimed **narrowly**, and the first draft claimed it too widely. Marking + * every cell outside the range of all sixteen known stations put a pale wash + * over 201 of 1,596 cells — the far Pacific corner and the Nevada line — which + * looked like weather in the frame and was a claim made from a partial list: the + * table is a regional subset of a national composite that every WSR-88D in the + * country feeds. The picture found that; the tests could not have. + * + * **"Not on-line" is not "down", and this is the trap in the operability + * column.** Of sixteen stations right now, six read `RDA - Maintenance Action + * Mandatory` — KVTX (Los Angeles), KNKX (San Diego), KDAX (Sacramento), KBBX, + * KYUX and KLRX. Every one of them is transmitting; the flag is a work order. + * Treating "anything but on-line" as down would blank the southern half of the + * state as unknown on a night with a monsoon over it. Only `Inoperable`, + * `Off-line` and `Shutdown` mean no beam, and `stationIsDown` says so in one + * regular expression rather than in a policy spread over a service. + * + * ### Promotion is decided from coverage, not from cell count + * + * A cell count is a fact about how much of the lattice the board happens to + * contain, and the board's bounds changed *this round*: a threshold tuned + * against the cropped board would have silently changed meaning under it. + * `wetFraction` — the share of California at or above the rain threshold, as + * measured upstream over the whole state's pixels — is scale-free and survives + * the next board too. + * + * Everything here is pure and imports nothing but types. It is imported by the + * browser *and* by `server/src/radar/index.ts`, which is how one statement about + * the data serves both ends of the wire. + */ + +import type { RadarField } from "../engine/types.ts"; +import type { RadarBody, RadarSourceId } from "./wire.ts"; + +// ---- The ladder ----------------------------------------------------------- + +/** + * At and above this, an isolated marine cell is treated as anomalous + * propagation rather than as weather. + * + * Forty dBZ is heavy rain — 12 mm an hour and up. Real convection that strong + * over open water does happen, and when it does it is almost never detached + * from everything else on the map, which is why the isolation test carries as + * much weight here as the threshold does. Below forty nothing is refused at + * all: marine stratus drizzle is genuine and is most of what the Pacific ever + * shows. + */ +export const AP_MIN_DBZ = 40; + +/** + * The largest offshore-only blob this will refuse. + * + * **An untested judgement, and it is worth saying so out loud** — the same + * posture `FIRE_TIER_MIN_ACRES` takes, for the same reason. Anomalous + * propagation is a near-field artefact of one radar's clutter ring, so it comes + * in patches; a winter frontal band arriving off the Pacific is enormous and is + * in any case joined to the coastal echo long before its core reaches 40 dBZ. + * Twenty-four cells is about 18,000 km², roughly one thunderstorm's rain shield, + * and it is a cap on how much weather one bad night can delete rather than a + * claim about clutter. The store has never yet held an offshore-only blob above + * the threshold at all, so this boundary has never been exercised against the + * case it exists for. If it bites, it is one constant — and `RadarFieldBuild` + * carries every refused cell so the number is visible rather than silent. + */ +export const AP_MAX_CELLS = 24; + +/** + * The lowest reflectivity this build treats as precipitation, in dBZ. + * + * 20 dBZ is the conventional rain threshold and it is also where the upstream + * collector cuts — every one of the 8,782 rows in the store is at or above it — + * so it is a fact about the data rather than a judgement made here. It lives in + * the gate rather than beside the colour ramp because it is a statement about + * water, not about pixels; `assets/radarRamp.ts` and `engine/precip.ts` both + * read it from here so that the threshold cannot disagree with itself. + */ +export const RADAR_RAIN_DBZ = 20; + +/** + * The share of California that must be raining before the sheet is drawn. + * + * A tenth of a percent is about 420 km² — one thunderstorm's rain shield. + * Tonight's quietest frame measured 0.472% and the twenty-six stored frames run + * 0.472% to 1.95%, so this floor is five times below anything real that has been + * seen; what it refuses is a frame whose entire content was clutter the rule + * above has already taken out. Below it the honest picture is an empty sky and a + * sentence, not sixteen hundred texels at two percent opacity. + */ +export const RADAR_MIN_WET_FRACTION = 0.001; + +/** + * What a working radar reports for a cell it can see nothing in. + * + * Not `null`, which is reserved for "nobody is looking". The upstream collector + * thresholds at 20 dBZ and writes no row below it, so the honest statement about + * a covered, empty cell is "under twenty", and this is the number that stands + * for it. Anything below `RADAR_RAIN_DBZ` draws at zero alpha, so the value + * itself is never seen — only the difference between it and `null` is. + */ +export const RADAR_DRY_DBZ = 0; + +/** + * Unambiguous reach of one radar, in kilometres, by station type. + * + * 230 km is the WSR-88D's base reflectivity range; the TDWR is a terminal radar + * and reaches about 90. Used only to answer "is anybody looking at this cell", + * so an error here costs a hole in the wrong place rather than a wrong echo. + */ +export const RADAR_RANGE_KM: Readonly> = { + "WSR-88D": 230, + TDWR: 90, +}; +const DEFAULT_RANGE_KM = 230; + +/** The lattice pitch of the NEXRAD composite the collector reduces to. Degrees. */ +export const RADAR_CELL_DEG = 0.25; + +/** + * The operability strings that mean the beam is off. + * + * Read the header before widening this. `Maintenance Action Mandatory` and + * `Maintenance Action Required` are work orders on a radar that is transmitting, + * and six of sixteen stations carry one right now. + */ +const RDA_DOWN = /\b(inoperable|off-?line|shut-?down|shutdown)\b/i; + +// ---- Shapes --------------------------------------------------------------- + +/** A rectangle in degrees. Restated rather than imported; see `fires.ts`. */ +export interface RadarBounds { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +/** + * A vertex of a coastline trace: `[lat, lng]`. + * + * Structurally `LatLng` from `engine/types.ts`, restated as a tuple rather than + * imported so this module stays importable by a test with no renderer in it. + * The tuple, and not an object, because a city pack's `landmasses` is passed + * straight in and a shape mismatch here reads as "every cell is inland" — which + * is silent, and which cost the first draft of this file an hour. + */ +export type RadarPoint = readonly [number, number]; + +/** One reduced cell as the collector writes it: a centre and a maximum. */ +export interface RadarCell { + lat: number; + lon: number; + dbz: number; +} + +/** + * One radar and whether it is producing. + * + * `operability` is carried verbatim rather than pre-judged upstream, for the + * same reason `FireDetection.confidence` is: the branch belongs in one place + * where the argument for it can be read, and MODIS/VIIRS taught this repo what + * happens when it is not. + */ +export interface RadarStation { + id: string; + lat: number; + lon: number; + /** `WSR-88D`, `TDWR`, or whatever the upstream said. */ + type?: string | null; + operability?: string | null; +} + +/** Where a cell centre sits, as far as the coastline trace can tell. */ +export type CellGround = "land" | "sea" | "unmapped"; + +/** What `buildRadarField` decided, including what it refused. */ +export interface RadarFieldBuild { + /** The lattice, or `null` when nothing was promoted. */ + field: RadarField | null; + /** Cells the anomalous-propagation rule refused. Kept, never silently dropped. */ + suppressed: RadarCell[]; + /** Lattice cells no operating radar covers. */ + unknown: number; + /** Lattice cells at or above `RADAR_RAIN_DBZ` after the gate. */ + wetCells: number; + /** The strongest surviving cell, and where it was. */ + peak: { dbz: number; lat: number; lng: number } | null; + /** Why nothing was promoted, when nothing was. `null` when the field is drawn. */ + quiet: "no-coverage" | "below-threshold" | "nothing-survived" | null; +} + +/** Everything a board needs to draw rain, and to explain an empty sky. */ +export interface RadarPromotion { + source: RadarSourceId; + /** ISO-8601 of the last successful upstream fetch. Epoch zero when never. */ + fetchedAt: string; + /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */ + ageMs: number | null; + /** Handed straight to `PrecipLayer.setField`. `null` draws nothing at all. */ + field: RadarField | null; + /** Statewide rain coverage, 0..1, as measured upstream over the whole state. */ + wetFraction: number; + stations: number; + stationsDown: number; + /** One true, specific sentence. Never blank, and never "no data". */ + message: string; +} + +/** The answer for a board with no feed behind it at all. */ +export function emptyRadarPromotion(): RadarPromotion { + return { + source: "none", + fetchedAt: new Date(0).toISOString(), + ageMs: null, + field: null, + wetFraction: 0, + stations: 0, + stationsDown: 0, + message: + "No radar feed is configured, so this board draws no weather. " + + "That is a fact about this box, not about the sky.", + }; +} + +// ---- Land, sea, and the third thing --------------------------------------- + +/** + * Even-odd ray cast. A copy of `World.pointInPolygon`, because this module must + * be importable by a test with no renderer in it and by the server, and the + * engine's copy arrives with three.js attached. + */ +export function insidePolygon(lat: number, lng: number, poly: readonly RadarPoint[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i]; + const b = poly[j]; + if (a === undefined || b === undefined) continue; + const [latI, lngI] = a; + const [latJ, lngJ] = b; + if (latI > lat === latJ > lat) continue; + const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI; + if (lng < crossing) inside = !inside; + } + return inside; +} + +/** + * Where the Pacific shore sits at one latitude: the westernmost point at which + * the coastline trace crosses that parallel. + * + * **Interpolated across the crossing edge, not taken from the nearest vertex.** + * The first draft took the westernmost *vertex* within half a degree, and on a + * coast that runs diagonally that is wrong by the whole diagonal: between 35.1 N + * and 36.1 N the shore moves from -120.7 to -121.9, so a band rule put the sea + * boundary a degree too far west and quietly declared the Big Sur clutter to be + * inland. Crossings are exact and cost the same. + * + * `null` where the trace says nothing about that latitude at all, which is the + * conservative answer: with no coast to be west of, nothing is called sea and + * nothing is refused. + */ +export function coastLongitudeAt( + lat: number, + coast: readonly (readonly RadarPoint[])[], +): number | null { + let west: number | null = null; + for (const poly of coast) { + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[i]; + const b = poly[j]; + if (a === undefined || b === undefined) continue; + const [latI, lngI] = a; + const [latJ, lngJ] = b; + if (latI > lat === latJ > lat) continue; + const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI; + if (west === null || crossing < west) west = crossing; + } + } + return west; +} + +/** + * Land, open sea, or somewhere this board's coastline has no opinion about. + * + * The third class is the whole point — see the header. Nevada is not the + * Pacific, and on the extended board there is a great deal of Nevada. + */ +export function classifyGround( + lat: number, + lng: number, + coast: readonly (readonly RadarPoint[])[], +): CellGround { + for (const poly of coast) { + if (insidePolygon(lat, lng, poly)) return "land"; + } + const west = coastLongitudeAt(lat, coast); + if (west !== null && lng < west) return "sea"; + return "unmapped"; +} + +// ---- Stations ------------------------------------------------------------- + +/** + * Whether this radar's beam is off. + * + * See the header: six of sixteen stations read `Maintenance Action Mandatory` + * right now and every one of them is transmitting. An absent or empty string is + * *unknown*, and unknown is not down — a station this box has never heard a + * status for is assumed to be working, because the alternative is a board that + * greys out whenever the status feed hiccups. + */ +export function stationIsDown(operability: string | null | undefined): boolean { + if (typeof operability !== "string") return false; + return RDA_DOWN.test(operability); +} + +/** Unambiguous reach of one station, in kilometres. */ +export function stationRangeKm(station: RadarStation): number { + const type = typeof station.type === "string" ? station.type.trim() : ""; + return RADAR_RANGE_KM[type] ?? DEFAULT_RANGE_KM; +} + +/** + * Great-circle distance in kilometres. Haversine, on a spherical earth. + * + * Good to a few parts in a thousand at these ranges, against a 230 km radius + * that is itself a round number, so the error is three orders of magnitude below + * the thing being decided. + */ +export function distanceKm( + aLat: number, + aLng: number, + bLat: number, + bLng: number, +): number { + const R = 6371.0088; + const dLat = ((bLat - aLat) * Math.PI) / 180; + const dLng = ((bLng - aLng) * Math.PI) / 180; + const s = + Math.sin(dLat / 2) ** 2 + + Math.cos((aLat * Math.PI) / 180) * + Math.cos((bLat * Math.PI) / 180) * + Math.sin(dLng / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(s))); +} + +// ---- Building the lattice ------------------------------------------------- + +export interface RadarFieldInput { + cells: readonly RadarCell[]; + stations: readonly RadarStation[]; + bounds: RadarBounds; + /** The board's own coastline traces. `city.landmasses`, passed rather than reached for. */ + coast: readonly (readonly RadarPoint[])[]; + /** ISO-8601 of the volume scan. */ + observedAt: string; + /** Statewide rain coverage 0..1, measured upstream over the whole state's pixels. */ + wetFraction: number; + cellDeg?: number; +} + +/** + * Turn a bag of cells into the lattice the layer draws, or into nothing. + * + * Pure and total. Called on **both** sides of the wire — by + * `server/src/radar/index.ts` when it builds a `RadarBody`, and by a test — so + * that the statement about the data is made once. + */ +export function buildRadarField(input: RadarFieldInput): RadarFieldBuild { + const cellDeg = input.cellDeg ?? RADAR_CELL_DEG; + const bounds = input.bounds; + const wetFraction = finite(input.wetFraction) ?? 0; + + const stations = input.stations.filter( + (s) => finite(s.lat) !== null && finite(s.lon) !== null, + ); + const down = stations.filter((s) => stationIsDown(s.operability)); + const up = stations.filter((s) => !stationIsDown(s.operability)); + + // The lattice is the product's own grid clipped to the board, so a texel is a + // cell and never a resampling of one. Centres sit on the half-cell. + const minLat = Math.ceil((bounds.minLat - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2; + const minLng = Math.ceil((bounds.minLng - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2; + const rows = Math.floor((bounds.maxLat - minLat) / cellDeg) + 1; + const cols = Math.floor((bounds.maxLng - minLng) / cellDeg) + 1; + if (rows <= 0 || cols <= 0) { + return { field: null, suppressed: [], unknown: 0, wetCells: 0, peak: null, quiet: "no-coverage" }; + } + + const index = (row: number, col: number) => row * cols + col; + const rowOf = (lat: number) => Math.round((lat - minLat) / cellDeg); + const colOf = (lng: number) => Math.round((lng - minLng) / cellDeg); + + // Ground class per lattice cell, computed once. The AP rule needs a cell's + // neighbours' classes, and re-testing a 135-point polygon eight times per cell + // would be eight times the work for the same answer. + const ground: CellGround[] = new Array(rows * cols); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + ground[index(r, c)] = classifyGround(minLat + r * cellDeg, minLng + c * cellDeg, input.coast); + } + } + + // Echo, keyed by lattice slot. A cell arriving twice keeps the stronger + // return, which is what the collector's own "max within the cell" means. + const echo = new Map(); + for (const cell of input.cells) { + const lat = finite(cell?.lat); + const lon = finite(cell?.lon); + const dbz = finite(cell?.dbz); + if (lat === null || lon === null || dbz === null) continue; + const r = rowOf(lat); + const c = colOf(lon); + if (r < 0 || r >= rows || c < 0 || c >= cols) continue; + const slot = index(r, c); + const held = echo.get(slot); + if (held === undefined || dbz > held.dbz) echo.set(slot, { lat, lon, dbz }); + } + + // Anomalous propagation. Refused by CONNECTED BLOB, not cell by cell, and the + // difference is the whole rule — see `AP_MAX_CELLS` and the header. + const suppressed: RadarCell[] = []; + { + const slots = [...echo.keys()]; + const visited = new Set(); + for (const start of slots) { + if (visited.has(start)) continue; + const blob: number[] = []; + const stack = [start]; + visited.add(start); + let touchesLand = false; + let peakDbz = -Infinity; + while (stack.length > 0) { + const slot = stack.pop() as number; + blob.push(slot); + if (ground[slot] !== "sea") touchesLand = true; + const cell = echo.get(slot); + if (cell !== undefined && cell.dbz > peakDbz) peakDbz = cell.dbz; + const r = Math.floor(slot / cols); + const c = slot % cols; + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + if (dr === 0 && dc === 0) continue; + const nr = r + dr; + const nc = c + dc; + if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; + const neighbour = index(nr, nc); + if (!echo.has(neighbour) || visited.has(neighbour)) continue; + visited.add(neighbour); + stack.push(neighbour); + } + } + } + if (touchesLand) continue; + if (peakDbz < AP_MIN_DBZ) continue; + if (blob.length > AP_MAX_CELLS) continue; + for (const slot of blob) { + const cell = echo.get(slot); + if (cell !== undefined) suppressed.push(cell); + echo.delete(slot); + } + } + } + + // Fill. `null` is "no operating radar covers this", which is a different + // statement from "a radar looked and saw nothing" and has its own value. + const dbz: (number | null)[] = new Array(rows * cols); + let unknown = 0; + let wetCells = 0; + let peak: { dbz: number; lat: number; lng: number } | null = null; + + for (let r = 0; r < rows; r++) { + const lat = minLat + r * cellDeg; + for (let c = 0; c < cols; c++) { + const slot = index(r, c); + const lng = minLng + c * cellDeg; + const hit = echo.get(slot); + if (hit !== undefined) { + dbz[slot] = hit.dbz; + if (hit.dbz >= RADAR_RAIN_DBZ) wetCells += 1; + if (peak === null || hit.dbz > peak.dbz) peak = { dbz: hit.dbz, lat, lng }; + continue; + } + // `null` is claimed narrowly, and the reason is worth reading. The + // station table this box is handed is a REGIONAL subset of the national + // composite's contributors — sixteen radars around California, against a + // product every WSR-88D in the country feeds. So "outside the range of + // all sixteen" is not "unobserved", and painting the far Pacific corner + // and the Nevada line as unknown on that basis would be a claim made from + // a partial list. What this box *can* say is that a radar it has a status + // for is off the air and nothing it knows of covers the hole. + const covered = up.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s)); + const blind = !covered && down.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s)); + if (!blind) { + dbz[slot] = RADAR_DRY_DBZ; + continue; + } + dbz[slot] = null; + unknown += 1; + } + } + + const quiet: RadarFieldBuild["quiet"] = + wetCells === 0 + ? "nothing-survived" + : wetFraction < RADAR_MIN_WET_FRACTION + ? "below-threshold" + : null; + + if (quiet !== null) { + return { field: null, suppressed, unknown, wetCells, peak, quiet }; + } + + return { + field: { + minLat, + minLng, + cellLat: cellDeg, + cellLng: cellDeg, + rows, + cols, + dbz, + observedAt: input.observedAt, + wetFraction, + stations: stations.length, + stationsDown: down.length, + }, + suppressed, + unknown, + wetCells, + peak, + quiet: null, + }; +} + +// ---- The client gate ------------------------------------------------------ + +/** + * Read one `RadarBody` and decide what this board draws. + * + * Pure and total: a malformed body, a body from a server one version behind, or + * `null` all produce an honest empty sky rather than an exception. The consumer + * is a render loop. + */ +export function promoteRadar( + body: RadarBody | null | undefined, + nowMs: number = Date.now(), +): RadarPromotion { + const empty = emptyRadarPromotion(); + if (body === null || body === undefined || typeof body !== "object") return empty; + + const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt; + const fetchedMs = Date.parse(fetchedAt); + const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null; + const source: RadarSourceId = body.source === "cloud1" ? "cloud1" : "none"; + + const field = readField(body.field); + const wetFraction = field?.wetFraction ?? 0; + const stations = field?.stations ?? 0; + const stationsDown = field?.stationsDown ?? 0; + + if (field === null) { + return { + source, + fetchedAt, + ageMs, + field: null, + wetFraction, + stations, + stationsDown, + message: + ageMs === null + ? empty.message + : `Nothing is falling on this board. ${stationLine(stations, stationsDown)} ` + + `Last scan ${age(ageMs)}.`, + }; + } + + let wet = 0; + let unknown = 0; + let peak = -Infinity; + for (const value of field.dbz) { + if (value === null) { + unknown += 1; + continue; + } + if (value >= RADAR_RAIN_DBZ) wet += 1; + if (value > peak) peak = value; + } + + return { + source, + fetchedAt, + ageMs, + field, + wetFraction, + stations, + stationsDown, + message: + `Rain over ${(wetFraction * 100).toFixed(2)}% of California — ` + + `${wet} cell${wet === 1 ? "" : "s"} of ${field.rows * field.cols}, ` + + `strongest ${peak.toFixed(1)} dBZ. ` + + `${stationLine(stations, stationsDown)}` + + (unknown > 0 ? ` ${unknown} cells have no radar over them. ` : " ") + + `Scan ${field.observedAt}${ageMs === null ? "" : `, ${age(ageMs)}`}.`, + }; +} + +// ---- Small helpers -------------------------------------------------------- + +function stationLine(stations: number, down: number): string { + if (stations === 0) return "No radar has reported."; + const up = Math.max(0, stations - down); + return down === 0 + ? `All ${stations} radars reporting.` + : `${up} of ${stations} radars reporting, ${down} off the air.`; +} + +function age(ms: number): string { + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "under a minute old"; + if (minutes < 120) return `${minutes} minute${minutes === 1 ? "" : "s"} old`; + const hours = Math.round(minutes / 60); + return `${hours} hour${hours === 1 ? "" : "s"} old`; +} + +/** + * Read the wire's lattice, or refuse it. + * + * A `dbz` array whose length disagrees with `rows * cols` is not a field with a + * problem, it is a field that would be drawn *rotated* — every row after the + * first offset by the difference. Refused whole rather than padded. + */ +function readField(raw: RadarBody["field"] | undefined): RadarField | null { + if (raw === null || raw === undefined || typeof raw !== "object") return null; + const rows = finite(raw.rows); + const cols = finite(raw.cols); + const minLat = finite(raw.minLat); + const minLng = finite(raw.minLng); + const cellLat = finite(raw.cellLat); + const cellLng = finite(raw.cellLng); + if (rows === null || cols === null || rows <= 0 || cols <= 0) return null; + if (minLat === null || minLng === null || cellLat === null || cellLng === null) return null; + if (cellLat <= 0 || cellLng <= 0) return null; + if (!Array.isArray(raw.dbz) || raw.dbz.length !== rows * cols) return null; + + const dbz: (number | null)[] = new Array(rows * cols); + for (let i = 0; i < dbz.length; i++) { + dbz[i] = finite(raw.dbz[i]); + } + + return { + minLat, + minLng, + cellLat, + cellLng, + rows, + cols, + dbz, + observedAt: typeof raw.observedAt === "string" ? raw.observedAt : "", + wetFraction: clamp01(finite(raw.wetFraction) ?? 0), + stations: Math.max(0, Math.round(finite(raw.stations) ?? 0)), + stationsDown: Math.max(0, Math.round(finite(raw.stationsDown) ?? 0)), + }; +} + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} diff --git a/src/server/vessels.ts b/src/server/vessels.ts new file mode 100644 index 0000000..eb33521 --- /dev/null +++ b/src/server/vessels.ts @@ -0,0 +1,973 @@ +/** + * Which hulls may be drawn, which way they point, and where they are *now*. + * + * This is the vessel feed's whole conscience and, like `server/fires.ts`, it is + * the file with the least code in it. Everything else moves bytes; this decides + * whether a harbour that looks alive is telling the truth. + * + * It is pure, it imports nothing but types, and it touches neither three.js nor + * the DOM. That is not tidiness: every claim below is a claim about *data*, and + * a claim about data that lives inside a mesh builder is a claim nobody can test + * without a WebGL context. `src/test/integration/barrel.test.ts` asserts the no- + * three half of that out loud. + * + * ### Three AIS sentinels, none of them NULL + * + * AIS encodes "not available" as perfectly valid numbers, in band, in the same + * column as the real ones: + * + * | field | sentinel | how often, in the store behind this | + * |---------|----------|-------------------------------------| + * | `sog` | 102.3 kn | 7 of 1,138 rows — and the only sog >= 40 | + * | `heading`| 511 | 450 of 1,138 rows — **40%** | + * | `cog` | 360.0 | 120 of 1,138 rows | + * + * Zero of the three columns are ever NULL, so a null check catches none of + * them. Two of the three are actively dangerous rather than merely wrong: + * + * 1. **102.3 kn is 52.6 m/s.** Dead-reckoned for sixty seconds that is 3.2 km — + * eight SoCal scene units — so one unstripped row throws a hull across the + * breakwater and out to sea while every test still passes. + * 2. **`cog % 360` is a booby trap and it will look correct.** Real course over + * ground reaches 358.7 (355.0, 355.4, 355.7, 356.3, 356.9, 357.0 and 358.7 + * all occur in the store) and the sentinel is exactly 360.0. The obvious + * normalisation therefore turns every unknown course into *due north*: the + * unknown-course fleet quietly lines up facing the same way and nothing in + * the suite notices. `aisCourse` rejects on the exact value and passes 358.7 + * through untouched, and `vesselGate.test.ts` asserts both halves — the + * second assertion is the one that matters. + * + * ### Gate motion on speed; label with `nav_status` + * + * Never the reverse. Of 197 vessels reporting `nav_status` 0, "under way using + * engine", **83 are sitting at under half a knot** — 42% disagreement, and + * speed over ground is the truthful one of the pair. So `nav_status` reaches the + * renderer as a word on a card (`VesselStatus`) and never as a gate on the + * dead-reckoner, and a fix that says "under way" at 0.2 kn is stopped. + * + * ### Orientation comes from the berth, because half the fleet has none + * + * Of 150 vessels whose latest fix is under 0.5 kn, only 75 report a real + * heading, 126 a real course, and **21 have neither**. For `nav_status` 5 + * (moored) specifically it is 24 of 37. A berthed hull's orientation therefore + * cannot come from the wire for at least half the fleet, and taking it from the + * berth is the only correct answer rather than a shortcut: a ship lying + * alongside a quay points the way the quay does, which is a fact about the + * concrete and is known before any ship arrives. + * + * The ladder in `resolveBearing` is berth, then heading, then course, and the + * berth wins outright when there is one. A hull we can neither orient from the + * quay nor from the wire is **suppressed and counted**, not spun to an invented + * angle — see `VesselPromotion.withoutOrientation`. + * + * ### Dead-reckon along the course; never spline between fixes + * + * Upstream listens for thirty seconds every fifteen minutes, so a hull under way + * has moved about five kilometres between two samples. The chord between two + * fixes is not a path anything took — a ship rounding the breakwater would be + * drawn cutting straight across it — and this is the lesson the aircraft layer + * already paid for: `Aircraft` sat frozen between snapshots for months precisely + * because the wire could not express a velocity. AIS *can*: every fix carries + * `sog` and `cog`, so `reckonVessel` advances along the reported course at the + * reported speed and no code path in this module can produce a point between two + * observations. `vesselGate.test.ts` asserts that as a geometric property rather + * than as an absence of a function. + * + * ### The source is `modelled` this round, and it is anonymous + * + * `modelHarbour` builds a `VesselsBody` from a board's own authored berths and + * channels. It exists because the cloud-1 projection for `sea.sqlite` does not: + * `/api/fires/incidents` answers, `/api/sea`, `/api/vessels` and `/api/ships` + * all 404, and the aisstream licence that would let real positions reach a page + * served `Cache-Control: public` is unread. Building the seam and driving it + * from a deterministic simulator is what lets the layer be finished and honest + * at the same time. + * + * It is called `modelled` rather than `sim` deliberately, and it carries **no + * name, no MMSI, no callsign and no destination**. The store has real ones in it + * right now — the temptation is to hardcode them — and that would be the fire + * layer's twenty-two orange marks in a nicer costume: plausible, specific, and a + * claim about a named commercial vessel behind which this deployment has no + * licensed feed. Identity arrives with a licence entry or it does not arrive. + */ + +import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts"; +import type { VesselsBody, VesselsSourceId, WireVessel } from "./wire.ts"; + +// ---- The sentinels -------------------------------------------------------- + +/** + * Speed over ground, knots, meaning "not available". + * + * The raw AIS field is 1023 in tenths of a knot. It is the only value at or + * above 40 kn anywhere in the store, which is a useful sanity check but not the + * test: a container ship does not do forty knots, and a gate that guessed at a + * plausible ceiling would be a gate with an opinion instead of a fact. + */ +export const AIS_SOG_UNAVAILABLE_KN = 102.3; + +/** True heading, degrees, meaning "not available". 40% of fixes carry it. */ +export const AIS_HEADING_UNAVAILABLE = 511; + +/** + * Course over ground, degrees, meaning "not available". + * + * Exactly 360.0, and exactly why `% 360` must never be applied to this field. + */ +export const AIS_COURSE_UNAVAILABLE = 360; + +/** One knot in metres per second. */ +export const KNOTS_TO_MPS = 0.514_444; + +/** + * The speed below which a hull is stopped, in knots. + * + * Half a knot is drift, moored slack and GPS noise. It is also the threshold the + * store's own numbers are quoted against — 150 of 305 vessels are under it, 554 + * of 1,138 individual fixes are *exactly* 0.0 — so using anything else here + * would make every figure in this file's comments unverifiable. + */ +export const VESSEL_MAKING_WAY_KN = 0.5; + +/** The same threshold in the units `Vessel.speed` is expressed in. */ +export const VESSEL_MAKING_WAY_MPS = VESSEL_MAKING_WAY_KN * KNOTS_TO_MPS; + +/** + * How far a hull may be from a berth and still be counted as lying alongside it, + * in metres. + * + * Generous on purpose. An AIS position is reported from the antenna, which on a + * 400 m ship is a couple of hundred metres from either end of it, and a berth is + * one authored point rather than a line. 400 m keeps a ULCV on its own berth + * without reaching across a slip to the next one — the container berths at San + * Pedro are roughly 350-400 m apart along a quay. + */ +export const BERTH_REACH_METRES = 400; + +/** + * The furthest a fix may be advanced by dead reckoning, in seconds. + * + * Fifteen minutes and no further, because that is the upstream sample interval: + * past it, the next fix is overdue and the honest picture is a hull that has + * stopped moving rather than one that has sailed a straight line for an hour. + * The same argument `flights.ts` makes for aircraft, at a longer interval. + */ +export const VESSEL_MAX_RECKON_SECONDS = 900; + +/** + * How many hulls a board draws at most. + * + * Not a data claim: the LA/LB box holds 81 vessels on a typical latest fix and + * SF Bay 82. This is the ceiling the renderer preallocates against, and it is + * applied here so that the cap is a decision taken over data, in a module a test + * can read, rather than an array length in a mesh builder. + */ +export const VESSEL_DRAW_LIMIT = 192; + +const METRES_PER_DEGREE_LAT = 111_320; +const DEG = Math.PI / 180; + +// ---- Shapes --------------------------------------------------------------- + +/** + * A rectangle in degrees. Structurally the `bounds` a city pack declares, + * restated rather than imported for the same reason `server/fires.ts` restates + * `FireBounds`: a city pack is three thousand lines of coastline that pulls in + * three.js, and this module has to be importable by a test with no renderer. + */ +export interface VesselBounds { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; +} + +/** + * The one thing the gate needs from a `Berth`: where it is and which way a hull + * lying on it points. + * + * A structural subset rather than `Berth` itself, so that a caller which has + * berths from somewhere other than a city pack — a test, a fixture, a future + * feed — can answer without inventing a `maxLength`. + */ +export interface BerthAnchor { + id: string; + lat: number; + lng: number; + /** TRUE bearing of the bow, degrees clockwise from north. */ + bearing: number; +} + +/** + * Everything a board needs to draw ships, and everything it needs to explain a + * harbour with none in it. + * + * The four counts after `drawn` are what make an empty harbour a *finding* + * rather than a blank: "the feed is not configured", "the feed answered and + * nothing is on this board", "eleven hulls answered and every one of them is + * outside the frame" and "nine hulls answered and none of them would say which + * way it was pointing" are four different sentences, and a layer that cannot + * tell them apart is a layer that gets guessed at. This is the same argument + * `FirePromotion.suppressed` makes, and it is the reason the quiet day is the + * case that gets designed first. + */ +export interface VesselPromotion { + source: VesselsSourceId; + /** ISO-8601 of the last successful upstream fetch. Epoch zero when never. */ + fetchedAt: string; + /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */ + ageMs: number | null; + /** Seconds between upstream samples. What licenses the dead reckoning. */ + intervalSeconds: number; + /** Hulls inside `bounds`, longest first. At most `VESSEL_DRAW_LIMIT`. */ + drawn: Vessel[]; + /** Hulls that passed the gate but fall outside `bounds`. */ + offBoard: number; + /** Rows the gate refused for a bad position, a bad size or a sentinel. */ + suppressed: number; + /** Rows refused because neither the quay nor the wire would orient them. */ + withoutOrientation: number; + /** How many of `drawn` are making way. The number the wakes are drawn from. */ + makingWay: number; + /** How many of `drawn` are lying on an authored berth. */ + alongside: number; +} + +/** The answer for a board with no feed behind it at all. */ +export function emptyVesselPromotion(): VesselPromotion { + return { + source: "none", + fetchedAt: new Date(0).toISOString(), + ageMs: null, + intervalSeconds: 0, + drawn: [], + offBoard: 0, + suppressed: 0, + withoutOrientation: 0, + makingWay: 0, + alongside: 0, + }; +} + +// ---- The three readers ---------------------------------------------------- +// +// One function per AIS field, each total, each returning `null` for "the source +// did not know". They are exported individually because they are the three +// assertions the whole feed rests on and a test that has to reach them through a +// promotion is a test that is really about something else. + +/** + * Speed over ground in metres per second, or `null`. + * + * `0` is a real answer and a common one — 554 of 1,138 fixes are exactly zero — + * so this must never conflate "stopped" with "unknown". A stopped ship is drawn; + * an unknown one is not dead-reckoned. + */ +export function aisSpeedMps(sogKnots: number | null | undefined): number | null { + if (typeof sogKnots !== "number" || !Number.isFinite(sogKnots)) return null; + if (sogKnots < 0) return null; + if (sogKnots === AIS_SOG_UNAVAILABLE_KN) return null; + return sogKnots * KNOTS_TO_MPS; +} + +/** + * True heading in degrees, or `null`. + * + * Rejects 511 on the exact value. The range check that follows is a second, + * independent condition rather than a restatement of it: 511 is out of range, + * but so is a corrupted 720, and neither is a heading. + */ +export function aisHeading(degrees: number | null | undefined): number | null { + if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null; + if (degrees === AIS_HEADING_UNAVAILABLE) return null; + if (degrees < 0 || degrees >= 360) return null; + return degrees; +} + +/** + * Course over ground in degrees, or `null`. + * + * **The exact-value rejection is the whole point and it is not interchangeable + * with a range check plus a modulo.** 358.7 is a real course and survives here; + * 360.0 is "not available" and does not. Writing this as `cog % 360` returns 0 + * for the sentinel, which is a course — due north — and every unknown-course + * hull on the board then points the same way, correctly, forever, with nothing + * to notice it. `vesselGate.test.ts` asserts 358.7 survives for exactly this + * reason. + */ +export function aisCourse(degrees: number | null | undefined): number | null { + if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null; + if (degrees === AIS_COURSE_UNAVAILABLE) return null; + if (degrees < 0 || degrees >= 360) return null; + return degrees; +} + +/** Is this speed motion, or is it slack? Gate on this and never on `nav_status`. */ +export function isMakingWay(speedMps: number | null): boolean { + return typeof speedMps === "number" && Number.isFinite(speedMps) && speedMps >= VESSEL_MAKING_WAY_MPS; +} + +/** + * `nav_status` as a word for a card. + * + * Sixteen AIS codes collapse to four, because fifteen of the sixteen are + * distinctions no renderer can draw: "constrained by her draught" and "engaged + * in fishing" are the same hull at the same angle from four kilometres up. + * Anything not 0, 1 or 5 is `unknown`, which is honest — including the codes + * that do mean something, because meaning something is not the same as being + * drawable. + */ +export function vesselStatus(navStatus: number | null | undefined): VesselStatus { + switch (navStatus) { + case 0: + return "under-way"; + case 1: + return "at-anchor"; + case 5: + return "moored"; + default: + return "unknown"; + } +} + +// ---- Orientation ---------------------------------------------------------- + +/** + * Which way the bow points, or `null` when nothing knows. + * + * The ladder, in order, and the order is the finding: + * + * 1. **The berth**, whenever the hull is lying on one. It wins outright — over a + * reported heading, and over a course — because a ship alongside a quay + * points the way the quay points, and because half the fleet at rest reports + * no heading at all. The wire heading is allowed a small perturbation on top + * (`BERTH_HEADING_PERTURBATION_DEG`) so that a hull whose antenna *does* + * report can sit a degree or two off square, which is what a real berth looks + * like; it can never swing the hull off the quay. + * 2. **The reported heading**, for a hull at rest that is not on a berth — an + * anchored ship swinging on its cable is genuinely pointing where it says. + * 3. **The course over ground**, for a hull making way. A ship crabbing across a + * tide is not pointing exactly where it is going, but the difference is a + * couple of degrees and the course is the field that is present. + * + * `null` is a real outcome and is not padded out with a default. Fourteen + * percent of stopped hulls have neither heading nor course, and drawing them at + * an invented angle would be an invented fact in a medium that reads as truthful. + */ +export function resolveBearing(input: { + heading: number | null; + course: number | null; + speedMps: number | null; + berthBearing?: number | null; +}): number | null { + const berth = input.berthBearing; + if (typeof berth === "number" && Number.isFinite(berth)) { + const heading = input.heading; + if (heading === null) return normaliseDegrees(berth); + // The perturbation, clamped: a reported heading nudges the hull off square + // and can never turn it round. A ship reported 180 degrees from its berth is + // a ship whose AIS is wrong about which end is the bow, not a ship moored + // backwards, and the quay is the thing that cannot be wrong. + const delta = signedDelta(heading, berth); + const nudge = Math.max( + -BERTH_HEADING_PERTURBATION_DEG, + Math.min(BERTH_HEADING_PERTURBATION_DEG, delta), + ); + return normaliseDegrees(berth + nudge); + } + if (!isMakingWay(input.speedMps)) { + if (input.heading !== null) return normaliseDegrees(input.heading); + if (input.course !== null) return normaliseDegrees(input.course); + return null; + } + if (input.course !== null) return normaliseDegrees(input.course); + if (input.heading !== null) return normaliseDegrees(input.heading); + return null; +} + +/** + * How far a reported heading may pull a berthed hull off the bearing its quay + * says it has, in degrees. + * + * Three, which is about the width of a fender pack plus the angle a ship sits at + * when it is warped forward for a crane. Big enough that a row of berthed hulls + * is not suspiciously parallel; small enough that a garbage heading cannot put a + * ship across its own quay. + */ +export const BERTH_HEADING_PERTURBATION_DEG = 3; + +/** The berth a fix is lying on, or `null`. Nearest inside `BERTH_REACH_METRES`. */ +export function nearestBerth( + lat: number, + lng: number, + berths: readonly BerthAnchor[], + reachMetres: number = BERTH_REACH_METRES, +): BerthAnchor | null { + let best: BerthAnchor | null = null; + let bestMetres = reachMetres; + for (const berth of berths) { + if (!Number.isFinite(berth.lat) || !Number.isFinite(berth.lng)) continue; + const metres = metresBetween(lat, lng, berth.lat, berth.lng); + if (metres <= bestMetres) { + best = berth; + bestMetres = metres; + } + } + return best; +} + +/** Flatten a board's ports into the anchors the gate reads. */ +export function berthAnchors(ports: readonly Port[] | undefined): BerthAnchor[] { + const anchors: BerthAnchor[] = []; + for (const port of ports ?? []) { + for (const berth of port.berths ?? []) { + anchors.push({ id: berth.id, lat: berth.lat, lng: berth.lng, bearing: berth.bearing }); + } + } + return anchors; +} + +// ---- Dead reckoning ------------------------------------------------------- + +/** + * Where a fix has got to after `seconds`, along its **reported course** at its + * **reported speed**. + * + * Pure, closed form, and the only function in this repo permitted to move a + * ship. What it cannot do is the point of it: it takes one fix, so there is no + * second fix for it to interpolate toward, and therefore no code path anywhere + * downstream can produce a point on the chord between two observations. That is + * a property of the signature rather than of the discipline of the caller, which + * is why the signature is this and not `(from, to, t)`. + * + * A hull with no course, or one that is not making way, does not move. Neither + * does one whose fix is older than `VESSEL_MAX_RECKON_SECONDS`: past the sample + * interval the next fix is overdue, and a ship drawn sailing a perfectly + * straight line for an hour is a ship the feed has lost. + */ +export function reckonVessel( + fix: { lat: number; lng: number; speed: number; course: number | null }, + seconds: number, +): { lat: number; lng: number } { + const here = { lat: fix.lat, lng: fix.lng }; + if (!Number.isFinite(seconds) || seconds <= 0) return here; + if (fix.course === null || !Number.isFinite(fix.course)) return here; + if (!isMakingWay(fix.speed)) return here; + const dt = Math.min(seconds, VESSEL_MAX_RECKON_SECONDS); + const distance = fix.speed * dt; + const radians = fix.course * DEG; + const lat = fix.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT; + // The cosine is taken at the starting latitude rather than the mean of the + // two, exactly as `flights.ts` does: fifteen minutes of steaming is under five + // kilometres, over which the correction differs in the seventh decimal place, + // and using the start keeps this a closed form rather than an iteration. + const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(fix.lat * DEG); + const lng = + metresPerDegreeLng > 1 ? fix.lng + (Math.sin(radians) * distance) / metresPerDegreeLng : fix.lng; + return { lat, lng }; +} + +// ---- The gate ------------------------------------------------------------- + +/** + * Default hull dimensions, in metres, for a source that did not send any. + * + * The static AIS message carries length and beam and is absent for most hulls + * most of the time, so this is the difference between drawing a plausible ship + * and drawing nothing. It is a **display default and not an observation**: it is + * per-kind, it is stated here where it can be read, and nothing downstream may + * present it as a measurement. There is no draught in this table for the reason + * `Vessel` gives at length — draught is a hull dimension we author, never a + * cargo claim, and `engine/vessels.ts` derives it from the length. + */ +export const DEFAULT_HULL: Readonly> = { + container: { length: 300, beam: 45 }, + tanker: { length: 250, beam: 44 }, + bulk: { length: 225, beam: 32 }, + "vehicle-carrier": { length: 200, beam: 32 }, + tug: { length: 30, beam: 11 }, + ferry: { length: 60, beam: 14 }, + fishing: { length: 25, beam: 7 }, + other: { length: 90, beam: 16 }, +}; + +const KINDS = new Set(Object.keys(DEFAULT_HULL)); + +/** A wire `kind` narrowed to the union, falling back to `other`. */ +export function vesselKind(kind: string | null | undefined): VesselKind { + return typeof kind === "string" && KINDS.has(kind) ? (kind as VesselKind) : "other"; +} + +/** + * One wire row to one drawable hull, or `null`. + * + * Total: a malformed row returns `null` rather than throwing, because the + * consumer is a render loop and a body one server version behind is a normal + * thing to be handed. + */ +export function readVessel( + row: WireVessel | null | undefined, + berths: readonly BerthAnchor[], +): { vessel: Vessel; berthed: boolean } | { vessel: null; reason: "invalid" | "unoriented" } { + if (!row || typeof row !== "object") return { vessel: null, reason: "invalid" }; + const lat = row.lat; + const lng = row.lon; + if (typeof lat !== "number" || !Number.isFinite(lat) || lat < -90 || lat > 90) { + return { vessel: null, reason: "invalid" }; + } + if (typeof lng !== "number" || !Number.isFinite(lng) || lng < -180 || lng > 180) { + return { vessel: null, reason: "invalid" }; + } + const id = typeof row.id === "string" && row.id.length > 0 ? row.id : null; + if (id === null) return { vessel: null, reason: "invalid" }; + + /** + * The wire speed is already metres per second and already stripped upstream — + * and it is re-checked here anyway, in the units it arrives in. + * + * Not belt and braces: the upstream half of this feed lives in a different + * repo on a different box and does not yet exist, so "already stripped" is a + * promise nobody can currently keep. A sentinel that gets through moves a hull + * eight scene units a minute. The check is one comparison. + */ + const speed = readWireSpeed(row.speed); + if (speed === null) return { vessel: null, reason: "invalid" }; + const heading = aisHeading(row.heading); + const course = aisCourse(row.course); + + const berth = nearestBerth(lat, lng, berths); + const bearing = resolveBearing({ + heading, + course, + speedMps: speed, + berthBearing: berth ? berth.bearing : null, + }); + if (bearing === null) return { vessel: null, reason: "unoriented" }; + + const kind = vesselKind(row.kind); + const fallback = DEFAULT_HULL[kind]; + const length = positive(row.length) ?? fallback.length; + const beam = positive(row.beam) ?? fallback.beam; + const ageSeconds = + typeof row.ageSeconds === "number" && Number.isFinite(row.ageSeconds) && row.ageSeconds >= 0 + ? row.ageSeconds + : 0; + + const vessel: Vessel = { + id, + kind, + lat, + lng, + bearing, + length, + beam, + speed: isMakingWay(speed) ? speed : 0, + course, + status: vesselStatus(row.navStatus), + ...(berth ? { berthId: berth.id } : {}), + ageSeconds, + }; + return { vessel, berthed: berth !== null }; +} + +/** + * Apply the gate to one body, for one board. + * + * Pure and total: a malformed body, a body from a server one version behind, or + * `null` all produce an empty promotion rather than an exception — the same + * posture `server/fires.ts` takes, and for the same reason. + * + * `nowMs` is injected so a test can assert on `ageMs` without owning the clock. + */ +export function promoteVessels( + body: VesselsBody | null | undefined, + bounds: VesselBounds, + berths: readonly BerthAnchor[] = [], + nowMs: number = Date.now(), +): VesselPromotion { + const empty = emptyVesselPromotion(); + if (body === null || body === undefined || typeof body !== "object") return empty; + + const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt; + const fetchedMs = Date.parse(fetchedAt); + const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null; + const intervalSeconds = + typeof body.intervalSeconds === "number" && body.intervalSeconds > 0 ? body.intervalSeconds : 0; + const source: VesselsSourceId = + body.source === "cloud1" || body.source === "modelled" ? body.source : "none"; + + const rows = Array.isArray(body.vessels) ? body.vessels : []; + const drawn: Vessel[] = []; + let suppressed = 0; + let withoutOrientation = 0; + let offBoard = 0; + + for (const row of rows) { + const read = readVessel(row, berths); + if (read.vessel === null) { + if (read.reason === "unoriented") withoutOrientation += 1; + else suppressed += 1; + continue; + } + const { vessel } = read; + if (!inBounds(vessel.lat, vessel.lng, bounds)) { + offBoard += 1; + continue; + } + drawn.push(vessel); + } + + // Longest first, so that a board over the draw limit loses the hulls least + // able to carry a pixel rather than whichever the feed happened to list last. + drawn.sort((a, b) => b.length - a.length); + const kept = drawn.slice(0, VESSEL_DRAW_LIMIT); + suppressed += drawn.length - kept.length; + + return { + source, + fetchedAt, + ageMs, + intervalSeconds, + drawn: kept, + offBoard, + suppressed, + withoutOrientation, + makingWay: kept.filter((v) => isMakingWay(v.speed)).length, + alongside: kept.filter((v) => v.berthId !== undefined).length, + }; +} + +/** + * The sentence a panel writes when a harbour is empty. + * + * Designed before the field was, which is the rule this repo arrived at the hard + * way: a layer with a beautiful full state and a blank empty one is a layer that + * looks broken on most days. Every branch below names both what is being shown + * and what is being withheld, because "there are no ships here" and "I have not + * heard from the feed since Tuesday" are the same picture and different facts. + */ +export function vesselSummary(promotion: VesselPromotion): string { + const { drawn, source } = promotion; + if (source === "none") { + return "No vessel feed is configured for this deployment, so no ships are drawn."; + } + const modelled = source === "modelled"; + const provenance = modelled + ? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured." + : "Live AIS."; + if (drawn.length === 0) { + const parts: string[] = ["The feed answered and no ship is on this board."]; + if (promotion.offBoard > 0) parts.push(`${promotion.offBoard} outside the frame.`); + if (promotion.withoutOrientation > 0) { + parts.push(`${promotion.withoutOrientation} would not say which way they were pointing.`); + } + if (promotion.suppressed > 0) parts.push(`${promotion.suppressed} unreadable.`); + parts.push(provenance); + return parts.join(" "); + } + const still = drawn.length - promotion.makingWay; + const parts = [ + `${drawn.length} ${drawn.length === 1 ? "hull" : "hulls"}: ${promotion.makingWay} making way, ${still} at rest, ${promotion.alongside} alongside a berth.`, + ]; + if (promotion.withoutOrientation > 0) { + parts.push( + `${promotion.withoutOrientation} withheld — neither the quay nor the wire would orient them.`, + ); + } + parts.push("No hull is labelled laden or in ballast: that is a port figure, not a ship one."); + parts.push(provenance); + return parts.join(" "); +} + +// ---- The modelled harbour ------------------------------------------------- + +/** What `modelHarbour` needs to be reproducible. */ +export interface ModelledHarbourOptions { + /** + * The seed, so two people see the same harbour and a capture script shoots the + * same frame twice. Everything below is a hash of this and a stable string + * (a berth id, a port id), never a call to `Math.random`. + */ + seed?: number; + /** Wall clock for the body's `fetchedAt`, and the phase of the moving hulls. */ + atMs?: number; + /** Sample interval to declare. 900 s, matching the store this stands in for. */ + intervalSeconds?: number; + /** How many hulls are under way per port with a channel. */ + underWayPerPort?: number; + /** What proportion of a port's berths are occupied, 0..1. */ + occupancy?: number; +} + +/** + * A harbour built from a board's own authored geometry. + * + * Berths carry hulls; channels carry the handful making way. Both are things the + * pack already declares, which is what makes this a *model* of the board rather + * than a fiction laid on top of it: a berth with no ship on it is an empty berth + * you can see, and moving a berth moves the ship. + * + * Deliberately absent, and the absences are the design: no name, no MMSI, no + * callsign, no destination, and no laden state. The output is a `VesselsBody` + * with `source: "modelled"` and it goes through `promoteVessels` exactly like a + * live one, so the seam is exercised rather than bypassed. + */ +export function modelHarbour( + ports: readonly Port[] | undefined, + options: ModelledHarbourOptions = {}, +): VesselsBody { + const seed = options.seed ?? 115; + const atMs = options.atMs ?? 0; + const intervalSeconds = options.intervalSeconds ?? 900; + const occupancy = clamp01(options.occupancy ?? 0.72); + const underWayPerPort = Math.max(0, Math.floor(options.underWayPerPort ?? 3)); + const vessels: WireVessel[] = []; + + for (const port of ports ?? []) { + for (const berth of port.berths ?? []) { + const key = `${port.id}:${berth.id}`; + if (hash01(seed, `${key}:occupied`) > occupancy) continue; + const kind = berthKind(seed, key, berth); + const fallback = DEFAULT_HULL[kind]; + const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length; + /** + * 70-88% of the berth, and the ceiling is what stops a terminal reading + * as one continuous wall of steel. + * + * Photographed: Pier 400's authored berths are 356 m apart and it fills to + * 400 m, so at 97% two consecutive hulls touched stem to stern and the two + * vehicle carriers alongside read as one 700 m object. A berth whose hull + * exactly fills it every time also reads as a diagram rather than as a + * working quay. + */ + const length = Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`))); + vessels.push({ + id: `m-${key}`, + kind, + lat: berth.lat, + lon: berth.lng, + speed: 0, + course: null, + /** + * `null`, always, and this is the most deliberate line in the simulator. + * + * Half the fleet at rest reports no heading, so a modelled harbour whose + * every hull volunteered one would exercise the easy path and leave the + * berth-supplied orientation — the thing this workstream exists to get + * right — permanently untested by the picture. + */ + heading: null, + navStatus: 5, + length, + beam: Math.round(beamFor(kind, length)), + ageSeconds: 0, + }); + } + + const channel = port.channel ?? []; + if (channel.length < 2 || underWayPerPort === 0) continue; + for (let i = 0; i < underWayPerPort; i++) { + const key = `${port.id}:under-way:${i}`; + const kind = i === underWayPerPort - 1 ? "tug" : underWayKind(seed, key); + const fallback = DEFAULT_HULL[kind]; + const length = Math.round(fallback.length * (0.85 + 0.3 * hash01(seed, `${key}:length`))); + // Speed first, because it is what the phase is measured in: a tug at six + // knots and a container ship at twelve are at different places on the same + // channel a minute later, which is the whole reason the wakes differ. + const speed = (kind === "tug" ? 3.2 : 6.4) * (0.8 + 0.4 * hash01(seed, `${key}:speed`)); + const phase = (hash01(seed, `${key}:phase`) + (atMs / 1000 / (intervalSeconds * 6))) % 1; + const along = i % 2 === 0 ? phase : 1 - phase; + const point = alongPath(channel, along); + if (!point) continue; + vessels.push({ + id: `m-${key}`, + kind, + lat: point.lat, + lon: point.lng, + speed, + course: i % 2 === 0 ? point.bearing : normaliseDegrees(point.bearing + 180), + heading: null, + navStatus: 0, + length, + beam: Math.round(beamFor(kind, length)), + ageSeconds: 0, + }); + } + } + + return { + source: "modelled", + fetchedAt: new Date(atMs).toISOString(), + vessels, + intervalSeconds, + ttlSeconds: intervalSeconds, + attribution: [ + "Modelled from this board's authored berths and channels. Not an observation of any vessel.", + ], + }; +} + +// ---- Arithmetic ----------------------------------------------------------- + +function readWireSpeed(speed: number | null | undefined): number | null { + if (typeof speed !== "number" || !Number.isFinite(speed) || speed < 0) return null; + // The sentinel in the units the wire uses. Compared with a tolerance because + // 102.3 * 0.514444 does not round-trip exactly through a float. + if (Math.abs(speed - AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS) < 1e-6) return null; + return speed; +} + +function positive(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function inBounds(lat: number, lng: number, bounds: VesselBounds): boolean { + return ( + lat >= bounds.minLat && lat <= bounds.maxLat && lng >= bounds.minLng && lng <= bounds.maxLng + ); +} + +/** Great-circle-enough distance for a few hundred metres of harbour. */ +export function metresBetween( + aLat: number, + aLng: number, + bLat: number, + bLng: number, +): number { + const dLat = (bLat - aLat) * METRES_PER_DEGREE_LAT; + const dLng = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG); + return Math.hypot(dLat, dLng); +} + +/** Bearing from one point to another, degrees clockwise from true north. */ +export function bearingBetween( + aLat: number, + aLng: number, + bLat: number, + bLng: number, +): number { + const north = (bLat - aLat) * METRES_PER_DEGREE_LAT; + const east = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG); + return normaliseDegrees((Math.atan2(east, north) / DEG)); +} + +export function normaliseDegrees(degrees: number): number { + const wrapped = degrees % 360; + return wrapped < 0 ? wrapped + 360 : wrapped; +} + +/** `a - b` folded into -180..180, so a clamp on it is a clamp on a turn. */ +function signedDelta(a: number, b: number): number { + return ((((a - b) % 360) + 540) % 360) - 180; +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +/** + * A point a fraction of the way along a polyline, with the path's bearing there. + * + * By segment length rather than by index, so a channel authored with a long + * outer leg and three short dogleg points does not park every modelled ship in + * the dogleg. + */ +export function alongPath( + path: readonly [number, number][], + fraction: number, +): { lat: number; lng: number; bearing: number } | null { + if (path.length === 0) return null; + const first = path[0]; + if (!first) return null; + if (path.length === 1) return { lat: first[0], lng: first[1], bearing: 0 }; + const legs: number[] = []; + let total = 0; + for (let i = 1; i < path.length; i++) { + const a = path[i - 1]; + const b = path[i]; + if (!a || !b) continue; + const metres = metresBetween(a[0], a[1], b[0], b[1]); + legs.push(metres); + total += metres; + } + if (total <= 0) return { lat: first[0], lng: first[1], bearing: 0 }; + let want = clamp01(fraction) * total; + for (let i = 0; i < legs.length; i++) { + const leg = legs[i] ?? 0; + const a = path[i]; + const b = path[i + 1]; + if (!a || !b) continue; + if (want <= leg || i === legs.length - 1) { + const t = leg > 0 ? clamp01(want / leg) : 0; + return { + lat: a[0] + (b[0] - a[0]) * t, + lng: a[1] + (b[1] - a[1]) * t, + bearing: bearingBetween(a[0], a[1], b[0], b[1]), + }; + } + want -= leg; + } + return null; +} + +/** + * A stable 0..1 from a seed and a string. + * + * FNV-1a, the same shape every other deterministic thing in this repo uses. It + * is here rather than imported because the alternative is a dependency from a + * pure data module on a renderer helper, and the whole of this file's value is + * that it depends on nothing. + */ +export function hash01(seed: number, key: string): number { + let h = (2_166_136_261 ^ Math.trunc(seed)) >>> 0; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 16_777_619) >>> 0; + } + return h / 4_294_967_296; +} + +/** + * Beam from length, per kind. + * + * A display default like `DEFAULT_HULL`, and stated as a ratio because that is + * what it is: a container ship is about 6.6 times as long as it is wide, a tug + * under 3. The one number worth knowing is that a Panamax-plus box ship is 400 x + * 61 m, which this returns to within a metre. + */ +export function beamFor(kind: VesselKind, length: number): number { + const ratio = + kind === "tug" + ? 2.8 + : kind === "fishing" + ? 3.6 + : kind === "ferry" + ? 4.4 + : kind === "bulk" + ? 7.0 + : kind === "tanker" + ? 5.7 + : 6.6; + return length / ratio; +} + +function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): VesselKind { + const maxLength = "maxLength" in berth ? berth.maxLength : 0; + const roll = hash01(seed, `${key}:kind`); + // The berth's own length is the strongest signal there is: a 120 m berth is + // not a container terminal and a 400 m one is not a fishing dock. + if (maxLength > 0 && maxLength < 80) return roll < 0.6 ? "fishing" : "tug"; + if (maxLength > 0 && maxLength < 180) return roll < 0.5 ? "ferry" : "tug"; + if (roll < 0.62) return "container"; + if (roll < 0.78) return "tanker"; + if (roll < 0.9) return "bulk"; + return "vehicle-carrier"; +} + +function underWayKind(seed: number, key: string): VesselKind { + const roll = hash01(seed, `${key}:kind`); + if (roll < 0.55) return "container"; + if (roll < 0.75) return "tanker"; + if (roll < 0.9) return "bulk"; + return "vehicle-carrier"; +} diff --git a/src/server/wire.ts b/src/server/wire.ts index c0e3df1..cab2ac6 100644 --- a/src/server/wire.ts +++ b/src/server/wire.ts @@ -26,6 +26,10 @@ * | `GET /fires` | `FiresBody` | yes | * | `GET /weather` | `WeatherBody` | yes | * | `GET /markers` | `MarkersBody` | yes | + * | `GET /vessels` | `VesselsBody` | yes | + * | `GET /ports` | `PortsBody` | yes | + * | `GET /radar` | `RadarBody` | yes | + * | `GET /birds` | `BirdsBody` | yes | * | `GET /offices/:id` | `OfficeDoc` | public offices only | * | `GET /offices/:id/presence` | `PresenceBody` | never | * | `GET /offices/:id/devices` | `DevicesBody` | never | @@ -94,6 +98,26 @@ export type SatellitesSourceId = "none" | "celestrak"; */ export type FiresSourceId = "none" | "cloud1"; export type MarkersSourceId = "none" | "file"; +/** + * Where ship positions come from. + * + * `none` is the default and it is the honest default for longer than usual: + * cloud-1 serves no `/api/sea` and no `/api/vessels` today, and aisstream.io's + * terms have not been read. `flights/licence.ts` exists because this box once + * re-served an unchecked feed under a credit line the source had never seen, and + * that mistake is one line of config away from being repeated here. + * + * `modelled` is not a simulator in the `FlightsSourceId: "sim"` sense and the + * different word is deliberate. A simulated aeroplane carries a callsign and a + * plausible identity; a modelled vessel is an **anonymous** hull at an authored + * berth, with no name and no MMSI, and the panel says the feed is not + * configured. An invented named ship is a claim about a real commercial vessel. + */ +export type VesselsSourceId = "none" | "modelled" | "cloud1"; +/** Where the reflectivity raster comes from. `none` draws no sheet at all. */ +export type RadarSourceId = "none" | "cloud1"; +/** Where nocturnal migration comes from. `none` draws no motes and says so. */ +export type BirdsSourceId = "none" | "cloud1"; /** * Where device readings come from. * @@ -170,6 +194,21 @@ export interface HealthBody { * optional. */ fires?: FiresSourceId; + /** + * The three feeds this build added, all optional and all read the same + * defensive way `devices` and `fires` are: a browser meeting a server one + * version behind sees `undefined` and must conclude the box serves none of + * them, which is the safe direction for a missing field to fall. + * + * All three default to `none`, and for all three an empty answer is the + * commonest correct one. California is under rain a mean 0.596% of the time + * and birds are absent about fourteen hours in every twenty-four by + * construction, so a board with nothing on it is the normal picture and only + * the age beside it separates that from a dead feed. + */ + vessels?: VesselsSourceId; + radar?: RadarSourceId; + birds?: BirdsSourceId; }; auth: { mode: AuthMode; @@ -824,3 +863,199 @@ export interface DeviceCommandResultBody { /** Epoch milliseconds. */ observedAt: number; } + +// ---- Vessels -------------------------------------------------------------- + +/** + * One ship position, with **every AIS sentinel already stripped**. + * + * This is the shape after the gate, not the shape off the wire from a receiver, + * and the difference is the whole point of the type. AIS encodes "not available" + * as in-band values that are perfectly valid numbers: speed over ground 102.3 + * knots, heading 511, course 360.0. None of the three is ever NULL, so a + * null-check catches nothing, and `cog % 360` turns the course sentinel into due + * north — which looks entirely correct until you notice the whole fleet is + * facing the same way. + * + * So absence is expressed as absence here: `heading` and `course` are + * `number | null`, `speed` is a real speed or zero, and anything that could not + * be believed was dropped upstream where one hand-written SELECT can be read. + * + * **No name, no MMSI, no callsign, no destination.** See `Vessel` in + * `engine/types.ts`: identity arrives with a licensed feed and a written licence + * entry, or it does not arrive. + */ +export interface WireVessel { + /** Opaque and stable for the life of a fix set. Never an MMSI. */ + id: string; + /** AIS ship-type reduced to a display bucket. Structurally `VesselKind`. */ + kind: string; + lat: number; + lon: number; + /** Metres per second over the ground. Never 102.3 knots. */ + speed: number; + /** Course over ground, degrees true, or `null`. Never exactly 360. */ + course: number | null; + /** True heading, degrees, or `null`. Never 511. */ + heading: number | null; + /** `0` under way, `1` at anchor, `5` moored. Labels; never gates motion. */ + navStatus: number | null; + /** Overall length and beam in metres, where the static message gave them. */ + length: number | null; + beam: number | null; + /** Seconds between the fix and `fetchedAt`. */ + ageSeconds: number; +} + +/** + * Everything afloat this deployment knows about, and when it last asked. + * + * `fetchedAt` is not optional for the same reason `FiresBody.fetchedAt` is not: + * an empty harbour and a dead feed are the same picture. + * + * `interval` is stated rather than implied because it changes what a client is + * allowed to do with the positions. Upstream listens for thirty seconds every + * fifteen minutes, so a hull under way has moved about five kilometres between + * two fixes. A client may dead-reckon **along the reported course at the + * reported speed**; it may never spline between two fixes, because the chord + * between them is not a path anything took. + */ +export interface VesselsBody { + source: VesselsSourceId; + /** ISO-8601, the last time a fetch **succeeded**. Epoch zero when never. */ + fetchedAt: string; + vessels: WireVessel[]; + /** Seconds between upstream samples. 900 for the store behind this. */ + intervalSeconds: number; + ttlSeconds: number; + attribution?: string[]; +} + +// ---- Ports ---------------------------------------------------------------- + +/** + * A month of boxes across one quay, and the freight rates that explain it. + * + * Static in this build and served with the month it describes, because that is + * what the honest version of this looks like: `teu_obs` is monthly and the + * newest row is July 2026. A figure with no month on it is a claim about now. + */ +export interface WirePortThroughput { + /** UN/LOCODE. */ + portId: string; + /** `YYYY-MM`. */ + asOf: string; + loadedExport: number; + emptyExport: number; + loadedImport: number; + emptyImport: number; +} + +/** One published freight index, in dollars per forty-foot equivalent. */ +export interface WireFreightRate { + /** The index code: `FBX01`. */ + id: string; + lane: string; + usdPerFeu: number; +} + +/** + * Port throughput, as the card reads it. + * + * There is deliberately no observation timestamp on a rate. The store's + * `observed_at` is our own read clock — Freightos publishes none — and a card + * that renders it as "as of" is lying about precision. + */ +export interface PortsBody { + source: FiresSourceId; + fetchedAt: string; + throughput: WirePortThroughput[]; + rates: WireFreightRate[]; + ttlSeconds: number; + attribution?: string[]; +} + +// ---- The sky -------------------------------------------------------------- + +/** + * The statewide reflectivity lattice. + * + * Structurally `RadarField` in `engine/types.ts` minus the two panel counters, + * restated here for the reason the head of this file gives: the browser hands + * the promoted field straight to the layer and the server must be able to build + * one without importing three.js. + * + * **`null` in `dbz` is unknown, not dry.** A cell inside the coverage radius of + * a radar whose RDA is inoperable is a hole, and a hole drawn as clear sky is a + * claim nobody made. It is also why this is a nullable array and not a packed + * buffer. + * + * `distance_km` from the upstream store is **not** in this type and must never + * be requested. It is measured from a private home, and the protection is that + * the projection never emits the column — not that a client politely avoids + * selecting it. That is the whole reason `fires/cloud1.ts` has no database. + */ +export interface RadarBody { + source: RadarSourceId; + fetchedAt: string; + /** `null` when nothing has been promoted — an honest empty sky. */ + field: { + minLat: number; + minLng: number; + cellLat: number; + cellLng: number; + rows: number; + cols: number; + dbz: (number | null)[]; + observedAt: string; + wetFraction: number; + stations: number; + stationsDown: number; + } | null; + ttlSeconds: number; + attribution?: string[]; +} + +/** One county's ten-minute migration granule. Structurally `MigrationCounty`. */ +export interface WireBirdCounty { + id: string; + name: string; + lat: number; + lon: number; + areaKm2: number; + aloft: number; + /** Metres above ground. */ + altitude: number; + /** Bearing they are heading toward, degrees true. */ + direction: number; + /** Metres per second. */ + speed: number; +} + +/** + * Nocturnal migration, including — and especially — the empty case. + * + * `quiet` carries the reason and it is not optional. 168 of 297 granules are + * daytime with 77 rows in them against 7,498 at night, so the empty sky is what + * most visitors see and the sentence beside it is the layer. + * + * `statewide` comes from the **state row** and never from a sum over counties. + * Summing all 58 gives 2,360,086 against an authoritative 393,290, because a + * bird crossing four counties is counted in four of them. + */ +export interface BirdsBody { + source: BirdsSourceId; + fetchedAt: string; + observedAt: string | null; + counties: WireBirdCounty[]; + statewide: { + crossed: number; + peakAloft: number; + peakAt: string; + meanAltitude: number; + heading: string; + } | null; + quiet: { reason: "daylight" | "off-season" | "no-data"; message: string } | null; + ttlSeconds: number; + attribution?: string[]; +} diff --git a/src/test/californiaCity.test.ts b/src/test/californiaCity.test.ts index 3837a9a..e013ce3 100644 --- a/src/test/californiaCity.test.ts +++ b/src/test/californiaCity.test.ts @@ -18,13 +18,39 @@ describe("California corridor city", () => { } }); - it("offers route chapters plus doors into both detailed city boards", () => { + it("offers route chapters, doors into both detailed boards, and the north", () => { + // Order is asserted rather than membership, and the first entry doubly so: + // `createScene` opens on `chapters[0]` and `scripts/brand-assets/shots.mjs` + // reaches the others by *index*, so an insertion anywhere but the end + // re-points every marketing still at a different photograph. assert.deepEqual( CALIFORNIA_CITY.chapters.map((chapter) => chapter.id), - ["california-overview", "la-sf-us-101", "la-sf-i-5", "los-angeles", "san-francisco"], + [ + "california-overview", + "la-sf-us-101", + "la-sf-i-5", + "los-angeles", + "san-francisco", + "shasta-cascades", + ], ); }); + it("puts a pose on the half of the state the corridor never reaches", () => { + // The board runs to 42.05 N. Before the north chapter existed every authored + // pose sat below 38, so a visitor who never dragged the camera saw none of + // the two hundred kilometres of state above San Francisco. + const north = CALIFORNIA_CITY.chapters.filter((chapter) => chapter.focus.lat > 39); + assert.ok(north.length >= 1, "no chapter looks at the north of the board"); + for (const chapter of north) { + assert.ok( + chapter.focus.lat < CALIFORNIA_CITY.bounds.maxLat && + chapter.focus.lng > CALIFORNIA_CITY.bounds.minLng, + `chapter "${chapter.id}" points off the board`, + ); + } + }); + it("uses a state-scale field rather than city-scale cells", () => { assert.ok(CALIFORNIA_CITY.cellLat >= 0.01); assert.ok(CALIFORNIA_CITY.cellLng >= 0.01); diff --git a/src/test/data/birdsGate.test.ts b/src/test/data/birdsGate.test.ts new file mode 100644 index 0000000..cfe9913 --- /dev/null +++ b/src/test/data/birdsGate.test.ts @@ -0,0 +1,253 @@ +/** + * The migration gate, and the two errors it exists to prevent. + * + * **The six-times headline.** `SELECT SUM(birds_crossed)` over the 58 county + * rows for the night of 2026-08-21 gives 2,360,086. The authoritative figure, on + * the `US-CA` state row for the same night, is 393,290. Both numbers are + * correct — a bird crossing four counties is counted in four of them — and only + * one of them is a crossing count. A headline wrong by six times is the fire + * layer's twenty-two orange marks in a different costume, and the only thing + * standing between this build and it is that nothing here ever adds counties up. + * + * **The state row placed as a county.** `counties` upstream is 59 rows, not 58. + * The 59th is `US-CA`, `kind='state'`, with NULL lat and lon, and in tonight's + * granule it carries 793,141 birds aloft against the largest county's 82,549. A + * per-county path that does not take it out puts a three-quarter-million-bird + * blob at 0,0 — and it looks exactly like the other fifty-eight rows, which is + * why it is refused on three independent grounds and asserted on all three. + * + * The third thing here is the **empty state**, which is not an edge case: 176 of + * 297 granules in the store are daytime and hold 104 rows between them against + * 7,719 at night, so the layer is absent about fourteen hours in every + * twenty-four by construction. Every default frame `scripts/look.mjs` takes is a + * daylight frame. The sentence is the layer, for most visitors, and it is tested + * as such. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + BIRDS_MAX_SOLAR_ELEVATION_DEG, + BIRDS_STATE_ROW_ID, + countyRows, + emptyBirdsPromotion, + headingWords, + isCountyRow, + promoteBirds, + quietMessage, + statewideHeadline, +} from "../../server/birds.ts"; +import type { BirdsBody, WireBirdCounty } from "../../server/wire.ts"; + +/** + * The granule of 2026-08-23T03:20Z, as the store holds it — the six busiest + * counties, plus the row that is not a county at all. + * + * Real numbers, from `migration` joined to `counties`. The `US-CA` row's + * `birds_aloft` of 793,141 is what makes the exclusion visible: it is 9.6 times + * the largest county in the same granule. + */ +const STATE_ROW: WireBirdCounty = { + id: "US-CA", + name: "California", + lat: null as unknown as number, + lon: null as unknown as number, + areaKm2: null as unknown as number, + aloft: 793141, + altitude: 605, + direction: 140.6, + speed: 4.8, +}; + +const COUNTIES: WireBirdCounty[] = [ + { id: "US-CA-019", name: "Fresno County", lat: 36.761006, lon: -119.655019, areaKm2: 15569, aloft: 82549, altitude: 333, direction: 140.0, speed: 6.9 }, + { id: "US-CA-107", name: "Tulare County", lat: 36.228834, lon: -118.781055, areaKm2: 12531, aloft: 52108, altitude: 446, direction: 130.1, speed: 7.5 }, + { id: "US-CA-027", name: "Inyo County", lat: 36.56216, lon: -117.404209, areaKm2: 26488, aloft: 36214, altitude: 826, direction: 118.0, speed: 6.4 }, + { id: "US-CA-029", name: "Kern County", lat: 35.346629, lon: -118.729506, areaKm2: 21147, aloft: 30492, altitude: 469, direction: 122.3, speed: 7.3 }, + { id: "US-CA-073", name: "San Diego County", lat: 33.023604, lon: -116.776117, areaKm2: 11722, aloft: 29809, altitude: 760, direction: 144.5, speed: 6.6 }, + { id: "US-CA-039", name: "Madera County", lat: 37.209821, lon: -119.749802, areaKm2: 5576, aloft: 28061, altitude: 351, direction: 141.0, speed: 6.0 }, +]; + +/** The state row for the night of 2026-08-21, verbatim. */ +const LAST_NIGHT = { + crossed: 393290.37, + peakAloft: 1501193.14, + peakAt: "2026-08-22T06:20:00Z", + meanAltitude: 726.0, + heading: "south-east", +}; + +/** What summing the 58 county rows for that night gives. The wrong answer. */ +const COUNTY_SUM_TRAP = 2360086; + +const NOW = Date.parse("2026-08-23T03:41:04Z"); + +function body(over: Partial = {}): BirdsBody { + return { + source: "cloud1", + fetchedAt: "2026-08-23T03:41:04Z", + observedAt: "2026-08-23T03:20:00Z", + counties: [...COUNTIES, STATE_ROW], + statewide: LAST_NIGHT, + quiet: null, + ttlSeconds: 600, + ...over, + }; +} + +// ---- The state row -------------------------------------------------------- + +describe("the row that is a state and not a county", () => { + it("is refused on all three of the grounds that identify it", () => { + assert.equal(isCountyRow(STATE_ROW), false); + // By id… + assert.equal(isCountyRow({ ...STATE_ROW, lat: 37, lon: -119, areaKm2: 423967 }), false); + // …by a missing coordinate… + assert.equal(isCountyRow({ ...COUNTIES[0]!, lat: null as unknown as number }), false); + // …and by a missing area, because the motes scatter in a disc of it. + assert.equal(isCountyRow({ ...COUNTIES[0]!, areaKm2: 0 }), false); + assert.equal(isCountyRow(COUNTIES[0]), true); + assert.equal(BIRDS_STATE_ROW_ID, "US-CA"); + }); + + it("never reaches any per-county output", () => { + const rows = countyRows([...COUNTIES, STATE_ROW]); + assert.equal(rows.length, COUNTIES.length); + assert.ok(!rows.some((row) => row.id === BIRDS_STATE_ROW_ID)); + + const out = promoteBirds(body(), { nowMs: NOW, solarElevationDeg: -20 }); + assert.ok(out.field); + assert.ok(!out.field.counties.some((c) => c.id === BIRDS_STATE_ROW_ID)); + // And the largest thing on the board is the largest COUNTY, not the state. + const biggest = Math.max(...out.field.counties.map((c) => c.aloft)); + assert.equal(biggest, 82549); + }); +}); + +// ---- The headline --------------------------------------------------------- + +describe("the statewide headline", () => { + it("is the state row's own figure and never the sum of counties", () => { + const head = statewideHeadline(body()); + assert.ok(head); + assert.equal(Math.round(head.crossed), 393290); + assert.notEqual(Math.round(head.crossed), COUNTY_SUM_TRAP); + assert.equal(Math.round(head.peakAloft), 1501193); + }); + + it("is null rather than reconstructed when the state row is missing", () => { + // A box up for less than one night has no crossing count, and the honest + // answer is to have none — not to add the counties together and be wrong by + // six times without saying so. + assert.equal(statewideHeadline(body({ statewide: null })), null); + const out = promoteBirds(body({ statewide: null }), { nowMs: NOW, solarElevationDeg: 20 }); + assert.ok(out.field?.quiet); + assert.ok(!out.message.includes("Last night")); + }); + + it("says which way, in words the mean can support", () => { + assert.equal(headingWords(130.3), "south-east"); + assert.equal(headingWords(0), "north"); + assert.equal(headingWords(359), "north"); + assert.equal(headingWords(181), "south"); + }); +}); + +// ---- The empty sky -------------------------------------------------------- + +describe("the daytime sky", () => { + it("returns an explicit empty-with-reason rather than an empty array", () => { + // The sun is up. This is the case most visitors meet, and a bare `[]` beside + // nothing else is indistinguishable from a dead feed. + const out = promoteBirds(body(), { nowMs: NOW, solarElevationDeg: 34 }); + assert.ok(out.field); + assert.deepEqual(out.field.counties, []); + assert.ok(out.field.quiet, "quiet is not optional and must be filled in"); + assert.equal(out.field.quiet.reason, "daylight"); + }); + + it("names last night's figures in the reason", () => { + const out = promoteBirds(body(), { nowMs: NOW, solarElevationDeg: 34 }); + const message = out.field?.quiet?.message ?? ""; + assert.match(message, /BirdCast measures migration only after dark/); + assert.match(message, /393,290/); + assert.match(message, /1,501,193/); + assert.match(message, /south-east/); + assert.match(message, /726 metres/); + // Los Angeles time, not UTC. 06:20Z is 23:20 the previous evening in PDT, + // and a caption that said 06:20 would put the peak at breakfast. + assert.match(message, /23:20/); + assert.equal(out.message, message); + }); + + it("refuses daytime rows even when the feed sends them", () => { + // 104 daytime rows exist in the store across 176 granules. BirdCast does not + // measure by day, so a daytime row is a measurement of something the + // instrument does not measure, and the sun wins over the feed. + const justAfterDawn = BIRDS_MAX_SOLAR_ELEVATION_DEG + 0.1; + const out = promoteBirds(body(), { nowMs: NOW, solarElevationDeg: justAfterDawn }); + assert.deepEqual(out.field?.counties, []); + assert.equal(out.field?.quiet?.reason, "daylight"); + assert.equal(out.suppressed, 7, "…and counts every row it put back"); + }); + + it("tells a quiet night from a daytime one and from a dead feed", () => { + const night = promoteBirds(body({ counties: [STATE_ROW] }), { nowMs: NOW, solarElevationDeg: -20 }); + assert.equal(night.field?.quiet?.reason, "off-season"); + assert.match(night.field?.quiet?.message ?? "", /every county is quiet/); + + const dead = promoteBirds(body({ fetchedAt: new Date(0).toISOString() }), { nowMs: NOW }); + assert.equal(dead.field, null); + assert.equal(dead.ageMs, null); + assert.match(dead.message, /fact about this box/); + }); + + it("has an empty promotion that is a sentence and not a blank", () => { + const empty = emptyBirdsPromotion(); + assert.equal(empty.source, "none"); + assert.equal(Date.parse(empty.fetchedAt), 0); + assert.ok(empty.message.length > 40); + assert.ok(quietMessage("no-data", null).length > 20); + }); +}); + +// ---- A night with something in it ----------------------------------------- + +describe("a night with birds over it", () => { + it("draws the counties and says where they are going", () => { + const out = promoteBirds(body(), { nowMs: NOW, solarElevationDeg: -20 }); + assert.equal(out.field?.quiet, null); + assert.equal(out.field?.counties.length, 6); + assert.match(out.message, /aloft over 6 counties/); + assert.match(out.message, /drifting south-east/); + // And it does NOT print a current statewide total, because the only way to + // get one from what crosses the wire is to add the counties up. + assert.ok(!out.message.includes(String(COUNTY_SUM_TRAP))); + }); + + it("averages bearings circularly, so the wrap does not point the state south", () => { + const wrap: WireBirdCounty[] = [ + { ...COUNTIES[0]!, direction: 350, aloft: 1000 }, + { ...COUNTIES[1]!, direction: 10, aloft: 1000 }, + ]; + const out = promoteBirds(body({ counties: wrap }), { nowMs: NOW, solarElevationDeg: -20 }); + // An arithmetic mean of 350 and 10 is 180: due south, for a state flying + // due north. Tonight's rows all sit in the 120-150 bucket and would survive + // a naive mean, which is exactly why this would ship broken and stay broken. + assert.match(out.message, /drifting north/); + assert.ok(!out.message.includes("drifting south")); + }); + + it("survives a null, an undefined and a body full of nonsense", () => { + for (const input of [null, undefined, 7, "birds", { source: "cloud1" }]) { + const out = promoteBirds(input as unknown as BirdsBody, { nowMs: NOW }); + assert.notEqual(out.message, ""); + } + const junk = promoteBirds( + body({ counties: [null, { id: "x" }] as unknown as WireBirdCounty[] }), + { nowMs: NOW, solarElevationDeg: -20 }, + ); + assert.deepEqual(junk.field?.counties, []); + }); +}); diff --git a/src/test/data/radarGate.test.ts b/src/test/data/radarGate.test.ts new file mode 100644 index 0000000..60ff133 --- /dev/null +++ b/src/test/data/radarGate.test.ts @@ -0,0 +1,316 @@ +/** + * The radar gate, held to the three things a screenshot cannot check. + * + * **It refuses sea clutter.** DATA-CATALOG.md:84 already records a 54.5 dBZ cell + * between Long Beach and Catalina on a clear day, and the upstream applies no + * anomalous-propagation filter of any kind. A thunderstorm drawn over the ocean + * on a blue afternoon is the fire layer's twenty-two-orange-marks failure in a + * different palette, and the only place it can be prevented is here — a + * statement about data that lives inside a mesh builder is a statement nobody + * can test without a WebGL context (`engine/fires.ts:16-23`). + * + * **It knows "not on-line" from "off the air".** Six of the sixteen stations in + * the store read `RDA - Maintenance Action Mandatory` right now, including Los + * Angeles and San Diego, and every one of them is transmitting. A gate that + * treated the flag as an outage would grey out the southern half of the state on + * a night with a monsoon over it. That case is asserted below with the real + * strings. + * + * **It knows Nevada from the Pacific.** The board now reaches -114.0 and 42.05 N, + * so a third of it is land the California coastline trace has never described. A + * land test of "inside the pack's polygon" calls a Great Basin thunderstorm sea + * clutter, and tonight's frame has one at 41.375,-117.125 carrying 53 dBZ. + * + * The coastline is the pack's own, not a fake: a rectangle would pass every one + * of these assertions while the shipped board drew rain over Reno. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import CALIFORNIA_CITY from "../../cities/california.ts"; +import { + AP_MAX_CELLS, + AP_MIN_DBZ, + buildRadarField, + classifyGround, + emptyRadarPromotion, + promoteRadar, + RADAR_DRY_DBZ, + RADAR_MIN_WET_FRACTION, + RADAR_RAIN_DBZ, + stationIsDown, + type RadarCell, + type RadarStation, +} from "../../server/radar.ts"; +import type { RadarBody } from "../../server/wire.ts"; + +const COAST = CALIFORNIA_CITY.landmasses; +const BOARD = CALIFORNIA_CITY.bounds; + +/** + * The station table as it stands, with the operability strings verbatim. + * + * Copied from `station_state` rather than invented. The point of the file is + * that six of these are flagged and none of the six is down. + */ +const STATIONS: RadarStation[] = [ + { id: "KBBX", lat: 39.49611, lon: -121.63165, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" }, + { id: "KBHX", lat: 40.49833, lon: -124.29215, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KDAX", lat: 38.50111, lon: -121.67782, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" }, + { id: "KESX", lat: 35.70111, lon: -114.89138, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KEYX", lat: 35.09777, lon: -117.56074, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KHNX", lat: 36.31416, lon: -119.63213, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KMUX", lat: 37.15522, lon: -121.89843, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KNKX", lat: 32.91888, lon: -117.04193, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" }, + { id: "KSOX", lat: 33.81773, lon: -117.63599, type: "WSR-88D", operability: "RDA - Maintenance Action Required" }, + { id: "KVBX", lat: 34.83855, lon: -120.3979, type: "WSR-88D", operability: "RDA - On-line" }, + { id: "KVTX", lat: 34.41166, lon: -119.1786, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" }, + { id: "TLAS", lat: 36.144, lon: -115.007, type: "TDWR", operability: "RDA - On-line" }, +]; + +/** A cell centre, on the product's own quarter-degree lattice. */ +function cell(lat: number, lon: number, dbz: number): RadarCell { + return { lat, lon, dbz }; +} + +function build(cells: RadarCell[], stations = STATIONS, wetFraction = 0.02) { + return buildRadarField({ + cells, + stations, + bounds: BOARD, + coast: COAST, + observedAt: "2026-08-23T03:55Z", + wetFraction, + }); +} + +function at(field: NonNullable["field"]>, lat: number, lng: number) { + const row = Math.round((lat - field.minLat) / field.cellLat); + const col = Math.round((lng - field.minLng) / field.cellLng); + assert.ok(row >= 0 && row < field.rows && col >= 0 && col < field.cols, `${lat},${lng} is off the lattice`); + return field.dbz[row * field.cols + col]; +} + +// ---- Anomalous propagation ------------------------------------------------ + +describe("the sea-clutter rule", () => { + it("drops 47.5 dBZ off Big Sur and keeps the same value over the Inland Empire", () => { + // 35.625,-121.375 is the lattice cell the design's own probe found carrying + // 47.5 dBZ over 302 pixels of open water. 33.875,-117.375 is Riverside. + assert.equal(classifyGround(35.625, -121.375, COAST), "sea"); + assert.equal(classifyGround(33.875, -117.375, COAST), "land"); + + const out = build([cell(35.625, -121.375, 47.5), cell(33.875, -117.375, 47.5)]); + const field = out.field; + assert.ok(field, "a frame with a real Riverside storm in it must be promoted"); + + assert.equal(at(field, 35.625, -121.375), RADAR_DRY_DBZ, "the ocean cell must not survive"); + assert.equal(at(field, 33.875, -117.375), 47.5, "the land cell must survive untouched"); + assert.equal(out.suppressed.length, 1); + assert.equal(out.suppressed[0]?.dbz, 47.5); + }); + + it("keeps an offshore cell that is part of the same blob as a cell over land", () => { + // Rain coming ashore. The seaward half is over water and reaches 55 dBZ, and + // deleting it would punch a hole in the middle of a real storm — which is + // the failure a per-cell version of this rule actually produced, measured on + // tonight's 03:55Z frame. + // 34.625 N, because at 34.375 the shore is already at -119.6: south of + // Point Conception the coast turns east and the Santa Barbara Channel is all + // water out to Ventura. Getting that wrong is how the first draft of this + // test wrote a cell "ashore near Vandenberg" that was 60 km out to sea. + assert.equal(classifyGround(34.625, -120.625, COAST), "land"); + const out = build([ + cell(34.625, -121.125, 55), + cell(34.625, -120.875, 52), + cell(34.625, -120.625, 48), // ashore, on Vandenberg + ]); + assert.deepEqual(out.suppressed, []); + assert.ok(out.field); + assert.equal(at(out.field, 34.625, -121.125), 55); + }); + + it("leaves weak marine echo alone, because drizzle offshore is real", () => { + const weak = AP_MIN_DBZ - 5; + const out = build([cell(35.625, -121.875, weak), cell(35.875, -121.875, weak), cell(33.875, -117.375, 45)]); + assert.deepEqual(out.suppressed, []); + assert.ok(out.field); + assert.equal(at(out.field, 35.625, -121.875), weak); + }); + + it("refuses to delete a blob larger than one radar's clutter ring", () => { + // AP_MAX_CELLS is a cap on how much weather one bad night can remove, and it + // is a judgement rather than a measurement — so it is asserted, not assumed. + const big: RadarCell[] = []; + for (let i = 0; i <= AP_MAX_CELLS; i++) { + big.push(cell(33.375 + Math.floor(i / 6) * 0.25, -121.875 + (i % 6) * 0.25, 45)); + } + const out = build(big); + assert.deepEqual(out.suppressed, [], "a system this size is not clutter"); + }); + + it("does not mistake Nevada for the Pacific", () => { + // The extended board's eastern third is land the California trace has never + // described. Tonight's Great Basin storm sits at 41.375,-117.125 at 53 dBZ. + assert.equal(classifyGround(41.375, -117.125, COAST), "unmapped"); + const out = build([cell(41.375, -117.125, 53)]); + assert.deepEqual(out.suppressed, []); + assert.ok(out.field); + assert.equal(at(out.field, 41.375, -117.125), 53); + }); + + it("puts the shore where the shore is, at every latitude", () => { + // The first version of the land test took the westernmost coastline VERTEX + // within half a degree, which on a diagonal coast is wrong by the diagonal: + // between 35.1 N and 36.1 N the shore moves 1.2 degrees of longitude, and + // the band rule declared the Big Sur clutter inland. + assert.equal(classifyGround(35.625, -121.125, COAST), "land"); + assert.equal(classifyGround(35.375, -121.125, COAST), "sea"); + assert.equal(classifyGround(35.875, -121.375, COAST), "land"); + assert.equal(classifyGround(35.625, -121.375, COAST), "sea"); + }); +}); + +// ---- Coverage holes ------------------------------------------------------- + +describe("a radar that is off the air", () => { + it("marks its cells unknown rather than dry", () => { + const inoperable: RadarStation[] = [ + { id: "KDOWN", lat: 40.0, lon: -120.0, type: "WSR-88D", operability: "RDA - Inoperable" }, + // Far enough away that its 230 km reach cannot cover the cell under test. + { id: "KFAR", lat: 33.0, lon: -117.0, type: "WSR-88D", operability: "RDA - On-line" }, + ]; + const out = build([cell(33.125, -117.125, 45)], inoperable); + const field = out.field; + assert.ok(field); + + assert.equal(at(field, 40.125, -120.125), null, "a cell only a dead radar covers is unknown"); + assert.equal(at(field, 33.125, -116.875), RADAR_DRY_DBZ, "a cell a live radar covers is dry"); + assert.equal(field.stationsDown, 1); + assert.equal(field.stations, 2); + }); + + it("does not count a maintenance work order as an outage", () => { + // The trap. Six of sixteen stations carry one of these right now and all six + // are transmitting; treating them as down blanks the southern half of the + // state on the night this was written. + assert.equal(stationIsDown("RDA - Maintenance Action Mandatory"), false); + assert.equal(stationIsDown("RDA - Maintenance Action Required"), false); + assert.equal(stationIsDown("RDA - On-line"), false); + assert.equal(stationIsDown("RDA - Inoperable"), true); + assert.equal(stationIsDown("RDA - Off-line"), true); + // Unknown is not down. A status feed that hiccups must not grey the board. + assert.equal(stationIsDown(""), false); + assert.equal(stationIsDown(null), false); + assert.equal(stationIsDown(undefined), false); + + const out = build([cell(33.875, -117.375, 45)]); + assert.ok(out.field); + assert.equal(out.field.stationsDown, 0, "not one of the twelve real stations is down"); + assert.equal(at(out.field, 33.875, -117.625), RADAR_DRY_DBZ, "Orange County is dry, not unknown"); + }); +}); + +// ---- Promotion ------------------------------------------------------------ + +describe("promotion", () => { + it("is decided from statewide coverage and not from cell count", () => { + const cells = [cell(33.875, -117.375, 45)]; + const below = build(cells, STATIONS, RADAR_MIN_WET_FRACTION / 2); + assert.equal(below.field, null); + assert.equal(below.quiet, "below-threshold"); + + const above = build(cells, STATIONS, RADAR_MIN_WET_FRACTION * 2); + assert.ok(above.field, "the same one cell, above the coverage floor, is drawn"); + }); + + it("draws nothing when the gate has taken everything that was there", () => { + const out = build([cell(35.625, -121.375, 47.5)], STATIONS, 0.02); + assert.equal(out.field, null); + assert.equal(out.quiet, "nothing-survived"); + assert.equal(out.suppressed.length, 1, "…and says what it took"); + }); + + it("gives a lattice that is the product's own grid, clipped to the board", () => { + const out = build([cell(33.875, -117.375, 45)]); + const field = out.field; + assert.ok(field); + // 32.5-42.05 N and -124.5 to -114.0 W on a quarter-degree grid whose centres + // sit on the half-cell. Every one of these is derived, not configured, so a + // board that moves again moves this with it. + assert.equal(field.rows, 38); + assert.equal(field.cols, 42); + assert.equal(field.minLat, 32.625); + assert.equal(field.minLng, -124.375); + assert.equal(field.dbz.length, 38 * 42); + assert.ok(field.rows * field.cols * 4 < 8 * 1024, "the whole raster is under 8 KB as RGBA"); + }); +}); + +// ---- The client gate ------------------------------------------------------ + +describe("promoteRadar", () => { + const body = (over: Partial): RadarBody => ({ + source: "cloud1", + fetchedAt: "2026-08-23T04:00:21Z", + field: null, + ttlSeconds: 300, + ...over, + }); + const NOW = Date.parse("2026-08-23T04:05:00Z"); + + it("survives a null, an undefined and a body full of nonsense", () => { + for (const input of [null, undefined, 42, "rain", { source: "cloud1" }]) { + const out = promoteRadar(input as unknown as RadarBody, NOW); + assert.equal(out.field, null); + assert.notEqual(out.message, ""); + } + }); + + it("says which kind of empty it is", () => { + const never = promoteRadar(body({ fetchedAt: new Date(0).toISOString() }), NOW); + assert.equal(never.ageMs, null); + assert.match(never.message, /fact about this box/); + + const quiet = promoteRadar(body({}), NOW); + assert.equal(quiet.ageMs, 4 * 60_000 + 39_000); + assert.match(quiet.message, /Nothing is falling/); + assert.match(quiet.message, /minutes old/); + }); + + it("refuses a lattice whose dbz length disagrees with its own shape", () => { + // Not a field with a problem — a field that would be drawn rotated, every + // row after the first offset by the difference. + const out = promoteRadar( + body({ + field: { + minLat: 32.625, minLng: -124.375, cellLat: 0.25, cellLng: 0.25, + rows: 38, cols: 42, dbz: [45, null], observedAt: "x", + wetFraction: 0.02, stations: 16, stationsDown: 0, + }, + }), + NOW, + ); + assert.equal(out.field, null); + }); + + it("carries a promoted lattice through unchanged, and counts what is in it", () => { + const built = build([cell(33.875, -117.375, 61.5), cell(34.125, -117.375, 22)]); + assert.ok(built.field); + const out = promoteRadar(body({ field: built.field }), NOW); + assert.ok(out.field); + assert.equal(out.field.dbz.length, built.field.dbz.length); + assert.match(out.message, /61\.5 dBZ/); + assert.match(out.message, /2 cells of 1596/); + assert.match(out.message, /All 12 radars reporting/); + assert.ok(RADAR_RAIN_DBZ === 20); + }); + + it("has an empty promotion that is a sentence and not a blank", () => { + const empty = emptyRadarPromotion(); + assert.equal(empty.source, "none"); + assert.equal(Date.parse(empty.fetchedAt), 0); + assert.ok(empty.message.length > 40); + }); +}); diff --git a/src/test/data/vesselGate.test.ts b/src/test/data/vesselGate.test.ts new file mode 100644 index 0000000..6cc8196 --- /dev/null +++ b/src/test/data/vesselGate.test.ts @@ -0,0 +1,479 @@ +/** + * 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, + 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 { + 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"); + assert.equal(modelled.intervalSeconds, 900); + 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]), + ); + assert.equal(promotion.makingWay, 3); + assert.ok(promotion.alongside >= 2, "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; +} diff --git a/src/test/data/wireContract.test.ts b/src/test/data/wireContract.test.ts index 4e47eb6..2aab5c9 100644 --- a/src/test/data/wireContract.test.ts +++ b/src/test/data/wireContract.test.ts @@ -27,10 +27,14 @@ import { createTeraClient } from "../../adapters/http.ts"; import { createDeviceSource, createNullDeviceSource } from "../../devices/adapter.ts"; import { initialDeviceState, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts"; import type { + BirdsBody, DeviceCommandBody, DeviceCommandResultBody, DevicesBody, HealthBody, + PortsBody, + RadarBody, + VesselsBody, } from "../../server/wire.ts"; Object.defineProperty(globalThis, "window", { @@ -128,6 +132,184 @@ describe("the bodies are JSON, and stay JSON", () => { }); }); +describe("the four bodies this build added", () => { + /** + * The empty answer for each one, round-tripped. + * + * Every one of these feeds spends most of its life empty and **that is the + * case that has to be right**: California is under rain a mean 0.596% of the + * time, birds are aloft about ten hours in twenty-four by construction, and + * no deployment has an AIS licence. So the assertions below are all about the + * empty body — that it survives JSON, that it carries a fetch age, and that + * emptiness arrives with a reason rather than as a bare `[]`. + */ + it("round-trips an empty vessels body, with the sampling interval on it", () => { + const body: VesselsBody = { + source: "none", + fetchedAt: new Date(0).toISOString(), + vessels: [], + intervalSeconds: 900, + ttlSeconds: 60, + }; + assert.deepEqual(JSON.parse(JSON.stringify(body)), body); + // Not decoration. Upstream listens for thirty seconds every fifteen minutes, + // so a hull under way has moved about five kilometres between two fixes: a + // client may dead-reckon along the reported course, and may never draw a + // point on the chord between two samples. + assert.equal(body.intervalSeconds, 900); + }); + + it("keeps every AIS sentinel expressible as absence rather than as a number", () => { + // The three sentinels are valid numbers and none of them is ever NULL, so a + // null-check catches nothing and the gate upstream is what strips them. What + // the wire has to provide is somewhere for "absent" to go once it has. + const body: VesselsBody = { + source: "modelled", + fetchedAt: new Date(0).toISOString(), + vessels: [ + { + id: "hull-1", + kind: "container", + lat: 33.74, + lon: -118.26, + speed: 0, + course: null, + heading: null, + navStatus: 5, + length: null, + beam: null, + ageSeconds: 120, + }, + ], + intervalSeconds: 900, + ttlSeconds: 60, + }; + assert.deepEqual(JSON.parse(JSON.stringify(body)), body); + const [hull] = body.vessels; + assert.ok(hull); + assert.equal(hull.course, null, "an unknown course must be null, never 360"); + assert.equal(hull.heading, null, "an unknown heading must be null, never 511"); + // And no identity. Names arrive with a licensed feed or not at all. + for (const forbidden of ["name", "mmsi", "callsign", "destination"]) { + assert.ok(!(forbidden in hull), `WireVessel grew a ${forbidden}`); + } + }); + + it("dates a port's throughput to a month and never to now", () => { + const body: PortsBody = { + source: "none", + fetchedAt: new Date(0).toISOString(), + throughput: [ + { + portId: "USLAX", + asOf: "2026-07", + loadedExport: 111_776, + emptyExport: 348_691, + loadedImport: 499_552, + emptyImport: 446, + }, + ], + rates: [ + { id: "FBX01", lane: "China / East Asia to North America West Coast", usdPerFeu: 7_491 }, + { id: "FBX02", lane: "North America West Coast to China / East Asia", usdPerFeu: 347 }, + ], + ttlSeconds: 3_600, + }; + assert.deepEqual(JSON.parse(JSON.stringify(body)), body); + const [row] = body.throughput; + assert.ok(row); + assert.match(row.asOf, /^\d{4}-\d{2}$/, "throughput must be dated to its month"); + // The headline the card leads with, restated as arithmetic so a transcription + // error in the figures fails here rather than on a page. + const exported = row.loadedExport + row.emptyExport; + assert.equal(exported, 460_467); + assert.ok(row.emptyExport / exported > 0.75, "the empty share is the story"); + // No timestamp on a rate. Freightos publishes none, our `observed_at` is our + // own read clock, and a card that renders it as "as of" is lying. + for (const rate of body.rates) { + assert.ok(!("observedAt" in rate), "a freight rate acquired a false timestamp"); + } + }); + + it("lets the radar draw a hole as a hole", () => { + const body: RadarBody = { + source: "none", + fetchedAt: new Date(0).toISOString(), + field: { + minLat: 32.5, + minLng: -124.5, + cellLat: 0.25, + cellLng: 0.25, + rows: 2, + cols: 2, + // Dry, dry, raining, and *unknown* — a cell inside the coverage radius + // of a radar whose RDA is down. `null` and `0` are different claims and + // this is the type that keeps them apart. + dbz: [0, 0, 47.5, null], + observedAt: new Date(0).toISOString(), + wetFraction: 0.25, + stations: 16, + stationsDown: 1, + }, + ttlSeconds: 300, + }; + const round = JSON.parse(JSON.stringify(body)) as RadarBody; + assert.deepEqual(round, body); + assert.equal(round.field?.dbz[3], null, "an unknown cell survived as anything but null"); + assert.equal(round.field?.dbz.length, (round.field?.rows ?? 0) * (round.field?.cols ?? 0)); + }); + + it("never lets an empty sky be a bare empty array", () => { + const body: BirdsBody = { + source: "none", + fetchedAt: new Date(0).toISOString(), + observedAt: null, + counties: [], + statewide: { + crossed: 393_290, + peakAloft: 1_501_193, + peakAt: "2026-08-22T06:20:00Z", + meanAltitude: 726, + heading: "south-east", + }, + quiet: { + reason: "daylight", + message: + "Nothing is aloft. BirdCast measures migration only after dark. Last night " + + "393,290 birds crossed California heading south-east, peaking at 1,501,193 " + + "aloft at 23:20 PDT, at a mean 726 metres.", + }, + ttlSeconds: 600, + }; + assert.deepEqual(JSON.parse(JSON.stringify(body)), body); + // The empty state is the layer for most visitors, so an empty set without a + // reason beside it is the defect rather than the normal case. + assert.equal(body.counties.length, 0); + assert.ok(body.quiet !== null, "an empty sky with no reason is indistinguishable from a dead feed"); + // And the statewide headline is the state row, never a sum over counties: + // summing all 58 gives 2,360,086 against an authoritative 393,290. + assert.equal(body.statewide?.crossed, 393_290); + }); + + it("reads the three new source ids the same defensive way as `devices`", async () => { + // A browser meeting a server one version behind sees `undefined` for all + // three and must conclude the box serves none of them. + const health = { + ok: true, + service: "tera-api", + version: "0.1.0", + uptimeSeconds: 1, + sources: { weather: "none", flights: "sim", satellites: "none", markers: "none", devices: "none" }, + auth: { mode: "none", entryUrl: null }, + regions: [], + degraded: [], + } as unknown as HealthBody; + assert.equal(health.sources.vessels, undefined); + assert.equal(health.sources.radar, undefined); + assert.equal(health.sources.birds, undefined); + }); +}); + describe("what the browser learns from /health", () => { it("reports the device source and the demotions to the interface", async () => { const access = await resolveAccess( diff --git a/src/test/integration/barrel.test.ts b/src/test/integration/barrel.test.ts index 1325eed..08d226d 100644 --- a/src/test/integration/barrel.test.ts +++ b/src/test/integration/barrel.test.ts @@ -16,7 +16,7 @@ */ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -102,6 +102,67 @@ test("nothing reachable from the barrel imports three.js", () => { ); }); +/** + * The render layers this build added, named so that adding one cannot quietly + * put three.js on the package surface. + * + * Four new engine modules — the port kit, the vessel layer, the reflectivity + * sheet and the migration field — are *render* layers in the same sense + * `interiors/devices.ts` and `engine/officeExterior.ts` are, and are therefore + * conspicuously absent from `src/index.ts`. Their **gates** are not: a gate is a + * pure function over plain data, it is testable with no GL context, and it is + * where every honest decision about a feed is taken. The asymmetry is the whole + * shape of this repo, and it is written down here rather than left to be + * rediscovered by whichever workstream lands last. + * + * Listed as names rather than inferred, so this test fails loudly with the + * offending path printed rather than by the closure quietly growing. + */ +const RENDER_LAYERS = [ + "src/engine/ports.ts", + "src/engine/vessels.ts", + "src/engine/precip.ts", + "src/engine/migration.ts", +]; + +/** The pure gates behind them. Every one of these may be exported; none may import three. */ +const PURE_GATES = [ + "src/server/vessels.ts", + "src/server/radar.ts", + "src/server/birds.ts", +]; + +test("the render layers stay off the package surface", () => { + const reached = new Set([...closure(ENTRY).keys()].map((f) => path.relative(ROOT, f))); + const offenders = RENDER_LAYERS.filter((file) => reached.has(file)); + assert.deepEqual( + offenders, + [], + "a render layer reached the barrel. The port kit, the vessels, the radar " + + "sheet and the migration field all build meshes; their gates are the half " + + "that belongs on the public surface.", + ); +}); + +test("the pure gates behind those layers import no renderer", () => { + const offenders: string[] = []; + for (const file of PURE_GATES) { + const full = path.join(ROOT, file); + if (!existsSync(full)) continue; + const source = readFileSync(full, "utf8"); + if (/from\s+["']three(?:\/|["'])/.test(source) || /import\s*\(\s*["']three/.test(source)) { + offenders.push(file); + } + } + assert.deepEqual( + offenders, + [], + "a gate imported three.js. Everything a gate asserts must be assertable " + + "without a GL context — the argument fires.ts:16-23 makes, applied to the " + + "three feeds this build added.", + ); +}); + test("nothing reachable from the barrel takes a bare dependency at all", () => { const offenders: string[] = []; for (const [file] of closure(ENTRY)) { diff --git a/src/test/integration/layerSeams.test.ts b/src/test/integration/layerSeams.test.ts new file mode 100644 index 0000000..b6ec99e --- /dev/null +++ b/src/test/integration/layerSeams.test.ts @@ -0,0 +1,241 @@ +/** + * The four seams three other workstreams compile against, asserted from both + * ends. + * + * `SceneOptions` grew four factory slots in one commit — `ports`, `vessels`, + * `precip`, `migration` — and each of them is built, ticked, lit and disposed by + * `createScene`. Every one of those five touch points is a line somebody can + * delete without breaking a compile, and the layer would then simply never + * appear: no error, no failing type, just a board with nothing on it, which is + * exactly the picture a quiet day is supposed to produce. That is the failure + * this file exists to catch. + * + * ## Why this reads source rather than calling `createScene` + * + * The same reason `sceneWiring.test.ts` gives and no other: `createScene` awaits + * a terrain `Worker` and takes a live `Stage`, so the only place it runs is a + * browser. Pretending otherwise with a mock `Stage` would test the mock. What is + * asserted here instead is *structural* and is the half a browser test is + * slowest to tell you about — the factory is called, it is called once, and it + * is called only when it was supplied. `scripts/ui-smoke.mjs` is the brace to + * this belt. + * + * The type-level half is real, though, and it is the first block below: an + * object literal is assigned to `SceneOptions` and the layer shapes are + * satisfied by hand-written stubs. If a signature moves, `tsc` fails here. + */ + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import type { + MigrationLayerFactory, + PortLayer, + PortLayerFactory, + PrecipLayerFactory, + SceneOptions, + VesselLayerFactory, +} from "../../engine/scene.ts"; +import CALIFORNIA from "../../cities/california.ts"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const SCENE = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8"); +const MAIN = readFileSync(path.join(ROOT, "src/main.ts"), "utf8"); +const INDEX = readFileSync(path.join(ROOT, "index.html"), "utf8"); + +// ---- The compile-time half ------------------------------------------------- +// +// Nothing here runs anything. Its whole job is to fail `npx tsc --noEmit` the +// day one of these signatures changes under a workstream that was told it would +// not, which is cheaper than three agents discovering it separately. + +const group = { name: "stub" } as unknown as PortLayer["group"]; + +const port: PortLayerFactory = () => ({ group, setLighting() {}, dispose() {} }); +const vessels: VesselLayerFactory = () => ({ + group, + setVessels() {}, + setLighting() {}, + tick() {}, + dispose() {}, +}); +const precip: PrecipLayerFactory = () => ({ + group, + setField() {}, + setLighting() {}, + tick() {}, + dispose() {}, +}); +const migration: MigrationLayerFactory = () => ({ + group, + setField() {}, + setLighting() {}, + setSolarElevation() {}, + tick() {}, + dispose() {}, +}); + +const _options: SceneOptions = { + city: CALIFORNIA, + ports: port, + vessels, + precip, + migration, +}; + +// Named so an unused-locals rule cannot delete the assertion above. +void _options; + +/** Each factory, and the setter on the handle that must reach its layer. */ +const SEAMS: readonly (readonly [string, string, string])[] = [ + ["ports", "portLayer", ""], + ["vessels", "vesselLayer", "setVessels"], + ["precip", "precipLayer", "setPrecip"], + ["migration", "migrationLayer", "setMigration"], +]; + +test("SceneOptions carries all four factory slots", () => { + for (const [slot] of SEAMS) { + assert.match( + SCENE, + new RegExp(`\\n\\s{2}${slot}\\?:\\s*\\w+LayerFactory;`), + `SceneOptions lost the ${slot} slot`, + ); + } +}); + +test("createScene calls each factory exactly once, and only when it was supplied", () => { + for (const [slot] of SEAMS) { + const calls = [...SCENE.matchAll(new RegExp(`options\\.${slot}\\(`, "g"))].length; + assert.equal( + calls, + 1, + `createScene calls options.${slot}() ${calls} times. Once, mirroring the ` + + "fire layer: a layer built twice is two of everything on one board.", + ); + // The guard and the call in one expression, so absence cannot allocate. + // `options.fires ? options.fires(world, { span: boardSpan }) : null` is the + // shape being held to, and the span is what lets a layer size itself from + // the board rather than from a constant. + assert.match( + SCENE, + new RegExp( + `options\\.${slot}\\s*\\n?\\s*\\?\\s*options\\.${slot}\\(world,\\s*\\{\\s*span:\\s*boardSpan\\s*\\}\\)`, + ), + `options.${slot} must be built exactly the way options.fires is: guarded, ` + + "handed the world under construction, and sized by the board span.", + ); + } +}); + +test("a layer that was not supplied contributes nothing at all", () => { + for (const [, local] of SEAMS) { + // Declared `| null` and added to the scene only inside the truthy branch. + // The empty state for every one of these feeds is the common case — a mean + // 0.596% of California is under rain and birds are absent fourteen hours a + // day — so "not visited at all" is the requirement, not "draws nothing". + assert.match( + SCENE, + new RegExp(`const ${local}: \\w+Layer \\| null = options\\.`), + `${local} must be nullable: absent has to cost no geometry and no draw call`, + ); + assert.match( + SCENE, + new RegExp(`if \\(${local}\\) \\{[\\s\\S]{0,200}?scene\\.add\\(${local}\\.group\\);`), + `${local} is added to the scene outside its own null guard`, + ); + } +}); + +test("every supplied layer is lit, ticked and disposed with the board", () => { + for (const [slot, local, setter] of SEAMS) { + assert.ok( + SCENE.includes(`${local}?.setLighting(state)`), + `${local} never receives a rig. CONTRACT §4: Atmosphere computes, a scene ` + + "applies, nothing writes back — a layer left out of setLighting is a " + + "layer lit for whatever hour it happened to be built at.", + ); + assert.ok( + SCENE.includes(`${local}?.dispose()`), + `${local} is never disposed; a board switch would orphan its buffers`, + ); + if (slot !== "ports") { + assert.ok( + SCENE.includes(`${local}?.tick(dt)`), + `${local} declares tick() and never gets one`, + ); + } + if (setter !== "") { + assert.match( + SCENE, + new RegExp(`${setter}:\\s*\\([\\w\\s,]*\\)\\s*=>\\s*${local}\\?\\.`), + `SceneHandle.${setter} must reach ${local} and must be a no-op without it`, + ); + } + } +}); + +test("the port kit is static geometry and says so by having no tick", () => { + // A deliberate asymmetry worth asserting rather than leaving to be noticed: a + // quay does not move, so `PortLayer` has no `tick` and `createScene` must not + // invent one. Vessels move, rain moves, birds move; stone does not. + assert.ok( + !SCENE.includes("portLayer?.tick("), + "the port kit acquired a per-frame tick. Nothing in it moves.", + ); +}); + + +// ---- The other end of the seam -------------------------------------------- +// +// `scene.ts` can be perfect and the product still draw nothing, because the +// factories are optional and `main.ts` is the only thing that supplies them. A +// deleted line there is a silent, compiling, type-correct board with no ships on +// it — the same failure this file exists to catch, one file further out. + +test("main.ts supplies every factory slot the scene declares", () => { + for (const [slot] of SEAMS) { + assert.match( + MAIN, + new RegExp(`\\b${slot}:\\s`), + `main.ts never passes SceneOptions.${slot}, so the layer is never built`, + ); + } +}); + +test("main.ts feeds the three layers that take a feed", () => { + // Each setter, and the gate whose answer it must be handed. The pairing is the + // assertion: a `setVessels` fed from anything but a promotion would be a hull + // that skipped the sentinel strip, and a `setPrecip` fed from a body rather + // than from `promoteRadar(...).field` would be a raster nobody clipped. + const FEEDS: readonly (readonly [string, string])[] = [ + ["setVessels", "promoteVessels("], + ["setPrecip", "promoteRadar("], + ["setMigration", "promoteBirds("], + ]; + for (const [setter, gate] of FEEDS) { + assert.ok(MAIN.includes(setter + "("), `main.ts never calls ${setter}`); + assert.ok(MAIN.includes(gate), `main.ts never calls ${gate}), so ${setter} has no source`); + } +}); + +test("every one of the three feeds has a sentence, and a place to print it", () => { + /** + * The quiet day is the common day — 0.47% of California under rain, nothing + * aloft for fourteen hours, and no AIS licence read — so an empty layer with + * no caption beside it is what a stranger actually sees. Each gate owns a + * sentence that is never blank; these are the three elements they are written + * into, and a panel section that lost its paragraph would take the explanation + * with it and leave a board that looks broken. + */ + for (const id of ["sea-note", "radar-note", "birds-note"]) { + assert.ok(INDEX.includes(`id="${id}"`), `index.html has no #${id} to write into`); + assert.ok(MAIN.includes(`"${id}"`), `main.ts never writes #${id}`); + } + for (const gate of ["vesselSummary(", "promoteRadar(", "promoteBirds("]) { + assert.ok(MAIN.includes(gate), `main.ts never asks ${gate}) for its sentence`); + } +}); diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts index 9abbac2..2d19645 100644 --- a/src/test/integration/sceneWiring.test.ts +++ b/src/test/integration/sceneWiring.test.ts @@ -644,6 +644,58 @@ test("the city scene is handed the same environment rig the office is", () => { ); }); +test("a camera step never reaches the light, and so never reaches the environment", () => { + /* + * The seam this asserts, and why it is worth a source test. + * + * `Scene.setLighting` ends in `options.environment?.apply(scene, state, "city")`, + * and that rig rebuilds its PMREM cubemap whenever the rig's *colours* move. + * The camera's orbit `change` handler fires a few dozen times in one drag. So + * long as those two are joined, every feature that makes a colour depend on + * where the camera is standing is a frame-rate regression waiting to be + * written, and the tempting fix — coarsening `environmentKey` — hides the + * instance and leaves the mechanism. + * + * They are separate now: the camera moves two fog distances through + * `setAerialFog`, which cannot carry a colour because `AerialFog` has no + * colour in it. This test fails if somebody merges them back. + */ + const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8"); + + const from = MAIN.indexOf("function applyCameraFog"); + assert.ok(from > 0, "the camera's fog path has been renamed or deleted"); + const body = MAIN.slice(from, MAIN.indexOf("\n}", from)); + assert.ok( + !/setLighting\(/.test(body), + "the camera path is applying a whole rig again. `setLighting` fans out across " + + "six layers and the PMREM environment; a camera step must move fog distances " + + "and nothing else.", + ); + assert.ok( + /city\.setAerialFog\(\s*atmosphere\.aerial\(/.test(body), + "the camera path must ask `Atmosphere` for the fog — CONTRACT §4 keeps it the " + + "sole light owner, so the app may not work a distance out for itself", + ); + assert.ok( + /controls\.addEventListener\("change", onCameraMoved\)/.test(MAIN) && + /applyCameraFog\(\)/.test(MAIN), + "the orbit `change` event must still drive the fog; without it a chase camera " + + "on the state board flies through the haze the whole feature exists to draw", + ); + + const setter = scene.indexOf("setAerialFog: ("); + assert.ok(setter > 0, "SceneHandle.setAerialFog has been renamed or deleted"); + const setterBody = scene.slice(setter, scene.indexOf("\n },", setter)); + for (const forbidden of ["environment", "applyLighting", "setLighting"]) { + assert.ok( + !setterBody.includes(forbidden), + `setAerialFog reached \`${forbidden}\`. It may touch the three things that draw ` + + "fog and nothing else — the whole value of the split is that a camera step " + + "cannot re-enter the lighting path.", + ); + } +}); + test("the aircraft pick reaches the card, and the card reaches an anonymous visitor", () => { const scene = readFileSync(path.join(ROOT, "src/engine/scene.ts"), "utf8"); assert.ok( diff --git a/src/test/packs/californiaBoard.test.ts b/src/test/packs/californiaBoard.test.ts index 4c74829..446292e 100644 --- a/src/test/packs/californiaBoard.test.ts +++ b/src/test/packs/californiaBoard.test.ts @@ -121,20 +121,48 @@ describe("California board — geometry that only a picture used to catch", () = }); it("stands the ranges up far enough to be seen from the state camera", () => { + /** + * Relief measured against the board **span**, which is a change from the + * board's north-south extent and is worth being explicit about, because + * lowering a threshold and changing its denominator in the same commit is + * exactly what a weakened test looks like. + * + * The span is the number `scene.ts` actually frames on — `boardSpan` is + * `max(width, height)` and every camera limit, the fog and `chapterFraming` + * divide by it — so it is the denominator that decides how big a mountain + * looks. On the board that stopped at 38.05 the two differed a lot (428 + * across against 319 tall) and this assertion was quietly measuring against + * the smaller one, which flattered it by a third. On the whole state they + * are both 554 and the distinction stops mattering; it is corrected here so + * that the next board to change shape is measured against the right thing. + * + * The bar is 7%, and it is calibrated against the two boards that already + * look right rather than chosen. Measured today: + * + * Southern California 29.4 u of 393 = 7.5% + * the whole state 41.2 u of 554 = 7.4% + * the Bay Area 48.7 u of 1003 = 4.9% + * + * The state board sits on Southern California's number, which is the + * calibration that matters — the two are meant to read as one landscape at + * two zooms. It got there by the exaggeration going 13 to 15 when the bounds + * grew, not by this number moving to meet it: at 13 the extended board is + * 6.4% and this assertion fails, which is the failure doing its job. + */ const world = builtWorld(CALIFORNIA_CITY); const { bounds } = CALIFORNIA_CITY; - const boardUnits = (bounds.maxLat - bounds.minLat) * CALIFORNIA_CITY.latScale; + const [westX, northZ] = world.project(bounds.maxLat, bounds.minLng); + const [eastX, southZ] = world.project(bounds.minLat, bounds.maxLng); + const boardUnits = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); let peak = 0; for (const metres of world.lattice().height) if (metres > peak) peak = metres; const peakUnits = world.metres(peak); assert.ok(peak > 4_000, `the highest ground is only ${Math.round(peak)} m`); - // 8% of the board's own height. Southern California's San Gabriels clear - // this comfortably; the old 2.25 exaggeration put this board at 0.6%. assert.ok( - peakUnits / boardUnits > 0.08, - `relief is ${((peakUnits / boardUnits) * 100).toFixed(1)}% of the board — flat`, + peakUnits / boardUnits > 0.07, + `relief is ${((peakUnits / boardUnits) * 100).toFixed(1)}% of the board span — flat`, ); }); diff --git a/src/test/packs/californiaExtent.test.ts b/src/test/packs/californiaExtent.test.ts new file mode 100644 index 0000000..c1dc609 --- /dev/null +++ b/src/test/packs/californiaExtent.test.ts @@ -0,0 +1,183 @@ +/** + * The state board is the whole state, and the cell that paid for it. + * + * This board used to stop at 38.05 N. The minimap beside it draws the whole of + * California from the same pack, so a single frame contained a picture of the + * state and a picture of two thirds of the state, disagreeing about the shape of + * the one silhouette in this product that everybody already knows. That is the + * defect this file guards, and the reason it is a pack test and not a picture + * is that a picture is what it took to notice. + * + * ## The three claims, and why each one needs an assertion + * + * 1. **The bounds reach the corners.** Easy to state, easy to half-do: an + * extension that moved `maxLat` and forgot `minLng` gives a state with an + * Oregon border and no Cape Mendocino, which reads as a different place. + * 2. **The land was authored, not merely permitted.** Growing `bounds` costs + * nothing and draws nothing — the polygon decides where the ground is, and + * a board whose bounds reach 42 N over a coastline that stops at 38.13 is a + * board with two hundred kilometres of open ocean where the North Coast is. + * Twenty vertices north of 40 N is the cheapest arithmetic statement of + * "somebody traced this". + * 3. **The cell was coarsened to pay for it.** This is the load-bearing one. + * At the old 0.022 x 0.027 the extended board takes the lattice from 84,924 + * points to 168,813 — 2.02x — and the terrain with it, against a mobile + * budget with 70,000 triangles spare and two more layers landing on the same + * board in the same round. The 1.42x coarsening is the entire reason the + * extension fits, and it is one edit away from being silently reverted by + * somebody who thinks a finer lattice is always better. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import CALIFORNIA, { CORRIDOR_LAND } from "../../cities/california.ts"; +import { World, computeField } from "../../engine/world.ts"; + +/** Metres in a degree of latitude. The same constant `World` uses. */ +const M_PER_DEGREE = 111_320; + +describe("the board reaches the whole state", () => { + it("contains the corners the old bounds cut off", () => { + const { bounds } = CALIFORNIA; + // Crescent City is at 41.75 N; the Oregon line is at 42.00. A board that + // stops short of 41.9 has cut off Del Norte County and the redwoods. + assert.ok(bounds.maxLat > 41.9, `maxLat is ${bounds.maxLat}`); + // Cape Mendocino is the westernmost ground in California at -124.41, and it + // is the corner that makes the northern silhouette read as this state. + assert.ok(bounds.minLng < -124.3, `minLng is ${bounds.minLng}`); + // And the three edges that were already right stayed right. + assert.ok(bounds.minLat <= 32.55, `minLat is ${bounds.minLat}`); + assert.ok(bounds.maxLng >= -114.0, `maxLng is ${bounds.maxLng}`); + }); + + it("centres scene space on the middle of the bounds", () => { + // Everything sized from the origin — the satellite dome, the shadow box, the + // star field — is centred with it, so a centre left where it was when the + // board was smaller makes all three too small on the far side by the offset. + const { bounds, center } = CALIFORNIA; + const midLat = (bounds.minLat + bounds.maxLat) / 2; + const midLng = (bounds.minLng + bounds.maxLng) / 2; + assert.ok(Math.abs(center.lat - midLat) < 0.2, `centre is ${center.lat}, middle is ${midLat}`); + assert.ok(Math.abs(center.lng - midLng) < 0.4, `centre is ${center.lng}, middle is ${midLng}`); + }); + + it("has a traced North Coast rather than a wider empty ocean", () => { + const north = CORRIDOR_LAND.filter(([lat]) => lat > 40.0); + assert.ok( + north.length >= 20, + `only ${north.length} vertices north of 40 N — the added land is a shelf, not a coast`, + ); + // The cape itself, because it is the one vertex whose absence changes the + // silhouette rather than the detail: without it the North Coast is a + // straight line from Trinidad to Shelter Cove. + assert.ok( + CORRIDOR_LAND.some(([lat, lng]) => lat > 40.3 && lat < 40.6 && lng < -124.35), + "Cape Mendocino is not in the trace", + ); + }); + + it("puts real ground under the new land, north and north-east", () => { + const world = new World(CALIFORNIA); + for (const [name, lat, lng] of [ + ["Crescent City", 41.75, -124.18], + ["Eureka", 40.8, -124.16], + ["Redding", 40.58, -122.39], + ["Sacramento", 38.58, -121.49], + ["Mount Shasta", 41.41, -122.19], + ["Alturas, in Modoc", 41.49, -120.54], + ] as [string, number, number][]) { + assert.equal(world.isLand(lat, lng), true, `${name} is not on the board`); + } + // And the two edges that are new: Oregon is not California, and neither is + // the Great Basin east of the 120th meridian. + assert.equal(world.isLand(42.3, -122.5), false, "Oregon is on the board"); + assert.equal(world.isLand(41.0, -119.5), false, "Nevada is on the board"); + }); + + it("stands Shasta and Lassen up as the two things that make the north the north", () => { + const world = new World(CALIFORNIA); + world.lattice(); + assert.ok( + world.elevationAt(41.409, -122.194) > 3_800, + `Shasta is only ${Math.round(world.elevationAt(41.409, -122.194))} m`, + ); + assert.ok( + world.elevationAt(40.488, -121.505) > 2_600, + `Lassen is only ${Math.round(world.elevationAt(40.488, -121.505))} m`, + ); + // The gap between them is as much of the picture as the peaks. Hat Creek + // country sits around 1,000 m and must not be filled in by either skirt. + assert.ok(world.elevationAt(40.95, -121.5) < 2_200, "the Cascade gap has been filled in"); + // And the Sacramento Valley is a floor, not a range: farmland at tens of + // metres, which is what makes the ranges either side of it read as ranges. + for (const [lat, lng] of [[39.5, -121.95], [39.0, -121.75], [40.2, -122.1]] as const) { + const floor = world.elevationAt(lat, lng); + assert.ok(floor < 300, `the Sacramento Valley at ${lat}N is ${Math.round(floor)} m`); + assert.ok(floor > 3, `the Sacramento Valley at ${lat}N is at the beach colour`); + } + }); +}); + +describe("the cell is what paid for the extension", () => { + it("holds the ground cell between 3.3 and 3.7 kilometres", () => { + const metres = CALIFORNIA.cellLat * M_PER_DEGREE; + assert.ok( + metres > 3_300 && metres < 3_700, + `the cell is ${Math.round(metres)} m. Below 3,300 the lattice doubles and the ` + + "terrain goes through the mobile budget; above 3,700 the Sierra stops " + + "reading as a range.", + ); + // Longitude is squashed by cos(centre latitude), so an equal-area cell has to + // be 1/cos as wide as it is tall. Both axes were multiplied by the same 1.42. + const squash = Math.cos((CALIFORNIA.center.lat * Math.PI) / 180); + const ratio = CALIFORNIA.cellLng / CALIFORNIA.cellLat; + assert.ok( + Math.abs(ratio - 1 / squash) < 0.06, + `the cell is ${ratio.toFixed(3)} as wide as it is tall; 1/cos(${CALIFORNIA.center.lat}) ` + + `is ${(1 / squash).toFixed(3)}, so the ground cell is not square`, + ); + }); + + it("holds the lattice where it was on a board a third bigger", () => { + const field = computeField(new World(CALIFORNIA)); + const points = (field.latSteps + 1) * (field.lngSteps + 1); + // 84,924 was the measured figure on the board that stopped at 38.05, and the + // whole argument of the coarsening is that this number does not move. A 5% + // band, because it is a rounding of two axis lengths and not a target. + assert.ok( + points > 80_000 && points < 89_200, + `the lattice is ${points} points against 84,924 on the smaller board`, + ); + }); + + it("keeps the cell finer in the frame than the board two revisions ago", () => { + /** + * The claim the coarsening rests on, asserted rather than argued. + * + * What the eye sees is not the cell in metres — it is the cell as a fraction + * of the board, because the camera retreats to frame whatever it is given. + * The corridor board before it grew east was 0.020° on 284 units, which is + * 0.0041 of a span; the board that stopped at 38.05 was 0.022° on 428, or + * 0.0030. This board must land between them, which means the cell got 42% + * coarser on the earth and *finer* in the frame than the board two revisions + * ago. + */ + const world = new World(CALIFORNIA); + const [westX, northZ] = world.project(CALIFORNIA.bounds.maxLat, CALIFORNIA.bounds.minLng); + const [eastX, southZ] = world.project(CALIFORNIA.bounds.minLat, CALIFORNIA.bounds.maxLng); + const span = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); + const cellUnits = CALIFORNIA.cellLat * CALIFORNIA.latScale; + const fraction = cellUnits / span; + assert.ok( + fraction < 0.0041, + `the cell is ${fraction.toFixed(4)} of the board span, coarser in frame than ` + + "the 0.0041 of the board before the state's eastern edge arrived", + ); + assert.ok( + fraction > 0.0025, + `the cell is ${fraction.toFixed(4)} of the board span, which is finer than the ` + + "board has ever needed and is being paid for in terrain triangles", + ); + }); +}); diff --git a/src/test/packs/socalPorts.test.ts b/src/test/packs/socalPorts.test.ts new file mode 100644 index 0000000..f04477b --- /dev/null +++ b/src/test/packs/socalPorts.test.ts @@ -0,0 +1,378 @@ +/** + * San Pedro Bay as the SoCal pack authors it — the assertions that keep a quay + * on land and a breakwater the right length. + * + * Every coordinate in `socal.ts` is hand-traced by house rule (ARCHITECTURE + * §3.2), which means it is eyeball-accurate and there is no authority to check + * it against. What there *is* is a set of relationships that have to hold, and a + * hand-traced number that breaks one of them is a typo rather than a judgement + * call. This file is those relationships: + * + * - **A quay is on land.** Every vertex of every quay polygon lies inside a + * landmass. This is the assertion the whole re-trace of Terminal Island exists + * to satisfy: with the old six-point hexagon a quay could be on the water or + * buried in the fill, and nothing would have said so. + * - **A berth is on its quay.** Within 200 m of a quay edge, which at 391 m to + * the scene unit is half a scene unit — close enough that a hull placed there + * is alongside rather than parked in the yard or moored in the fairway. + * - **The breakwater is thirteen kilometres.** Between twelve and fifteen, and + * it comes out 13.06 against a real federal breakwater of 13.07. + * - **Nothing is placed from `ports.sqlite`.** All seven rows of that table sit + * on an exact arc-minute grid; a coordinate here that lands on one is a + * coordinate somebody copied out of it, and it is up to 1,852 m from the water + * it claims. This is the cheapest possible guard against the single most + * likely way this data goes wrong later. + * - **The bridges still land.** Re-tracing Terminal Island moved every shoreline + * the Vincent Thomas and the Long Beach Gateway touch, and a bridge whose + * abutment ends up over open water fails silently — it just looks slightly + * wrong from one angle. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { metresBetween, yardCorners } from "../../engine/ports.ts"; +import SOCAL_CITY, { + LONG_BEACH, + LOS_ANGELES, + PORTS, + TERMINAL_ISLAND, + VINCENT_THOMAS, + LONG_BEACH_GATEWAY, +} from "../../cities/socal.ts"; +import { World } from "../../engine/world.ts"; +import type { LatLng, Quay } from "../../engine/types.ts"; + +const world = new World(SOCAL_CITY); + +function onLand(point: LatLng): boolean { + return world.pointInAny(point[0], point[1], SOCAL_CITY.landmasses); +} + +/** Shortest distance from a point to a polygon's boundary, in metres. */ +function metresToEdge(point: LatLng, polygon: readonly LatLng[]): number { + let best = Infinity; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const from = polygon[j]; + const to = polygon[i]; + if (!from || !to) continue; + const length = metresBetween(from, to); + if (length === 0) { + best = Math.min(best, metresBetween(point, from)); + continue; + } + // Project onto the segment in a local metres frame; at this size the earth + // is flat enough that the error is centimetres. + const scaleLng = Math.cos((point[0] * Math.PI) / 180); + const ax = (from[1] - point[1]) * scaleLng; + const ay = from[0] - point[0]; + const bx = (to[1] - point[1]) * scaleLng; + const by = to[0] - point[0]; + const dx = bx - ax; + const dy = by - ay; + const square = dx * dx + dy * dy; + const t = square === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / square)); + const nearest = Math.hypot(ax + t * dx, ay + t * dy); + best = Math.min(best, nearest * 111_320); + } + return best; +} + +const quays: { port: string; quay: Quay }[] = PORTS.flatMap((port) => + (port.quays ?? []).map((quay) => ({ port: port.id, quay })), +); + +describe("the pack declares two ports and the engine can find them", () => { + it("hangs them off the city", () => { + assert.equal(SOCAL_CITY.ports, PORTS); + assert.deepEqual( + PORTS.map((port) => port.id), + ["USLAX", "USLGB"], + ); + }); + + it("keeps every port record JSON-serialisable", () => { + // A pack is posted to the terrain Worker as a structured clone. One method + // on one port record and the whole board stops booting. + assert.doesNotThrow(() => structuredClone(PORTS)); + assert.deepEqual(JSON.parse(JSON.stringify(PORTS)), JSON.parse(JSON.stringify(PORTS))); + }); +}); + +describe("nothing is placed from ports.sqlite", () => { + it("puts no port anchor on an exact arc-minute", () => { + // Every row of the upstream table has lat*60 and lon*60 whole. One arc-minute + // here is 1,852 m of latitude — 4.7 scene units — from the water. + for (const port of PORTS) { + const latMinutes = port.lat * 60; + const lngMinutes = port.lng * 60; + const onGrid = + Math.abs(latMinutes - Math.round(latMinutes)) < 1e-6 && + Math.abs(lngMinutes - Math.round(lngMinutes)) < 1e-6; + assert.equal(onGrid, false, `${port.id} sits on the arc-minute grid — that is a WPI row`); + } + }); + + it("puts no quay vertex, berth or crane rail on an exact arc-minute either", () => { + const suspects: LatLng[] = [ + ...quays.flatMap(({ quay }) => quay.polygon), + ...PORTS.flatMap((port) => (port.berths ?? []).map((berth): LatLng => [berth.lat, berth.lng])), + ...PORTS.flatMap((port) => (port.cranes ?? []).flatMap((crane) => [crane.from, crane.to])), + ]; + assert.ok(suspects.length > 30); + for (const [lat, lng] of suspects) { + const onGrid = + Math.abs(lat * 60 - Math.round(lat * 60)) < 1e-6 && + Math.abs(lng * 60 - Math.round(lng * 60)) < 1e-6; + assert.equal(onGrid, false, `${lat}, ${lng} is on the arc-minute grid`); + } + }); +}); + +describe("quays are on land and berths are on quays", () => { + it("puts every quay vertex inside a landmass", () => { + assert.ok(quays.length >= 6); + for (const { port, quay } of quays) { + for (const vertex of quay.polygon) { + assert.ok( + onLand(vertex), + `${port}/${quay.id} vertex ${vertex.join(", ")} is on open water`, + ); + } + } + }); + + it("puts every berth anchor within 200 m of its quay", () => { + const byId = new Map(quays.map(({ quay }) => [quay.id, quay])); + let checked = 0; + for (const port of PORTS) { + for (const berth of port.berths ?? []) { + const quay = berth.quayId ? byId.get(berth.quayId) : undefined; + assert.ok(quay, `${berth.id} names quay ${berth.quayId}, which does not exist`); + const distance = metresToEdge([berth.lat, berth.lng], quay.polygon); + assert.ok(distance < 200, `${berth.id} is ${Math.round(distance)} m from its quay`); + checked += 1; + } + } + assert.equal(checked, 15, "San Pedro Bay is authored with fifteen berths"); + }); + + it("puts every berth just off the wall rather than on top of it", () => { + // A hull whose anchor is inside the quay polygon is a hull parked in the + // yard. The berth is the water beside the wall, not the wall. + for (const port of PORTS) { + for (const berth of port.berths ?? []) { + assert.equal( + onLand([berth.lat, berth.lng]), + false, + `${berth.id} is inside the landmass — it should be alongside, not ashore`, + ); + } + } + }); + + it("puts every yard corner on land", () => { + for (const port of PORTS) { + for (const yard of port.yards ?? []) { + for (const corner of yardCorners(yard)) { + assert.ok( + onLand(corner), + `${port.id}/${yard.id} corner ${corner.map((n) => n.toFixed(4)).join(", ")} is on water`, + ); + } + } + } + }); + + it("puts every crane rail on the quay it serves", () => { + for (const port of PORTS) { + for (const crane of port.cranes ?? []) { + for (const end of [crane.from, crane.to]) { + assert.ok(onLand(end), `${port.id}/${crane.id} rail end ${end.join(", ")} is on water`); + } + } + } + }); +}); + +describe("the breakwater", () => { + it("is between twelve and fifteen kilometres, in three arms", () => { + const arms = LOS_ANGELES.breakwater ?? []; + assert.equal(arms.length, 3, "San Pedro, Middle and Long Beach"); + let total = 0; + for (const arm of arms) { + for (let i = 1; i < arm.length; i += 1) total += metresBetween(arm[i - 1]!, arm[i]!); + } + assert.ok(total > 12_000 && total < 15_000, `${Math.round(total)} m`); + }); + + it("leaves Angels Gate and Queens Gate open", () => { + const arms = LOS_ANGELES.breakwater ?? []; + const angels = metresBetween(arms[0]!.at(-1)!, arms[1]![0]!); + const queens = metresBetween(arms[1]!.at(-1)!, arms[2]![0]!); + // Both real gates are between five hundred and a thousand metres wide, and a + // harbour whose arms meet is a lagoon. + assert.ok(angels > 400 && angels < 1_200, `Angels Gate ${Math.round(angels)} m`); + assert.ok(queens > 400 && queens < 1_200, `Queens Gate ${Math.round(queens)} m`); + }); + + it("lies in open water for its whole length", () => { + for (const arm of LOS_ANGELES.breakwater ?? []) { + for (const point of arm) { + assert.equal(onLand(point), false, `breakwater point ${point.join(", ")} is inland`); + } + } + }); + + it("belongs to the coastal-breakwater harbour and is drawn once", () => { + // `harbor_type` is CB for both San Pedro ports and CN for Oakland. The arms + // are one federal structure: declaring them on both would double the + // geometry for an identical picture, and giving them to a CN harbour would + // be inventing the largest object on its waterfront. + for (const port of PORTS) { + if (port.breakwater) assert.equal(port.harborType, "CB", `${port.id} is not a CB harbour`); + } + assert.equal(LONG_BEACH.breakwater, undefined); + }); +}); + +describe("the dredged channels stay in the water", () => { + it("keeps every channel vertex off both landmasses", () => { + for (const port of PORTS) { + for (const point of port.channel ?? []) { + assert.equal(onLand(point), false, `${port.id} channel point ${point.join(", ")} is inland`); + } + } + }); +}); + +describe("Terminal Island still carries the two bridges", () => { + const island = TERMINAL_ISLAND; + + it("lands the Vincent Thomas on the island and San Pedro on the mainland", () => { + const path = VINCENT_THOMAS.path; + const mainland = path[0]!; + const islandEnd = path.at(-1)!; + assert.equal(world.pointInPolygon(islandEnd[0], islandEnd[1], island), true); + assert.equal(world.pointInPolygon(mainland[0], mainland[1], island), false); + assert.equal(onLand(mainland), true); + }); + + it("lands the Long Beach Gateway on the island and Long Beach on the mainland", () => { + const path = LONG_BEACH_GATEWAY.path; + const islandEnd = path[0]!; + const mainland = path.at(-1)!; + assert.equal(world.pointInPolygon(islandEnd[0], islandEnd[1], island), true); + assert.equal(world.pointInPolygon(mainland[0], mainland[1], island), false); + }); + + it("carries the comb of slips rather than a hexagon", () => { + // The point of the re-trace. Six points cannot express a basin; this outline + // has the West Basin, the East Basin, Fish Harbor and the Pier 400 causeway + // in it, and every one of them shows up as a reversal in the north-south + // walk along the north shore. + assert.ok(island.length >= 24, `${island.length} points`); + const north = island.filter(([lat]) => lat > 33.755); + let reversals = 0; + for (let i = 2; i < north.length; i += 1) { + const a = north[i - 2]![0]; + const b = north[i - 1]![0]; + const c = north[i]![0]; + if (Math.sign(b - a) !== Math.sign(c - b)) reversals += 1; + } + assert.ok(reversals >= 3, `the north shore has ${reversals} basin walls cut into it`); + }); + + it("does not overlap the mainland", () => { + // The Main Channel and the Back Channel are the two pieces of water this + // board cannot afford to lose: an island fused to the shore has no harbour + // in it at all. + const mainland = SOCAL_CITY.landmasses[0]!; + for (const point of island) { + assert.equal( + world.pointInPolygon(point[0], point[1], mainland), + false, + `${point.join(", ")} is inside the mainland`, + ); + } + }); + + it("keeps the port off the district lattice", () => { + // The Harbour chapter's whole failure was that Terminal Island sat inside + // the San Pedro and Long Beach district polygons, so the busiest container + // terminal in the hemisphere came out as generic industrial blocks. No + // district may claim a quay. + for (const { port, quay } of quays) { + for (const vertex of quay.polygon) { + for (const district of SOCAL_CITY.districts) { + assert.equal( + world.pointInPolygon(vertex[0], vertex[1], district.polygon), + false, + `${port}/${quay.id} is inside district ${district.id}; blocks.ts will build on it`, + ); + } + } + } + }); +}); + +describe("the empty-box figures are the ones that were measured", () => { + it("leads Los Angeles with July 2026 and says which month it is", () => { + const throughput = LOS_ANGELES.throughput; + assert.ok(throughput); + assert.equal(throughput.asOf, "2026-07"); + const exported = throughput.loadedExport + throughput.emptyExport; + assert.equal(exported, 460_467); + assert.equal(throughput.emptyExport, 348_691); + const share = throughput.emptyExport / exported; + assert.ok(Math.abs(share - 0.757) < 0.001, `${(share * 100).toFixed(1)}%`); + }); + + it("draws that share in the yards rather than only writing it in a caption", () => { + for (const yard of LOS_ANGELES.yards ?? []) { + // The rail yard is the deliberate exception; see the next assertion. + if (yard.id === "rail-yard") continue; + assert.equal(yard.emptyShare, 0.757); + } + for (const yard of LONG_BEACH.yards ?? []) assert.equal(yard.emptyShare, 0.765); + }); + + it("leaves the one yard nobody counts without a share, rather than guessing one", () => { + // `emptyShare` absent means unknown, and `yardAtlas` paints an unknown yard + // in one flat colour. That difference is visible on the board, which is the + // point: a measured number and an unmeasured one must not look alike. + const rail = (LOS_ANGELES.yards ?? []).find((yard) => yard.id === "rail-yard"); + assert.ok(rail); + assert.equal(rail.emptyShare, undefined); + assert.equal("emptyShare" in rail, false); + }); + + it("carries the freight pair that explains it, and no timestamp on it", () => { + const rates = LOS_ANGELES.rates ?? []; + assert.deepEqual( + rates.map((rate) => [rate.id, rate.usdPerFeu]), + [ + ["FBX01", 7_491], + ["FBX02", 347], + ], + ); + // `observed_at` upstream is our own read clock; Freightos publishes none. + for (const rate of rates) assert.equal("asOf" in rate, false); + }); + + it("gives Long Beach no half-written throughput record", () => { + // Its export split is known — 341,806 empty against 104,843 loaded — and its + // import halves were never read. `PortThroughput` requires all four, and two + // real numbers beside two invented ones is the failure the fire layer nearly + // shipped. No record beats half a record. + assert.equal(LONG_BEACH.throughput, undefined); + }); + + it("puts the split in the Harbour chapter, which is the card the page shows", () => { + const chapter = SOCAL_CITY.chapters.find((one) => one.id === "harbour"); + assert.ok(chapter); + assert.match(chapter.description, /348,691 of 460,467/); + assert.match(chapter.description, /\$7,491/); + assert.match(chapter.description, /\$347/); + }); +}); diff --git a/src/test/render/aerialPerspective.test.ts b/src/test/render/aerialPerspective.test.ts new file mode 100644 index 0000000..a7b6610 --- /dev/null +++ b/src/test/render/aerialPerspective.test.ts @@ -0,0 +1,457 @@ +/** + * Aerial perspective, and the guard that stops it moving San Francisco. + * + * The state board has no haze in it. Every distance in this engine is a + * fraction of `boardSpan`, and on a board 1,063 km across the clear-day fog + * plane lands 1,200 km out — so nothing on it is ever in front of anything + * else. Converted to metres the three boards disagree by a factor of eight and + * San Francisco is the one that is physically right: 86 km at 94.34 m to the + * unit. + * + * ## The change that was proposed, and why it is not the change that landed + * + * The obvious fix is to state fog in physical metres and stop. It does not + * survive the arithmetic and this file is where that is written down as a test + * rather than as a comment. 86 km is 0.91 spans on the Bay Area board and + * **0.085 spans on the extended state board**, where the camera orbits out to + * 1,108 units. A literal metre fog puts California behind a wall 47 units from + * the lens and there is no pose on that board from which the state is visible. + * + * What landed instead is a fraction: how much of the reach a board was authored + * for the air at this altitude actually supports. It saturates at 1 at any + * whole-board pose, which is what leaves the Bay Area untouched **by + * construction rather than by measurement** — and the first test below is that + * claim, made structural, because "we checked and it did not move" is a + * property that decays the moment somebody edits the curve. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + aerialFog, + aerialReach, + createAtmosphere, + observe, + PACIFIC_MARINE_LAYER, +} from "../../engine/atmosphere.ts"; +import type { AerialView, Environment } from "../../engine/atmosphere.ts"; +import type { LightingState } from "../../engine/types.ts"; +import CALIFORNIA from "../../cities/california.ts"; +import SF from "../../cities/sf.ts"; +import SOCAL from "../../cities/socal.ts"; +import type { City } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +/** + * The board span and the exchange rate, from the pack itself. + * + * `boardSpan` is `max(width, height)` in scene units, computed exactly the way + * `createScene` computes it — projected through the same `World`, so a change to + * the projection cannot make this file agree with a scene that has moved on. + */ +function boardOf(city: City): { span: number; metresPerUnit: number } { + const world = new World(city); + const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng); + const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng); + return { + span: Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)), + metresPerUnit: world.metresPerUnit, + }; +} + +/** `cityDaylight`'s clear pair: the ceiling this function may never exceed. */ +function ceilingOf(span: number): { near: number; far: number } { + return { near: span * 0.91, far: span * 2 }; +} + +/** + * The pair a **deployed** board actually gets, which is not `cityDaylight`'s. + * + * `main.ts` hands `createAtmosphere` its own wider clear pair — `span * 1.15` + * and `span * 3.9` — because `scene.ts` lets the orbit reach two board spans so + * a visitor can get above the satellite dome, and at the narrower pair the board + * sat at half fog from that pose. Asserting only against `cityDaylight`'s + * numbers would test a formula no board on this page uses, and would have missed + * the one real regression this change nearly shipped: the Bay Area's opening + * pose saturates at 6.7 km against the narrow ceiling and at 12.4 km against the + * wide one, and its camera is 11.3 km up. + */ +function deployedCeilingOf(span: number): { near: number; far: number } { + return { near: span * 1.15, far: span * 3.9 }; +} + +/** The camera's distance to its target, in metres, at a pack's chapter. */ +function chapterStandoffM(city: City, index: number): number { + const chapter = city.chapters[index]; + assert.ok(chapter, `${city.id} has no chapter ${index}`); + // Horizontal, so `metresPerUnit` and not `unitsToMetres` — only the board's + // height is exaggerated, and dividing a stand-off by the exaggeration reports + // every pose as many times closer than it is. + const world = new World(city); + return Math.hypot(chapter.focus.distance, chapter.focus.height) * world.metresPerUnit; +} + +/** The camera's height above its target, in metres, at a pack's chapter. */ +function chapterAltitudeM(city: City, index: number): number { + const chapter = city.chapters[index]; + assert.ok(chapter, `${city.id} has no chapter ${index}`); + const world = new World(city); + // The pose's `height` is scene units above the ground under the target, and + // the vertical exaggeration divides back out — the same conversion + // `SceneHandle.cameraAltitudeMetres` does. + return world.unitsToMetres(chapter.focus.height); +} + +describe("San Francisco does not move", () => { + it("renders its resting chapter with exactly the fog it had before", () => { + const { span, metresPerUnit } = boardOf(SF); + const ceiling = ceilingOf(span); + const altitudeMetres = chapterAltitudeM(SF, 0); + const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres }); + + // Within 2%, and it is in fact within 0%: the Bay Area's opening pose is + // 430 units up, which at 94.34 m to the unit is 40.6 km, and the curve + // saturates by 6.7 km. The tolerance is there so a future adjustment to the + // scale height is allowed to be an adjustment rather than a regression. + assert.ok( + Math.abs(fog.near - ceiling.near) / ceiling.near < 0.02, + `SF fog near moved from ${ceiling.near.toFixed(1)} to ${fog.near.toFixed(1)}`, + ); + assert.ok( + Math.abs(fog.far - ceiling.far) / ceiling.far < 0.02, + `SF fog far moved from ${ceiling.far.toFixed(1)} to ${fog.far.toFixed(1)}`, + ); + }); + + it("saturates every board's whole-board pose against the DEPLOYED ceiling", () => { + /** + * The guard that matters, and the one the narrower ceiling does not give. + * + * Every marketing still on lumbridgecorp.com is shot from a board's opening + * chapter, and the promise of aerial perspective is that none of them is a + * different photograph. Measured at the poses the packs actually carry: + * + * california 67.6 km up, 1,551 km stand-off, 4,146 km authored reach + * socal 17.0 km up, 109 km stand-off, 597 km authored reach + * sf 11.3 km up, 71 km stand-off, 369 km authored reach + * + * The first two clear it on the air term alone. The Bay Area does not — 86 km + * of clear-day visibility times e^(11.3/8.5) is 324 km against 369 needed — + * and it is the stand-off clearance that carries it. That is exactly the + * regression this assertion exists to catch: with the clearance at 2.4 the + * Bay Area renders at 88% of its authored reach and the far corner of the + * board picks up haze it has never had. + */ + for (const city of [CALIFORNIA, SOCAL, SF]) { + const { span, metresPerUnit } = boardOf(city); + const ceiling = deployedCeilingOf(span); + const fog = aerialFog({ + ceiling, + metresPerUnit, + altitudeMetres: chapterAltitudeM(city, 0), + standoffMetres: chapterStandoffM(city, 0), + }); + assert.equal( + fog.far, + ceiling.far, + `${city.id}'s opening pose renders at ${((fog.far / ceiling.far) * 100).toFixed(1)}% ` + + "of the reach it was authored with. Every still shot from this board just moved.", + ); + assert.equal(fog.near, ceiling.near, `${city.id}'s fog near moved at the opening pose`); + } + }); + + it("leaves every board's whole-board pose where it was", () => { + // Not only San Francisco. The wide pose is the frame every marketing still + // on lumbridgecorp.com is shot from, on all three boards, and the promise of + // this change is that none of them is a different photograph. + for (const city of [CALIFORNIA, SOCAL, SF]) { + const { span, metresPerUnit } = boardOf(city); + const ceiling = ceilingOf(span); + const fog = aerialFog({ + ceiling, + metresPerUnit, + altitudeMetres: chapterAltitudeM(city, 0), + }); + assert.equal( + fog.far, + ceiling.far, + `${city.id}'s opening pose is no longer at the clear-day ceiling`, + ); + } + }); +}); + +describe("the state board gets the aerial perspective it has none of", () => { + const { span, metresPerUnit } = boardOf(CALIFORNIA); + const ceiling = ceilingOf(span); + + it("closes the fog right in at four kilometres", () => { + const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres: 4_000 }); + const spans = fog.near / span; + assert.ok( + spans < 0.2, + `at 4 km the fog still starts ${spans.toFixed(3)} spans out, which on a ` + + "1,063 km board is 200 km and is the whole defect this exists to fix", + ); + // And it is haze, not a wall: the near plane must stay well inside the far. + assert.ok(fog.near < fog.far * 0.6, "the fog closed to a single plane"); + }); + + it("is back at the authored ceiling by fifty kilometres", () => { + const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres: 50_000 }); + assert.ok( + Math.abs(fog.near - span * 0.91) / (span * 0.91) < 0.05, + `at 50 km the fog near is ${(fog.near / span).toFixed(3)} spans, not 0.91`, + ); + }); + + it("rises with altitude and never falls", () => { + let last = -1; + for (const h of [0, 500, 1_000, 2_000, 4_000, 8_500, 17_000, 30_000, 60_000, 200_000]) { + const { far } = aerialFog({ ceiling, metresPerUnit, altitudeMetres: h }); + assert.ok(far >= last, `fog far fell from ${last.toFixed(1)} to ${far.toFixed(1)} at ${h} m`); + last = far; + } + }); +}); + +describe("the shape of the curve", () => { + it("is exactly today's behaviour when nobody says where the camera is", () => { + // The whole compatibility story in one assertion. Every caller that has not + // been taught about altitude — every test, every offline boot, every future + // renderer — passes `null` and gets the pack's own pair back untouched. + const ceiling = { near: 210, far: 460 }; + const fog = aerialFog({ ceiling, metresPerUnit: 94.34, altitudeMetres: null }); + assert.deepEqual(fog, ceiling); + assert.equal(aerialReach(null, 1_000_000), 1); + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY]) { + assert.equal(aerialReach(bad, 1_000_000), 1, `altitude ${bad} must be treated as unknown`); + } + }); + + it("never exceeds the ceiling, however high the camera goes", () => { + // The ceiling is the contract. This function may only ever pull the fog in, + // so no board can be made to render further than the pose it was tuned at — + // which is what makes it safe to apply to three boards at once. + const ceiling = { near: 210, far: 460 }; + for (const h of [0, 1e4, 1e6, 1e9]) { + const fog = aerialFog({ ceiling, metresPerUnit: 94.34, altitudeMetres: h }); + assert.ok(fog.near <= ceiling.near + 1e-9, `near exceeded the ceiling at ${h} m`); + assert.ok(fog.far <= ceiling.far + 1e-9, `far exceeded the ceiling at ${h} m`); + } + }); + + it("never collapses the fog to a wall at ground level", () => { + // The failure mode of an aerial-perspective term has to be haze. A camera + // put at zero altitude by a controller bug must not render a board that is + // one flat colour. + const { span, metresPerUnit } = boardOf(CALIFORNIA); + const fog = aerialFog({ ceiling: ceilingOf(span), metresPerUnit, altitudeMetres: 0 }); + assert.ok(fog.far * metresPerUnit > 50_000, `on the ground you can only see ${fog.far} units`); + }); + + it("never fogs the thing the camera is looking at", () => { + // The clearance, stated as the property it buys rather than as its own + // number: with `main.ts`'s 1.15/3.9 ratio the near plane lands at 1.77 + // stand-offs, so the subject of any shot is outside the fog entirely and the + // haze begins somewhere behind it. A camera 223 km from a mountain on a + // 1,063 km board is a map being read, not an observer who cannot see. + const { span, metresPerUnit } = boardOf(CALIFORNIA); + const standoffMetres = 500_000; + const fog = aerialFog({ + ceiling: deployedCeilingOf(span), + metresPerUnit, + // Low enough that the air term cannot be what is being measured. + altitudeMetres: 1_000, + standoffMetres, + }); + assert.ok( + fog.near * metresPerUnit > standoffMetres, + `the fog starts at ${((fog.near * metresPerUnit) / 1000).toFixed(0)} km with the ` + + `subject at ${(standoffMetres / 1000).toFixed(0)} km — the shot is inside its own fog`, + ); + // But it is aerial perspective and not a clear day: something three + // stand-offs away has to be visibly hazed. + assert.ok(fog.far * metresPerUnit < standoffMetres * 8, "the fog is out past anything on the board"); + }); + + it("puts one scale height at a factor of e", () => { + // The one physical claim in the module, asserted as physics rather than as a + // number somebody liked: air thins as exp(-h/H), so a camera one scale + // height up sees e times as far. Taken well below saturation so the clamp + // is not what is being measured. + // A million metres of authored reach, chosen so neither clamp is in play: + // 86 km of it at the ground and 234 km at one scale height, both strictly + // between the 2% floor and the ceiling of 1. + const reachLow = aerialReach(0, 1_000_000); + const reachHigh = aerialReach(8_500, 1_000_000); + assert.ok( + Math.abs(reachHigh / reachLow - Math.E) < 0.001, + `one scale height gave ${(reachHigh / reachLow).toFixed(4)}x, not e`, + ); + }); +}); + +/** + * The seam that keeps a camera out of the environment map. + * + * ## What this is guarding, in one paragraph + * + * `environmentRig.ts` decides whether to re-render and re-convolve the sky's + * PMREM cubemap by fingerprinting the rig's **colours** — `sky.top`, + * `sky.horizon`, the hemisphere pair, the ambient and the sun. Separately, + * `interiors/daylight.ts` pins the sky's horizon stop to the fog colour on + * purpose, because that is what makes the horizon a horizon instead of the seam + * where a dome meets a haze. Put those two facts beside a fog that follows the + * camera and there is a live wire: the first camera-dependent term that reaches + * a *colour* puts a cubemap rebuild on every orbit step, and the symptom is a + * board that halves its frame rate while drawing exactly the same triangles. + * + * Aerial perspective does not do that — `aerialReach` scales three distances and + * touches nothing else — and this is that claim made structural rather than + * remembered. The fix it forecloses is the tempting one: coarsening the + * fingerprint until the rebuild stops hides one instance and leaves the + * mechanism armed for the next feature that varies a colour. + */ +describe("the camera moves the fog and nothing else", () => { + /** The deployed Bay Area rig: marine layer on, so obscuration is in play. */ + function bayArea() { + const { span, metresPerUnit } = boardOf(SF); + return createAtmosphere({ + lng: SF.center.lng, + metresPerUnit, + clearFog: deployedCeilingOf(span), + minVisibilityM: span * metresPerUnit * 1.6, + marineLayer: PACIFIC_MARINE_LAYER, + }); + } + + /** The deployed state board: no marine layer, and the board with real haze. */ + function state() { + const { span, metresPerUnit } = boardOf(CALIFORNIA); + return createAtmosphere({ + lng: CALIFORNIA.center.lng, + metresPerUnit, + clearFog: deployedCeilingOf(span), + minVisibilityM: span * metresPerUnit * 1.6, + marineLayer: null, + }); + } + + /** + * Everything a `LightingState` carries except the two numbers a camera is + * allowed to move. Compared as JSON so a field added later is compared too, + * which is the point — a guard that has to be updated to notice a new field is + * not a guard. + */ + function everythingButTheDistances(l: LightingState): string { + return JSON.stringify({ + sun: l.sun, + hemisphere: l.hemisphere, + ambient: l.ambient, + sky: l.sky, + fogColor: l.fog?.color ?? null, + moon: l.moon ?? null, + }); + } + + /** + * Four hours that exercise every branch that could plausibly acquire a camera + * term: full day, the golden hour, **dusk** — where the horizon pin is + * load-bearing and the seam shows — and astronomical night, where the moon is + * the key light and the fog is floored at a lifted blue. + */ + const HOURS = [ + ["noon", "2026-08-22T20:00:00Z"], + ["golden hour", "2026-08-23T02:00:00Z"], + ["dusk", "2026-08-23T02:30:00Z"], + ["night", "2026-08-23T09:00:00Z"], + ] as const; + + /** A chase camera on the deck, a mid-board pose, and above the whole board. */ + const VIEWS: readonly (readonly [string, AerialView])[] = [ + ["ground", { altitudeMetres: 0, standoffMetres: 500 }], + ["crow", { altitudeMetres: 1_200, standoffMetres: 4_000 }], + ["low", { altitudeMetres: 4_000, standoffMetres: 20_000 }], + ["high", { altitudeMetres: 40_000, standoffMetres: 200_000 }], + ["unknown", { altitudeMetres: null }], + ]; + + it("leaves every colour in the rig bit-identical however far the camera climbs", () => { + for (const [label, atmosphere] of [ + ["bay-area", bayArea()], + ["california", state()], + ] as const) { + const city = label === "bay-area" ? SF : CALIFORNIA; + for (const [hour, iso] of HOURS) { + const env: Environment = observe( + city.center.lat, + city.center.lng, + new Date(iso), + null, + ); + const reference = everythingButTheDistances(atmosphere.apply(env, VIEWS[0]![1])); + for (const [pose, view] of VIEWS) { + assert.equal( + everythingButTheDistances(atmosphere.apply(env, view)), + reference, + `${label} at ${hour}: the "${pose}" camera changed something that is not a fog ` + + "distance. Every field compared here is fingerprinted by " + + "`environmentRig.ts`, so a camera-dependent colour rebuilds and re-convolves " + + "the PMREM cubemap on every orbit step. Move the term out of the camera path " + + "rather than coarsening `environmentKey`.", + ); + } + } + } + }); + + it("does move the fog distances, or there is no feature here", () => { + // The other half of the assertion above, and it has to be stated or the + // first one passes perfectly on a rig that ignores the camera entirely. + const atmosphere = state(); + const env = observe( + CALIFORNIA.center.lat, + CALIFORNIA.center.lng, + new Date("2026-08-22T20:00:00Z"), + null, + ); + const low = atmosphere.apply(env, { altitudeMetres: 1_200, standoffMetres: 4_000 }).fog; + const high = atmosphere.apply(env, { altitudeMetres: 40_000, standoffMetres: 200_000 }).fog; + assert.ok(low && high); + assert.ok( + high.far > low.far * 4, + `the state board sees ${low.far.toFixed(0)} units at 1.2 km and ` + + `${high.far.toFixed(0)} at 40 km — aerial perspective has stopped working`, + ); + }); + + it("hands the camera path exactly the distances the clock path would write", () => { + /* + * `Atmosphere.aerial` is what a camera step calls now, and `apply` is what + * the clock still calls. Two derivations of one fog is how a drag and a + * clock tick start disagreeing about the weather, so `aerial` is defined as + * a slice of `apply` and this is that definition held in place. + */ + for (const [label, atmosphere] of [ + ["bay-area", bayArea()], + ["california", state()], + ] as const) { + const city = label === "bay-area" ? SF : CALIFORNIA; + for (const [hour, iso] of HOURS) { + const env = observe(city.center.lat, city.center.lng, new Date(iso), null); + for (const [pose, view] of VIEWS) { + const full = atmosphere.apply(env, view).fog; + assert.ok(full, `${label} lost its fog at ${hour}`); + assert.deepEqual( + atmosphere.aerial(env, view), + { near: full.near, far: full.far }, + `${label} at ${hour}, "${pose}": the camera path and the clock path disagree`, + ); + } + } + } + }); +}); diff --git a/src/test/render/migration.test.ts b/src/test/render/migration.test.ts new file mode 100644 index 0000000..ab5ed05 --- /dev/null +++ b/src/test/render/migration.test.ts @@ -0,0 +1,345 @@ +/** + * The migration field, held to the claims it must not make. + * + * **It is one `THREE.Points`, whatever the sky is doing.** Fifty-eight counties, + * one draw call, zero triangles — the `nightlights.ts` arrangement that puts San + * Francisco's 12,038 street lamps on the board for the cost of one cloud. The + * alternative that keeps suggesting itself is the articulated crow, and it is + * 4,390 triangles across 33 meshes: **33 draw calls per bird**, against a + * whole-board budget of 650. Forty of them would be 1,320. + * + * **No mote position is ever computed from two granules.** BirdCast is a + * forecast raster aggregated to county-nights. There is no track and no + * individual, so joining consecutive samples into a trajectory would be + * inventing the ten minutes in between — the same lie `Vessel` refuses between + * AIS fixes. A mote is spawned once with its county's reported heading and + * ground speed, integrated forward on its own, and respawned from whatever the + * newest granule says when its life runs out. The assertion below is that a mote + * alive across a granule change keeps moving on the velocity it was born with. + * + * **The state row never reaches it.** `US-CA` has NULL coordinates and 793,141 + * birds aloft against the largest county's 82,549, and it looks exactly like the + * other fifty-eight rows. + * + * **It constructs no light.** CONTRACT.md §4. + * + * The world is the real california board — 1,919 m to the unit — because a 1:1 + * fake would pass every drift assertion here while the shipped layer moved motes + * two thousand times too far. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; + +import CALIFORNIA_CITY from "../../cities/california.ts"; +import { + allocateMotes, + createMigrationLayer, + discRadiusKm, + MIGRATION_ALTITUDE_UNITS_PER_METRE, + MIGRATION_MAX_POINTS, + MIGRATION_MOTES_PER_COUNTY, +} from "../../engine/migration.ts"; +import type { MigrationLayerFactory } from "../../engine/scene.ts"; +import type { MigrationCounty, MigrationField } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +const world = new World(CALIFORNIA_CITY); +const CALIFORNIA_SPAN = 553.9; + +/** The seam `scene.ts` constructs through. Asserted at compile time. */ +const _factory: MigrationLayerFactory = (w, o) => createMigrationLayer(w, o); +void _factory; + +function county(over: Partial = {}): MigrationCounty { + return { + id: "US-CA-019", + name: "Fresno County", + lat: 36.761006, + lng: -119.655019, + areaKm2: 15569, + aloft: 82549, + altitude: 333, + direction: 140, + speed: 6.9, + ...over, + }; +} + +/** All 58 counties, at the areas and the busiest-night densities they really have. */ +function fullState(): MigrationCounty[] { + const out: MigrationCounty[] = []; + for (let i = 0; i < 58; i++) { + out.push( + county({ + id: `US-CA-${String(i * 2 + 1).padStart(3, "0")}`, + lat: 33 + (i % 20) * 0.4, + lng: -122 + Math.floor(i / 20) * 2, + // The real spread: 601 km² (San Francisco) to 52,073 (San Bernardino). + areaKm2: 601 + (i / 57) * (52073 - 601), + // Every county busy, which is the worst case for the point count. + aloft: 400_000, + }), + ); + } + return out; +} + +function field(counties: MigrationCounty[], over: Partial = {}): MigrationField { + return { + counties, + observedAt: "2026-08-23T03:20:00Z", + statewide: null, + quiet: null, + ...over, + }; +} + +function clouds(root: THREE.Object3D): THREE.Points[] { + const found: THREE.Points[] = []; + root.traverse((node) => { + if ((node as THREE.Points).isPoints) found.push(node as THREE.Points); + }); + return found; +} + +// ---- Cost ----------------------------------------------------------------- + +describe("the migration field's cost", () => { + it("is one Points and no triangles, for any number of counties", () => { + for (const counties of [[county()], fullState()]) { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field(counties)); + const points = clouds(layer.group); + assert.equal(points.length, 1, `${counties.length} counties must still be one cloud`); + assert.equal(layer.group.children.length, 1); + let meshes = 0; + layer.group.traverse((node) => { + if ((node as THREE.Mesh).isMesh) meshes += 1; + }); + assert.equal(meshes, 0, "not one triangle anywhere in it"); + layer.dispose(); + } + }); + + it("draws at most 700 points with every county in California busy", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field(fullState())); + assert.ok(layer.activeCount() <= MIGRATION_MAX_POINTS, `${layer.activeCount()} points`); + assert.equal(layer.activeCount(), 58 * MIGRATION_MOTES_PER_COUNTY); + const cloud = clouds(layer.group)[0] as THREE.Points; + assert.equal(cloud.geometry.drawRange.count, layer.activeCount()); + // The buffer is allocated once at the ceiling and never grows. + assert.equal(cloud.geometry.getAttribute("position").count, MIGRATION_MAX_POINTS); + layer.dispose(); + }); + + it("holds the allocation to the buffer even if the feed sends more counties", () => { + const counts = allocateMotes([...fullState(), ...fullState()]); + assert.ok(counts.reduce((a, b) => a + b, 0) <= MIGRATION_MAX_POINTS); + // …and drops the quietest rather than the last, so the busiest county always + // draws whatever order the wire happened to use. + const mixed = allocateMotes([county({ aloft: 10 }), county({ aloft: 400_000 })]); + assert.ok((mixed[1] as number) > (mixed[0] as number)); + }); + + it("draws nothing at all for a quiet sky", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setSolarElevation(-20); + layer.setField(field([], { quiet: { reason: "daylight", message: "…" } })); + assert.equal(layer.activeCount(), 0); + assert.equal((clouds(layer.group)[0] as THREE.Points).visible, false); + layer.dispose(); + }); + + it("retires every mote when the feed goes away, rather than leaving them drifting", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county()])); + layer.setSolarElevation(-20); + const before = layer.mote(0); + assert.ok(before); + + layer.setField(null); + assert.equal(layer.activeCount(), 0); + + // A different night, over a county four hundred kilometres away. Nothing + // may survive from the granule the feed stopped claiming. + layer.setField(field([county({ lat: 33.0, lng: -116.0, direction: 320, speed: 35 })])); + const after = layer.mote(0); + assert.ok(after); + const [x, z] = world.project(33.0, -116.0); + const radius = discRadiusKm(15569) / (world.metresPerUnit / 1000); + assert.ok(Math.hypot(after.x - x, after.z - z) <= radius + 1e-3); + layer.dispose(); + }); + + it("survives a null, an undefined and a field full of nonsense", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(null); + layer.setField(undefined as unknown as MigrationField); + layer.setField({ counties: [null, { id: "x" }] } as unknown as MigrationField); + layer.tick(0.016); + assert.equal(layer.activeCount(), 0); + layer.dispose(); + }); +}); + +// ---- The claim it must not make ------------------------------------------- + +describe("a mote's position", () => { + it("is never computed by interpolating between two consecutive granules", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + // Granule one: Fresno, flying south-east at 6.9 m/s. + layer.setField(field([county()])); + layer.setSolarElevation(-20); + const before = layer.mote(0); + assert.ok(before); + + // Granule two, ten minutes later: a county 400 km away, flying the opposite + // way at five times the speed. If anything blended the two, this is where it + // would show. + layer.setField(field([county({ lat: 33.0, lng: -116.0, direction: 320, speed: 35 })])); + const after = layer.mote(0); + assert.ok(after); + assert.deepEqual( + [after.x, after.z, after.vx, after.vz], + [before.x, before.z, before.vx, before.vz], + "a live mote must not move because a new granule arrived", + ); + + layer.tick(1); + const stepped = layer.mote(0); + assert.ok(stepped); + // Exactly its own velocity for exactly one second, and nothing else. + assert.ok(Math.abs(stepped.x - (before.x + before.vx)) < 1e-6); + assert.ok(Math.abs(stepped.z - (before.z + before.vz)) < 1e-6); + layer.dispose(); + }); + + it("takes the newest granule only when it is born again", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county()])); + layer.setSolarElevation(-20); + const fresno = layer.mote(0); + assert.ok(fresno); + + layer.setField(field([county({ lat: 33.0, lng: -116.0, direction: 320, speed: 35 })])); + // Past every mote's life, which is 900 seconds give or take a third. + layer.tick(2_000); + const reborn = layer.mote(0); + assert.ok(reborn); + const [x, z] = world.project(33.0, -116.0); + const radius = discRadiusKm(15569) / (world.metresPerUnit / 1000); + assert.ok(Math.hypot(reborn.x - x, reborn.z - z) <= radius + 1e-3, "inside the new county's disc"); + assert.ok(reborn.vx < 0, "…and flying north-west, the way the new granule says"); + layer.dispose(); + }); + + it("drifts at the reported ground speed and no faster", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county({ direction: 90, speed: 10 })])); + layer.setSolarElevation(-20); + const start = layer.mote(0); + assert.ok(start); + layer.tick(60); + const moved = layer.mote(0); + assert.ok(moved); + // Due east at 10 m/s for a minute is 600 m, which at 1,919 m to the unit is + // 0.313 units — a slow drift, deliberately not exaggerated. + const metres = Math.hypot(moved.x - start.x, moved.z - start.z) * world.metresPerUnit; + assert.ok(Math.abs(metres - 600) < 1, `${metres.toFixed(0)} m in sixty seconds`); + assert.ok(moved.x > start.x, "east is +X"); + assert.ok(Math.abs(moved.z - start.z) < 1e-6, "…and due east is not north or south"); + }); + + it("flies toward the reported bearing, on a board where north is -Z", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county({ direction: 0, speed: 10 })])); + layer.setSolarElevation(-20); + const start = layer.mote(0); + layer.tick(60); + const moved = layer.mote(0); + assert.ok(start && moved); + // A field drifting north-west when the feed says south-east is the one bug + // here nobody would see, because a cloud of dots has no other way to be wrong. + assert.ok(moved.z < start.z, "heading 0 must go north, which is -Z"); + layer.dispose(); + }); +}); + +// ---- The disc ------------------------------------------------------------- + +describe("where the motes are", () => { + it("scatters them inside a disc of the county's true area", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + // San Bernardino: 52,073 km², a 129 km disc. + const big = county({ areaKm2: 52073, lat: 34.84, lng: -116.18 }); + layer.setField(field([big])); + layer.setSolarElevation(-20); + const [cx, cz] = world.project(big.lat, big.lng); + const radiusUnits = (discRadiusKm(big.areaKm2) * 1000) / world.metresPerUnit; + assert.ok(radiusUnits > 60 && radiusUnits < 75, `${radiusUnits.toFixed(1)} units`); + + let far = 0; + for (let i = 0; i < layer.activeCount(); i++) { + const mote = layer.mote(i); + assert.ok(mote); + const d = Math.hypot(mote.x - cx, mote.z - cz); + assert.ok(d <= radiusUnits * 1.02, `${d.toFixed(1)} units from the internal point`); + if (d > radiusUnits * 0.5) far += 1; + } + // Uniform over the disc, not clustered at the point: more than half the area + // is outside half the radius, so most motes should be. + assert.ok(far > layer.activeCount() * 0.5, "the scatter fills the disc rather than the middle"); + layer.dispose(); + }); + + it("puts them above the ground they are over, not above sea level", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + // Inyo County: the internal point is up against the White Mountains, and + // `height_mean_m` is above ground level. At true scale 826 m would be 0.43 + // units and inside the hill. + const inyo = county({ lat: 36.56216, lng: -117.404209, areaKm2: 26488, altitude: 826 }); + layer.setField(field([inyo])); + layer.setSolarElevation(-20); + for (let i = 0; i < layer.activeCount(); i++) { + const mote = layer.mote(i); + assert.ok(mote); + assert.ok(mote.y > 0); + } + const mote = layer.mote(0); + assert.ok(mote); + const lift = 826 * MIGRATION_ALTITUDE_UNITS_PER_METRE; + assert.ok(lift > 8 && lift < 9, "826 m is 8.3 units on the aircraft seam"); + assert.ok(mote.y >= lift, "…measured up from the terrain under it"); + layer.dispose(); + }); +}); + +// ---- Night ---------------------------------------------------------------- + +describe("the field and the light rig", () => { + it("constructs no light of any kind", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county()])); + layer.group.traverse((node) => { + assert.ok(!(node as THREE.Light).isLight, `${node.name || node.type} is a light`); + }); + layer.dispose(); + }); + + it("is gone in daylight, whatever the feed sent", () => { + const layer = createMigrationLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(field([county()])); + const cloud = clouds(layer.group)[0] as THREE.Points; + + layer.setSolarElevation(-20); + assert.equal(cloud.visible, true); + layer.setSolarElevation(12); + assert.equal(cloud.visible, false, "BirdCast does not measure by day"); + layer.setSolarElevation(-8); + assert.equal(cloud.visible, true); + layer.dispose(); + }); +}); diff --git a/src/test/render/ports.test.ts b/src/test/render/ports.test.ts new file mode 100644 index 0000000..e83c2ee --- /dev/null +++ b/src/test/render/ports.test.ts @@ -0,0 +1,365 @@ +/** + * The port kit, held to the four things a screenshot cannot check. + * + * **It is four draw calls, and it stays four when a second port arrives.** That + * is the whole design in one assertion. `socal mobile` measures 140 draw calls + * against a cap of 170 — thirty spare for this feature and every future one — + * and the obvious shape for a kit like this, a `Group` per port with a mesh per + * surface, lands at fifteen for two ports and fifty for a board with six. So the + * merge-across-ports property is asserted here rather than assumed: **four ports + * must produce exactly the same mesh count as one.** + * + * **No crane is a `Group`.** Fifty-six gantries at five boxes each is 280 + * matrices in one `InstancedMesh` or 280 draw calls, and this repo has already + * made the second mistake twice — a suspension bridge at ~34 draw calls, and + * twelve identical asphalt freeways that could never merge because a fresh + * material was allocated per ribbon. A test is the only thing that keeps the + * first answer once somebody wants a crane to be pickable. + * + * **Every geometry carries position, normal AND uv, indexed.** `airports.ts:43` + * records the scar: `mergeGeometries` returns `null` for a bucket whose + * attribute sets disagree, and the bucket vanishes with no error and no missing + * pixels to notice — it is simply not there. The `Batch` warns; this asserts it + * never had to. + * + * **It constructs no light.** CONTRACT §4 gives `Atmosphere` sole ownership of + * the rig, and a working container terminal under high-mast floods is one of the + * more tempting exceptions in the product — `fires.ts` records the last time + * somebody nearly took it. `setLighting` reaches for a material's `emissive` + * instead, which is a property of a surface and not a light in the scene. + * + * The world below is a real board projection rather than a tidy 1:1 fake — + * Southern California's `latScale: 285` and `verticalExaggeration: 3.4`, which + * puts one scene unit at 390.6 m. A 1:1 fake would pass while every crane was + * four hundred times too tall. + * + * There is no `document` in a node test, so `yardAtlas` correctly returns + * `null` and the yard material falls back to a flat colour. The geometry — which + * is what this file is about — is identical either way, because `groundQuad` + * writes uv whether or not anything samples it. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; + +import { + createPortLayer, + createPorts, + craneStations, + metresBetween, + pathLengthMetres, + yardCorners, + PORT_PALETTE, + type PortLayer, +} from "../../engine/ports.ts"; +import type { PortLayerFactory, PortLayer as ScenePortLayer } from "../../engine/scene.ts"; +import { LOS_ANGELES, LONG_BEACH, PORTS } from "../../cities/socal.ts"; +import type { LightingState, Port } from "../../engine/types.ts"; +import type { World } from "../../engine/world.ts"; + +/** `socal.ts`: centre 33.82 / -118.05, `latScale: 285`, exaggeration 3.4. */ +function socalWorld(ground: (lat: number, lng: number) => number = () => 0): World { + const centre = { lat: 33.82, lng: -118.05 }; + const latScale = 285; + const lngScale = latScale * Math.cos((centre.lat * Math.PI) / 180); + const metresPerUnit = 111_320 / latScale; + return { + project(lat: number, lng: number): [number, number] { + return [(lng - centre.lng) * lngScale, -(lat - centre.lat) * latScale]; + }, + groundAt: ground, + metresPerUnit, + metres(value: number): number { + return (value / metresPerUnit) * 3.4; + }, + } as unknown as World; +} + +/** + * `createPortLayer` must be a `PortLayerFactory` once its ports are closed over, + * and the module's own `PortLayer` must be the one `scene.ts` declares. + * + * Asserted at compile time, which is the only place it can be. Both are + * type-only imports, so nothing about `scene.ts` is pulled into this test at + * runtime and `engine/ports.ts` still imports nothing from it — that is the + * whole point of the arrangement. + */ +const _factory: PortLayerFactory = (world, options) => createPortLayer(world, PORTS, options); +const _sameShape: (layer: PortLayer) => ScenePortLayer = (layer) => layer; +void _factory; +void _sameShape; + +function meshes(root: THREE.Object3D): THREE.Mesh[] { + const found: THREE.Mesh[] = []; + root.traverse((object) => { + if (object instanceof THREE.Mesh) found.push(object); + }); + return found; +} + +const NOON: LightingState = { + sun: { direction: [0.1, 0.94, 0.32], color: 0xfff3e2, intensity: 2.4 }, + hemisphere: { sky: 0x8db2d4, ground: 0xa9a291, intensity: 0.6 }, + ambient: { color: 0xffffff, intensity: 0.2 }, + sky: { top: 0x8db2d4, horizon: 0xe6ded0 }, + fog: { color: 0xe6ded0, near: 200, far: 900 }, +}; +/** + * Full night, and note what it does with `sun`: the key light is **thirty + * degrees up**, because `atmosphere.ts` hands the key over to the moon after + * dark and floors the direction besides. A layer that reads night off + * `sun.direction[1]` passes a naive fixture and then stays dark on the board on + * every moonlit night. This fixture exists to fail that implementation. + */ +const MIDNIGHT: LightingState = { + ...NOON, + sun: { direction: [0.1, 0.5, 0.32], color: 0x2a3550, intensity: 1.15 }, + sky: { top: 0x05070f, horizon: 0x121a2c }, +}; + +/** Civil twilight: the sky has blue in it and the floods are half up. */ +const DUSK: LightingState = { + ...NOON, + sun: { direction: [0.1, 0.02, 0.32], color: 0xffb27a, intensity: 0.4 }, + sky: { top: 0x101a3a, horizon: 0x3b4a68 }, +}; + +describe("the port kit is four draw calls and stays four", () => { + it("returns at most six meshes for the whole board", () => { + const group = createPorts(socalWorld(), PORTS); + const drawn = meshes(group); + assert.ok( + drawn.length <= 6, + `a port board must fit in six meshes; got ${drawn.length}: ${drawn.map((m) => m.name).join(", ")}`, + ); + // And it is not accidentally empty: San Pedro Bay has stone, yards, water + // and cranes, so all four buckets must be present. + const names = new Set(drawn.map((mesh) => mesh.name)); + for (const bucket of ["ports:stone", "ports:yard", "ports:channel", "ports:cranes"]) { + assert.ok(names.has(bucket), `missing bucket ${bucket}`); + } + }); + + it("costs the same number of meshes for four ports as for one", () => { + const world = socalWorld(); + const one = meshes(createPorts(world, [LOS_ANGELES])).length; + const four: Port[] = [ + LOS_ANGELES, + LONG_BEACH, + { ...LOS_ANGELES, id: "USLAX-B" }, + { ...LONG_BEACH, id: "USLGB-B" }, + ]; + const many = meshes(createPorts(world, four)); + assert.equal( + many.length, + one, + `four ports drew ${many.length} meshes against one port's ${one} — the buckets stopped merging across ports`, + ); + }); + + it("draws nothing at all for a board with no port", () => { + const group = createPorts(socalWorld(), []); + assert.equal(meshes(group).length, 0); + assert.equal(group.children.length, 0); + }); +}); + +describe("every gantry on the board is one InstancedMesh", () => { + it("has no Group anywhere in the layer", () => { + const group = createPorts(socalWorld(), PORTS); + const groups: string[] = []; + group.traverse((object) => { + if (object !== group && object instanceof THREE.Group) groups.push(object.name || "(unnamed)"); + }); + assert.deepEqual(groups, [], `a crane became a Group: ${groups.join(", ")}`); + }); + + it("puts five boxes per gantry in a single instanced mesh", () => { + const group = createPorts(socalWorld(), PORTS); + const cranes = meshes(group).filter((mesh) => mesh.name === "ports:cranes"); + assert.equal(cranes.length, 1, "there must be exactly one crane mesh for the whole board"); + const mesh = cranes[0]; + assert.ok(mesh instanceof THREE.InstancedMesh); + const gantries = PORTS.flatMap((port) => port.cranes ?? []).reduce( + (total, row) => total + row.count, + 0, + ); + assert.equal(gantries, 56, "San Pedro Bay is authored with fifty-six gantries"); + assert.equal((mesh as THREE.InstancedMesh).count, gantries * 5); + }); + + it("raises exactly the booms the pack asked for, from the far end of the rail", () => { + // Pier T East is the quiet frontage: five of its six booms are up. + const row = (LONG_BEACH.cranes ?? []).find((crane) => crane.id === "pier-t-east"); + assert.ok(row); + const stations = craneStations(row); + assert.equal(stations.length, 6); + assert.equal(stations.filter((station) => station.idle).length, 5); + // Clustered at one end, never scattered — a random pattern of raised booms + // reads as a fault rather than as a berth with nothing alongside. + assert.equal(stations[0]?.idle, false); + assert.ok(stations.slice(1).every((station) => station.idle)); + }); + + it("puts a raised boom higher than a lowered one, through the exaggerated axis", () => { + const world = socalWorld(); + const working = createPorts(world, [ + { ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 0 }] }, + ]); + const idle = createPorts(world, [ + { ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 1 }] }, + ]); + const topOf = (group: THREE.Object3D) => { + const mesh = meshes(group).find((m) => m.name === "ports:cranes") as THREE.InstancedMesh; + const matrix = new THREE.Matrix4(); + const position = new THREE.Vector3(); + const scale = new THREE.Vector3(); + const quaternion = new THREE.Quaternion(); + let highest = -Infinity; + for (let i = 0; i < mesh.count; i += 1) { + mesh.getMatrixAt(i, matrix); + matrix.decompose(position, quaternion, scale); + highest = Math.max(highest, position.y + scale.x / 2); + } + return highest; + }; + assert.ok( + topOf(idle) > topOf(working) * 1.4, + "a raised boom must reach well above a lowered one; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated", + ); + }); +}); + +describe("no bucket is silently dropped", () => { + it("gives every geometry position, normal, uv and an index", () => { + const group = createPorts(socalWorld(), PORTS); + for (const mesh of meshes(group)) { + const geometry = mesh.geometry; + for (const attribute of ["position", "normal", "uv"]) { + assert.ok( + geometry.getAttribute(attribute), + `${mesh.name} has no ${attribute} — mergeGeometries drops a bucket whose attribute sets disagree, in silence`, + ); + } + assert.ok(geometry.getIndex(), `${mesh.name} is not indexed`); + } + }); + + it("never warns that a bucket failed to merge", () => { + const warnings: unknown[][] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => warnings.push(args); + try { + createPorts(socalWorld(), PORTS); + } finally { + console.warn = original; + } + assert.deepEqual(warnings, []); + }); +}); + +describe("the layer owns no light", () => { + it("constructs no THREE.Light anywhere in the subtree", () => { + const layer = createPortLayer(socalWorld(), PORTS, { span: 393 }); + layer.setLighting(MIDNIGHT); + const lights: string[] = []; + layer.group.traverse((object) => { + if (object instanceof THREE.Light) lights.push(object.type); + }); + assert.deepEqual(lights, [], `CONTRACT §4: Atmosphere owns the rig. Found ${lights.join(", ")}`); + layer.dispose(); + }); + + it("brings the yard up at night and puts it away by day, through emissive", () => { + const layer = createPortLayer(socalWorld(), PORTS, { span: 393 }); + const yardMaterial = () => { + const mesh = meshes(layer.group).find((m) => m.name === "ports:yard"); + return mesh?.material as THREE.MeshLambertMaterial; + }; + layer.setLighting(NOON); + assert.equal(yardMaterial().emissiveIntensity, 0); + layer.setLighting(MIDNIGHT); + assert.ok(yardMaterial().emissiveIntensity > 0.5, "a moonlit night is still night"); + layer.setLighting(DUSK); + const dusk = yardMaterial().emissiveIntensity; + assert.ok(dusk > 0.1 && dusk < 0.5, `twilight should be partway up, got ${dusk}`); + layer.setLighting(NOON); + assert.equal(yardMaterial().emissiveIntensity, 0); + layer.dispose(); + }); + + it("disposes its geometry and clears the group", () => { + const layer = createPortLayer(socalWorld(), PORTS, { span: 393 }); + assert.ok(layer.group.children.length > 0); + layer.dispose(); + assert.equal(layer.group.children.length, 0); + }); +}); + +describe("the kit is sized in metres against this board", () => { + it("makes a gantry taller than a container ship is long", () => { + const world = socalWorld(); + // The claim the module comment is built on: at 390.6 m per unit and 3.4x + // exaggeration a 130 m gantry stands 1.13 units while a 400 m ship is 1.02 + // units long. If that ever stops being true the crane stops being the hero. + const craneUnits = world.metres(130); + const shipUnits = 400 / world.metresPerUnit; + assert.ok(craneUnits > shipUnits, `${craneUnits.toFixed(3)} vs ${shipUnits.toFixed(3)}`); + // And the arithmetic that makes a container paint rather than instances. + assert.ok(12.2 / world.metresPerUnit < 0.04); + }); + + it("keeps the whole board's stone, yards and water within twenty thousand triangles", () => { + // The socal mobile budget has 134,404 triangles spare. The whole port kit is + // allowed 20,000 of them, and it is nowhere near that: this is the number + // that stops a later "just a few more boxes" landing without anybody noticing. + const group = createPorts(socalWorld(), PORTS); + let triangles = 0; + for (const mesh of meshes(group)) { + const index = mesh.geometry.getIndex(); + const per = index ? index.count / 3 : mesh.geometry.getAttribute("position").count / 3; + triangles += per * (mesh instanceof THREE.InstancedMesh ? mesh.count : 1); + } + assert.ok(triangles < 20_000, `port kit is ${triangles} triangles`); + }); + + it("uses a palette that stays off the Vincent Thomas green", () => { + // `0x3f7d55` is already the most saturated object in the Harbour frame. Real + // container red and Maersk blue put two more loud hues beside it and the + // whole harbour reads as a toy. Every box colour here is under half the + // chroma of the bridge. + const chroma = (hex: number) => { + const colour = new THREE.Color(hex); + const max = Math.max(colour.r, colour.g, colour.b); + const min = Math.min(colour.r, colour.g, colour.b); + return max === 0 ? 0 : (max - min) / max; + }; + const bridge = chroma(0x3f7d55); + for (const key of ["boxLoadedA", "boxLoadedB", "boxEmpty", "boxUnknown", "stone"] as const) { + assert.ok( + chroma(PORT_PALETTE[key]) < bridge, + `${key} is more saturated than the Vincent Thomas`, + ); + } + }); +}); + +describe("the authoring helpers agree with the renderer", () => { + it("derives a yard's corners the way the quad is built", () => { + const yard = { lat: 33.75, lng: -118.25, length: 1000, width: 400, bearing: 0 }; + const corners = yardCorners(yard); + assert.equal(corners.length, 4); + // 1000 m along a bearing of zero is 1000 m of latitude. + assert.ok(Math.abs(metresBetween(corners[0]!, corners[1]!) - 1000) < 2); + assert.ok(Math.abs(metresBetween(corners[1]!, corners[2]!) - 400) < 2); + }); + + it("measures the federal breakwater at thirteen kilometres", () => { + const total = (LOS_ANGELES.breakwater ?? []).reduce( + (sum, arm) => sum + pathLengthMetres(arm), + 0, + ); + assert.ok(total > 12_000 && total < 15_000, `${Math.round(total)} m`); + }); +}); diff --git a/src/test/render/precip.test.ts b/src/test/render/precip.test.ts new file mode 100644 index 0000000..1ca016a --- /dev/null +++ b/src/test/render/precip.test.ts @@ -0,0 +1,344 @@ +/** + * The reflectivity sheet, held to what a picture cannot show. + * + * **It is two triangles and one mesh, whatever the weather is doing.** That is + * the entire cost argument for the layer, and it holds because `echo_cells` + * turned out to be a regular lattice — so the field is a texture and the + * geometry is a quad. A version built from per-cell geometry would look + * identical in a screenshot and cost 1,596 quads. + * + * **It contributes nothing at all to a quiet frame.** Not an invisible mesh, not + * 1,596 transparent texels: no scene child. California is under rain a mean + * 0.596% of the time, so this is the layer's ordinary state and it has to cost + * nothing. + * + * **It is never built for a board too fine to carry the cell.** A 0.25-degree + * cell is 27.8 km — 4.3 texels tall on the SoCal board and 3.4 on the Bay Area + * one. Four enormous squares over Los Angeles is a lie about resolution told in + * a medium that reads as truthful, and the refusal is a pure function so it can + * be asserted rather than screenshotted. + * + * **It constructs no light.** CONTRACT.md §4 gives `Atmosphere` sole ownership + * of the rig; the build spec's grep catches the letter, and walking the subtree + * catches the spirit. + * + * The world below is a real board projection — california's `latScale: 58`, + * 1,919 m to the unit, `verticalExaggeration: 15` — because a 1:1 fake would + * pass every one of these while the shipped sheet was buried in the Sierra. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; + +import CALIFORNIA_CITY from "../../cities/california.ts"; +import SOCAL_CITY from "../../cities/socal.ts"; +import SF_CITY from "../../cities/sf.ts"; +import { + alphaForDbz, + boardCarriesRaster, + createPrecipLayer, + paint, + precipFactoryFor, + PRECIP_ALTITUDE_M, +} from "../../engine/precip.ts"; +import { radarRampRgb } from "../../assets/radarRamp.ts"; +import { buildRadarField, RADAR_DRY_DBZ, RADAR_RAIN_DBZ, type RadarCell } from "../../server/radar.ts"; +import type { PrecipLayerFactory } from "../../engine/scene.ts"; +import type { RadarField } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +/** The real board. Its heightfield is what puts the Sierra at 39.5 units. */ +const world = new World(CALIFORNIA_CITY); +/** `scene.ts`'s own derivation: the larger projected extent of `city.bounds`. */ +const CALIFORNIA_SPAN = 553.9; + +/** + * `createPrecipLayer` must be a `PrecipLayerFactory` — the one seam `scene.ts` + * constructs through. A type-only import, so nothing about `scene.ts` is pulled + * in at runtime and `engine/precip.ts` still imports nothing from it. Without + * this line the first time anyone found out the two had drifted would be the + * moment somebody wired them. + */ +const _factory: PrecipLayerFactory = (w, o) => createPrecipLayer(w, o); +void _factory; + +function fieldWith(cells: RadarCell[], wetFraction = 0.02): RadarField { + const built = buildRadarField({ + cells, + stations: [{ id: "KHNX", lat: 36.31416, lon: -119.63213, type: "WSR-88D", operability: "RDA - On-line" }], + bounds: CALIFORNIA_CITY.bounds, + coast: CALIFORNIA_CITY.landmasses, + observedAt: "2026-08-23T03:55Z", + wetFraction, + }); + assert.ok(built.field, "the fixture must promote or the test asserts nothing"); + return built.field; +} + +const RAINY = fieldWith([ + { lat: 36.375, lon: -119.625, dbz: 55 }, + { lat: 36.125, lon: -119.625, dbz: 34 }, + { lat: 36.375, lon: -119.375, dbz: 22 }, +]); + +function meshes(root: THREE.Object3D): THREE.Mesh[] { + const found: THREE.Mesh[] = []; + root.traverse((node) => { + if ((node as THREE.Mesh).isMesh) found.push(node as THREE.Mesh); + }); + return found; +} + +function triangles(mesh: THREE.Mesh): number { + const geometry = mesh.geometry; + const index = geometry.getIndex(); + const count = index !== null ? index.count : (geometry.getAttribute("position")?.count ?? 0); + return count / 3; +} + +// ---- Cost ----------------------------------------------------------------- + +describe("the reflectivity sheet's cost", () => { + it("is exactly one mesh and two triangles with a whole state raining", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + + const drawn = meshes(layer.group); + assert.equal(drawn.length, 1); + assert.equal(triangles(drawn[0] as THREE.Mesh), 2); + assert.equal(layer.group.children.length, 1); + assert.equal(layer.drawing(), true); + assert.equal(layer.wetTexels(), 3); + layer.dispose(); + }); + + it("costs one draw call and not the two a double-sided transparent quad costs", () => { + // three.js renders `transparent` + `DoubleSide` in two passes by default — + // back faces then front — so that a closed transparent solid composites + // correctly. Measured on the shipped board with `renderer.info.render.calls` + // before this line existed: 11 calls with no sky layers, **13** with the + // sheet alone. A single flat quad cannot overlap itself, so the second pass + // buys nothing and spends one of the forty-five draws this round has. + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + const material = (meshes(layer.group)[0] as THREE.Mesh).material as THREE.MeshBasicMaterial; + assert.equal(material.side, THREE.DoubleSide, "visible from under the cloud base too"); + assert.equal(material.forceSinglePass, true, "…in one pass"); + assert.equal(material.premultipliedAlpha, true); + assert.equal(material.depthWrite, false); + layer.dispose(); + }); + + it("contributes zero scene children when nothing is promoted", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + assert.equal(layer.group.children.length, 0, "before any field at all"); + + layer.setField(RAINY); + assert.equal(layer.group.children.length, 1); + + layer.setField(null); + assert.equal(layer.group.children.length, 0, "an invisible mesh is still a mesh"); + assert.equal(layer.drawing(), false); + assert.equal(layer.wetTexels(), 0); + layer.dispose(); + }); + + it("holds the whole statewide raster in under eight kilobytes", () => { + const painted = paint(RAINY); + assert.equal(painted.bytes.length, RAINY.rows * RAINY.cols * 4); + assert.ok(painted.bytes.length < 8 * 1024, `${painted.bytes.length} bytes`); + }); + + it("survives a null, an undefined and a field full of nonsense", () => { + // The consumer is a render loop, so a throw here is a black page. + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(null); + layer.setField(undefined as unknown as RadarField); + layer.setField({ ...RAINY, dbz: [1, 2] } as unknown as RadarField); + layer.setField({ ...RAINY, rows: 0 }); + layer.setField({ ...RAINY, cellLat: 0 }); + layer.tick(0.016); + assert.equal(layer.group.children.length, 0); + layer.dispose(); + }); +}); + +// ---- Which boards --------------------------------------------------------- + +describe("which boards carry a raster", () => { + it("is not constructed for the socal or sf packs", () => { + assert.equal(precipFactoryFor(SOCAL_CITY.bounds), null); + assert.equal(precipFactoryFor(SF_CITY.bounds), null); + assert.notEqual(precipFactoryFor(CALIFORNIA_CITY.bounds), null); + }); + + it("says why, in cells rather than in board names", () => { + // The rule is about the cell, so it survives a board being re-cut — which + // happened to california this very round. + assert.equal(boardCarriesRaster(CALIFORNIA_CITY.bounds), true); + assert.equal(boardCarriesRaster(SOCAL_CITY.bounds), false); + assert.equal(boardCarriesRaster(SF_CITY.bounds), false); + // SoCal is 4.3 cells tall and 6.6 wide at a quarter degree. + const socalRows = (SOCAL_CITY.bounds.maxLat - SOCAL_CITY.bounds.minLat) / 0.25; + assert.ok(socalRows < 5, `socal is ${socalRows.toFixed(1)} cells tall`); + // …and the same board would carry a raster at a tenth of a degree. + assert.equal(boardCarriesRaster(SOCAL_CITY.bounds, 0.08), true); + }); +}); + +// ---- The picture ---------------------------------------------------------- + +describe("what a texel says", () => { + it("draws unknown as a mark and dry as nothing at all", () => { + // `null` is "nobody is looking there", and a hole drawn as clear sky is a + // claim nobody made. `RADAR_DRY_DBZ` is "a working radar saw under 20". + const field: RadarField = { ...RAINY, dbz: [null, RADAR_DRY_DBZ, 45], rows: 1, cols: 3 }; + const { bytes, wet } = paint(field); + assert.equal(wet, 1); + assert.ok((bytes[3] as number) > 0, "unknown is visible"); + assert.ok((bytes[3] as number) < 40, "…but faint"); + assert.equal(bytes[7], 0, "dry is fully transparent"); + assert.ok((bytes[11] as number) > 100, "rain is not"); + }); + + it("climbs steeply off the rain threshold", () => { + assert.equal(alphaForDbz(RADAR_RAIN_DBZ - 0.5), 0); + assert.ok(alphaForDbz(RADAR_RAIN_DBZ) > 0.25, "a cell that has just crossed is already a mark"); + assert.ok(alphaForDbz(30) > alphaForDbz(RADAR_RAIN_DBZ) * 1.5); + assert.ok(alphaForDbz(55) > 0.9); + assert.ok(alphaForDbz(70) <= 1); + assert.equal(alphaForDbz(Number.NaN), 0); + }); + + it("uses the NWS ramp, so 25 dBZ is green and 50 is red", () => { + const { bytes } = paint({ ...RAINY, dbz: [25, 50], rows: 1, cols: 2 }); + assert.ok((bytes[1] as number) > (bytes[0] as number), "25 dBZ is green"); + assert.ok((bytes[4] as number) > 200 && (bytes[5] as number) < 60, "50 dBZ is red"); + }); + + it("writes premultiplied bytes, so a rain edge fades out rather than to black", () => { + // 1,596 texels stretched across a 554-unit board is a very long linear + // interpolation between a coloured texel and a transparent one. With + // straight alpha the *colour* walks to black on the way, and the first frame + // this layer ever produced had a dark halo round every echo and a hard dark + // line where the lattice is clipped at the board edge. Invisible in a test + // that only checks hue; visible in the first screenshot. + const { bytes } = paint({ ...RAINY, dbz: [RADAR_RAIN_DBZ], rows: 1, cols: 1 }); + const alpha = (bytes[3] as number) / 255; + const [r, g, b] = [0, 1, 2].map((i) => bytes[i] as number); + const straight = radarRampRgb(RADAR_RAIN_DBZ); + assert.ok(alpha > 0 && alpha < 1, "the threshold texel is partly transparent"); + for (const [was, now] of [[straight[0], r], [straight[1], g], [straight[2], b]]) { + assert.ok(Math.abs((now as number) - (was as number) * alpha) <= 1, `${now} vs ${was} * ${alpha}`); + } + }); +}); + +// ---- Where it sits -------------------------------------------------------- + +describe("where the sheet sits", () => { + it("sits at the cloud base and lets the Sierra rise through it", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + const sheet = meshes(layer.group)[0] as THREE.Mesh; + + // Measured, not assumed: the highest ground on this board is 39.47 units, at + // 36.60,-118.30, because `verticalExaggeration` is 15. The sheet is at 10.6. + // That is deliberate — see the header. A sheet high enough to clear the + // crest is 47 units up, and 47 units of lift under a camera at fifty degrees + // draws the rain seventy-five kilometres from where it fell. + const crest = world.groundAt(36.6, -118.3); + assert.ok(crest > 38 && crest < 41, `the crest measures ${crest.toFixed(2)} units`); + assert.equal(sheet.position.y, world.metres(PRECIP_ALTITUDE_M)); + assert.ok(sheet.position.y < crest, "the Sierra rises through the rain, as it should"); + // …but it is well clear of the Central Valley floor, which is what it is + // actually a sheet over: 1,350 m at 15x is ten units above a valley at 0.6. + const valley = world.groundAt(36.7, -119.8); + assert.ok(sheet.position.y > valley + 8, `the valley floor is ${valley.toFixed(2)} units`); + // …and far under the aircraft, which fly at 0.01 units to the metre. + assert.ok(sheet.position.y < 10_000 * 0.01); + layer.dispose(); + }); + + it("covers the lattice's own footprint, half a cell outside the outer centres", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + const sheet = meshes(layer.group)[0] as THREE.Mesh; + + const [westX] = world.project(RAINY.minLat, RAINY.minLng - RAINY.cellLng / 2); + const [eastX] = world.project(RAINY.minLat, RAINY.minLng + (RAINY.cols - 0.5) * RAINY.cellLng); + // Half a cell is 14 km — invisible, and wrong. + assert.ok(Math.abs(sheet.scale.x - Math.abs(eastX - westX)) < 1e-6); + layer.dispose(); + }); + + it("crossfades a new scan in rather than cutting to it", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN, crossfadeSeconds: 10 }); + layer.setField(RAINY); + const wetter = fieldWith([ + { lat: 36.375, lon: -119.625, dbz: 65 }, + { lat: 36.125, lon: -119.625, dbz: 34 }, + { lat: 36.375, lon: -119.375, dbz: 22 }, + ]); + layer.setField(wetter); + const sheet = meshes(layer.group)[0] as THREE.Mesh; + const map = (sheet.material as THREE.MeshBasicMaterial).map as THREE.DataTexture; + const before = (map.image.data as Uint8Array).slice(); + layer.tick(1); + const after = map.image.data as Uint8Array; + assert.notDeepEqual([...after], [...before], "a tick during the fade must move the pixels"); + layer.tick(20); + layer.tick(1); + const settled = (map.image.data as Uint8Array).slice(); + layer.tick(1); + assert.deepEqual([...(map.image.data as Uint8Array)], [...settled], "…and stop when it lands"); + layer.dispose(); + }); +}); + +// ---- The rig -------------------------------------------------------------- + +describe("the sheet and the light rig", () => { + it("constructs no light of any kind", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + layer.group.traverse((node) => { + assert.ok(!(node as THREE.Light).isLight, `${node.name || node.type} is a light`); + }); + layer.dispose(); + }); + + it("dims after dark without disappearing, and reads the sky rather than the fill", () => { + const layer = createPrecipLayer(world, { span: CALIFORNIA_SPAN }); + layer.setField(RAINY); + const material = (meshes(layer.group)[0] as THREE.Mesh).material as THREE.MeshBasicMaterial; + + // The two rigs are the ones measured off the shipped `atmosphere.ts` at + // 20:00Z and 04:35Z — including `hemisphere.intensity`, which is HIGHER at + // night (1.33) than at noon (0.95) because the fill compensates a moonlit + // scene. The first draft of this layer read that as a day/night signal and + // ran the sheet at full strength in the dark; the fixture carries the real + // numbers so that cannot come back. + layer.setLighting({ + sky: { top: 0x77a1cb, horizon: 0xe1ebf1 }, + hemisphere: { sky: 0xe6f2fb, ground: 0x74786a, intensity: 0.95 }, + } as never); + const day = material.opacity; + + layer.setLighting({ + sky: { top: 0x0d1730, horizon: 0x232f4e }, + hemisphere: { sky: 0x374d88, ground: 0x1f2740, intensity: 1.33 }, + } as never); + const night = material.opacity; + + assert.ok(night < day, `rain at night is darker (${night} vs ${day})`); + assert.ok(night > 0.3, "…but a data overlay that vanishes after sunset is a defect"); + assert.ok(day > 0.9, "…and it is at full strength in daylight"); + + // An interior rig has no sky at all. It must not throw and must not go dark. + layer.setLighting({ hemisphere: { sky: 0xe6f2fb, ground: 0x74786a, intensity: 1 } } as never); + assert.ok(material.opacity > 0.3); + layer.dispose(); + }); +}); diff --git a/src/test/render/vessels.test.ts b/src/test/render/vessels.test.ts new file mode 100644 index 0000000..ac9b34a --- /dev/null +++ b/src/test/render/vessels.test.ts @@ -0,0 +1,410 @@ +/** + * The vessel layer, counted rather than looked at. + * + * Three of the four things this file pins are *shapes of the scene graph*, and + * they are here because the picture cannot see them. A harbour drawn from two + * hundred `THREE.Mesh`es and a harbour drawn from one `InstancedMesh` are the + * same photograph and a different frame budget — the SoCal mobile cell has + * thirty draw calls spare for the whole of ports and ships — and this repo has + * been bitten by exactly that twice already: a suspension bridge at ~34 draws + * and twelve identical freeways that could never merge because each allocated + * its own material. + * + * The fourth is the empty state, which is most days. A layer that draws nothing + * must be a layer nothing *visits*: zero children, not one cheap mesh with + * `count = 0` still walked by the renderer. + * + * The board below is synthetic and small, because none of these facts are about + * California. Scale against real packs is `vehicle/vesselScale.test.ts`'s job. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; + +import { + KELVIN_HALF_ANGLE_DEG, + VESSEL_HULL_CAPACITY, + WAKE_CAPACITY, + bearingRotation, + createVesselLayer, + hullGeometry, + metresAcross, + wakeLengthMetres, +} from "../../engine/vessels.ts"; +import type { City, LightingState, Vessel } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +// ---- A board ------------------------------------------------------------- + +const BOARD: City = { + id: "test-harbour", + name: "Test Harbour", + center: { lat: 33.72, lng: -118.24 }, + bounds: { minLat: 33.5, maxLat: 33.95, minLng: -118.5, maxLng: -118.0 }, + latScale: 285, + verticalExaggeration: 3.4, + cellLat: 0.02, + cellLng: 0.024, + coastFalloff: 0.02, + landmasses: [], + parks: [], + inlandWater: [], + hills: [], + districts: [], + landmarks: [], + bridges: [], + roads: [], + chapters: [], +}; + +/** No `ready()`: nothing in this layer samples the heightfield. Ships float. */ +function board(): World { + return new World(BOARD); +} + +function moored(id: string, overrides: Partial = {}): Vessel { + return { + id, + kind: "container", + lat: 33.72, + lng: -118.24, + bearing: 118, + length: 300, + beam: 45, + speed: 0, + course: null, + status: "moored", + berthId: "a", + ...overrides, + }; +} + +function underWay(id: string, overrides: Partial = {}): Vessel { + return moored(id, { + speed: 6.2, + course: 210, + bearing: 210, + status: "under-way", + berthId: undefined, + ...overrides, + }); +} + +function instanced(group: THREE.Object3D): THREE.InstancedMesh[] { + return group.children.filter((c): c is THREE.InstancedMesh => (c as THREE.InstancedMesh).isInstancedMesh); +} + +function lines(group: THREE.Object3D): THREE.LineSegments[] { + return group.children.filter((c): c is THREE.LineSegments => (c as THREE.LineSegments).isLineSegments); +} + +const DAYLIGHT: LightingState = { + sun: { direction: [0.3, 0.8, 0.5], color: 0xfff4e2, intensity: 2.1 }, + hemisphere: { sky: 0x8fb6d8, ground: 0x9d9482, intensity: 0.6 }, + ambient: { color: 0xffffff, intensity: 0.2 }, + sky: { top: 0x2f6fb0, horizon: 0xbcd6e8 }, + fog: { color: 0xbcd6e8, near: 100, far: 900 }, +}; + +// ---- One mesh, one line --------------------------------------------------- + +describe("the whole board's ships are two draw calls", () => { + it("draws 1 vessel and 200 vessels from exactly one InstancedMesh", () => { + const layer = createVesselLayer(board(), { span: 400 }); + + layer.setVessels([underWay("one")]); + assert.equal(instanced(layer.group).length, 1); + + const fleet = Array.from({ length: 200 }, (_, i) => + underWay(`v-${i}`, { lat: 33.6 + i * 0.001, lng: -118.3 + i * 0.0005 }), + ); + layer.setVessels(fleet); + assert.equal( + instanced(layer.group).length, + 1, + "two hundred ships must not be two hundred meshes", + ); + assert.equal(layer.hullCount(), VESSEL_HULL_CAPACITY, "the instance cap should bind, not grow"); + layer.dispose(); + }); + + it("draws every wake on the board from exactly one LineSegments", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("one")]); + assert.equal(lines(layer.group).length, 1); + + layer.setVessels( + Array.from({ length: 200 }, (_, i) => underWay(`v-${i}`, { lat: 33.6 + i * 0.001 })), + ); + assert.equal(lines(layer.group).length, 1, "one wake buffer, whatever the traffic"); + assert.equal(layer.wakeCount(), WAKE_CAPACITY, "the wake cap should bind"); + layer.dispose(); + }); + + it("is one hull geometry of about forty triangles", () => { + const geometry = hullGeometry("generic"); + const triangles = geometry.getAttribute("position").count / 3; + assert.equal(triangles, 40, `the hull is ${triangles} triangles`); + // Position, normal AND uv, or a future merge silently drops the bucket — + // `airports.ts:43` records that exact scar. + for (const attribute of ["position", "normal", "uv"]) { + assert.ok(geometry.getAttribute(attribute), `the hull has no ${attribute}`); + } + geometry.dispose(); + }); + + it("is wound outward, which a picture found and a sign fixed", () => { + // The first photograph of this hull had its deck wound downward: back-face + // culled, lit from inside, and reading as "the ships came out a bit dark" + // rather than as a hole in the ship. `computeVertexNormals` takes its answer + // from the winding, so this is the only thing standing between a sign error + // and a fleet of hollow boxes. + const geometry = hullGeometry("generic"); + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + + // The divergence theorem: for a closed surface wound outward, the sum of + // r . n over the faces is three times the enclosed volume, and it is + // positive. Flip any face and the sum drops by twice that face's share. + let flux = 0; + let checked = 0; + for (let t = 0; t < position.count; t += 3) { + const a = new THREE.Vector3().fromBufferAttribute(position, t); + const b = new THREE.Vector3().fromBufferAttribute(position, t + 1); + const c = new THREE.Vector3().fromBufferAttribute(position, t + 2); + const face = new THREE.Vector3() + .subVectors(b, a) + .cross(new THREE.Vector3().subVectors(c, a)) + .multiplyScalar(0.5); + flux += a.clone().add(b).add(c).divideScalar(3).dot(face); + + const n = new THREE.Vector3().fromBufferAttribute(normal, t); + // The two faces whose orientation is unambiguous by inspection: the + // funnel's cap is the highest thing on the ship and the bottom plating is + // the lowest. + if (a.y === b.y && b.y === c.y) { + if (a.y > 1.8) { + assert.ok(n.y > 0.9, "the funnel cap points down"); + checked += 1; + } + if (a.y === 0) { + assert.ok(n.y < -0.9, "the bottom plating points up"); + checked += 1; + } + } + } + assert.ok(flux > 0, `the hull encloses ${(flux / 3).toFixed(3)} of signed volume`); + assert.ok(checked >= 4, "the horizontal-face check found nothing to check"); + geometry.dispose(); + }); +}); + +// ---- The buffer is allocated once ----------------------------------------- + +describe("the wake buffer", () => { + it("is allocated at construction and does not resize across 100 ticks", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("a"), underWay("b", { lat: 33.7, course: 30, bearing: 30 })]); + + const line = lines(layer.group)[0]; + assert.ok(line); + const positions = line.geometry.getAttribute("position") as THREE.BufferAttribute; + const colors = line.geometry.getAttribute("color") as THREE.BufferAttribute; + const positionLength = positions.array.length; + const colorLength = colors.array.length; + const positionArray = positions.array; + + for (let i = 0; i < 100; i++) { + layer.tick(1 / 60); + const now = line.geometry.getAttribute("position") as THREE.BufferAttribute; + assert.equal(now.array.length, positionLength, `the wake buffer resized on tick ${i}`); + assert.equal(now.array, positionArray, `the wake buffer was reallocated on tick ${i}`); + assert.equal( + (line.geometry.getAttribute("color") as THREE.BufferAttribute).array.length, + colorLength, + ); + } + // And it is `setDrawRange` that decides how much of it is read. + assert.ok(line.geometry.drawRange.count > 0); + assert.ok(line.geometry.drawRange.count <= positionLength / 3); + layer.dispose(); + }); + + it("holds two rails per wake at the Kelvin half-angle", () => { + // 19.47 degrees regardless of speed. It is a real constant, not a tuned one, + // and using it means the picture is right for a reason. + assert.ok(Math.abs(KELVIN_HALF_ANGLE_DEG - (Math.asin(1 / 3) * 180) / Math.PI) < 0.01); + + const world = board(); + const layer = createVesselLayer(world, { span: 400 }); + layer.setVessels([underWay("a", { course: 0, bearing: 0 })]); + const line = lines(layer.group)[0]; + assert.ok(line); + const positions = line.geometry.getAttribute("position") as THREE.BufferAttribute; + const count = line.geometry.drawRange.count; + + // Steaming due north, so the wake trails south (+z) and opens in x. The + // widest pair of vertices should sit at the tail, at tan(19.47) of its + // length either side. + let widest = 0; + let deepest = 0; + const shipZ = world.project(33.72, -118.24)[1]; + for (let i = 0; i < count; i++) { + widest = Math.max(widest, Math.abs(positions.getX(i))); + deepest = Math.max(deepest, positions.getZ(i) - shipZ); + } + const expected = deepest * Math.tan((KELVIN_HALF_ANGLE_DEG * Math.PI) / 180); + assert.ok( + Math.abs(widest - expected) < expected * 0.25 + metresAcross(world, 45), + `wake half-width ${widest.toFixed(3)} against ${expected.toFixed(3)} at the Kelvin angle`, + ); + layer.dispose(); + }); +}); + +// ---- A wake is a claim about motion --------------------------------------- + +describe("a wake is speed through water, so a moored ship has none", () => { + it("draws no wake behind a berthed hull", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([moored("a"), moored("b", { lat: 33.73 })]); + assert.equal(layer.hullCount(), 2); + assert.equal(layer.wakeCount(), 0); + assert.equal(lines(layer.group).length, 0, "an empty wake buffer must not be visited"); + layer.dispose(); + }); + + it("scales the wake with speed and hull length, and stops at a standstill", () => { + assert.equal(wakeLengthMetres(0, 400), 0); + assert.equal(wakeLengthMetres(0.1, 400), 0); + // A 400 m ship at twelve knots: about 1.5 km, which is 3.84 units on SoCal + // and the length at which a moving vessel reads from the whole-board pose. + const full = wakeLengthMetres(6.17, 400); + assert.ok(Math.abs(full - 1_500) < 20, `${full.toFixed(0)} m of wake behind a ULCV`); + // A tug is not a container ship with a shorter name. + assert.ok(wakeLengthMetres(6.17, 30) < 150); + // Half speed, half wake. + assert.ok(Math.abs(wakeLengthMetres(3.085, 400) - full / 2) < 1); + }); + + it("makes the wake longer than the hull it trails, which is the design claim", () => { + const world = board(); + const hull = metresAcross(world, 400); + const wake = metresAcross(world, wakeLengthMetres(6.17, 400)); + assert.ok(Math.abs(hull - 1.024) < 0.01, `a 400 m hull is ${hull.toFixed(3)} units`); + assert.ok(wake / hull > 3.5, `the wake is only ${(wake / hull).toFixed(1)}x the hull`); + }); +}); + +// ---- The empty state ------------------------------------------------------ + +describe("the harbour with no ships in it", () => { + it("contributes zero scene children for an empty list", () => { + const layer = createVesselLayer(board(), { span: 400 }); + assert.equal(layer.group.children.length, 0, "a layer nothing has answered must be empty"); + layer.setVessels([]); + assert.equal(layer.group.children.length, 0, "the feed answered and this board is empty"); + assert.equal(layer.hullCount(), 0); + assert.equal(layer.wakeCount(), 0); + layer.dispose(); + }); + + it("treats null and [] as the same picture", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("a")]); + assert.equal(layer.group.children.length, 2); + layer.setVessels(null); + assert.equal(layer.group.children.length, 0); + layer.dispose(); + }); + + it("survives a tick, a lighting change and a dispose with nothing in it", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setLighting(DAYLIGHT); + layer.tick(1 / 60); + assert.equal(layer.group.children.length, 0); + layer.dispose(); + assert.equal(layer.group.children.length, 0); + }); +}); + +// ---- Orientation and motion, through the scene graph ---------------------- + +describe("where a hull is drawn and which way it faces", () => { + it("turns the bow to the bearing, with north at -z", () => { + // A sign error here sails the whole fleet backwards and is completely + // plausible in a still frame, which is why it is asserted rather than seen. + assert.ok(Math.abs(bearingRotation(0)) < 1e-12); + assert.ok(Math.abs(bearingRotation(90) + Math.PI / 2) < 1e-12); + + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([moored("a", { bearing: 90 })]); + const mesh = instanced(layer.group)[0]; + assert.ok(mesh); + const matrix = new THREE.Matrix4(); + mesh.getMatrixAt(0, matrix); + // `decompose`, not `setFromRotationMatrix`: the instance matrix carries a + // deliberately non-uniform scale — beam, depth, length — and reading a + // quaternion straight off it folds the ship's proportions into its heading. + const rotation = new THREE.Quaternion(); + matrix.decompose(new THREE.Vector3(), rotation, new THREE.Vector3()); + const bow = new THREE.Vector3(0, 0, -1).applyQuaternion(rotation); + // Bearing 090 is due east, which on this board is +x. + assert.ok(bow.x > 0.99, `the bow points ${bow.x.toFixed(3)} east`); + assert.ok(Math.abs(bow.z) < 0.01); + layer.dispose(); + }); + + it("advances a hull under way along its course, and only along it", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("a", { course: 90, bearing: 90, speed: 6 })]); + const start = layer.positionOf("a"); + assert.ok(start); + + for (let i = 0; i < 60; i++) layer.tick(1); + const after = layer.positionOf("a"); + assert.ok(after); + // Due east for a minute at 6 m/s: 360 m, which is 0.92 units on this board. + assert.ok(after.x - start.x > 0.8, `moved ${(after.x - start.x).toFixed(3)} units east`); + assert.ok(Math.abs(after.z - start.z) < 0.01, "a course of 090 changed the latitude"); + layer.dispose(); + }); + + it("leaves a moored hull exactly where the feed put it, for ever", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([moored("a")]); + const start = layer.positionOf("a"); + assert.ok(start); + for (let i = 0; i < 600; i++) layer.tick(1); + assert.deepEqual(layer.positionOf("a")?.toArray(), start.toArray()); + layer.dispose(); + }); + + it("stops reckoning once the next fix is overdue", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("a", { course: 90, bearing: 90, speed: 6 })]); + for (let i = 0; i < 900; i++) layer.tick(1); + const atLimit = layer.positionOf("a"); + for (let i = 0; i < 3_600; i++) layer.tick(1); + assert.deepEqual(layer.positionOf("a")?.toArray(), atLimit?.toArray()); + layer.dispose(); + }); +}); + +// ---- Lighting ------------------------------------------------------------- + +describe("the layer owns no light", () => { + it("adds no THREE.Light, at any hour", () => { + const layer = createVesselLayer(board(), { span: 400 }); + layer.setVessels([underWay("a"), moored("b")]); + layer.setLighting(DAYLIGHT); + layer.setLighting({ ...DAYLIGHT, sun: { direction: [0, -0.2, 1], color: 0x223355, intensity: 0.1 } }); + let lights = 0; + layer.group.traverse((object) => { + if ((object as THREE.Light).isLight) lights += 1; + }); + assert.equal(lights, 0, "CONTRACT §4 gives the rig to atmosphere.ts and to nothing else"); + layer.dispose(); + }); +}); diff --git a/src/test/vehicle/vesselScale.test.ts b/src/test/vehicle/vesselScale.test.ts new file mode 100644 index 0000000..7a0dbba --- /dev/null +++ b/src/test/vehicle/vesselScale.test.ts @@ -0,0 +1,177 @@ +/** + * Ships are drawn at true size, on both boards, and this is the file that says + * so out loud. + * + * ### Why this needs an assertion at all + * + * "Make the ships a bit bigger so you can see them" is a reasonable-sounding + * change that somebody will propose, and there is a precedent in this repo that + * appears to license it: `aircraftGeometry.ts` holds an aeroplane at a fixed + * 0.42 units on every board, which over Southern California is about four times + * life size, and nobody has ever noticed. That oversizing is correct **because an + * aeroplane in flight is alone in the sky with nothing to be wrong against.** + * + * A ship is never alone. It is alongside a quay, under a gantry crane and inside + * a breakwater, all of which `ports.ts` draws at true scale. A hull scaled up to + * be legible is a hull visibly longer than the berth it is lying in, and the + * error is unmissable in exactly the frame the feature exists for. So the + * inversion is deliberate, it is the opposite of the neighbouring convention, + * and a bare number in a mesh builder would not survive a reviewer who + * remembered the aircraft rule. + * + * The legibility oversizing would have bought is bought by the wake instead — + * see the last case here, which is the whole design in one ratio. + * + * ### The numbers + * + * They come from the packs rather than from a comment. SoCal declares + * `latScale: 285`, so a unit is 111,320 / 285 = 390.6 m and a 400 m ULCV is + * **1.024 units**. The Bay declares `latScale: 1180`, a unit is 94.3 m, and the + * same ship is **4.24 units** — four times bigger for the same steel, which is + * the thing about board scale that is hardest to hold in your head and the + * reason the tolerance below is tight. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { SOCAL_CITY } from "../../cities/socal.ts"; +import { SAN_FRANCISCO_CITY } from "../../cities/sf.ts"; +import { + WAKE_FULL_SPEED_MPS, + hullDraughtMetres, + hullFreeboardMetres, + hullLengthUnits, + metresAcross, + wakeLengthMetres, +} from "../../engine/vessels.ts"; +import { DEFAULT_HULL, beamFor } from "../../server/vessels.ts"; +import type { Vessel } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +/** No `ready()`: every number here is projection, and projection is the constructor. */ +const socal = new World(SOCAL_CITY); +const bay = new World(SAN_FRANCISCO_CITY); + +const ULCV: Pick = { + kind: "container", + length: 400, + beam: 61, +}; + +describe("a ship is the size a ship is", () => { + it("draws a 400 m hull as 1.024 units on the Southern California board", () => { + const units = hullLengthUnits(socal, ULCV); + assert.ok( + Math.abs(units - 1.024) < 0.01, + `a 400 m ULCV came out ${units.toFixed(4)} units on SoCal, not 1.024`, + ); + // And the board's own scale is where that came from, not a constant. + assert.ok(Math.abs(socal.metresPerUnit - 390.6) < 0.2); + }); + + it("draws the same hull as 4.24 units on the Bay Area board", () => { + const units = hullLengthUnits(bay, ULCV); + assert.ok( + Math.abs(units - 4.24) < 0.05, + `a 400 m ULCV came out ${units.toFixed(4)} units on the Bay, not 4.24`, + ); + assert.ok(Math.abs(bay.metresPerUnit - 94.3) < 0.2); + }); + + it("is 4.14x bigger on the Bay for the same steel", () => { + const ratio = hullLengthUnits(bay, ULCV) / hullLengthUnits(socal, ULCV); + assert.ok(Math.abs(ratio - socal.metresPerUnit / bay.metresPerUnit) < 1e-9); + assert.ok(ratio > 4 && ratio < 4.3, `boards differ by ${ratio.toFixed(2)}x`); + }); + + it("does NOT hold a hull at a fixed map-symbol size, the way an aeroplane is held", () => { + // `aircraftGeometry.ts`'s AIRLINER glyph is a flat 0.42 units on every + // board. If a hull were ever given the same treatment these two would be + // equal, and they must never be. + assert.notEqual(hullLengthUnits(socal, ULCV), hullLengthUnits(bay, ULCV)); + }); + + it("scales a tug and a ULCV from one geometry, thirteen times apart", () => { + const tug = { kind: "tug" as const, length: 30, beam: 11 }; + const ratio = hullLengthUnits(socal, ULCV) / hullLengthUnits(socal, tug); + assert.ok(Math.abs(ratio - 400 / 30) < 1e-9, "the same solid at 13.3x, not two solids"); + // 30 m is 0.077 units on SoCal: a tug alone is genuinely invisible from the + // board pose, which is why it is drawn with a wake or not noticed at all. + assert.ok(hullLengthUnits(socal, tug) < 0.08); + }); + + it("does not run a plan measurement through the vertical exaggeration", () => { + // `world.metres()` multiplies by `verticalExaggeration` — 3.4 on SoCal — + // because it is for heights. Running a 400 m length through it would make + // the ship 3.5 units long and overhang its berth by two ship lengths. The + // separation between the two functions is the assertion. + assert.ok(socal.metres(400) > 3.4); + assert.ok(Math.abs(socal.metres(400) / metresAcross(socal, 400) - 3.4) < 1e-9); + }); +}); + +describe("the vertical, which is the axis the board does exaggerate", () => { + it("stands a laden box ship about a third of its own length tall", () => { + // Draught plus freeboard, through `world.metres`, is what makes a hull read + // as a solid rather than as a decal — and it is exaggerated on purpose, + // exactly as the 130 m gantry crane beside it is. + const depth = hullDraughtMetres(ULCV) + hullFreeboardMetres(ULCV); + assert.ok(depth > 40 && depth < 50, `${depth.toFixed(1)} m of hull, keel to deck`); + const units = socal.metres(depth); + assert.ok(units > 0.3 && units < 0.45, `${units.toFixed(3)} units tall on SoCal`); + }); + + it("authors draught rather than observing it, and never as a cargo claim", () => { + // `vessels` in the store has no draught column at all. This is a hull + // dimension, it is derived from the length, and an authored value overrides + // it — but nothing here is ever an observation and no hull is ever labelled + // laden or in ballast. "Empty or full" is answered at the port. + assert.ok(Math.abs(hullDraughtMetres(ULCV) - 14.4) < 0.1); + assert.equal(hullDraughtMetres({ ...ULCV, draught: 12 }), 12); + const tug = hullDraughtMetres({ kind: "tug", length: 30 }); + assert.ok(tug > 3 && tug < 5, `a harbour tug drawing ${tug.toFixed(1)} m`); + }); +}); + +describe("the wake is what is actually legible", () => { + it("is 3.75 hull lengths at speed, and 3.84 units on SoCal", () => { + const metres = wakeLengthMetres(WAKE_FULL_SPEED_MPS, 400); + assert.ok(Math.abs(metres - 1_500) < 20, `${metres.toFixed(0)} m of wake`); + const units = metresAcross(socal, metres); + assert.ok(Math.abs(units - 3.84) < 0.06, `${units.toFixed(3)} units of wake on SoCal`); + }); + + it("is 3.7x more legible than the hull it trails, which is the design", () => { + // At the whole-board pose a 1.02-unit hull is about four pixels and is + // invisible; a 3.84-unit wake is about fourteen and reads. That ratio is why + // the wake is the primary object in this layer and the hull is the thing at + // the sharp end of it. + const hull = hullLengthUnits(socal, ULCV); + const wake = metresAcross(socal, wakeLengthMetres(WAKE_FULL_SPEED_MPS, 400)); + assert.ok(Math.abs(wake / hull - 3.75) < 0.01, `the wake is ${(wake / hull).toFixed(2)}x`); + }); + + it("is 15.9 units on the Bay, where everything is four times bigger", () => { + const units = metresAcross(bay, wakeLengthMetres(WAKE_FULL_SPEED_MPS, 400)); + assert.ok(Math.abs(units - 15.9) < 0.2, `${units.toFixed(2)} units of wake on the Bay`); + }); +}); + +describe("the display defaults for a source that sent no dimensions", () => { + it("gives a Panamax-plus box ship its real 400 x 61", () => { + // The ratios are display defaults, stated as ratios because that is what + // they are. The one worth checking is the box ship, which is the hull the + // whole San Pedro complex is shaped around. + assert.ok(Math.abs(beamFor("container", 400) - 61) < 1.5); + assert.ok(Math.abs(beamFor("tug", 30) - 10.7) < 1); + }); + + it("keeps every default hull a plausible ship rather than a placeholder", () => { + for (const [kind, hull] of Object.entries(DEFAULT_HULL)) { + const ratio = hull.length / hull.beam; + assert.ok(ratio > 2 && ratio < 9, `${kind} is ${ratio.toFixed(1)} long to a beam`); + assert.ok(hull.length > 10 && hull.length < 450, `${kind} is ${hull.length} m long`); + } + }); +});