feat: the state becomes California, and the port fills with ships
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.
**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.
The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.
**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.
`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.
**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.
At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.
**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.
"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.
**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."
Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.
Tests 1,340 -> 1,540, server 280 -> 295.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user