1
0

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:
2026-08-22 23:35:09 -07:00
parent b25f217e3e
commit acf4d1a510
52 changed files with 14436 additions and 142 deletions
+447 -3
View File
@@ -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<void> {
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<HTMLElement>(`#${sectionId}`);
if (section === null) return;
let any = false;
for (const [id, text] of notes) {
if (text !== "") any = true;
const line = document.querySelector<HTMLElement>(`#${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.