1
0

feat: the boards stop being three products

The owner asked twice why there are still three separate boards. The honest
answer, and what this round executes: **it feels like three boards, but not
because the scale jumps 20x — because the three packs draw three different
Californias, and the loudest difference is that the mountains are four times
taller on one of them.**

**THE 20x HORIZONTAL SCALE JUMP IS INVISIBLE**, and measuring that collapsed the
cost of this whole round. `World.project` is a uniform scale in x/z with no
vertical term, and a uniform scale leaves a perspective image identical — so a
camera carried across the seam on matched true-metre offsets draws a
pixel-identical horizontal frame. 1,919 -> 94 m/unit costs nothing to look at.
Rescaling was never the problem. A boot card, a tab strip and a 4.17x vertical
deflation were.

**THE PAUSE WAS MOSTLY FAKE.** A switch covered the screen for 1,715 ms but only
608 ms blocked the main thread; the page drew 46 of 69 frames with nothing to
show, because the outgoing board had already been disposed. `mountCity` now
retains it: the incoming board builds BEHIND a live, interactive picture, and
`stage.setScene` fires only on completion. Measured across all six directions,
three runs each — boot card yes -> **no**, opaque cover 726-1,415 ms -> **0**,
blank frames 21-46 -> **exactly 1**, wall clock down 12-29%, blocked main thread
down 15-47%. A return to a board already seen links **zero** shader programs and
blocks **zero** milliseconds: 298-312 ms of camera flight where it was ~1,600 ms
behind a card. Disposal had been throwing away the shader cache too — linkProgram
ran 38, 59, 78, 109, 127 across five mounts and never reused one.

**The transition is a fog dip, not a crossfade**, through the `setAerialFog` seam
built last round. Every both-boards-live crossfade breaks a budget — ca+sf is
2,640,307 triangles against bay-area's 2,600,000 cap — and a fade never lands
inside the harness's sample window, which is the "a cap you do not measure is a
cap you do not have" failure this repo already argues against. The dip costs zero
triangles and zero draw calls, and it hides the 4.17x deflation, the 4,025 m
projection disagreement and the vanishing 2 km freeway symbols at once, because
all three happen at maximum obscuration. It is also diegetic: a descent through
haze.

The first dip was wrong and the photograph caught it: collapsing to 6% of board
SPAN turned the whole night frame into one flat field — the exact "turns the map
off" failure the risk list named. Re-anchored to 70% of camera STAND-OFF, so the
coastline survives and only the relief melts.

**One ladder, one places list.** 26 authored chapters become 24 rungs sorted
descending by STAND-OFF, not altitude — by altitude they interleave badly and
altitude cannot tell a low oblique from a high plan. The three-board tab strip is
off by default; the left column is now one scrolling list of all 24 rungs under
three region headings that does not change when the board does. Only which row is
lit changes. Label collisions are resolved in the ladder and never in a pack, so
the 29 index-aimed capture guards are untouched.

The minimap stops turning through 90 degrees between boards: every board is
pinned to a rectangle with California's proportions.

**SF and SoCal are not regressed**, and that was the acceptance that mattered:
95.9-98.8% of board pixels are delta-0 against a baseline hash-verified identical
to what the live site serves, and every one of the 34-70 surviving pixels per
frame is an aircraft or a hull.

**A real defect found only by photograph:** `minimap.setMarkers()` had zero call
sites. Every marker on every board was gone — the LA studio's door dot, the Bay
Area's eight company markers — dropped when the minimap went per-board.
Typecheck, tests, budgets and the console were all green with that bug in.

Also fixed: two capture presets that lied. `look.mjs`'s `glyph-la` and `glyph-sf`
claimed California chapter closeups and returned SoCal and Bay Area frames,
because they aimed by chapter index and the indices had moved. Aiming is now by
identity, with a guard test.

NOT SHIPPED, DELIBERATELY: the pack merge. At Bay density it is 34.04M triangles,
13x the highest budget — dead, not a trade. At SoCal density it is 1.99M and fits
today, and the price is San Francisco rendering at 164 m lots instead of 40 m,
i.e. SF looking the way SoCal looks now. SF and SoCal carry every marketing still
on the site. That is the owner's decision and it is worthless as an argument and
decisive as a photograph, so it ships as a measurement artifact with a
side-by-side still and is wired into nothing. The four data reconciliations that
would make one world honest — one exaggeration rule, roads in metres, one
projection centre, one coastline convention — are behind TERA_RECONCILE, default
OFF.

Tests 1,570 -> 1,651, server 295. All ten budget cells pass, no cap raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 06:20:18 -07:00
parent bcac6aa41a
commit 4bd8481be1
32 changed files with 6628 additions and 264 deletions
+99
View File
@@ -842,6 +842,105 @@ 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.
### 12.4 Three packs draw three Californias, and what one would cost
The complaint this section answers is "why does it still feel like three
boards", and the first thing to record is the measurement that removes the
obvious suspect: **the 20× horizontal scale jump at the seam is invisible.**
`World.project` is a uniform scale in x/z with no vertical term, and a uniform
scale leaves a perspective image identical, so a camera carried across the seam
on matched true-metre offsets draws a pixel-identical horizontal frame. 1,919 →
94 m per unit costs nothing to look at. Rescaling was never the problem.
What is visible is that the three packs disagree, in data, about what California
looks like. `src/cities/reconcile.ts` holds four rules that replace those
disagreements with one authored constant each, behind `?reconcile=` and **off by
default**, because San Francisco and Southern California carry every marketing
still on lumbridgecorp.com and a photograph is the only instrument that can say
whether a rule made a board better.
| | rule | california | sf | socal |
| --- | --- | --- | --- | --- |
| (a) `verticalExaggeration` | `RELIEF_IN_FRAME` = 0.0724, peak units over span | 15 → **15.00** | 3.6 → **5.78** | 3.4 → **3.41** |
| (b) road width | true metres, floored at `LEGIBLE_SPAN_FRACTION` = 0.000199 of span | 1,9192,034 m → **212 m** | 1932 m → **unchanged** | 3155 m → **unchanged** |
| (c) `coastFalloff` | `COAST_RAMP_CELLS` = 0.8 × the coarse ground cell | 0.0250° → **0.0250°** | 0.0018° → **0.0032°** | 0.0045° → **0.0058°** |
| (c) palette | one family, mixed by how far south and how far inland the board reaches | **unchanged** | mix 0.05 | mix 0.77 |
| (d) `lngScale` | one `REFERENCE_LAT` = 37.3° for every board | **unchanged** | 87,990 → 88,552 m/° | 92,484 → **88,552** m/° |
Three of the four leave the state board exactly where §12.1's photograph put it,
and that is not luck: each constant is taken *off* California, which is the board
this document already tuned against a picture. What the rules then say is that
**San Francisco is the board that is out of family** — 1.6× too flat for its own
span, and the only pack with no palette at all.
Four things worth writing down so they are not re-derived:
- **The relief rule narrows the seam step; it does not close it.** Apparent
relief at a *matched* pose is proportional to the exaggeration itself, not to
relief-in-frame, so the rule takes California → Bay Area from 4.17× to 2.60×
and leaves California → Southern California at 4.4×, because those two were
already on one number. Making it 1.0× means one exaggeration everywhere, which
puts the state at 1.7% of its own frame. Relief-in-frame and matched-pose
relief are the same quantity only for boards of the same span.
- **The relief rule moves buildings too, and on a metro board the buildings are
the subject.** §12.1's argument that a taller exaggeration makes towers a
*smaller* fraction of the frame holds only because California's span grew in
the same change. San Francisco's span does not move, so the Financial District
comes out 1.60× taller in an unchanged frame — measured and photographed, and
the one place where (a) makes a board arguably worse rather than better. It is
the sub-item most likely to be reverted or split into a separate building
term.
- **The road floor was measured, not chosen.** The two metro packs, authored
separately, put their narrowest road at 0.00019940 and 0.00020356 of their own
span. The floor sits just under the tighter of the two, so neither metro board
moves and California's two-kilometre freeway symbols fall onto it.
- **The projection rule costs local aspect.** Southern California draws 4.25%
narrower in x at 37.3° than at its own 33.82°, and San Francisco 0.64% wider,
against a registration error that goes from 4,010 m to **zero by
construction**.
Measured with the flag on, against the flag off: California is identical on
every layer to the digit; the Bay Area gains 10.8% of terrain triangles and 1.0%
of building instances; Southern California loses 4.3% of instances with the
narrower board. No cap is approached, let alone raised.
### 12.5 The merge, priced
`scripts/merge-feasibility.mjs` builds the statewide merged pack in node and
counts it. It is a measurement artifact: no board id, no route, nothing wired
into the product.
A lot is `LOT = 0.42` **scene units**, so how much ground a building stands on is
decided entirely by the board's metres-per-unit — 40 m on the Bay Area, 164 m on
Southern California, 806 m on the state. A merged board must pick one:
| density | lot | extent | instances | triangles | vs the 2.6M desktop cap |
| --- | --- | --- | --- | --- | --- |
| Southern California | 164 m | 2,380 × 2,722 u | 93,253 | 1,119,036 | 0.43× |
| Southern California, all 116 districts | 164 m | 2,380 × 2,722 u | 165,417 | 1,985,004 | 0.76× |
| Bay Area | 40 m | 9,856 × 11,269 u | 1,599,401 | 19,192,812 | 7.4× |
| Bay Area, all 116 districts | 40 m | 9,856 × 11,269 u | 2,829,753 | 33,957,036 | 13.1× |
The two rows per density differ by one judgement: whether the state pack's own
coarse Bay Area and Los Angeles districts are dropped where a metro pack already
draws that ground. They should be — but both are printed, because a number
quoted without saying which one it is has already produced a 1.8× disagreement
inside this round's own brief. Terrain adds 75,996 triangles on California's
existing 3,473 m cell; it is the cheap half.
So **Bay Area density is dead** and **Southern California density fits**, and its
price is that San Francisco is drawn the way Southern California is drawn now.
That price is a picture and not an argument: `merge-sf-40m-vs-164m.png` is the
Financial District at 40 m lots beside the same frame at 164 m, and downtown
stops being a skyline. The still varies `LOT` in a throwaway tree under `/tmp`
rather than `latScale`, so the camera, the terrain and every chapter pose are
held constant and lot size is the only variable.
One measurement is noise and is labelled as such: instance and triangle counts
are deterministic to the digit across runs, and placement milliseconds on this
box span 323 to 1,989 ms for the same 93,253 lots.
## 13. Four layers, one seam, and two gates each
The port kit, the ships, the reflectivity raster and the migration field arrived
+51
View File
@@ -48,6 +48,57 @@ way out is not acceptable.
- **`StageScene`** carries its own `THREE.Scene`, `PerspectiveCamera` and
`OrbitControls`. `stage.setScene(s)` swaps; the outgoing scene is **retained
and paused, never disposed**.
### 1.1 A city board is retained across a **board** switch, not only an office visit
This clause used to be about one door: the city was kept because you were coming
straight back out of an office. It now covers the other direction too, and for
the same measured reason.
A board switch used to dispose the outgoing board at the top of `mountCity` and
then build the next one behind a full-screen card. Measured against `bcac6aa`
with a real GPU, that card covered the screen for 1,711 / 1,079 / 997 ms
(bay-area / socal / california) while only 604 / 372 / 222 ms actually blocked
the main thread, and the page drew 68 / 45 / 49 frames inside the gap — so
two thirds of every pause was a live, animating page with nothing to draw.
Disposal was also throwing away the shader cache: `three` refcounts programs per
material and deletes at zero, `linkProgram` ran 38 → 59 → 78 → 109 → 127 times
across five mounts and never once reused, and a second visit to the Bay Area was
only 115 ms faster than the first.
So:
- **`main.ts` holds a `Map<boardId, MountedBoard>`** (`src/engine/boards.ts`),
and the incoming board is built **behind** the outgoing one. The outgoing board
stays `stage.current()`, live and interactive, for the whole build.
`stage.setScene` is called only on completion, under a fog dip.
- **`createScene` no longer presents what it constructs.** `SceneOptions.present`
defaults to `true` so every existing caller is unchanged; `present: false` says
"build it, do not show me yet", and the caller that asks owns the
`stage.setScene(handle.stageScene)`.
- **Who disposes a board.** Still whoever built it, which is now the cache's
eviction: `boards.put()` returns whatever it dropped and `main.ts` disposes
exactly that. `SceneHandle.dispose()` is unchanged and still calls
`environmentRig.release(scene)` first — the rig's ledger is a strong reference,
and a disposed board left in it retains the whole scene graph.
- **What is retained and what is not.** A `MountedBoard` holds everything
expensive or derived from the pack (the handle, the plan view, the atmosphere,
the board span, the harbour model, the traffic dial). It does **not** hold
subscriptions — the weather watch, the fire watch, the fire panel, the camera's
fog listener and the pose editor belong to the board that is *on screen*, and
are stopped and restarted as boards swap.
- **Residency.** California is pinned (5.86 MB of GPU buffers, and the root of
every descent). Three boards resident on a desktop, two on a handheld —
`deviceProfile()` changes only the pixel ratio and the shadow map, so a phone
carries the same resident geometry as a laptop against a much smaller budget.
Two is provably enough: `sf` and `socal` bounds do not intersect and California
contains both, so the set of boards that can describe one camera position is
always `{california}` or `{california, one metro}`.
- **The boot card is the first mount of a session and nothing else.** Every
switch after it raises a small non-modal pill instead. Measured after this
change, all six board-to-board directions cover **zero** frames with the boot
card, and a return to an already-visited board links **zero** shader programs
and blocks the main thread for **0 ms**.
- Camera, controls, lights and picking live in a per-scene **`SceneKit`**
helper that both `createScene` (city) and `createOfficeScene` construct.
They do *not* live on Stage.
+207 -4
View File
@@ -51,7 +51,7 @@ things fall out of it that somebody has to pick up.
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
### Aerial perspective is wired, and the coarse seam is now a live defect
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
@@ -62,9 +62,32 @@ 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.
to four times instead of easing it.
**Promoted from hypothetical to live, 2026-08-23.** This entry used to end "a
continuous state-to-city descent *would* need this on the frame loop rather than
on an event". That descent is now the thing being built: the state-to-metro
transition is a **fog dip** — collapse the outgoing board's `setAerialFog`
toward the camera over ~0.3 s, swap in the obscured frame, lift the incoming
board's over ~0.5 s — chosen over a crossfade because a crossfade cannot afford
the triangles (ca+sf is 2,640,307 against the Bay Area's 2,600,000 cap) and
because a dip is the only transition that hides the 4.2x vertical-exaggeration
pop, the 4 km projection slide and the vanishing 2 km freeways at once, since
all three happen at maximum obscuration.
That makes fog a **transition mechanism** rather than an ambience setting, and a
mechanism driven by an event the descent does not fire is a mechanism that does
not run. Two specific consequences:
- A dip driven from a follow camera — leaving the corridor drive into the Bay
Area is the obvious case — gets the one-hertz tick, so a 300 ms collapse gets
at most one step. The dip either does not happen or happens as a single jump.
- The 2%-of-altitude throttle is a threshold on *altitude*, and a dip changes
near/far with the altitude held still. Nothing in the current trigger notices
that at all.
So this moves onto the frame loop, and it moves as part of the transition work
rather than after it.
### The north is authored, and three things in it are thin
@@ -530,3 +553,183 @@ The complete fix is to clamp against the camera's focus distance rather than the
aircraft's, so the glyph collapses toward its authored size whenever the viewer
has zoomed in on something near, at any aircraft range. That is a signature change
through `glyphScale`, `tick` and their callers.
The four presets that photograph this clamp used to cover three stand-offs, not
four, and two of them were pictures of a different board — see "The capture
harness was aiming by position" below. They are now `glyph-board` (the whole
state), `glyph-north` (Shasta and the Klamath knot), `glyph-bay` (the Bay Area
oblique) and `glyph-fidi` (downtown San Francisco): four aerial stand-offs over
three orders of magnitude, all four aimed by `data-view`.
## The capture harness was aiming by position, and two presets were lying
Fixed 2026-08-23. Recorded because the *class* of defect is the one this repo
keeps paying for, and because two of the three fixes are guards rather than
changes.
**What was wrong.** `scripts/look.mjs`'s `glyph-la` and `glyph-sf` were
documented as "two chapter closeups on that board" — California — and were
`--click "^LA$"` and `--click "^SF$"`. Chapters 04 and 05 of the state board are
not camera poses: `main.ts:4485` matches them against `CALIFORNIA_DESTINATIONS`
and calls `switchCity()`, so both presets left California and returned frames
byte-comparable to `hero-socal` and `hero-sf`. Two of the four stand-offs the
glyph set claimed to cover were duplicates of two others, and nothing failed.
**What photographing the fix turned up.** California has exactly **two** aerial
poses on it. Of its six chapters, one is the whole board, two are doors, and two
`la-sf-us-101` and `la-sf-i-5` — call
`requestControlMode("drive")` at `main.ts:4493`, so clicking either lands a chase
camera on a freeway rather than a stand-off. "Two chapter closeups on that board"
was never available. The glyph ladder is spread across boards instead, which is
a better ladder: `glyph-board``glyph-north``glyph-bay``glyph-fidi`.
**The three things that landed with it.**
- `scripts/look.mjs` takes `--chapter <data-view> [--expect <short label>]`, so
every preset aims at a chapter's **identity** rather than at its position or at
the text printed on it. Both of the other two have now produced a photograph of
somewhere else in this repo.
- `scripts/performance-budget.mjs` gives every scene a **signature chapter**
one `data-view` that exists on that board and on no other — and refuses to
become ready until it is on screen; `california-drive` aims at
`data-view='la-sf-us-101'` instead of at chapter index 1. `?city=` falls back
to the first board rather than failing, silently, so a `bay-area` cell that had
quietly measured California would have passed its 2.6M-triangle cap by a factor
of six and stayed green through any regression a phone would choke on. A budget
that cannot fail.
**A signature chapter rather than the pressed board tab, and that is a lesson
rather than a preference.** It was written against `[data-board]` first and
broke inside the hour, because the tab strip is chrome and chrome is precisely
what the continuity work is allowed to change. A chapter id is pack data. Every
harness in this repo should key on the thing a camera resolves through, not on
the thing a designer is free to move: `check-chapter-identity.mjs` demotes tab
drift to a printed note for the same reason, and `switch-cost.mjs` detects
arrival from `#chapters` rather than from either the tab or the boot card.
- `scripts/check-chapter-identity.mjs` + `scripts/fixtures/chapter-identity.json`
pin every chapter's `data-view`, position, printed number and short label under
every `?city=`, and `src/test/chapterIdentity.test.ts` re-states the pack half
of it in `npm test` with three negative cases that are watched failing.
**The 26 aims that are still index-aimed, and are deliberately left that way.**
`scripts/brand-assets/shots.mjs` (21), `films.mjs` (4) and `capture.mjs` (1, the
Open Graph card) each point at `#chapters .chapter[N]` and assert a short label.
21 of the 26 aim at a board a `?city=` reaches — 12 Bay Area, 6 California, 3
SoCal. Re-pointing them means re-shooting the imagery on lumbridgecorp.com, which
is an owner decision rather than a refactor, so this round landed the check that
*reports* when they would break instead of moving them.
`check-chapter-identity.mjs` reads those three files and prints every aim whose
label no longer matches the board it points at.
The label assertion on its own is **not** enough and the reason is in the data:
"Whole Board" is chapter 01 of both metro boards and "The Valley" is a different
valley on each of them, so a reorder that preserves labels passes all 26 and
shoots the wrong place. Identity is `(board, chapter id, position)` together.
## A board switch is measurable now: `node scripts/switch-cost.mjs`
The pause between one board and the next was the most-complained-about moment in
the product and the one moment nothing measured, because `performance-budget.mjs`
waits `warmup-ms` and throws away exactly the window the switch happens in.
Baseline at `bcac6aa`, desktop 1440x900, `--repeat 3`, medians. `covered` is
click-to-`#boot`-hidden; `blocked` is Long Tasks inside that window; `blank` is
frames that issued zero WebGL draw calls:
| direction | covered | opaque | blocked | longest | frames | blank | live |
|---|---|---|---|---|---|---|---|
| california→sf | 1,715 | 1,415 | 608 | 526 | 69 | 46 | 65% |
| socal→sf | 1,627 | 1,326 | 523 | 459 | 69 | 46 | 68% |
| california→socal | 1,109 | 809 | 400 | 312 | 45 | 22 | 64% |
| sf→socal | 1,027 | 726 | 328 | 245 | 44 | 21 | 68% |
| sf→california | 1,120 | 820 | 340 | 196 | 49 | 26 | 70% |
| socal→california | 1,061 | 761 | 319 | 205 | 47 | 24 | 70% |
Two things to read off it.
**Two thirds of every pause is not work.** 64-70% of the cover is a live,
animating page with an empty world in it, because the outgoing board is disposed
before the incoming one is built. Retaining boards converts a 1.0-1.7 s
full-screen modal into a 0.2-0.6 s freeze of a live picture and costs zero new
triangles.
**Cost belongs to the destination, not to the pair.** The two arrivals at the Bay
Area differ by 5%, the two at California by 6%, and Bay Area against California is
1.6x. Nothing is reused between boards today — `three` refcounts shader programs
per material and deletes them at zero, so disposing a board deletes its programs
and returning relinks them — and this table is what will show that changing.
**And the residual is a separate, nameable defect.** The 526 ms task on an
arrival at the Bay Area is main-thread instance placement for 83,137 buildings.
Retention does not touch it and will make it *more* visible: today it hides
behind a full-screen card with a percentage on it, which reads as busy, and
afterwards it will freeze a live picture, which reads as a hang. Chunking that
placement across frames, or moving it behind the worker, is its own piece of work
with its own payoff.
The command reports `card` and `arrival` beside the durations for exactly that
reason. `card=yes` with two thirds of the frames blank is the disposal model;
`card=no` with `blank` near zero is retention, and the same command measures
both, because arrival is detected from `#chapters` rather than from the card.
A single direction run against the in-flight retention build read
`card=no`, 1 blank frame of 60, and a 508 ms single task — which is the shape
this section predicts: the modal is gone and the hitch is not.
## The quadtree's stated blocker was the wrong one
Recorded 2026-08-23 so the next round does not lose a week re-deriving it. The
terrain quadtree has been deferred repeatedly on the grounds that the synchronous
sampler stands in its way. **It does not, and the code says so in one line.**
- `elevationAt` has exactly **one** production caller: `world.ts:545`, inside
`computeField`. Nothing in the render path samples it. Everything else reads
the cached `Field` through `elevationSampled` / `isLandSampled` /
`inParkSampled` / `groundAt`.
- `world.lattice()` — the thing that hands out flat arrays — has exactly **two**
callers, both in `terrain.ts` (282 and 404).
- Measured on this box: `elevationAt` is 1.97 µs/call on SF and 11.98 µs on
California, so a complete permanently-resident base pyramid (levels 0-2, 15
land tiles, **183 ms** on SF, covering the whole board at 370 m cells) makes
every point query synchronous by construction — descend-to-finest-resident is
integer arithmetic against today's *two* binary searches in `cellIndex`. No
caller ever needs an `await`. The base has to be complete because
`minimap.ts:407` samples a 160x160 grid across the **whole** board, including
ground the camera never looks at; level 2 satisfies it exactly.
The API a quadtree must replace is therefore one accessor and two call sites. The
real blockers, none of which had been written down:
1. **Placement drift.** Buildings, landmarks, bridges, airports, ports, markers
and road ribbons are placed **once** at scene build against `groundAt`. A
level-3 (185 m) representation sits p95 119 m / max 453 m from level 5, so if
the resident level under a placed object changes after placement it moves by
up to **1.15 scene units** on SF — against a lot of 0.42 units. Buildings
float or sink. This, not the sampler, is what the deferral was groping at.
2. **The shadow caster.** Selected at the shadow map's own texel error a quadtree
caster is 826,206 triangles against today's 53,806 — 15x. View-distance
weighted it is 1.3x at the wide pose and 2.9-4.2x at the close ones, i.e.
+100k to +174k triangles, against 183,824 of headroom on bay-area/mobile and
39 free draw calls. The caster must be bounded by the *visible* selection
rather than selected independently, because **never raise a cap**.
3. **Determinism.** `performance-budget.mjs`'s own comment says "geometry here is
deterministic". A streaming tree makes triangles a function of time and camera
path. It survives only because the harness runs `reducedMotion: "reduce"` on a
planted pose and waits 3,000 ms of warmup — and realistic selections settle in
232-830 ms — but that has to become an explicit `settled()` gate the harness
waits on, not a hope.
Two more facts worth keeping. A single statewide lattice at SF fidelity is dead —
23.7M points, ~74 s of worker, 142 MB — because `buildAxis` refines per **axis**,
so two focus regions at opposite corners refine nearly the whole board. And the
board that would demonstrate the mechanism paying for itself is **SoCal**, not
SF: 48.4% of SoCal's lattice is the per-axis cross's arms (64,677 of 133,566)
against SF's 31.0%, and SF is the one board where a single hand-drawn rectangle
is exactly right — its 52 districts union to 9.6% of the board and its one
`focusRegion` box is 9.2%.
**And today's SF board is not error-bounded, which nobody had noticed.**
Cell-centre height error against the analytic truth is p99 12.0 m / max 64.9 m in
the fine region and p99 29.1 m / **max 459.3 m** in the coarse one — 27.2 px at
the default whole-board pose, out in the bay. The board is cheap because the pack
declares 90% of it unimportant. That is a defensible trade and it is not the same
thing as being right.
+148 -4
View File
@@ -424,6 +424,58 @@
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
#blurb { margin: 0; color: var(--ink-3); font-size: 10px; line-height: 1.6; }
/* ---- Places -----------------------------------------------------------
The same row as a chapter, so the two lists cannot look like two
different kinds of thing, plus a region heading and a stand-off readout.
Capped and scrollable: twenty-four rungs is a long column on a phone,
where the panel is already a bottom sheet. */
.places {
display: flex;
flex-direction: column;
gap: 1px;
max-height: min(22rem, 42vh);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.places__region {
margin: var(--s2) 0 var(--s1);
font-size: 9px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-4);
}
.places__region:first-child { margin-top: 0; }
.place {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2);
background: none;
border: 0;
border-radius: var(--r-sm);
cursor: pointer;
text-align: left;
font: inherit;
font-size: 12px;
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
@media (hover: hover) {
.place:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
}
.place[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
.place__name { flex: 1 1 auto; min-width: 0; }
/* The ladder's own coordinate, in the row it orders. Tabular so the column
of numbers reads as a scale rather than as ragged text. */
.place__standoff {
flex: 0 0 auto;
font-size: 9px;
letter-spacing: 0.06em;
opacity: 0.5;
font-variant-numeric: tabular-nums;
}
/* The panel toggle only exists on a narrow screen, where it doubles as the
header — hence the board name in it. */
.panel-toggle {
@@ -932,6 +984,48 @@
min-height: 1.4em; }
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
/* ---- The switch pill --------------------------------------------------
Bottom centre, above the mode dock's own row on a desktop and out of the
way of the left column on a phone. Deliberately small, deliberately not
modal, and it never takes a pointer event: the whole point of it is that
the board underneath stays usable while the next one builds. */
.switching {
position: fixed;
left: 50%;
bottom: calc(var(--s6) + env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: var(--z-boot);
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2) var(--s4);
border-radius: 999px;
border: 1px solid var(--hairline);
background: var(--glass-strong);
backdrop-filter: var(--blur);
box-shadow: var(--shadow);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-2);
pointer-events: none;
white-space: nowrap;
}
.switching[hidden] { display: none; }
.switching-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber);
animation: switching-pulse 1100ms ease-in-out infinite;
}
@keyframes switching-pulse {
0%, 100% { opacity: 0.25; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.switching-dot { animation: none; opacity: 1; }
}
/* ---- The play HUD and the mode dock ----------------------------------- */
.play-hud {
position: fixed;
@@ -1213,12 +1307,18 @@
.act,
.board,
.chapter,
.place,
.help,
.detail-close,
.sheet-close {
min-height: var(--tap);
}
.chapter { align-items: center; }
.chapter,
.place { align-items: center; }
/* Twenty-four rungs at a thumb's height each is taller than the sheet.
Shorter here, not shorter everywhere: the list is the navigation on a
phone and scrolling past it to reach the door is the failure. */
.places { max-height: 38vh; }
.help,
.detail-close {
display: inline-flex;
@@ -1321,7 +1421,14 @@
<p id="clock" class="clock" aria-live="polite"></p>
</header>
<section class="panel-group">
<!-- The board strip, which is now the *studio* picker and, outside a
building, a developer's control rather than a visitor's.
Three tabs naming three modes is the single most direct way to tell
somebody they are looking at three products; the places list below
is what replaced it. Kept alive, and reachable with `?boards=1` or
godmode, because it is still the only control that addresses a board
by id. -->
<section class="panel-group" id="boards-section">
<p id="boards-title" class="group-title t-caps">Boards</p>
<nav id="cities" class="boards" aria-label="Board"></nav>
</section>
@@ -1379,10 +1486,36 @@
<p id="birds-note" class="note" hidden></p>
</section>
<section class="panel-group">
<!-- One list of places, under three region headings, ordered by how far
the camera stands back. It is the whole tour of all three boards in
one column, and the rung the camera is standing on lights up and
scrolls itself into view as you descend — so the list reads as an
altimeter rather than as a menu, and crossing from the state into
the Bay is a click on the next line down rather than a change of
mode. See `src/engine/ladder.ts`. -->
<section class="panel-group" id="places-section">
<p class="group-title t-caps">Places</p>
<nav id="places" class="places" aria-label="Places"></nav>
</section>
<!-- The per-board chapter list, unchanged and unchangeable.
Twenty-nine capture guards in `scripts/brand-assets` reach this by
`document.querySelectorAll("#chapters .chapter")[i].click()` and
assert the button's short label, and every still on
lumbridgecorp.com is aimed that way. So the ids, the order, the
short labels, `data-view` and `data-view-index` are byte-identical
to what they were, and the list stays in the document with real
buttons in it — a DOM `.click()` does not care whether an element is
displayed. Outside a building it is `hidden`, because the places
list above is the same journey said better; inside one it is the
viewpoint list and is the visible control. -->
<section class="panel-group" id="chapters-section">
<p class="group-title t-caps">Chapters</p>
<nav id="chapters" class="chapters" aria-label="Chapters"></nav>
<p id="blurb" hidden></p>
</section>
<section class="panel-group" id="blurb-section" hidden>
<p id="blurb"></p>
</section>
</div>
</div>
@@ -1485,6 +1618,17 @@
</div>
</div>
<!-- The switch pill: what a board change says now that it no longer covers
the screen. The board you are looking at stays up, live and draggable,
for the whole of the next one's build, so the only thing left to say is
that something is happening — and it says it in a corner rather than
over the top of the picture. The opaque card below is the first mount of
a session and nothing else. -->
<div class="switching" id="switching" hidden aria-live="polite">
<span class="switching-dot" aria-hidden="true"></span>
<span id="switching-step">Building…</span>
</div>
<div class="boot" id="boot">
<div class="boot-inner">
<p class="boot-mark">Tera</p>
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env node
/**
* The chapter list is a public interface. This is the check that says so.
*
* node scripts/check-chapter-identity.mjs # against dist/
* node scripts/check-chapter-identity.mjs --dist … # against another build
* node scripts/check-chapter-identity.mjs --write # re-cut the fixture (read the warning)
*
* ## What it protects, and why it had to exist before the ladder work
*
* Twenty-six index-aimed guards outside this file point a camera at a chapter by
* its **position in a list** and then assert its short label. Counted from the
* source rather than taken from a plan — comment lines that *quote* the pattern
* are excluded, which is where the "29" in the round's brief came from:
*
* - `scripts/brand-assets/shots.mjs` — 21, every one of them a still on
* lumbridgecorp.com right now.
* - `scripts/brand-assets/films.mjs` — 4.
* - `scripts/brand-assets/capture.mjs` — 1: the Open Graph card, which is what
* every link to the site renders as, and the most-seen frame in the product.
*
* Twenty-one of them aim at a board a `?city=` can reach — 12 at the Bay Area,
* 6 at California, 3 at SoCal — and this check covers those. The other five are
* office shots aiming at a studio's own view list, which no `?city=` selects.
*
* That pattern was chosen for a good reason and it is written up at
* `capture.mjs:70`: a card once shipped for a fortnight showing a chase camera
* on a freeway under the headline "Cities from above", because `keyboard.press("2")`
* is an unguarded index into pack data and the default board had changed under it.
* The label assertion was the fix. It is a *good* fix and it is not sufficient,
* for two reasons this repo can now demonstrate rather than argue:
*
* 1. **Labels are not unique across boards.** "Whole Board" is chapter 01 of the
* Bay Area *and* chapter 01 of SoCal; "The Valley" is Silicon Valley on one
* board and the San Fernando Valley on the other. A reorder that preserves
* labels passes every one of those twenty-six guards and shoots the wrong place.
* 2. **A guard on the chapter you clicked says nothing about the one you did
* not.** Several shots take no chapter at all and inherit whatever board the
* URL opened on, which is a fallback rather than an assertion.
*
* So this file snapshots the *identity* of every chapter on every board — its
* `data-view`, its position, its printed number and its short label — against a
* checked-in fixture, and fails on any drift. It is deliberately not a test of
* whether the chapters are *good*; it is a test of whether they are the same
* ones the marketing imagery was aimed at.
*
* ## It reads the DOM, not the packs
*
* A unit test over `src/cities/*.ts` would be cheaper and would miss the thing
* that actually breaks: the harnesses read `#chapters .chapter`, and what lands
* there is `chromeState.ts`'s `views` array after `main.ts` has decided which
* board is up. Number is taken from the pack *or* the ordinal, and the
* `data-view-index` written into the button is the index into that rendered
* list. Only the DOM knows all of it at once. `src/test/chapterIdentity.test.ts`
* carries the fast pack-level half of the same contract, including the negative
* case, so that a reorder fails in `npm test` in a second rather than here in a
* minute.
*
* ## --write
*
* Regenerating the fixture is how you record a deliberate change, and it is not
* a way to make this go green. Every drift it reports is a frame on
* lumbridgecorp.com that is about to become a picture of somewhere else, and the
* 26 aims above have to be re-pointed in the same commit — which means
* re-shooting the imagery, which is an owner decision, not a refactor.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const DEFAULT_FIXTURE = join(ROOT, "scripts", "fixtures", "chapter-identity.json");
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
};
const args = process.argv.slice(2);
const option = (name, fallback) => {
const at = args.indexOf(`--${name}`);
return at < 0 ? fallback : args[at + 1];
};
const has = (name) => args.includes(`--${name}`);
const DIST = resolve(option("dist", join(ROOT, "dist")));
const WRITE = has("write");
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
/*
* `--fixture` exists so the failure can be watched.
*
* A guard nobody has seen fail is not a guard, and the only other way to see
* this one fail is to break the packs — on a shared tree, with two other
* workstreams in the same files. Point it at a deliberately scrambled copy
* instead:
*
* jq '.boards.sf.chapters |= (.[0:2] + [.[3], .[2]] + .[4:])' \
* scripts/fixtures/chapter-identity.json > /tmp/scrambled.json
* node scripts/check-chapter-identity.mjs --fixture /tmp/scrambled.json # must FAIL
*/
const FIXTURE = resolve(option("fixture", DEFAULT_FIXTURE));
/**
* The boards, by the `?city=` value that selects them.
*
* This list is itself part of the contract: `?city=` is the coordinate every
* capture harness and every deep link uses, and `main.ts` falls back to the
* first board rather than failing on an unknown id — so a board that quietly
* lost its id would not throw, it would silently serve California to every shot
* aimed at SoCal. The snapshot records which board answered, so that failure
* shows up as a mismatch instead of as a fortnight of wrong pictures.
*/
const CITY_PARAMS = ["california", "sf", "socal"];
async function serve() {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://local.invalid");
const json = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/api/v1/health") {
return json(200, {
auth: { mode: "jwt", entryUrl: "/login.html" },
sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
regions: [],
});
}
if (url.pathname === "/api/v1/session") {
return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false });
}
if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
const target = resolve(DIST, `.${relative}`);
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
try {
const body = await readFile(target);
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
res.end(body);
return;
} catch {
/* fall through to the SPA entry */
}
}
res.writeHead(404).end("not found");
});
await new Promise((ok, fail) => {
server.once("error", fail);
server.listen(0, "127.0.0.1", ok);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("no TCP port");
return { server, port: address.port };
}
async function launch() {
const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
const common = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];
const ladder = [
["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]],
];
let last;
for (const [backend, flags] of ladder) {
try {
const browser = await chromium.launch({ channel: "chrome", args: [...common, resolver, ...flags] });
return { browser, backend };
} catch (error) {
last = error;
}
}
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}
/** Everything the DOM knows about the chapter strip on one board. */
async function snapshotBoard(browser, port, cityParam) {
const page = await browser.newPage();
try {
await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${cityParam}`, {
waitUntil: "networkidle",
timeout: READY_TIMEOUT_MS,
});
await page.waitForFunction(
() =>
document.getElementById("boot")?.hidden === true &&
document.querySelectorAll("#chapters .chapter").length > 0,
null,
{ timeout: READY_TIMEOUT_MS },
);
return await page.evaluate(() => {
const text = (node) => (node?.textContent ?? "").trim();
const chapters = [...document.querySelectorAll("#chapters .chapter")].map((button, index) => {
const spans = [...button.querySelectorAll("span")];
return {
index,
dataView: button.getAttribute("data-view"),
dataViewIndex: button.getAttribute("data-view-index"),
number: text(spans[0]),
shortLabel: text(spans[spans.length - 1]),
};
});
const boards = [...document.querySelectorAll("[data-board]")].map((button) => ({
id: button.getAttribute("data-board"),
label: text(button.querySelector(".board__name")) || text(button),
active: button.getAttribute("aria-pressed") === "true",
}));
return { chapters, boards, activeBoard: boards.find((b) => b.active)?.id ?? null };
});
} finally {
await page.close();
}
}
// ---- The aims this exists to protect --------------------------------------
//
// Read out of the capture harnesses rather than duplicated here, because a copy
// would rot in exactly the way this file is trying to stop. Nothing under
// `scripts/brand-assets/` is written to — this reads their source and reports
// which of their `chapter: N` / `expect: "…"` pairs no longer agree with the
// board they aim at.
async function readAims() {
const files = [
["scripts/brand-assets/shots.mjs", "shot"],
["scripts/brand-assets/films.mjs", "film"],
["scripts/brand-assets/capture.mjs", "og-card"],
];
const aims = [];
for (const [relative, kind] of files) {
let source;
try {
source = await readFile(join(ROOT, relative), "utf8");
} catch {
continue;
}
const lines = source.split("\n");
let id = null;
let door = null;
let city = null;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (/^\s*\*/.test(line)) continue; // a comment quoting one of these is not one
const idAt = /^\s*id:\s*"([^"]+)"/.exec(line);
if (idAt) {
id = idAt[1];
door = null;
city = null;
}
const doorAt = /^\s*door:\s*"([^"]+)"/.exec(line);
if (doorAt) door = doorAt[1];
const cityAt = /^\s*city:\s*"([^"]+)"/.exec(line);
if (cityAt) city = cityAt[1];
// `capture.mjs` does not carry a shot record — it calls `shootApp` with a
// URL. The Open Graph card is the single most-seen frame in the product
// (it is what every link to lumbridgecorp.com renders as), so its aim is
// read out of the URL rather than left uncovered.
const urlAt = /"https?:\/\/[^"]*[?&]city=([a-z-]+)[^"]*"/.exec(line);
if (urlAt) {
city = urlAt[1];
door = null;
const nameAt = /"([\w-]+\.png)"/.exec(line);
if (nameAt) id = nameAt[1];
}
const chapterAt = /^\s*chapter:\s*(\d+)/.exec(line);
if (!chapterAt) continue;
let expect = null;
for (let j = i + 1; j < Math.min(lines.length, i + 6); j += 1) {
const expectAt = /^\s*expect:\s*"([^"]+)"/.exec(lines[j]);
if (expectAt) {
expect = expectAt[1];
break;
}
}
aims.push({
file: relative,
line: i + 1,
kind,
id: id ?? "(unnamed)",
// An office shot has no `city:` — it aims at the studio's own view list,
// which this check does not cover and which no `?city=` can reach.
city: door === "office" ? null : city,
chapter: Number(chapterAt[1]),
expect,
});
}
}
return aims;
}
// ---- Run ------------------------------------------------------------------
const { server, port } = await serve();
const { browser, backend } = await launch();
let observed;
try {
observed = {};
for (const cityParam of CITY_PARAMS) {
observed[cityParam] = await snapshotBoard(browser, port, cityParam);
}
} finally {
await browser.close();
server.close();
}
const snapshot = {
takenAt: "bcac6aa",
note:
"The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as " +
"part of a deliberate, owner-approved re-shoot: 26 index-aimed guards in " +
"scripts/brand-assets/** point at these positions, 21 of them at a board a ?city= " +
"reaches, and every one of them is a frame on lumbridgecorp.com.",
boards: Object.fromEntries(
CITY_PARAMS.map((cityParam) => [
cityParam,
{
activeBoard: observed[cityParam].activeBoard,
boardTabs: observed[cityParam].boards.map((board) => ({ id: board.id, label: board.label })),
chapters: observed[cityParam].chapters,
},
]),
),
};
if (WRITE) {
await writeFile(FIXTURE, `${JSON.stringify(snapshot, null, 2)}\n`);
console.log(`check-chapter-identity: wrote ${FIXTURE}`);
console.log(
"check-chapter-identity: a re-cut fixture means the 26 aims in scripts/brand-assets/**\n" +
" are now pointed at whatever this build happens to render. Re-point them and re-shoot.",
);
process.exit(0);
}
let expected;
try {
expected = JSON.parse(await readFile(FIXTURE, "utf8"));
} catch (error) {
console.error(`check-chapter-identity: cannot read ${FIXTURE}${error}`);
console.error(" Take one with --write, from a build you have looked at.");
process.exit(1);
}
/*
* TWO SEVERITIES, AND THE LINE BETWEEN THEM IS "DOES A CAMERA AIM AT IT".
*
* `failures` is the chapter strip: `data-view`, position, printed number, short
* label, and how many there are. Every one of those is something a capture
* harness resolves a frame through, so drift there is a picture of somewhere
* else and stops the run.
*
* `notes` is the board chrome — the tab strip and which tab is pressed. That was
* a hard failure in the first version of this file and it fired within the hour,
* on the continuity work legitimately removing the three-tab strip. Nothing aims
* a camera at a tab: `?city=` selects the board and the chapter list is what
* proves which board answered, because the ids are unique per pack. So chrome
* drift is printed and does not fail.
*
* The silent-fallback case is still caught, and caught harder: `?city=sf`
* degrading to California renders California's six chapters, which is a
* count-and-id failure on every row.
*/
const failures = [];
const notes = [];
const field = (where, want, got, soft = false) => {
if (want === got) return;
(soft ? notes : failures).push(
`${where}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`,
);
};
for (const cityParam of CITY_PARAMS) {
const want = expected.boards?.[cityParam];
const got = snapshot.boards[cityParam];
if (!want) {
failures.push(`?city=${cityParam}: not in the fixture — a board appeared`);
continue;
}
field(`?city=${cityParam} pressed tab`, want.activeBoard, got.activeBoard, true);
field(
`?city=${cityParam} board tabs`,
want.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
got.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
true,
);
field(`?city=${cityParam} chapter count`, want.chapters.length, got.chapters.length);
const count = Math.min(want.chapters.length, got.chapters.length);
for (let i = 0; i < count; i += 1) {
for (const key of ["dataView", "dataViewIndex", "number", "shortLabel", "index"]) {
field(`?city=${cityParam} chapter[${i}].${key}`, want.chapters[i][key], got.chapters[i][key]);
}
}
}
for (const cityParam of Object.keys(expected.boards ?? {})) {
if (!CITY_PARAMS.includes(cityParam)) failures.push(`?city=${cityParam}: gone from this build`);
}
// Whether or not the fixture drifted, say which aims are currently right — a
// green fixture with a misaimed shot is exactly the state this repo was in at
// bcac6aa, and it was found with a photograph rather than with a check.
const aims = await readAims();
const misaimed = [];
for (const aim of aims) {
if (aim.city === null) continue;
const board = snapshot.boards[aim.city];
if (!board) {
misaimed.push(`${aim.file}:${aim.line} ${aim.id} — city:"${aim.city}" is not a board`);
continue;
}
const chapter = board.chapters[aim.chapter];
if (!chapter) {
misaimed.push(
`${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} does not exist on ${aim.city}`,
);
continue;
}
if (aim.expect !== null && chapter.shortLabel !== aim.expect) {
misaimed.push(
`${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} on ${aim.city} is ` +
`"${chapter.shortLabel}" (${chapter.dataView}), the aim says "${aim.expect}"`,
);
}
}
const covered = aims.filter((aim) => aim.city !== null).length;
console.log(`check-chapter-identity — dist ${DIST} (${backend})`);
for (const cityParam of CITY_PARAMS) {
const board = snapshot.boards[cityParam];
console.log(
` ?city=${cityParam.padEnd(11)} ${String(board.chapters.length).padStart(2)} chapters ` +
board.chapters.map((chapter) => chapter.dataView).join(", "),
);
}
console.log(
` ${aims.length} index-aimed guards read from the capture harnesses; ` +
`${covered} of them aim at a board this check covers.`,
);
if (notes.length > 0) {
console.log("");
console.log("NOTE — the board chrome moved. Nothing aims a camera at it, so this is not a");
console.log("failure; re-cut the fixture with --write when the change is settled:");
for (const line of notes) console.log(` ${line}`);
}
if (misaimed.length > 0) {
console.log("");
console.log("MISAIMED — these guards would shoot a different frame than their comment claims:");
for (const line of misaimed) console.log(` ${line}`);
}
if (failures.length > 0) {
console.log("");
console.log(`FAIL — the chapter strip moved under ${failures.length} field(s):`);
for (const line of failures) console.log(` ${line}`);
console.log("");
console.log(
"Every one of these is a capture aim that now points somewhere else. Re-point the\n" +
"guards in scripts/brand-assets/**, re-shoot the imagery, and only then --write.",
);
}
const bad = failures.length + misaimed.length;
if (bad === 0) console.log("check-chapter-identity: OK");
process.exit(bad === 0 ? 0 : 1);
+245
View File
@@ -0,0 +1,245 @@
{
"takenAt": "bcac6aa",
"note": "The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as part of a deliberate, owner-approved re-shoot: 30 index-aimed guards in scripts/brand-assets/** point at these positions and every one of them is a frame on lumbridgecorp.com.",
"boards": {
"california": {
"activeBoard": "california",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "california-overview",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "State"
},
{
"index": 1,
"dataView": "la-sf-us-101",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "101"
},
{
"index": 2,
"dataView": "la-sf-i-5",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "I-5"
},
{
"index": 3,
"dataView": "los-angeles",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "LA"
},
{
"index": 4,
"dataView": "san-francisco",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "SF"
},
{
"index": 5,
"dataView": "shasta-cascades",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "North"
}
]
},
"sf": {
"activeBoard": "sf",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "all",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "Whole Board"
},
{
"index": 1,
"dataView": "hayes-valley",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "Hayes Valley"
},
{
"index": 2,
"dataView": "soma",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "SoMa"
},
{
"index": 3,
"dataView": "fidi",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "FiDi"
},
{
"index": 4,
"dataView": "mission-bay",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "Mission Bay"
},
{
"index": 5,
"dataView": "mission",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "Mission"
},
{
"index": 6,
"dataView": "peninsula",
"dataViewIndex": "6",
"number": "07",
"shortLabel": "Peninsula"
},
{
"index": 7,
"dataView": "silicon-valley",
"dataViewIndex": "7",
"number": "08",
"shortLabel": "The Valley"
},
{
"index": 8,
"dataView": "south-bay",
"dataViewIndex": "8",
"number": "09",
"shortLabel": "San Jose"
},
{
"index": 9,
"dataView": "east-bay",
"dataViewIndex": "9",
"number": "10",
"shortLabel": "East Bay"
},
{
"index": 10,
"dataView": "north-bay",
"dataViewIndex": "10",
"number": "11",
"shortLabel": "Marin"
},
{
"index": 11,
"dataView": "bay-area",
"dataViewIndex": "11",
"number": "12",
"shortLabel": "The Bay"
}
]
},
"socal": {
"activeBoard": "socal",
"boardTabs": [
{
"id": "california",
"label": "California"
},
{
"id": "sf",
"label": "Bay Area"
},
{
"id": "socal",
"label": "SoCal"
}
],
"chapters": [
{
"index": 0,
"dataView": "all",
"dataViewIndex": "0",
"number": "01",
"shortLabel": "Whole Board"
},
{
"index": 1,
"dataView": "dtla",
"dataViewIndex": "1",
"number": "02",
"shortLabel": "DTLA"
},
{
"index": 2,
"dataView": "westside",
"dataViewIndex": "2",
"number": "03",
"shortLabel": "Westside"
},
{
"index": 3,
"dataView": "hollywood",
"dataViewIndex": "3",
"number": "04",
"shortLabel": "Hollywood"
},
{
"index": 4,
"dataView": "valley",
"dataViewIndex": "4",
"number": "05",
"shortLabel": "The Valley"
},
{
"index": 5,
"dataView": "harbour",
"dataViewIndex": "5",
"number": "06",
"shortLabel": "Harbour"
},
{
"index": 6,
"dataView": "orange-county",
"dataViewIndex": "6",
"number": "07",
"shortLabel": "Orange County"
},
{
"index": 7,
"dataView": "inland-empire",
"dataViewIndex": "7",
"number": "08",
"shortLabel": "Riverside"
}
]
}
}
}
+123 -7
View File
@@ -9,6 +9,17 @@
*
* node scripts/look.mjs <name> [--url <path-and-query>] [--phone] [--at <iso>]
* [--wait <ms>] [--click <text>] [--api <origin>]
* [--chapter <data-view>] [--expect <short label>]
*
* `--chapter` aims at a chapter by its **identity** — the `data-view` attribute
* the button carries, which is the chapter's id in the city pack — and not by
* its position in the list or by the text printed on it. Both of the other two
* have already produced a photograph of somewhere else in this repo: an index
* because a pack was reordered under it, and button text because two of
* California's six chapters are *doors* whose labels ("LA", "SF") match a
* click and then leave the board entirely. `--expect` asserts the printed short
* label as a second, independent check on the same button, which is what
* `scripts/brand-assets/shots.mjs` does and is worth keeping.
*
* Writes /tmp/tera-look/<name>.png. `--at` pins the clock, because the sun's
* position is computed from the real one and a shot taken at 03:00 tells you
@@ -51,12 +62,45 @@ const PRESETS = {
"sf-night": { url: "/?city=sf", at: "2026-08-23T04:35:00Z" },
"sky-night": { url: "/?city=california", at: "2026-08-23T04:35:00Z" },
"socal-night": { url: "/?city=socal", at: "2026-08-23T04:35:00Z" },
// The aeroplane glyph, at the four stand-offs its clamp has to serve: a whole
// board, two chapter closeups on that board, and a detailed metro.
"glyph-board": { url: "/?city=california" },
"glyph-la": { url: "/?city=california", click: "^LA$" },
"glyph-sf": { url: "/?city=california", click: "^SF$" },
"glyph-bay": { url: "/?city=sf", click: "The Bay" },
/*
* The aeroplane glyph, at the four stand-offs its clamp has to serve.
*
* TWO OF THESE WERE PHOTOGRAPHS OF SOMEWHERE ELSE, AND THE SET AS A WHOLE WAS
* DESCRIBED WRONG. The old comment read "a whole board, two chapter closeups
* on that board, and a detailed metro", and `glyph-la` / `glyph-sf` were
* `click: "^LA$"` / `click: "^SF$"` — the California board's chapters 04 and
* 05. Neither of those is a camera pose. Both are matched against
* `CALIFORNIA_DESTINATIONS` in `main.ts` and call `switchCity()` rather than
* `flyTo()`, so clicking them left California entirely: `glyph-la` returned a
* SoCal frame and `glyph-sf` a Bay Area one, byte-comparable to `hero-socal`
* and `hero-sf`. Two of the four stand-offs the set claimed to cover were
* duplicates of two others.
*
* Photographing the fix turned up the rest of it. California's six chapters
* are one whole-board pose, TWO DRIVES (`la-sf-us-101` and `la-sf-i-5` call
* `requestControlMode("drive")` at `main.ts:4493`, so clicking either lands a
* chase camera on a freeway, not a stand-off), two doors, and the north. The
* state board therefore has exactly TWO aerial poses on it — `State` and
* `North` — and "two chapter closeups on that board" was never available.
*
* So the clamp's four stand-offs are spread across the boards that have them,
* which is a better ladder anyway: three orders of magnitude of stand-off,
* every rung an aerial camera, every rung aimed by `data-view`.
*/
"glyph-board": { url: "/?city=california", chapter: "california-overview", expect: "State" },
"glyph-north": { url: "/?city=california", chapter: "shasta-cascades", expect: "North" },
"glyph-bay": { url: "/?city=sf", chapter: "bay-area", expect: "The Bay" },
"glyph-fidi": { url: "/?city=sf", chapter: "fidi", expect: "FiDi" },
/*
* The corridor, which is a drive and is photographed as one.
*
* These are what `glyph-la` and `glyph-sf` now resolve to: the two California
* chapters that stay on the California board. They are named for what they
* are rather than for the glyph, because the frame they produce is a chase
* camera behind the EV and no aeroplane is in it.
*/
"corridor-101": { url: "/?city=california", chapter: "la-sf-us-101", expect: "101" },
"corridor-i5": { url: "/?city=california", chapter: "la-sf-i-5", expect: "I-5" },
// The opening move, landed. `--reduced` collapses it to a cut, which is what
// makes an arrival frame reproducible.
"hero-california": { url: "/?city=california" },
@@ -64,7 +108,30 @@ const PRESETS = {
"hero-sf": { url: "/?city=sf" },
};
const preset = PRESETS[name] ?? {};
/**
* Old names that still have to answer, pointed at what they always meant.
*
* Deleting them would be tidier and worse: `glyph-la` appears in the write-ups
* that commissioned these frames, and a name that 404s sends somebody back to
* `--click "^LA$"`, which is the exact command that produced the wrong picture.
* An alias that says out loud what it resolved to cannot do that.
*/
const ALIASES = {
"glyph-la": "corridor-101",
"glyph-sf": "corridor-i5",
};
const resolved = ALIASES[name] ?? name;
if (resolved !== name) {
console.log(
`look: "${name}" is an alias for "${resolved}". The California board's LA and SF chapters ` +
`are doors into the metro boards, not camera poses, so a preset named for them used to ` +
`photograph the other board. The two chapters that stay on California are the corridor ` +
`legs, and both open in DRIVE mode — for the aeroplane glyph's aerial stand-offs use ` +
`glyph-board, glyph-north, glyph-bay, glyph-fidi.`,
);
}
const preset = PRESETS[resolved] ?? {};
const flag = (f, d) => {
const i = args.indexOf(f);
if (i !== -1) return args[i + 1];
@@ -249,6 +316,55 @@ try {
/* already dismissed, or not shown */
}
/**
* The chapter, by identity, before any `--click`.
*
* `flyTo` eases over about two seconds of scene time, so the wait after it is
* the same 8 s a click gets. The label check is a hard failure rather than a
* log line: a preset that silently photographs the wrong chapter is worse than
* one that refuses, because the picture still looks deliberate.
*/
const chapter = flag("--chapter", null);
if (chapter !== null && chapter !== "") {
const expect = flag("--expect", null);
const found = await page.evaluate((id) => {
const button = document.querySelector(`#chapters .chapter[data-view="${id}"]`);
if (!(button instanceof HTMLElement)) {
return {
ok: false,
available: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
node.getAttribute("data-view"),
),
};
}
const spans = [...button.querySelectorAll("span")];
const label = (spans[spans.length - 1]?.textContent ?? "").trim();
const index = button.getAttribute("data-view-index");
button.click();
return { ok: true, label, index };
}, chapter);
if (!found.ok) {
console.error(
`look: no chapter with data-view="${chapter}" on this board — it has ` +
`${JSON.stringify(found.available)}. Not photographing a frame nobody asked for.`,
);
await browser.close();
shutdown();
process.exit(1);
}
if (expect !== null && found.label !== expect) {
console.error(
`look: chapter "${chapter}" prints "${found.label}", the preset expects "${expect}" — ` +
`a pack was re-labelled, so this shot would be captioned wrong.`,
);
await browser.close();
shutdown();
process.exit(1);
}
console.log(`look: chapter ${chapter} — "${found.label}" at position ${found.index}`);
await page.waitForTimeout(8000);
}
/**
* `--click` may be given more than once, and they run in order.
*
+338
View File
@@ -0,0 +1,338 @@
/**
* What one statewide board would actually cost, measured rather than argued.
*
* node scripts/merge-feasibility.mjs [--no-still] [--out <dir>]
*
* **Nothing here is wired into the product.** No board id, no route, no flag
* that reaches a deploy. It builds the merged pack in node, counts what comes
* out, and takes one photograph — and the photograph is the deliverable, because
* the decision it supports is not an engineering one.
*
* ## The question
*
* "Why are there still three boards" has an answer that costs nothing —
* `cities/reconcile.ts`, four data rules — and an answer that costs a
* rebuild: one pack, one lattice, one lot size, from Yreka to the border.
* The second one is priced here.
*
* A lot is `LOT = 0.42` **scene units** (`blocks.ts`), so how much ground a
* building stands on is decided entirely by the board's metres-per-unit: 40 m on
* the Bay Area, 164 m on Southern California, 806 m on the state. A merged board
* therefore has to pick one, and the pick is the whole decision:
*
* - at Bay Area density the state is **dead** — the anonymous city alone is
* tens of millions of triangles against a 2.6M desktop cap;
* - at Southern California density it **fits**, and San Francisco is drawn the
* way Southern California is drawn now: 164 m lots instead of 40 m.
*
* The second is a real option and it costs the product its best-looking board.
* That trade is the owner's to make against a picture, which is why this ends
* with `--out/merge-sf-40m-vs-164m.png` rather than with a recommendation.
*
* ## What the still actually varies
*
* `LOT`, not `latScale`. Rescaling San Francisco's pack would move its camera,
* its terrain lattice and every chapter pose at once, and the resulting pair
* would differ in a dozen ways of which lot size was one. Changing `LOT` to
* `0.42 × (390.6 / 94.34) = 1.7392` in a **throwaway tree under /tmp** puts
* exactly 164 m of ground under a building and leaves the frame otherwise
* identical — which is the comparison that was asked for. `blocks.ts` in this
* repo is not touched, and `git status` after a run says so.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
import { createBlocks } from "../src/engine/blocks.ts";
import { createTerrain } from "../src/engine/terrain.ts";
import { World } from "../src/engine/world.ts";
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const OUT = flag("--out", "/tmp/tera-look/merge");
const STILL = !args.includes("--no-still");
mkdirSync(OUT, { recursive: true });
const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const BUDGETS = JSON.parse(readFileSync(`${REPO}/scripts/performance-budgets.json`, "utf8"));
// ---- The merged pack --------------------------------------------------------
/** Does a lat/lng fall inside a board's rectangle? */
const inside = (city, lat, lng) =>
lat >= city.bounds.minLat && lat <= city.bounds.maxLat &&
lng >= city.bounds.minLng && lng <= city.bounds.maxLng;
const centroid = (polygon) => {
let lat = 0;
let lng = 0;
for (const [a, b] of polygon) {
lat += a;
lng += b;
}
return [lat / polygon.length, lng / polygon.length];
};
/**
* One California, at whatever density the caller asks for.
*
* The two metro rectangles do not intersect and California contains both, so
* merging the districts is a union with one subtraction: a state district whose
* centre falls inside a metro board is dropped, because the metro pack draws
* that ground in far more detail. That is 116 authored districts before the
* subtraction — which is the number that kills a per-axis focus lattice, since
* two focus regions at opposite corners refine nearly the whole board.
*/
function merged(metresPerUnit, dropCovered = true) {
const latScale = 111_320 / metresPerUnit;
const metros = [SF, SOCAL];
const covered = (polygon) => {
const [lat, lng] = centroid(polygon);
return metros.some((city) => inside(city, lat, lng));
};
const districts = [
...CALIFORNIA.districts.filter((d) => !dropCovered || !covered(d.polygon)),
...SF.districts,
...SOCAL.districts,
];
return {
...CALIFORNIA,
id: "california-merged",
name: "California",
latScale,
// California's own lattice, unchanged: it is the only ground cell that
// covers 989,000 km² at all. Southern California's coarse cell over the
// state is 1.6M lattice points and 21 s of single-threaded field.
landmasses: [...CALIFORNIA.landmasses, ...SF.landmasses, ...SOCAL.landmasses],
inlandWater: [...CALIFORNIA.inlandWater, ...SF.inlandWater, ...SOCAL.inlandWater],
parks: [...CALIFORNIA.parks, ...SF.parks, ...SOCAL.parks],
hills: [...CALIFORNIA.hills, ...SF.hills, ...SOCAL.hills],
districts,
roads: [...CALIFORNIA.roads, ...SF.roads, ...SOCAL.roads],
landmarks: [...CALIFORNIA.landmarks, ...SF.landmarks, ...SOCAL.landmarks],
bridges: [...SF.bridges, ...SOCAL.bridges],
airports: [...(SF.airports ?? []), ...(SOCAL.airports ?? [])],
ports: [...(SF.ports ?? []), ...(SOCAL.ports ?? [])],
reconciled: true,
};
}
const triangles = (mesh) => {
const geometry = mesh.geometry;
const perInstance = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3;
return perInstance * (mesh.isInstancedMesh ? mesh.count : 1);
};
function measure(label, metresPerUnit, dropCovered = true) {
const city = merged(metresPerUnit, dropCovered);
const world = new World(city);
// `createBlocks` samples the land and park masks, so the field is built here,
// lazily, on this thread — which is also where the placement time below is
// measured from, and is why the field build is outside the timer.
world.lattice();
const t0 = performance.now();
const blocks = createBlocks(world);
const placementMs = performance.now() - t0;
const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale;
const lng = (city.bounds.maxLng - city.bounds.minLng) * world.lngScale;
return {
label,
metresPerUnit,
lotMetres: 0.42 * metresPerUnit,
extent: [lng, lat],
districts: city.districts.length,
instances: blocks.count,
triangles: triangles(blocks),
placementMs,
world,
};
}
console.log("=== the merged pack, built and counted ===\n");
console.log("A lot is 0.42 scene units, so lot size in metres is the board's scale and nothing else.");
console.log("These are the ANONYMOUS CITY only — `createBlocks`, one instanced mesh — which is the");
console.log("layer that scales with density and the layer that decides the answer.\n");
const socalDensity = measure("SoCal density", 111_320 / 285);
const bayDensity = measure("Bay Area density", 111_320 / 1180);
/**
* The same two boards without the subtraction, because the subtraction is a
* judgement and the reader should be able to see what it is worth.
*
* Keeping all 116 authored districts means the state pack's coarse Bay Area and
* Los Angeles polygons place a second, 164 m city on top of the metro packs'
* own. It is the wrong merge — but it is the honest upper bound, and quoting a
* number without saying which of the two it is has already produced one 1.8x
* disagreement in this round's own brief.
*/
const socalDensityAll = measure("SoCal density, all 116 districts", 111_320 / 285, false);
const bayDensityAll = measure("Bay Area density, all 116 districts", 111_320 / 1180, false);
const cap = BUDGETS.scenes["bay-area"].desktop.maxTriangles;
for (const row of [socalDensity, socalDensityAll, bayDensity, bayDensityAll]) {
console.log(`${row.label}`);
console.log(` metres per unit ${row.metresPerUnit.toFixed(2)} lot ${row.lotMetres.toFixed(0)} m`);
console.log(` board extent ${row.extent[0].toFixed(0)} x ${row.extent[1].toFixed(0)} units`);
console.log(` districts ${row.districts}`);
console.log(` instances ${row.instances.toLocaleString("en-US")}`);
console.log(` triangles ${row.triangles.toLocaleString("en-US")}` +
` (${(row.triangles / cap).toFixed(2)}x the bay-area desktop cap of ${cap.toLocaleString("en-US")})`);
console.log(` placement ${row.placementMs.toFixed(0)} ms on the main thread (NOISY — see below)\n`);
}
/**
* Read the counts, not the clock.
*
* Instances and triangles are a function of the pack and the seeded RNG and come
* back identical to the digit on every run. The placement millisecond does not:
* five runs of the *same* build on this box gave 323, 554, 617, 909 and 1,989 ms
* for the same 93,253 lots — a factor of six, on a box whose GPU never leaves
* 500 MHz of a possible 2,725 and which usually has two other agents on it. The
* brief's 432 ms is inside that spread and so is almost anything else. Quote the
* counts; treat the milliseconds as an order of magnitude.
*/
console.log("Placement time on this box spans ~320-2,000 ms for the same 93,253 lots across runs.");
console.log("The instance and triangle counts are deterministic to the digit. Trust those.\n");
// The terrain the merged board would carry, on California's own lattice. Held
// back until after the block counts because it is the cheap half and saying so
// in the wrong order invites the wrong conclusion.
{
const t0 = performance.now();
const terrain = createTerrain(socalDensity.world);
const ms = performance.now() - t0;
let tris = 0;
terrain.traverse((node) => {
if (node.isMesh) tris += triangles(node);
});
console.log(`terrain, on California's 3,473 m cell: ${tris.toLocaleString("en-US")} triangles, ${ms.toFixed(0)} ms`);
console.log(`whole merged board at SoCal density: ${(tris + socalDensity.triangles).toLocaleString("en-US")} triangles\n`);
}
console.log("Today, for comparison — measured by scripts/performance-budget.mjs at bcac6aa:");
console.log(" california 375,351 tri / 374 draws socal 1,429,993 / 218 bay-area 2,264,956 / 209\n");
// ---- The picture ------------------------------------------------------------
if (!STILL) {
console.log("merge-feasibility: --no-still, stopping before the photograph");
process.exit(0);
}
/**
* Two builds of the same commit, differing in one constant.
*
* `git archive` rather than a copy of the working tree: the point of the
* picture is what a lot size does, and a working tree with three agents in it
* is not a controlled variable.
*/
const SCRATCH = `${OUT}/scratch`;
const COARSE_LOT = (0.42 * (111_320 / 285)) / (111_320 / 1180);
const trees = [
{ name: "sf-40m", lot: null },
{ name: "sf-164m", lot: COARSE_LOT },
];
for (const tree of trees) {
const root = `${SCRATCH}/${tree.name}`;
rmSync(root, { recursive: true, force: true });
mkdirSync(root, { recursive: true });
execFileSync("bash", ["-c", `cd ${REPO} && git archive HEAD | tar -x -C ${root}`]);
execFileSync("ln", ["-sfn", `${REPO}/node_modules`, `${root}/node_modules`]);
if (tree.lot !== null) {
const path = `${root}/src/engine/blocks.ts`;
const source = readFileSync(path, "utf8");
const next = source.replace(
/^const LOT = 0\.42;.*$/m,
`const LOT = ${tree.lot.toFixed(6)}; // 164 m at SF's scale — merge-feasibility.mjs, throwaway tree`,
);
if (next === source) throw new Error("merge-feasibility: could not find LOT in blocks.ts");
writeFileSync(path, next);
}
execFileSync("npx", ["vite", "build", "--outDir", `${OUT}/dist-${tree.name}`, "--emptyOutDir"], {
cwd: root,
stdio: "ignore",
});
console.log(`merge-feasibility: built ${tree.name}${tree.lot === null ? "" : ` (LOT ${tree.lot.toFixed(4)})`}`);
}
const { chromium } = await import("playwright");
const { spawn } = await import("node:child_process");
const { createServer } = await import("node:net");
async function shoot(dist, out) {
const port = await new Promise((resolve, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const { port: p } = probe.address();
probe.close(() => resolve(p));
});
});
const server = spawn(`${REPO}/node_modules/.bin/vite`,
["preview", "--outDir", dist, "--port", String(port), "--strictPort"],
{ detached: true, stdio: ["ignore", "pipe", "pipe"], cwd: REPO });
await new Promise((resolve) => {
const settle = setTimeout(resolve, 30000);
const read = (chunk) => {
if (/http:\/\/(?:localhost|127\.0\.0\.1):\d+/.test(String(chunk))) {
clearTimeout(settle);
resolve();
}
};
server.stdout.on("data", read);
server.stderr.on("data", read);
});
const browser = await chromium.launch({
channel: "chrome",
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const context = await browser.newContext({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 1,
timezoneId: "America/Los_Angeles",
reducedMotion: "reduce",
});
await context.clock.setFixedTime(new Date("2026-08-21T20:00:00Z"));
const page = await context.newPage();
await page.goto(`http://localhost:${port}/?city=sf`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(11000);
try {
await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
await page.waitForTimeout(1200);
} catch {
/* not shown */
}
// Downtown, by identity rather than by position in the list.
await page.locator('.chapter[data-view="fidi"]').first().click({ timeout: 5000 });
await page.waitForTimeout(7000);
await page.screenshot({ path: out, clip: { x: 340, y: 0, width: 860, height: 900 } });
await browser.close();
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* already gone */
}
}
const halves = [];
for (const tree of trees) {
const path = `${OUT}/${tree.name}.png`;
await shoot(`${OUT}/dist-${tree.name}`, path);
halves.push(path);
console.log(`merge-feasibility: shot ${path}`);
}
const still = `${OUT}/merge-sf-40m-vs-164m.png`;
execFileSync("montage", [...halves, "-tile", "2x1", "-geometry", "+4+4", "-background", "#101418", still]);
console.log(`\nmerge-feasibility: ${still}`);
console.log("Left: San Francisco today, 40 m lots. Right: the same view at 164 m lots — what");
console.log("San Francisco looks like on a statewide board that fits. That picture is the decision.");
if (!existsSync(still)) process.exit(1);
+56 -5
View File
@@ -16,12 +16,54 @@ const VIEWPORTS = {
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
/*
* EVERY SCENE NAMES THE BOARD IT MEANS, AND CHECKS IT GOT IT.
*
* `?city=` falls back to the first board rather than failing on an unknown id —
* deliberately, so a deep link to a city this build does not have shows a city
* instead of a black screen — and that fallback is silent. A `bay-area` cell
* that quietly measured California would pass its 2.6M-triangle cap by a factor
* of six and would keep passing through any regression a phone would choke on:
* a budget that cannot fail. So every scene names a `signature` — one chapter
* `data-view` that exists on that board and on no other — and does not become
* ready until it is on screen.
*
* A SIGNATURE CHAPTER RATHER THAN THE BOARD TAB, deliberately. The obvious check
* is the `data-board` of the pressed tab, and it was written that way first; it
* broke within the hour, because the tab strip is chrome and chrome is exactly
* what the continuity work is allowed to change. A chapter id is pack data, it
* is what `scripts/fixtures/chapter-identity.json` pins, and it is what every
* capture aim resolves to. `hayes-valley` and `dtla` are unique across the three
* packs; `all` is not, which is why neither metro is identified by its first
* chapter.
*
* `california-drive` used to reach its chapter with
* `document.querySelectorAll("#chapters .chapter")[1].click()`. That is an
* unguarded index into pack data, and it is the exact pattern that once shipped
* a share card of a chase camera on a freeway under the headline "Cities from
* above" — see the note at `scripts/brand-assets/capture.mjs:70`. It now aims at
* `data-view="la-sf-us-101"` and asserts the printed label, so a reordered pack
* stops the run instead of quietly measuring a different scene under the old
* scene's caps.
*/
const SCENES = {
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
california: {
host: "tera.lumbridgecorp.com",
signature: "california-overview",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null,
},
"california-drive": {
host: "tera.lumbridgecorp.com",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
signature: "california-overview",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='california-overview']") !== null && document.querySelector("#chapters .chapter[data-view='la-sf-us-101']") !== null,
activate: () => {
const button = document.querySelector("#chapters .chapter[data-view='la-sf-us-101']");
if (button === null) throw new Error("no chapter with data-view='la-sf-us-101' on this board");
const spans = [...button.querySelectorAll("span")];
const label = (spans[spans.length - 1]?.textContent ?? "").trim();
if (label !== "101") throw new Error(`chapter la-sf-us-101 prints "${label}", not "101" — the pack was re-labelled`);
button.click();
},
active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
},
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
@@ -45,12 +87,14 @@ const SCENES = {
"bay-area": {
host: "tera.lumbridgecorp.com",
query: "?city=sf",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0,
signature: "hayes-valley",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='hayes-valley']") !== null,
},
socal: {
host: "tera.lumbridgecorp.com",
query: "?city=socal",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0,
signature: "dtla",
ready: () => document.getElementById("boot")?.hidden === true && document.querySelector("#chapters .chapter[data-view='dtla']") !== null,
},
};
@@ -408,6 +452,13 @@ async function measure(browser, port, sceneName, viewportName, budget, requestLo
bootHidden: document.getElementById("boot")?.hidden ?? null,
bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null,
chapters: document.querySelectorAll("#chapters .chapter").length,
// Which chapters actually answered. A readiness timeout on a metro cell
// is now most likely to mean `?city=` fell back to California, and
// reading the ids back makes that a one-word diagnosis rather than a
// twenty-minute one. `activeBoard` is best-effort: the board tab strip
// is chrome and may not exist.
views: [...document.querySelectorAll("#chapters .chapter")].map((node) => node.getAttribute("data-view")),
activeBoard: document.querySelector("[data-board][aria-pressed='true']")?.getAttribute("data-board") ?? null,
enter: document.getElementById("enter")?.textContent?.trim() ?? null,
})).catch(() => null);
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
+1
View File
@@ -1,5 +1,6 @@
{
"version": 1,
"note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding — the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six.",
"scenes": {
"california": {
"desktop": {
+169
View File
@@ -0,0 +1,169 @@
/**
* The before/after pair, at every authored chapter of all three packs.
*
* `TERA_RECONCILE` exists so that four data changes can be *looked at* rather
* than argued about, and the acceptance for every one of them is a photograph:
* a board that is worse in the picture means that rule reverts. Twenty-six
* chapters times two flag states is fifty-two frames, which is more than anyone
* will take one `look.mjs` invocation at a time so this is one command, one
* preview and one browser, and it names its output after the chapter's identity
* rather than after its position in a list.
*
* node scripts/reconcile-shots.mjs [--dist <dir>] [--out <dir>]
* [--rules <a,b>] [--only <board>]
* [--at <iso>] [--wait <ms>]
*
* `--rules` is passed through as `?reconcile=<rules>`, so a single rule can be
* photographed alone which is the point of the four being independently
* switchable in the first place.
*
* The port and build checks are `look.mjs`'s, for `look.mjs`'s reason: this box
* once accumulated a hundred and forty-seven abandoned previews in an afternoon
* and photographed somebody else's dist through one of them.
*/
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { createServer } from "node:net";
import { chromium } from "playwright";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
};
const DIST = flag("--dist", "dist");
const OUT = flag("--out", "/tmp/tera-look/reconcile");
const RULES = flag("--rules", "1");
const ONLY = flag("--only", null);
const AT = flag("--at", "2026-08-21T20:00:00Z");
const WAIT = Number(flag("--wait", "6000"));
const FIRST_WAIT = Number(flag("--first-wait", "11000"));
mkdirSync(OUT, { recursive: true });
/**
* The two California chapters that are doors, not poses.
*
* `main.ts` matches them against `CALIFORNIA_DESTINATIONS` and calls
* `switchCity()` rather than `flyTo()`, so clicking one leaves the board which
* is what made `look.mjs`'s `glyph-la` and `glyph-sf` presets photographs of the
* wrong board for as long as they existed. They are shot anyway, because a
* before/after pair at *every* authored chapter is what was asked for and
* because the frame they produce is a real frame of the product; the page is
* reloaded afterwards so the next chapter is aimed from California again.
*/
const DOORS = new Set(["los-angeles", "san-francisco"]);
const BOARDS = [CALIFORNIA, SF, SOCAL].filter((city) => ONLY === null || city.id === ONLY);
const PORT = await new Promise((resolve, reject) => {
const probe = createServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});
const server = spawn(
new URL("../node_modules/.bin/vite", import.meta.url).pathname,
["preview", "--outDir", DIST, "--port", String(PORT), "--strictPort"],
{ detached: true, stdio: ["ignore", "pipe", "pipe"] },
);
const shutdown = () => {
try {
process.kill(-server.pid, "SIGTERM");
} catch {
/* already gone */
}
};
process.on("exit", shutdown);
const bound = await new Promise((resolve) => {
let seen = "";
const settle = setTimeout(() => resolve(null), 30000);
const read = (chunk) => {
seen += String(chunk);
const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen);
if (match) {
clearTimeout(settle);
resolve(Number(match[1]));
}
};
server.stdout.on("data", read);
server.stderr.on("data", read);
});
if (bound !== PORT) {
console.error(`reconcile-shots: preview bound ${bound}, not ${PORT} — refusing to photograph it`);
shutdown();
process.exit(1);
}
const browser = await chromium.launch({
channel: "chrome",
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const context = await browser.newContext({
viewport: { width: 1600, height: 1000 },
deviceScaleFactor: 1,
timezoneId: "America/Los_Angeles",
// The opening arrival collapses to a cut under this, which is the only way
// fifty-two frames taken over half an hour are the same fifty-two frames when
// the box is busy.
reducedMotion: "reduce",
});
await context.clock.setFixedTime(new Date(AT));
const page = await context.newPage();
const errors = [];
page.on("console", (m) => {
if (m.type() === "error") errors.push(m.text());
});
async function land(url) {
await page.goto(`http://localhost:${PORT}${url}`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(FIRST_WAIT);
try {
await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
await page.waitForTimeout(1200);
} catch {
/* already dismissed, or not shown */
}
}
let taken = 0;
for (const city of BOARDS) {
for (const state of ["off", "on"]) {
const url = `/?city=${city.id}${state === "on" ? `&reconcile=${RULES}` : ""}`;
await land(url);
for (const chapter of city.chapters) {
// Aim by identity. `data-view` is the chapter's own id, so a pack that is
// reordered under this script still photographs the chapter it names —
// the failure `look.mjs`'s two broken presets were an instance of.
const button = page.locator(`.chapter[data-view="${chapter.id}"]`).first();
try {
await button.click({ timeout: 5000 });
} catch {
console.log(`reconcile-shots: could not click ${city.id}/${chapter.id}`);
await land(url);
continue;
}
await page.waitForTimeout(WAIT);
const path = `${OUT}/${city.id}-${chapter.id}-${state}.png`;
await page.screenshot({ path });
taken += 1;
console.log(`reconcile-shots: ${path}`);
if (DOORS.has(chapter.id)) await land(url);
}
}
}
const real = errors.filter((e) => !/404|Failed to load resource/.test(e));
console.log(`reconcile-shots: ${taken} frames into ${OUT}`);
console.log(real.length === 0 ? "reconcile-shots: no console errors" : `reconcile-shots: ERRORS ${JSON.stringify([...new Set(real)].slice(0, 5))}`);
await browser.close();
shutdown();
process.exit(0);
+733
View File
@@ -0,0 +1,733 @@
#!/usr/bin/env node
/**
* What a board switch actually costs, in the four numbers that decide whether
* retaining a board was worth it.
*
* node scripts/switch-cost.mjs # all six directions, once each
* node scripts/switch-cost.mjs --repeat 3 # median of three per direction
* node scripts/switch-cost.mjs --only california->sf
* node scripts/switch-cost.mjs --json /tmp/switch-cost.json
* node scripts/switch-cost.mjs --dist /some/other/dist
*
* ## Why this exists, and why the frame timer could not answer it
*
* `performance-budget.mjs` measures a *settled* board: it waits `warmup-ms`,
* throws away everything before that, and samples a stationary camera. That is
* the right instrument for "is this board affordable" and it is structurally
* incapable of seeing a board *change*, because the change happens entirely
* inside the window it discards. So the most-complained-about moment in the
* product the pause between one board and the next was the one moment
* nothing measured. Every claim about it was a feeling.
*
* It is also the moment where this box's frame timer lies worst. The GPU here
* never leaves 500 MHz of a possible 2,725, so p95 frame interval flips between
* 16.8 and 33.3 ms on fragment changes with geometry identical to the digit.
* None of the four numbers below is a frame time. They are wall-clock cover,
* main-thread block, the single worst task, and how many frames the page
* managed to paint and every one of them is a count or a duration that a
* throttled GPU does not move.
*
* ## The numbers
*
* **coveredMs** from the gesture to the end of the switch, where the end is
* whichever comes last: the destination board's chapters appearing, or the boot
* card coming off. Today the card is last, because `building()` in `main.ts`
* adds `.done` and hides the card 300 ms later; `opaqueMs` is reported beside it
* as click-to-`.done`, the part that is fully opaque.
*
* **arrivalMs and card** when the destination was on screen, and whether a
* boot card was raised at all. These two are the *shape* of the switch rather
* than its length, and they are the pair that tells you which world you are in:
* `card=yes` with `arrival ≈ covered - 300` is the disposal model, and `card=no`
* with `blank` near zero is retention. Arrival is detected by watching
* `#chapters` for the destination's signature chapter not by the card, which
* retention removes, and not by the board tab, which the continuity work
* removes.
*
* **blockedMs** the sum of Long Tasks inside that window. This is the part
* that is genuinely frozen: no input, no animation, no clock. Everything else
* in `coveredMs` is a live, animating page that simply has nothing to draw.
* The gap between the two is the prize: it is the fraction of the pause that
* exists only because the outgoing board was disposed before the incoming one
* was built, and retention deletes it without a single new triangle.
*
* **longestTaskMs** the worst single task. Sums are consoling and a 512 ms
* task is what a hitch feels like, so the maximum is reported separately and is
* the number to watch after retention lands: today it hides inside a full-screen
* card with a percentage on it, which reads as *busy*; behind a live picture the
* same half second reads as a *hang*.
*
* **frames / blankFrames** frames the page painted during the switch, and how
* many of them issued zero WebGL draw calls. A blank frame is the compositor
* doing its job over a scene that no longer exists. Two thirds blank is the
* disposal model 46 of 69 on the baseline below and that ratio collapsing
* toward zero is retention working, measured rather than felt.
*
* ## The baseline, measured
*
* `bcac6aa`, before any retention work, desktop 1440x900, Chrome/ANGLE/Vulkan on
* a Radeon RX 6700 XT, `--repeat 3` and the median of each column:
*
* | direction | covered | opaque | blocked | longest | frames | blank | live |
* |---|---|---|---|---|---|---|---|
* | california->sf | 1,715 | 1,415 | 608 | 526 | 69 | 46 | 65% |
* | socal->sf | 1,627 | 1,326 | 523 | 459 | 69 | 46 | 68% |
* | california->socal | 1,109 | 809 | 400 | 312 | 45 | 22 | 64% |
* | sf->socal | 1,027 | 726 | 328 | 245 | 44 | 21 | 68% |
* | sf->california | 1,120 | 820 | 340 | 196 | 49 | 26 | 70% |
* | socal->california | 1,061 | 761 | 319 | 205 | 47 | 24 | 70% |
*
* Two things to read off it before changing anything.
*
* **Cost is a property of the destination, not of the pair.** The two arrivals
* at the Bay Area differ by 5% and the two at California by 6%, while Bay Area
* against California is 1.6x. Nothing is being reused between boards today, and
* this table is what will show that changing.
*
* **Roughly two thirds of every pause is not work.** 64-70% of the cover is a
* live, animating page with an empty world in it, because the outgoing board was
* disposed before the incoming one was built. That is the fraction retention
* gets back without a single new triangle and the residual, the 526 ms task on
* an arrival at the Bay Area, is a separate and nameable defect (main-thread
* instance placement for 83,137 buildings) that retention does not touch and
* will make *more* visible, because today it hides behind a full-screen card
* with a percentage on it and afterwards it will freeze a live picture.
*
* Reproduce it before trusting a change, on the same box, with nothing else
* competing for the GPU. This is a wall-clock instrument on a shared machine:
* treat 5% as noise and 2x as a finding.
*
* ## GPU flags
*
* Same ladder as `performance-budget.mjs` and `ui-smoke.mjs`, for the same
* reason: this box has an AMD card and no monitor, `--use-angle=vulkan` is what
* makes Chrome render headlessly on it at all, and the SwiftShader rung behind
* it keeps the script runnable on a CI box with no card. A software fall-back is
* reported in the output, because switch cost measured on SwiftShader is a
* different quantity and must not be compared to the table above.
*/
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".webmanifest": "application/manifest+json",
};
const args = process.argv.slice(2);
const option = (name, fallback) => {
const at = args.indexOf(`--${name}`);
return at < 0 ? fallback : args[at + 1];
};
const has = (name) => args.includes(`--${name}`);
// A six-direction sweep is ten minutes of browser. `--help` must not start one:
// this file was asked for its usage once and answered by launching Chrome.
if (has("help") || args.includes("-h")) {
console.log(
[
"node scripts/switch-cost.mjs [options]",
"",
" --dist <path> build to measure (default ./dist)",
" --repeat <n> takes per direction, median reported (default 1)",
" --only <a->b> one direction, e.g. --only california->sf",
" --viewport <name> desktop | mobile (default desktop)",
" --settle-ms <ms> wait on the first board before switching (default 2500)",
" --timeout-ms <ms> per-wait timeout (default 90000)",
" --json <path> write the full result, samples included",
" --software SwiftShader only; NOT comparable to the GPU baseline",
" --headed watch it happen",
"",
"Reports, per direction: how long the switch covered the screen, when the",
"destination arrived, whether a boot card was raised at all, blocked",
"main-thread milliseconds, the longest single task, and how many painted",
"frames issued zero WebGL draw calls. See the header for the baseline.",
].join("\n"),
);
process.exit(0);
}
const DIST = resolve(option("dist", join(ROOT, "dist")));
const REPEAT = Math.max(1, Number(option("repeat", "1")) || 1);
const SETTLE_MS = Number(option("settle-ms", "2500")) || 2500;
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
const ONLY = option("only", null);
const JSON_OUT = option("json", null);
const softwareOnly = has("software");
const headed = has("headed");
/**
* The three boards, and every ordered pair of them.
*
* Six directions rather than three, because the cost is not symmetric and the
* asymmetry is the interesting part: leaving the Bay Area is cheap and arriving
* at it is not, so a table that averaged the two would hide the only direction
* anybody complains about. `id` is the `?city=` value, which is also what the
* board tab and the places rung both carry.
*/
const BOARDS = [
/*
* `signature` is one chapter `data-view` that exists on this board and on no
* other, and it is how arrival is detected.
*
* Not the pressed board tab, which is what this file checked first and which
* stopped existing within the hour when the continuity work removed the tab
* strip. Not the boot card either: retention's whole point is that a switch
* stops raising one, so an instrument that timed "card up to card down" would
* report nothing at all on the build it exists to measure. A chapter id is
* pack data, it is what `scripts/fixtures/chapter-identity.json` pins, and the
* moment it appears in `#chapters` is the moment the destination board is on
* screen. `all` is shared by both metro boards, which is why neither is
* identified by its first chapter.
*/
{ id: "california", label: "California", signature: "california-overview" },
{ id: "sf", label: "the Bay Area", signature: "hayes-valley" },
{ id: "socal", label: "SoCal", signature: "dtla" },
];
const DIRECTIONS = BOARDS.flatMap((from) =>
BOARDS.filter((to) => to.id !== from.id).map((to) => ({ from, to })),
);
const VIEWPORTS = {
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
const VIEWPORT_NAME = option("viewport", "desktop");
const VIEWPORT = VIEWPORTS[VIEWPORT_NAME];
if (!VIEWPORT) throw new Error(`--viewport must be one of ${Object.keys(VIEWPORTS).join(", ")}`);
// ---- The deployment: the built bundle and a server that says nothing -------
//
// Every optional source off, exactly as `ui-smoke.mjs` serves it. A switch that
// waits on a weather fetch is measuring somebody's uplink, and CONTRACT §0's
// visitor has no key anyway.
async function serve() {
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://local.invalid");
const json = (status, body) => {
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/api/v1/health") {
return json(200, {
auth: { mode: "jwt", entryUrl: "/login.html" },
sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
regions: [],
});
}
if (url.pathname === "/api/v1/session") {
return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false });
}
if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
const target = resolve(DIST, `.${relative}`);
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
try {
const body = await readFile(target);
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
res.end(body);
return;
} catch {
/* fall through to the SPA entry */
}
}
res.writeHead(404).end("not found");
});
await new Promise((ok, fail) => {
server.once("error", fail);
server.listen(0, "127.0.0.1", ok);
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("no TCP port");
return { server, port: address.port };
}
// ---- Chrome ---------------------------------------------------------------
const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];
async function rendererOf(browser) {
const page = await browser.newPage();
try {
await page.goto("about:blank");
return await page.evaluate(() => {
const gl = document.createElement("canvas").getContext("webgl2");
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
});
} finally {
await page.close();
}
}
async function launch() {
const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
const ladder = softwareOnly
? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]]]
: [
["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]],
];
let last;
for (const [backend, flags] of ladder) {
try {
const browser = await chromium.launch({
channel: "chrome",
headless: !headed,
args: [...COMMON_ARGS, resolver, ...flags],
});
const renderer = await rendererOf(browser);
const software = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
if (renderer !== null && (backend === "swiftshader" || !software)) {
return { browser, backend, renderer, software };
}
await browser.close();
} catch (error) {
last = error;
}
}
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}
/**
* Everything measured is measured in the page, before any of the page's own
* script runs.
*
* Four instruments, and none of them is a frame time:
*
* - **Draw calls per painted frame.** The same four `drawArrays`/`drawElements`
* entry points `performance-budget.mjs` patches, counted per animation frame.
* A frame with zero of them is a frame with no world in it.
* - **Long Tasks.** `buffered: true`, so a task that started before the observer
* attached is still seen.
* - **The boot card.** A `MutationObserver` on `#boot`'s `hidden` and `class`,
* which is how `building()` covers and uncovers the screen. Observed rather
* than polled, because a poll inside a 512 ms task returns after it and would
* report the card as having gone away early.
* - **A mark for the click**, written in the same task as the click itself, so
* the window starts at the gesture rather than at Playwright's round trip.
*
* The observer has to wait for `#boot` to exist: this runs at document-start and
* the element is in `index.html`, so a `readystatechange` hook is enough and
* costs nothing on a page that is already parsed.
*/
function instrumentation() {
const state = {
frames: [],
longTasks: [],
boot: [],
chapters: [],
clickAt: null,
calls: 0,
longTaskSupported: false,
};
Object.defineProperty(globalThis, "__teraSwitchCost", { value: state });
const patch = (prototype, method) => {
if (!prototype || typeof prototype[method] !== "function") return;
const original = prototype[method];
if (original.__teraSwitchCostPatched) return;
const wrapped = function (...values) {
state.calls += 1;
return original.apply(this, values);
};
Object.defineProperty(wrapped, "__teraSwitchCostPatched", { value: true });
prototype[method] = wrapped;
};
for (const prototype of [
globalThis.WebGLRenderingContext?.prototype,
globalThis.WebGL2RenderingContext?.prototype,
]) {
for (const method of ["drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced"]) {
patch(prototype, method);
}
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
state.longTasks.push({ startTime: entry.startTime, duration: entry.duration });
}
}).observe({ type: "longtask", buffered: true });
state.longTaskSupported = true;
} catch {
/* Long Tasks API is optional. */
}
requestAnimationFrame(function sample(now) {
state.frames.push({ at: now, calls: state.calls });
state.calls = 0;
requestAnimationFrame(sample);
});
const watchBoot = () => {
const card = document.getElementById("boot");
if (card === null) return false;
const snapshot = () =>
state.boot.push({
at: performance.now(),
hidden: card.hidden === true,
done: card.classList.contains("done"),
});
snapshot();
new MutationObserver(snapshot).observe(card, {
attributes: true,
attributeFilter: ["hidden", "class"],
});
return true;
};
if (!watchBoot()) document.addEventListener("readystatechange", watchBoot, { once: false });
/*
* When the destination board arrived, timestamped in the page.
*
* `#chapters` is replaced wholesale when a board mounts (`mount.ts` rebuilds
* it from a signature over the view list), so a `childList` observer on it
* fires once, at the moment the new board's chapters exist. That instant is
* "the new board is on screen" independent of the boot card, which retention
* removes, and independent of the board tab strip, which the continuity work
* removes. Observed rather than polled: a poll inside a 512 ms task returns
* after the task and would credit the switch with time it did not take.
*/
const watchChapters = () => {
const nav = document.getElementById("chapters");
if (nav === null) return false;
const snapshot = () =>
state.chapters.push({
at: performance.now(),
views: [...nav.querySelectorAll(".chapter")].map((node) => node.getAttribute("data-view")),
});
snapshot();
new MutationObserver(snapshot).observe(nav, { childList: true, subtree: true });
return true;
};
if (!watchChapters()) document.addEventListener("readystatechange", watchChapters, { once: false });
}
const percentile = (values, p) => {
if (values.length === 0) return null;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))];
};
const median = (values) => percentile(values, 0.5);
const round = (value) => (value === null || value === undefined ? null : Math.round(value));
/**
* One switch, from a settled board to another board.
*
* A fresh page per sample, deliberately. A second visit to a board reuses
* nothing today `three` refcounts shader programs per material and deletes
* them at zero, so disposing a board deletes its programs and coming back
* relinks them but that is a fact about today's engine, not a rule, and the
* whole point of this file is to be able to see it change. A shared page would
* bake the current answer into the instrument.
*/
async function measureSwitch(browser, port, from, to) {
const context = await browser.newContext({
viewport: { width: VIEWPORT.width, height: VIEWPORT.height },
deviceScaleFactor: VIEWPORT.deviceScaleFactor,
isMobile: VIEWPORT.isMobile,
hasTouch: VIEWPORT.hasTouch,
});
const page = await context.newPage();
const consoleErrors = [];
page.on("pageerror", (error) => consoleErrors.push(String(error)));
page.on("console", (message) => {
if (message.type() !== "error") return;
const at = message.location()?.url ?? "";
if (/\/api\/v1\//.test(at)) return;
consoleErrors.push(message.text());
});
await page.addInitScript(instrumentation);
try {
await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${from.id}`, {
waitUntil: "networkidle",
timeout: READY_TIMEOUT_MS,
});
const ready = () =>
document.getElementById("boot")?.hidden === true &&
document.querySelectorAll("#chapters .chapter").length > 0;
await page.waitForFunction(ready, null, { timeout: READY_TIMEOUT_MS });
await page.waitForTimeout(SETTLE_MS);
// The onboarding coach covers the board it is teaching you about, and a
// click that lands on its scrim is not a board switch.
await page
.getByText(/^Skip$/)
.first()
.click({ timeout: 1500 })
.then(() => page.waitForTimeout(600))
.catch(() => undefined);
/*
* The gesture, by whatever affordance this build offers for it.
*
* `[data-board]` is the three-tab board strip. `[data-place-board]` is a rung
* of the places list, which carries the board it belongs to. The continuity
* work replaces the first with the second the tab strip is the loudest
* "you are somewhere else now" signal on the screen and it is being removed
* so an instrument that only knew about tabs would stop working on exactly
* the change it exists to measure. Which one was used is reported, because
* they are not the same gesture: a tab selects a board and a rung selects a
* board *and a pose on it*.
*/
const clicked = await page.evaluate((boardId) => {
const state = globalThis.__teraSwitchCost;
state.frames.length = 0;
state.longTasks.length = 0;
state.boot.length = 0;
state.chapters.length = 0;
state.calls = 0;
const tab = document.querySelector(`[data-board="${boardId}"]`);
const rung = document.querySelector(`[data-place-board="${boardId}"]`);
const button = tab instanceof HTMLElement ? tab : rung instanceof HTMLElement ? rung : null;
if (button === null) return null;
state.clickAt = performance.now();
button.click();
return { at: state.clickAt, via: tab instanceof HTMLElement ? "board-tab" : "place-rung" };
}, to.id);
if (clicked === null) {
throw new Error(
`nothing on screen selects "${to.id}" — no [data-board="${to.id}"] and no ` +
`[data-place-board="${to.id}"]. If the affordance moved again, teach this file about it.`,
);
}
/*
* Wait for the destination, not for the cover.
*
* The signature chapter on screen and the boot card down. Both conditions,
* because today the card outlives the arrival by 300 ms and after retention
* there may be no card at all and a wait written against the card alone
* would hang forever on the build this file exists to measure. Everything
* timed is timed by the in-page observers; this wait only decides when it is
* safe to read them.
*/
await page.waitForFunction(
(signature) =>
document.querySelector(`#chapters .chapter[data-view="${signature}"]`) !== null &&
document.getElementById("boot")?.hidden === true,
to.signature,
{ timeout: READY_TIMEOUT_MS },
);
const raw = await page.evaluate((signature) => {
const state = globalThis.__teraSwitchCost;
return {
clickAt: state.clickAt,
frames: state.frames.map((frame) => ({ at: frame.at, calls: frame.calls })),
longTasks: state.longTasks.map((task) => ({ ...task })),
boot: state.boot.map((entry) => ({ ...entry })),
chapterEvents: state.chapters.map((entry) => ({
at: entry.at,
arrived: entry.views.includes(signature),
})),
longTaskSupported: state.longTaskSupported,
views: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
node.getAttribute("data-view"),
),
};
}, to.signature);
const start = raw.clickAt;
const raised = raw.boot.find((entry) => entry.at >= start && !entry.hidden) ?? null;
const done = raw.boot.find((entry) => entry.at >= start && entry.done) ?? null;
const cleared =
raised === null
? null
: (raw.boot.find((entry) => entry.at > raised.at && entry.hidden) ?? null);
const arrived = raw.chapterEvents.find((entry) => entry.at >= start && entry.arrived) ?? null;
/*
* The window runs from the gesture to whichever happened last: the
* destination's chapters appearing, or the cover coming off.
*
* Today the cover is last, because `building()` fades the card 300 ms after
* the board is up. Under retention there may be no card at all, and then
* arrival is the whole story. Taking the later of the two means the same
* command measures both worlds without an argument about which one it is in.
*/
const end = Math.max(arrived?.at ?? start, cleared?.at ?? start, start);
const inWindow = (at) => at >= start && at <= end;
const frames = raw.frames.filter((frame) => inWindow(frame.at));
const tasks = raw.longTasks.filter(
(task) => task.startTime + task.duration >= start && task.startTime <= end,
);
// Clipped to the window: a task that straddles the click contributes only
// the part of itself that froze the switch.
const blockedMs = tasks.reduce(
(sum, task) =>
sum + Math.max(0, Math.min(end, task.startTime + task.duration) - Math.max(start, task.startTime)),
0,
);
return {
// Landing is asserted on the destination's signature chapter, not on a
// board tab: the tab strip is chrome and the chapter id is pack data.
ok: raw.views.includes(to.signature),
landedOn: raw.views.includes(to.signature) ? to.id : (raw.views[0] ?? null),
chapters: raw.views.length,
via: clicked.via,
bootCardRaised: raised !== null,
coveredMs: end - start,
arrivalMs: arrived === null ? null : arrived.at - start,
opaqueMs: done === null ? null : done.at - start,
raiseLatencyMs: raised === null ? null : raised.at - start,
blockedMs,
longestTaskMs: tasks.length === 0 ? 0 : Math.max(...tasks.map((task) => task.duration)),
longTaskCount: tasks.length,
longTaskSupported: raw.longTaskSupported,
frames: frames.length,
blankFrames: frames.filter((frame) => frame.calls === 0).length,
consoleErrors,
};
} finally {
await context.close();
}
}
// ---- Run ------------------------------------------------------------------
const { server, port } = await serve();
const { browser, backend, renderer, software } = await launch();
const started = new Date().toISOString();
const rows = [];
let failed = false;
try {
for (const direction of DIRECTIONS) {
const name = `${direction.from.id}->${direction.to.id}`;
if (ONLY !== null && ONLY !== name) continue;
const samples = [];
for (let take = 0; take < REPEAT; take += 1) {
const sample = await measureSwitch(browser, port, direction.from, direction.to);
samples.push(sample);
if (!sample.ok) failed = true;
if (sample.consoleErrors.length > 0) failed = true;
}
const pick = (key) => median(samples.map((sample) => sample[key]).filter((v) => v !== null));
rows.push({
direction: name,
into: direction.to.label,
takes: samples.length,
via: [...new Set(samples.map((sample) => sample.via))].join("+"),
bootCard: samples.every((sample) => sample.bootCardRaised)
? "yes"
: samples.some((sample) => sample.bootCardRaised)
? "mixed"
: "no",
coveredMs: round(pick("coveredMs")),
arrivalMs: round(pick("arrivalMs")),
opaqueMs: round(pick("opaqueMs")),
blockedMs: round(pick("blockedMs")),
longestTaskMs: round(pick("longestTaskMs")),
longTaskCount: round(pick("longTaskCount")),
frames: round(pick("frames")),
blankFrames: round(pick("blankFrames")),
samples: samples.map((sample) => ({
via: sample.via,
bootCardRaised: sample.bootCardRaised,
coveredMs: round(sample.coveredMs),
arrivalMs: round(sample.arrivalMs),
opaqueMs: round(sample.opaqueMs),
blockedMs: round(sample.blockedMs),
longestTaskMs: round(sample.longestTaskMs),
frames: sample.frames,
blankFrames: sample.blankFrames,
ok: sample.ok,
landedOn: sample.landedOn,
consoleErrors: sample.consoleErrors,
})),
});
}
} finally {
await browser.close();
server.close();
}
const pad = (value, width) => String(value ?? "—").padStart(width);
const header = [
pad("direction", 22),
pad("via", 11),
pad("card", 6),
pad("covered", 9),
pad("arrival", 9),
pad("opaque", 8),
pad("blocked", 9),
pad("longest", 9),
pad("tasks", 7),
pad("frames", 8),
pad("blank", 7),
pad("live%", 7),
].join(" ");
console.log(`switch-cost — ${started}`);
console.log(` dist ${DIST}`);
console.log(` viewport ${VIEWPORT_NAME} ${VIEWPORT.width}x${VIEWPORT.height}`);
console.log(` backend ${backend}${software ? " (SOFTWARE — not comparable to the GPU baseline)" : ""}`);
console.log(` renderer ${renderer ?? "unknown"}`);
console.log(` takes ${REPEAT} per direction (median reported)`);
console.log("");
console.log(header);
console.log("-".repeat(header.length));
for (const row of rows) {
const live = row.coveredMs ? Math.round(((row.coveredMs - row.blockedMs) / row.coveredMs) * 100) : null;
console.log(
[
pad(row.direction, 22),
pad(row.via, 11),
pad(row.bootCard, 6),
pad(row.coveredMs, 9),
pad(row.arrivalMs, 9),
pad(row.opaqueMs, 8),
pad(row.blockedMs, 9),
pad(row.longestTaskMs, 9),
pad(row.longTaskCount, 7),
pad(row.frames, 8),
pad(row.blankFrames, 7),
pad(live === null ? "—" : `${live}%`, 7),
].join(" "),
);
}
console.log("");
console.log(
"live% is the share of the cover that is NOT blocked — a live, animating page with\n" +
"nothing to draw because the outgoing board was disposed. That is the part retention\n" +
"gets back for free. blank frames are frames that issued zero WebGL draw calls.",
);
if (rows.every((row) => row.longTaskCount === 0)) {
console.log("NOTE: no Long Tasks were observed at all — check longTaskSupported before believing it.");
}
for (const row of rows) {
for (const sample of row.samples) {
if (!sample.ok) console.log(`FAIL ${row.direction}: landed on ${sample.landedOn ?? "nothing"}`);
if (sample.consoleErrors.length > 0) {
console.log(`FAIL ${row.direction}: console errors ${JSON.stringify(sample.consoleErrors)}`);
}
}
}
if (JSON_OUT !== null) {
await writeFile(
JSON_OUT,
`${JSON.stringify({ started, dist: DIST, viewport: VIEWPORT_NAME, backend, renderer, software, repeat: REPEAT, rows }, null, 2)}\n`,
);
console.log(`wrote ${JSON_OUT}`);
}
process.exit(failed ? 1 : 0);
+21 -2
View File
@@ -1837,9 +1837,28 @@ export const CALIFORNIA_CITY: City = {
{ name: "San Francisco", lat: 37.7749, lng: -122.4194, height: 720, footprint: 0.026, shape: "tower", color: 0x9fb2c2, label: true },
],
bridges: [],
/**
* The two corridors, authored twice: once as the symbol this board draws and
* once as the road it actually is.
*
* `width` is 1 and 1.06 scene units, which at 1,919 m to the unit is 1,919 m
* and 2,034 m of asphalt wider, by a factor of two, than the widest thing in
* downtown Los Angeles, and the most prominent marks on the state board. That
* was a deliberate choice and it is defensible on its own terms: a 47 m
* freeway on a board 554 units across is a fortieth of a unit and would not
* exist at all. It is not defensible at the seam, where the same road is
* 4355 m on Southern California and 2330 m on the Bay Area, because a 2 km
* ribbon becoming a 30 m line is a change of drawing convention rather than an
* LOD transition.
*
* `widthM` is US-101 and I-5 as they are on the ground six lanes plus
* shoulders through the Salinas Valley, eight through the San Joaquin and it
* is read by `cities/reconcile.ts` and by nothing else. The engine draws
* `width`, always.
*/
roads: [
{ path: CALIFORNIA_US_101, width: 1, kind: "freeway" },
{ path: CALIFORNIA_I_5, width: 1.06, kind: "freeway" },
{ path: CALIFORNIA_US_101, width: 1, widthM: 44, kind: "freeway" },
{ path: CALIFORNIA_I_5, width: 1.06, widthM: 47, kind: "freeway" },
],
chapters: [
{
+461
View File
@@ -0,0 +1,461 @@
/**
* One California, from three packs behind a flag, so both answers can be
* photographed and the owner can choose between them.
*
* ## The complaint this exists for
*
* The three boards feel like three boards, and the measured reason is *not*
* that the horizontal scale jumps 20× at the seam. `World.project` is a uniform
* scale in x/z with no vertical term, and a uniform scale leaves a perspective
* image identical: a camera carried across the seam on matched true-metre
* offsets draws a pixel-identical horizontal frame. 1,919 94 m per unit costs
* nothing to look at.
*
* What is visible is that **the three packs draw three different Californias**,
* and every one of those differences is a number in a data file:
*
* | | california | sf | socal | spread |
* | --- | --- | --- | --- | --- |
* | `verticalExaggeration` | 15 | 3.6 | 3.4 | 4.41× at a matched pose |
* | freeway width, true metres | 1,9192,034 | 2330 | 4355 | 88× |
* | longitude squashed by cos | 37.30° | 37.77° | 33.82° | up to ~4 km of drift |
* | `coastFalloff` | 0.0250° | 0.0018° | 0.0045° | 14× in metres |
* | palette stops declared | 11 | 0 | 11 (different) | |
*
* So this module is four rules, each replacing one column of that table with a
* single authored constant plus facts the pack already states. It reads as data
* because it *is* data: nothing here is an engine change, and the whole of the
* blast radius in `world.ts` is that the constructor asks for a reconciled pack
* and reads `lngScale` off it.
*
* ## The flag, and why it defaults off
*
* San Francisco and Southern California carry every marketing still on
* lumbridgecorp.com. A reconciliation that leaves a board *worse* is a bad trade
* however good the argument for it, and the only instrument that can tell is a
* photograph. So: `?reconcile=1` in the browser, `setReconcile()` from a node
* script, **off** everywhere else, and each of the four rules can be turned on
* alone `?reconcile=exaggeration,roads` so a rule that loses its photograph
* is reverted rather than argued for.
*
* With the flag off `reconciledCity` returns the pack it was handed, by
* identity. Not a copy with the same numbers in it: the same object. That is
* what makes "no pixel moves with the flag off" a fact about the code rather
* than a claim about a diff.
*
* ## Why the reconciliation rewrites the pack rather than the engine
*
* `World` posts `this.city` to the terrain worker as a structured clone, and the
* worker builds a second `World` from it. A flag read from `location.search` is
* not visible in there a worker's `location` is the worker script's URL so a
* reconciliation that lived in the engine would produce a *reconciled* board on
* the main thread standing on an *unreconciled* heightfield. Rewriting the pack
* puts the answer in the thing that crosses the wire. `reconciled: true` on the
* way out is what makes the second pass in the worker a no-op, and the rule
* idempotent.
*/
import type { City, Road, ScenePalette } from "../engine/types.ts";
// ---- The flag --------------------------------------------------------------
/** The four rules, each independently switchable. */
export type Rule = "exaggeration" | "roads" | "ground" | "projection";
export const RULES: readonly Rule[] = ["exaggeration", "roads", "ground", "projection"];
/**
* Set from a node script; wins over the query string.
*
* `null` hands the decision back to the URL, which is what a test wants in its
* teardown. Deliberately not memoised: a test that flips the flag between two
* assertions must see two answers.
*/
let forced: ReadonlySet<Rule> | null = null;
export function setReconcile(rules: boolean | readonly Rule[] | null): void {
if (rules === null) forced = null;
else if (rules === true) forced = new Set(RULES);
else if (rules === false) forced = new Set();
else forced = new Set(rules);
}
function parse(value: string | null): ReadonlySet<Rule> {
if (value === null || value === "" || value === "0" || value === "false") return new Set();
if (value === "1" || value === "true" || value === "all") return new Set(RULES);
const wanted = value.split(/[,+\s]+/).filter((part) => part !== "");
return new Set(RULES.filter((rule) => wanted.some((part) => rule.startsWith(part))));
}
/** Which rules are on right now. */
export function activeRules(): ReadonlySet<Rule> {
if (forced !== null) return forced;
const search = (globalThis as { location?: { search?: string } }).location?.search;
if (search === undefined) return new Set();
return parse(new URLSearchParams(search).get("reconcile"));
}
export function reconcileEnabled(rule?: Rule): boolean {
const on = activeRules();
return rule === undefined ? on.size > 0 : on.has(rule);
}
// ---- The four authored constants -------------------------------------------
/**
* Relief in the frame: peak scene units over board span, the rule
* ARCHITECTURE §12.1 already argues and already tuned California against.
*
* §12.1 moved California from 13 to 15 precisely to hold this number while the
* board grew, and recorded that 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". This constant is that number, taken
* off California so the board the photograph tuned does not move at all:
*
* | board | today | one rule | relief today | relief reconciled |
* | --- | --- | --- | --- | --- |
* | california | 15 | 15.00 | 7.24% | 7.24% |
* | socal | 3.4 | 3.41 | 6.92% | 7.24% |
* | sf | 3.6 | 5.78 | 4.51% | 7.24% |
*
* **So the rule's verdict is that San Francisco is the board that is wrong**,
* by 1.6×, and the other two were already on one number. That is a real claim
* about a board that carries most of this product's imagery, which is exactly
* why it is behind a flag with a photograph attached.
*
* What the rule does **not** do is remove the deflation at the seam, and that
* is worth stating because it is easy to assume otherwise. Apparent relief at a
* *matched* pose same true-metre stand-off on both boards is proportional
* to the exaggeration itself, not to relief-in-frame, so the 4.17× step from
* California to the Bay Area becomes 2.60× and the 4.41× step to Southern
* California becomes 4.22×. Making it 1.0× would mean one exaggeration on every
* board, which flattens the state to 1.7% of its own frame. Relief-in-frame and
* matched-pose relief are the same quantity only when two boards have the same
* span, and these do not.
*/
export const RELIEF_IN_FRAME = 0.0724;
/**
* The narrowest a road may be drawn, as a fraction of the board's span.
*
* Measured rather than chosen. The two metro packs' narrowest roads sit at
* 0.00019940 (San Francisco, a 19 m residential street) and 0.00020356
* (Southern California, a 31 m boulevard) of their own board spans two packs
* authored a year apart agreeing to within 2% about how thin a line may get
* before it stops being a line. The floor is set just under the tighter of the
* two, so **neither metro board moves**, and California's freeways fall onto it.
*
* California's US-101 and I-5 are authored 1 and 1.06 scene units wide, which at
* 1,919 m to the unit is 1,919 m and 2,034 m black ribbons wider than the
* cities they join, and the most prominent marks on the state board. Read as
* true metres instead (44 m and 47 m, `Road.widthM`) they are 0.023 units and
* would vanish, so the floor is what draws them: 0.110 units, or 212 m. A 9.1×
* narrowing, and a symbol at the same fraction of its board that the Bay Area
* already draws a side street at.
*/
export const LEGIBLE_SPAN_FRACTION = 0.000199;
/**
* The coastal ramp, in coarse ground cells.
*
* `coastFalloff` exists for one mechanical reason: the terrain grid is clipped
* to land, so its rim is stair-stepped at cell size, and the ramp takes every
* height to zero across that rim so the steps land flat on the shore plate and
* disappear (`terrain.ts` header). The size of the thing being hidden is the
* **coarse** cell the fine cell only exists inside a focus region, and coasts
* run outside them so the ramp is a multiple of that and of nothing else.
*
* 0.8 is California's own ratio, and it reproduces its authored 0.025° to
* within 0.2%. The metro packs both come out slightly wider than they are
* today: San Francisco 0.0018° 0.0032° (200 m 360 m) and Southern
* California 0.0045° 0.0058° (500 m 641 m), which is the direction that
* *removes* stair-stepping rather than adding it.
*/
export const COAST_RAMP_CELLS = 0.8;
/**
* The one latitude every board squashes longitude by.
*
* `World.lngScale` is `latScale × cos(centre.lat)`, and each pack uses its own
* centre 37.30°, 37.77°, 33.82°. In true metres a degree of longitude is then
* 88,551 m on California and 92,484 m on Southern California, so the two boards
* disagree about where Riverside is by 3,435 m and about their shared north-east
* corner by 4,025 m. At a 109 km stand-off that is about 3% of the frame a
* visible sideways slide in any transition that shows both boards at once.
*
* 37.3° is California's centre: the state board is the root of every descent and
* the only board whose rectangle is a place rather than a crop. The price is
* local aspect, and it is small and one-directional Southern California draws
* 4.25% narrower in x than a projection taken at its own latitude would, San
* Francisco 0.64% wider against a registration error that goes to zero by
* construction.
*/
export const REFERENCE_LAT = 37.3;
/**
* The palette anchors: one dry inland-southern end, one cool coastal-northern
* end, mixed by where the board is.
*
* The south anchor is California's authored palette, unchanged, so the state
* board renders exactly as it does today. The north anchor is the engine's
* `DEFAULT_PALETTE` which is to say San Francisco's, since it declares no
* override plus an `alpine` stop it never needed.
*
* **The two authored palettes are not on one line and no rule can put them
* there.** Southern California's `flats` is 0xa9a291; the point 76.8% of the way
* from the default to California's 0xb49b57 is 0xaf9b65. Its green channel is
* *below* both anchors, so it is off the family in a direction a one-parameter
* mix cannot reach. Something had to move, and the choice made here is that
* California the board §12 tuned against a photograph, and the board whose
* desert Southern California is drawing a corner of is the one that does not.
* This is the riskiest of the four rules for that reason.
*/
const SOUTH_ANCHOR: ScenePalette = {
skyTop: 0x7da6c9,
skyHorizon: 0xe9d8bb,
sea: 0x3c6d8b,
lake: 0x4a7d93,
shore: 0xc4b184,
sand: 0xceba8c,
flats: 0xb49b57,
upland: 0x9d8a63,
alpine: 0xb9b3a4,
park: 0x76854e,
parkHigh: 0x3d5739,
};
/**
* `terrain.ts`'s `DEFAULT_PALETTE`, copied rather than imported, plus an
* `alpine` stop it never needed.
*
* Copied because `world.ts` reaches this module and `terrain.worker.ts` reaches
* `world.ts`: importing `terrain.ts` here would drag three.js into the
* heightfield worker, which is a worker that exists precisely so that half a
* million samples of noise cost the page nothing. `reconcile.test.ts` asserts
* the ten stops still agree, so the copy cannot drift silently.
*/
const NORTH_ANCHOR: ScenePalette = {
skyTop: 0x8fb8d8,
skyHorizon: 0xd9e6ee,
sea: 0x4a7a99,
lake: 0x527f9c,
shore: 0xa8a495,
sand: 0xc4b79b,
flats: 0x9d9c93,
upland: 0x8f9084,
park: 0x6f8a5c,
parkHigh: 0x5d7a4c,
alpine: 0xa6a8a2,
};
/** Metres above sea level at which `terrain.ts` starts painting `alpine`. */
const ALPINE_FROM = 1_900;
// ---- The rules -------------------------------------------------------------
/** Scene units across the board, the larger of the two axes. See §12.1. */
function spanUnits(city: City, lngScale: number): number {
const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale;
const lng = (city.bounds.maxLng - city.bounds.minLng) * lngScale;
return Math.max(lat, lng);
}
function lngScaleOf(city: City, on: ReadonlySet<Rule>): number {
const lat = on.has("projection") ? REFERENCE_LAT : city.center.lat;
return city.latScale * Math.cos((lat * Math.PI) / 180);
}
/**
* The tallest ground the pack can produce, in metres, by the same blend
* `World.elevationAt` uses tallest hill plus 35% of the rest evaluated at
* every hill's own summit.
*
* A property of the pack and not of the field, which is what lets the
* exaggeration be derived at construction time. The heightfield's true maximum
* is 2.78.1% higher because the roughness multiplier peaks at 1.18 somewhere
* near a summit; that bias is in the same direction on all three boards and
* divides out of a ratio, and the alternative half a million samples of
* four-octave noise before the first triangle is the cost this whole engine is
* arranged around avoiding.
*
* O(hills²): 469² on California, and 7 ms. Only ever run with the flag on.
*/
export function blendedPeakMetres(city: City): number {
const squash = Math.cos((city.center.lat * Math.PI) / 180);
let best = 0;
for (const at of city.hills) {
let peak = 0;
let total = 0;
for (const hill of city.hills) {
const dLat = at.lat - hill.lat;
const dLng = (at.lng - hill.lng) * squash;
const d = Math.hypot(dLat, dLng) / hill.radius;
if (d >= 1) continue;
const f = (1 - d * d) ** 2;
const h = hill.elevation * f;
total += h;
if (h > peak) peak = h;
}
const here = peak === 0 ? 0 : peak + (total - peak) * 0.35;
if (here > best) best = here;
}
return best;
}
/** (a) One relief-in-frame constant instead of three unrelated exaggerations. */
export function reconciledExaggeration(city: City, lngScale = lngScaleOf(city, new Set())): number {
const metresPerUnit = 111_320 / city.latScale;
const peak = blendedPeakMetres(city);
if (peak <= 0) return city.verticalExaggeration;
return (RELIEF_IN_FRAME * spanUnits(city, lngScale) * metresPerUnit) / peak;
}
/**
* (b) A road's width in true metres, floored at the narrowest legible fraction
* of the board.
*
* `widthM` is the pack's own statement of what the road really is; a pack that
* does not declare one is read as having authored true metres already, which
* both metro packs did.
*/
export function reconciledRoadWidth(city: City, road: Road, lngScale?: number): number {
const metresPerUnit = 111_320 / city.latScale;
const trueMetres = road.widthM ?? road.width * metresPerUnit;
const floor = LEGIBLE_SPAN_FRACTION * spanUnits(city, lngScale ?? lngScaleOf(city, new Set()));
return Math.max(trueMetres / metresPerUnit, floor);
}
/** (c) The coastal ramp as a multiple of the coarse ground cell. */
export function reconciledCoastFalloff(city: City): number {
return COAST_RAMP_CELLS * city.cellLat * (city.coarseFactor ?? 1);
}
/**
* (c) One palette, mixed by how far south and how far inland the board reaches.
*
* The two terms are the two things that separate a golden Central Valley from a
* grey-green Bay Area, and both are already in `bounds`: 37.5°N to 33.0°N for
* the southern term, and the coast at -122.0° to the Colorado at -114.0° for the
* inland one. California lands on 1.00 and gets its own palette back exactly;
* Southern California on 0.77; San Francisco on 0.05, which is the engine
* default it already renders with.
*/
export function reconciledPaletteMix(city: City): number {
const south = (37.5 - city.bounds.minLat) / (37.5 - 33.0);
const inland = (city.bounds.maxLng + 122.0) / 8.0;
return Math.min(1, Math.max(0, 0.5 * south + 0.5 * inland));
}
const CHANNELS = ["skyTop", "skyHorizon", "sea", "lake", "shore", "sand", "flats", "upland", "park", "parkHigh"] as const;
function mixChannel(a: number, b: number, t: number): number {
const r = Math.round((((a >> 16) & 0xff) * (1 - t) + ((b >> 16) & 0xff) * t));
const g = Math.round((((a >> 8) & 0xff) * (1 - t) + ((b >> 8) & 0xff) * t));
const bl = Math.round(((a & 0xff) * (1 - t) + (b & 0xff) * t));
return (r << 16) | (g << 8) | bl;
}
export function reconciledPalette(city: City): ScenePalette {
const t = reconciledPaletteMix(city);
const out = {} as ScenePalette;
for (const key of CHANNELS) out[key] = mixChannel(NORTH_ANCHOR[key], SOUTH_ANCHOR[key], t);
// A board gets the alpine stop when it has ground above the snow line, which
// is the fact `alpine` is about. San Francisco's tallest is 1,186 m and does
// not; the other two do. Today that is authored, one pack at a time.
if (blendedPeakMetres(city) > ALPINE_FROM) {
out.alpine = mixChannel(NORTH_ANCHOR.alpine ?? 0, SOUTH_ANCHOR.alpine ?? 0, t);
}
return out;
}
// ---- Putting it back into a pack -------------------------------------------
const cache = new WeakMap<City, Map<string, City>>();
/**
* The pack the engine should actually draw.
*
* Returns the argument **by identity** when nothing is on, and the same object
* for the same pack and the same rule set when something is, so a `World` and
* the `World` its worker rebuilds agree without either of them knowing about the
* other.
*/
export function reconciledCity(city: City): City {
if (city.reconciled === true) return city;
const on = activeRules();
if (on.size === 0) return city;
const key = RULES.filter((rule) => on.has(rule)).join(",");
let byRules = cache.get(city);
if (byRules === undefined) cache.set(city, (byRules = new Map()));
const hit = byRules.get(key);
if (hit !== undefined) return hit;
const lngScale = lngScaleOf(city, on);
const next: City = { ...city, reconciled: true };
if (on.has("projection")) next.lngScale = lngScale;
if (on.has("exaggeration")) next.verticalExaggeration = reconciledExaggeration(city, lngScale);
if (on.has("roads")) {
next.roads = city.roads.map((road) => ({
...road,
width: reconciledRoadWidth(city, road, lngScale),
}));
}
if (on.has("ground")) {
next.coastFalloff = reconciledCoastFalloff(city);
next.palette = reconciledPalette(city);
}
byRules.set(key, next);
return next;
}
/**
* What the reconciliation did to a pack, for a script that wants to print it.
*
* Here rather than in the script so the numbers a report quotes and the numbers
* the engine draws come from one place.
*/
export interface Reconciliation {
id: string;
spanUnits: number;
peakMetres: number;
exaggeration: { before: number; after: number };
reliefInFrame: { before: number; after: number };
roadMetres: { before: [number, number]; after: [number, number] };
coastFalloff: { before: number; after: number };
lngMetresPerDegree: { before: number; after: number };
paletteMix: number;
}
export function describe(city: City): Reconciliation {
const metresPerUnit = 111_320 / city.latScale;
const before = lngScaleOf(city, new Set());
const after = lngScaleOf(city, new Set(RULES));
const peak = blendedPeakMetres(city);
const spanBefore = spanUnits(city, before);
const spanAfter = spanUnits(city, after);
const exAfter = reconciledExaggeration(city, after);
const widths = (pack: City, scale: number, use: boolean): [number, number] => {
const metres = pack.roads.map((road) =>
use ? reconciledRoadWidth(pack, road, scale) * metresPerUnit : road.width * metresPerUnit,
);
return [Math.min(...metres), Math.max(...metres)];
};
return {
id: city.id,
spanUnits: spanAfter,
peakMetres: peak,
exaggeration: { before: city.verticalExaggeration, after: exAfter },
reliefInFrame: {
before: ((peak / metresPerUnit) * city.verticalExaggeration) / spanBefore,
after: ((peak / metresPerUnit) * exAfter) / spanAfter,
},
roadMetres: { before: widths(city, before, false), after: widths(city, after, true) },
coastFalloff: { before: city.coastFalloff, after: reconciledCoastFalloff(city) },
lngMetresPerDegree: {
before: (before / city.latScale) * 111_320,
after: (after / city.latScale) * 111_320,
},
paletteMix: reconciledPaletteMix(city),
};
}
+144
View File
@@ -520,6 +520,150 @@ export interface AerialFog {
far: number;
}
/**
* How long the board takes to disappear into its own haze, and how long the next
* one takes to come out of it. Seconds.
*
* ## Why a fog dip is the transition, and a crossfade is not
*
* Two independent reasons, either one sufficient.
*
* **A crossfade cannot be afforded.** Every both-boards-live pairing breaks a
* desktop triangle cap: California + the Bay Area is 2,640,307 against a cap of
* 2,600,000; California + the Southland is 1,805,344 against 1,700,000; the two
* metros together are 3,694,949 against everything. Rendering only the outgoing
* board's terrain still breaks the California direction (586,535 against
* 440,000). And `scripts/performance-budget.mjs` samples eight seconds after a
* three-second warm-up, so a 0.8 s fade never lands inside the window shipping
* one would create exactly the cap-you-do-not-measure this repo argues against.
*
* **A crossfade should not be wanted.** The three packs draw three different
* Californias, and a true dissolve at a matched pose puts the two loudest
* differences on screen simultaneously and in register: terrain and buildings
* deflate 4.17x across the California-to-Bay seam and 4.41x into the Southland,
* because `verticalExaggeration` is 15 / 3.6 / 3.4; and the Southland slides up
* to 4,025 m sideways, because `World.lngScale` squashes longitude by the cosine
* of each pack's *own* centre latitude. A dissolve does not hide a 4.2x
* deflation, it stages it. A dip to haze hides the deflation, the slide and
* California's 1,919 m-wide freeway symbols all at once, because all three
* happen at maximum obscuration and it costs zero triangles, zero draw calls
* and no render target, because `SceneHandle.setAerialFog` already exists and
* `main.ts` already drives it on every controls `change`.
*
* Out is shorter than in on purpose: leaving is an instruction the visitor just
* gave and wants obeyed, arriving is a picture they are being shown.
*/
export const FOG_DIP_OUT_SECONDS = 0.3;
export const FOG_DIP_IN_SECONDS = 0.5;
/**
* The fog pair that hides a board, in that board's own scene units.
*
* `near: 0` and a far plane at a small fraction of the board's span puts every
* fragment past the far plane, which `fog_fragment` resolves to the fog colour
* outright a flat field of the horizon's own colour, which is what the sky
* dome behind it is already painted with, so the two meet without a seam.
*
* The far plane is not zero, and the reason is the night. The night fog colour
* lands around #16203a and `atmosphere.ts` has already had to floor the night
* ground rig once to stop a fog plane behind the camera turning the whole map
* off. A collapse that leaves a few per cent of the board's span in front of the
* camera keeps the nearest relief faintly readable through the haze, so the dip
* reads as weather rather than as a power cut.
*/
/**
* How deep the dip goes, as a fraction of how far the camera is standing back.
*
* ## It is measured against the camera, not against the board, and that is a fix
*
* The first version collapsed the far plane to 6% of the board's span. It hid
* everything, which was the intent, and at night it hid everything *uniformly*:
* photographed at 04:35Z the whole frame was one flat field of #16203a with no
* relief, no coastline and no horizon in it. That is the failure
* `atmosphere.ts` already had to floor the night ground rig once for a dip
* that turns the map off rather than putting weather on it and it reads as a
* wipe rather than as a descent through haze.
*
* Against the camera it behaves. `fog_fragment` is linear between the two
* planes, so a far plane at 70% of the stand-off leaves the *near* limb of the
* board at roughly 85% obscuration and everything at or beyond the orbit target
* at 100%: the land keeps a faint shape while the relief which is what the
* seam actually changes, by 4.17x goes completely. Losing the mountains and
* keeping the coastline is the right thing to lose, because the coastline is the
* half that is continuous across the seam.
*
* It also scales itself. The same fraction is a plan view of a state and a
* street-level oblique, with no per-board number to keep in step.
*/
export const FOG_DIP_REACH_FRACTION = 0.7;
/**
* The near plane sits a quarter of the way in rather than on the camera, and
* that is arithmetic rather than taste.
*
* `dipFog` interpolates in ratios, and a ratio cannot reach zero from a positive
* start so a collapsed `near` of exactly 0 has to be lerped linearly while
* `far` is lerped geometrically, and the two cross partway through: measured on
* the Bay Area's clear pair, the near plane overtakes the far plane at about the
* midpoint of the dip and `scene.fog` is then a pair that draws nothing
* coherent. Keeping both distances positive and proportional keeps the whole
* dip on one curve, and `near < far` for every value of `t` falls out of the
* arithmetic instead of needing a guard.
*/
const FOG_DIP_NEAR_FRACTION = 0.25;
/**
* The fog pair that hides a board, in that board's own scene units.
*
* `reachUnits` is how far the camera is standing back from what it is looking
* at, in **scene units of the board being hidden** the fog planes are scene
* distances, and California's unit is twenty times the Bay Area's, so one shared
* pair would leave one board unfogged and wash the other out completely.
*
* The caller floors it against the board's own span, because a camera that has
* been dragged onto its target has a stand-off near zero and a fog pair of
* (0, 0) draws nothing at all.
*/
export function collapsedFog(reachUnits: number): AerialFog {
const far = Math.max(1e-3, reachUnits * FOG_DIP_REACH_FRACTION);
return { near: far * FOG_DIP_NEAR_FRACTION, far };
}
/**
* One step of a dip, as a pure function of how far through it is.
*
* **Not a third source of fog.** Both endpoints are pairs `Atmosphere` authored
* the live aerial perspective at one end, `collapsedFog` at the other so
* CONTRACT §4's single direction is intact: this interpolates between two of
* this module's own answers and cannot invent a colour, a sun or a sky. It is
* here rather than in `main.ts` for that reason, and because it is the only part
* of the transition worth testing without a GPU.
*
* Geometric rather than linear, because fog distances are perceived in ratios:
* a far plane stepping 3,900 -> 2,000 -> 1,000 -> 500 units looks like an even
* fade, and 3,900 -> 2,925 -> 1,950 -> 975 looks like nothing at all followed by
* a slam. `t` is clamped, so a caller that overshoots its own clock lands on the
* endpoint rather than past it.
*/
export function dipFog(from: AerialFog, to: AerialFog, t: number): AerialFog {
// `NaN` is a caller whose clock went wrong, and the honest answer to that is
// the pair it started from rather than a fog of `NaN`, which three writes
// straight into a uniform and renders as an untextured void.
const clamped = Number.isFinite(t) ? t : 0;
// The endpoints are returned rather than computed. `a * (b / a) ** 1` is not
// `b` in floating point, and a transition that settles a rounding error away
// from the pair `Atmosphere` authored leaves the board very slightly wrong for
// as long as nothing else recomputes the fog.
if (clamped <= 0) return { near: from.near, far: from.far };
if (clamped >= 1) return { near: to.near, far: to.far };
const u = clamped * clamped * (3 - 2 * clamped);
const ratio = (a: number, b: number): number => {
if (a <= 0 || b <= 0) return a + (b - a) * u;
return a * Math.pow(b / a, u);
};
return { near: ratio(from.near, to.near), far: ratio(from.far, to.far) };
}
export interface Atmosphere {
/**
* The rig this observation implies. Pure; the caller applies the result.
+168
View File
@@ -0,0 +1,168 @@
/**
* Which boards stay in memory, and which one gets thrown away first.
*
* ## Why a cache at all
*
* Because most of a board switch was never work. Measured on this box against
* `dist@bcac6aa` with a real GPU: switching to the Bay Area covers the screen
* with the opaque boot card for 1,711 ms, the Southland for 1,079 and California
* for 997 but only 604 / 372 / 222 ms of that **blocks the main thread**, and
* the page draws 68 / 45 / 49 frames inside the gap. Sixty-five to seventy-eight
* per cent of every pause is a live, animating page with nothing to draw,
* because `mountCity` disposed the outgoing board before the incoming one
* existed. Keeping it turns a full-screen modal into a live picture that
* freezes for a fraction of a second, and it costs no triangles: `Stage`
* retains and pauses by contract, `tick()` renders only the current scene, and a
* paused board is zero draw calls.
*
* It also recovers the shader cache, which disposal was throwing away. `three`
* refcounts programs per material and deletes at zero, so disposing a board's
* materials deletes its programs: `linkProgram` ran 38, 59, 78, 109 and 127
* times across five mounts and never once reused, and a second visit to the Bay
* Area was only 115 ms faster than the first. Retained, a return costs nothing.
*
* ## Why two deep is enough
*
* The `sf` and `socal` bounds do not intersect (37.1838.03 N against
* 33.2834.36 N) and California contains both, so the set of boards that can
* describe any one camera position is always `{california}` or
* `{california, one metro}` never three. Measured GPU vertex and index bytes
* are california 5.86 MB, sf 14.12 and socal 8.06, plus heightfields of 0.49,
* 3.06 and 0.77; California and the worst metro resident together is about
* 40 MB, and all three about 60. `deviceProfile().handheld` changes only the
* pixel ratio and the shadow map, so a phone carries the same resident geometry
* as a laptop against a much smaller WebGL budget hence two on a handheld and
* three on a desktop, and `residentCapacity` is the one place that says so.
*
* ## What this module deliberately does not know
*
* What a board *is*. It holds an opaque payload, so the record bundling the
* sixteen per-board variables `main.ts` used to tear down can stay in `main.ts`
* where its types live, and this file stays a pure, testable eviction policy
* with no DOM, no WebGL and no `three` import. The caller disposes what it is
* handed back; nothing here frees anything, for the same reason `Stage` does not
* (CONTRACT.md §1).
*/
/**
* How many boards may be resident at once, including the pinned one.
*
* Three on a desktop is every board this build has, which is what makes the
* SoCal-to-Bay-Area rebuild disappear as well as the return to California. Two
* on a handheld is California plus whichever metro is on screen, which is the
* provably sufficient set above.
*/
export function residentCapacity(handheld: boolean): number {
return handheld ? 2 : 3;
}
export interface BoardCacheOptions {
/**
* Board ids that are never evicted.
*
* California, in practice: it is the cheapest board (5.86 MB) and the root of
* every descent, so it is the one most likely to be asked for again and the
* one that costs least to keep.
*/
pinned?: readonly string[];
/** Resident boards, including pinned ones. See `residentCapacity`. */
capacity: number;
}
export interface BoardCache<T> {
get(id: string): T | null;
has(id: string): boolean;
/** Every resident id, least-recently-shown first. */
ids(): readonly string[];
size(): number;
/**
* Store a board and mark it the most recently shown.
*
* Returns whatever had to be evicted to stay inside `capacity` the caller's
* job to dispose, and it must not forget `environmentRig.release(scene)`: the
* rig's ledger is a strong reference, and a board left in it is the whole
* scene graph retained.
*/
put(id: string, value: T): readonly T[];
/** Mark an already-resident board as the most recently shown. */
touch(id: string): void;
/** Take one out without evicting anything else. */
remove(id: string): T | null;
/** Take everything out, most-recently-shown last. */
drain(): readonly T[];
}
export function createBoardCache<T>(options: BoardCacheOptions): BoardCache<T> {
const pinned = new Set(options.pinned ?? []);
const capacity = Math.max(1, Math.floor(options.capacity));
/**
* Insertion order is recency order.
*
* A `Map` iterates in insertion order and `delete` + `set` is how a key is
* moved to the end of it, so the least recently shown board is simply the
* first key the iterator yields. That is the whole LRU and it needs no
* timestamps which matters, because a timestamp would have to come from a
* clock, and this module has no business reading one.
*/
const entries = new Map<string, T>();
function evictable(protectedId: string | null): string | null {
for (const id of entries.keys()) {
if (pinned.has(id)) continue;
if (id === protectedId) continue;
return id;
}
return null;
}
return {
get: (id) => entries.get(id) ?? null,
has: (id) => entries.has(id),
ids: () => [...entries.keys()],
size: () => entries.size,
put(id, value) {
const evicted: T[] = [];
// Replacing an id in place is not an eviction and must not free the
// incoming board — the common shape of that mistake is a rebuild racing a
// switch, which the caller's `AbortController` already prevents, and this
// is the second line of defence.
const previous = entries.get(id);
if (previous !== undefined && previous !== value) evicted.push(previous);
entries.delete(id);
entries.set(id, value);
while (entries.size > capacity) {
const victim = evictable(id);
// Nothing left that may be freed: every remaining board is pinned or is
// the one being shown. Over capacity is the correct outcome — a pin is a
// statement that the board matters more than the ceiling does.
if (victim === null) break;
const record = entries.get(victim);
entries.delete(victim);
if (record !== undefined) evicted.push(record);
}
return evicted;
},
touch(id) {
const value = entries.get(id);
if (value === undefined) return;
entries.delete(id);
entries.set(id, value);
},
remove(id) {
const value = entries.get(id);
if (value === undefined) return null;
entries.delete(id);
return value;
},
drain() {
const all = [...entries.values()];
entries.clear();
return all;
},
};
}
+433
View File
@@ -0,0 +1,433 @@
/**
* One ladder over three boards: every authored chapter as a rung, ordered by
* how far the camera stands off.
*
* ## Why this file exists
*
* The product ships three city packs and, until this module, the interface said
* so out loud: a strip of three tabs naming three *modes*, each swapping a
* different list of chapters underneath it. That is the single most direct way
* to tell a visitor they are looking at three products rather than one place.
* The boards themselves are not the problem measured, the 20x horizontal
* scale jump between California and the Bay Area is *invisible*, because
* `World.project` is a uniform scale in x/z with no vertical term and a uniform
* scale leaves a perspective image identical. What announces three boards is the
* chrome around them, and this is the data that lets the chrome stop.
*
* ## Stand-off, and not altitude
*
* The rungs are ordered by `cameraStandoffMetres` the true-metre distance from
* the camera to what it is looking at and that choice is measured rather than
* aesthetic. Sorted by *altitude* the three boards interleave badly: SoCal's
* Orange County chapter sits 5.97 km up and the Bay Area's Peninsula 3.93 km,
* both of them inside California's own range, and an altitude cannot tell a low
* oblique from a high plan. Sorted by stand-off they separate cleanly
* California bottoms out at 242 km, the Southland's whole-board pose is 108.8 km
* and the Bay Area's is 71.2 km. Stand-off is what decides how much earth is in
* frame, which is what decides which board can fill it.
*
* Both numbers are read straight off the authored `focus` block and are
* therefore facts about the *pack*, not about the runtime: `standoffM` is
* `hypot(distance, height) x metresPerUnit` and `altitudeM` is
* `height x metresPerUnit / verticalExaggeration`, which is exactly what
* `SceneHandle.cameraStandoffMetres()` and `cameraAltitudeMetres()` report once
* the camera has arrived. Deriving them here rather than measuring them at
* runtime is what keeps this module pure and the ladder stable across a resize.
* (`chapterFraming` scales a pose on a narrow window; that is a framing
* correction for a phone, not a change to what the chapter *is*.)
*
* ## What this module is not allowed to do
*
* **It never edits a pack.** Twenty-nine capture guards in `scripts/brand-assets`
* aim at `#chapters .chapter` by index and assert the button's `shortLabel`, so
* a pack whose chapter order or short labels moved would silently re-shoot every
* still on lumbridgecorp.com. Two rungs currently read "Whole Board" and two read
* "The Valley"; both collisions are resolved *here*, in `LADDER_LABELS`, and the
* packs keep the strings the guards assert on. The ladder is a new **view** over
* the same data, never a replacement for it.
*
* **It holds the handover table, and `City` does not.** `handoverStandoffM` is a
* fact about how the *ladder* hands one board to the next, and putting it on the
* `City` type would widen a pack a file that must stay pure, reviewable data
* for the benefit of one consumer. It lives in `HANDOVER_STANDOFF_M` below, next
* to the argument for its value.
*/
import type { Chapter, City } from "./types.ts";
/** The three regions a rung can belong to, and the order they descend in. */
export type LadderRegion = "state" | "southland" | "bay";
/** The heading the places list prints above each region's rungs. */
export const REGION_LABELS: Readonly<Record<LadderRegion, string>> = Object.freeze({
state: "The state",
southland: "The Southland",
bay: "The Bay",
});
/** Which region a board id belongs to. */
export const BOARD_REGION: Readonly<Record<string, LadderRegion>> = Object.freeze({
california: "state",
socal: "southland",
sf: "bay",
});
/**
* The stand-off, in true metres, below which a board is willing to take the
* camera from the one above it. Authored, and deliberately not derived.
*
* The natural derived rule hand over only once the incoming board's bounds
* cover the frame computes a maximum stand-off of 31.6 km for a target at
* downtown San Francisco with a 63.1 degree horizontal field of view, because
* the board's west edge is only 19.4 km away. The Bay Area's own whole-board
* chapter stands off 71.2 km, and the shipped marketing still for it shows the
* board's northern land edge across the top of the frame. So the derived rule
* would refuse the board at its own opening pose. One authored number per pack
* is honest, is one line, and is checkable against a photograph.
*
* California is absent: it is the root of every descent and hands over to
* nothing. `handoverStandoff` returns `Infinity` for it, which reads correctly
* in the comparison below without a special case.
*/
export const HANDOVER_STANDOFF_M: Readonly<Record<string, number>> = Object.freeze({
sf: 71_200,
socal: 108_800,
});
export function handoverStandoff(boardId: string): number {
return HANDOVER_STANDOFF_M[boardId] ?? Number.POSITIVE_INFINITY;
}
/**
* The labels the ladder uses where a pack's own would collide.
*
* Keyed `board:chapter`. Four entries, and every one of them exists because two
* packs independently chose the same words for two different places: both metro
* packs open on a chapter labelled "Whole Board", and both call a chapter "The
* Valley" the San Fernando one and the silicon one. A list that merges the
* three packs has to say which is which, and it has to say it without touching
* `shortLabel`, which the capture guards assert on.
*/
const LADDER_LABELS: Readonly<Record<string, string>> = Object.freeze({
"socal:all": "The Southland",
"sf:all": "The Bay Area",
// Its pack label is also "The Bay Area", which the whole-board rung above has
// just taken. Its short label is unique and is the better name anyway: this is
// the wide plan of the water, not the board.
"sf:bay-area": "The Bay",
});
/**
* A chapter on the California board that is a **door** rather than a camera
* pose, and the rung it folds onto.
*
* `main.ts` matches these two ids against `CALIFORNIA_DESTINATIONS` and calls
* `switchCity()` instead of `flyTo()`, so their `focus` blocks are dead for the
* camera and live only as dots on the plan. As rungs they would be duplicates:
* two entries at 88.4 km of stand-off called "LA" and "SF" sitting between the
* Southland's whole-board rung and the Bay Area's, which are the two places they
* go. So they fold on, and the ladder is 24 rungs rather than 26.
*/
const FOLDED_DOORS: Readonly<Record<string, string>> = Object.freeze({
"california:los-angeles": "socal:all",
"california:san-francisco": "sf:all",
});
/** One rung: a place you can be, and how far back you stand to see it. */
export interface LadderRung {
/** `board:chapter`. Unique across the ladder; the ladder's own identity. */
key: string;
/** The chapter id **on its own board**, unchanged from the pack. */
id: string;
board: string;
region: LadderRegion;
/** Unique across the ladder. See `LADDER_LABELS`. */
label: string;
/** The pack's own short label, untouched — the string the capture guards assert. */
shortLabel: string;
/** The pack's own chapter number, untouched. */
number: string;
description: string;
lat: number;
lng: number;
rotation: number;
/** True metres from the camera to the target at this chapter's authored pose. */
standoffM: number;
/** True metres of air under the camera, vertical exaggeration divided out. */
altitudeM: number;
/**
* The California door chapter that folded onto this rung, if one did. Kept so
* the places list can say a rung is reachable from the state board, and so a
* click on it from California takes the authored door rather than inventing a
* second route.
*/
doorFrom?: { board: string; id: string };
}
/** What `buildLadder` needs from a board. A `City` satisfies it. */
export interface LadderBoard {
id: string;
city: Pick<City, "chapters" | "latScale" | "verticalExaggeration">;
}
/** Metres per scene unit, horizontally. The same arithmetic `World` does. */
function metresPerUnit(latScale: number): number {
return 111_320 / latScale;
}
export function chapterStandoffMetres(chapter: Chapter, latScale: number): number {
return Math.hypot(chapter.focus.distance, chapter.focus.height) * metresPerUnit(latScale);
}
export function chapterAltitudeMetres(
chapter: Chapter,
latScale: number,
verticalExaggeration: number,
): number {
return (chapter.focus.height * metresPerUnit(latScale)) / verticalExaggeration;
}
/**
* Every authored chapter of every board, as one array sorted **descending by
* stand-off**, with the two California doors folded onto the metro rungs they
* open.
*
* This is the ordered array the brief asks for and it is the coordinate the
* handover rule reads. It is *not* the order the places list prints see
* `placesRows`, which groups it, and the note there for why a global sort
* produces nine headings for three regions.
*/
export function buildLadder(boards: readonly LadderBoard[]): readonly LadderRung[] {
const rungs: LadderRung[] = [];
const doors: { key: string; onto: string }[] = [];
for (const board of boards) {
const region = BOARD_REGION[board.id] ?? "state";
for (const chapter of board.city.chapters) {
const key = `${board.id}:${chapter.id}`;
const onto = FOLDED_DOORS[key];
if (onto !== undefined) {
doors.push({ key, onto });
continue;
}
rungs.push({
key,
id: chapter.id,
board: board.id,
region,
label: LADDER_LABELS[key] ?? chapter.label,
shortLabel: chapter.shortLabel,
number: chapter.number,
description: chapter.description,
lat: chapter.focus.lat,
lng: chapter.focus.lng,
rotation: chapter.focus.rotation,
standoffM: chapterStandoffMetres(chapter, board.city.latScale),
altitudeM: chapterAltitudeMetres(
chapter,
board.city.latScale,
board.city.verticalExaggeration,
),
});
}
}
for (const door of doors) {
const rung = rungs.find((candidate) => candidate.key === door.onto);
if (!rung) continue;
const [board = "", id = ""] = door.key.split(":");
rung.doorFrom = { board, id };
}
// Descending, and ties broken by key so the array is a pure function of the
// packs. Two rungs at an identical stand-off is not hypothetical — the two
// folded California doors shared one before they folded.
rungs.sort((a, b) => b.standoffM - a.standoffM || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
return rungs;
}
/**
* The ladder as the places list prints it: grouped by region, groups ordered by
* their own top rung, rungs inside a group still descending by stand-off.
*
* ## Why the printed list is grouped and the array is not
*
* Because a globally interleaved order is unreadable. Sorted purely by stand-off
* the twenty-four rungs alternate Southland, Bay, Southland, Bay eleven times
* 108.8 km Southland, 71.2 km Bay, 38.6 Southland, 30.1 Southland, 30.0 Bay,
* 28.8 Southland, 26.3 Bay so a list that printed a heading whenever the
* region changed would print **nine headings for three regions**. That is
* noise, and it argues for three boards more loudly than the tab strip it
* replaces.
*
* Grouping keeps one ordering rule stand-off, everywhere and gives the three
* headings the brief asks for. The groups themselves are ordered by their top
* rung, so the page still reads top to bottom as a descent: the state from
* 1,551 km down to 242, the Southland from 108.8 km down to 9.1, the Bay from
* 71.2 km down to 3.9.
*/
export function placesRows(ladder: readonly LadderRung[]): readonly LadderRung[] {
const regions: LadderRegion[] = ["state", "southland", "bay"];
const top = new Map<LadderRegion, number>();
for (const rung of ladder) {
const seen = top.get(rung.region);
if (seen === undefined || rung.standoffM > seen) top.set(rung.region, rung.standoffM);
}
const ordered = regions
.filter((region) => top.has(region))
.sort((a, b) => (top.get(b) ?? 0) - (top.get(a) ?? 0));
const rows: LadderRung[] = [];
for (const region of ordered) {
for (const rung of ladder) if (rung.region === region) rows.push(rung);
}
return rows;
}
/**
* Which rung the camera is standing on, for the highlight that makes the list
* an altimeter rather than a menu.
*
* Two rules, in this order, and the order is the whole design:
*
* 1. **If the camera is where a chapter put it, that chapter wins.** Within
* `PARKED_TOLERANCE` of its authored stand-off, the rung you clicked stays
* lit even when a neighbour's number is arithmetically nearer. Four of the
* Bay Area's rungs sit inside 12% of each other (Marin 21.5 km, Silicon
* Valley 20.7, San Jose 19.6, the East Bay 19.2), and a highlight that
* hopped between them the instant you arrived would read as a bug.
* 2. **Otherwise the nearest stand-off on the resident board wins**, in log
* space, because the ladder spans four orders of magnitude and a linear
* "nearest" would pin everything below 20 km onto one rung.
*
* `currentId` is the resident board's own current chapter `SceneHandle.current()`
* and `null` is a legitimate answer: a camera dragged out past the top rung of
* a board is not standing on any of them.
*/
const PARKED_TOLERANCE = 0.18;
export function activeRung(
ladder: readonly LadderRung[],
boardId: string,
standoffM: number,
currentId: string | null,
): LadderRung | null {
const onBoard = ladder.filter((rung) => rung.board === boardId);
if (onBoard.length === 0) return null;
if (currentId !== null) {
const parked = onBoard.find((rung) => rung.id === currentId);
if (parked && Math.abs(standoffM - parked.standoffM) <= parked.standoffM * PARKED_TOLERANCE) {
return parked;
}
}
if (!Number.isFinite(standoffM) || standoffM <= 0) {
return onBoard.find((rung) => rung.id === currentId) ?? null;
}
let best: LadderRung | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const rung of onBoard) {
if (rung.standoffM <= 0) continue;
const distance = Math.abs(Math.log(standoffM) - Math.log(rung.standoffM));
if (distance < bestDistance) {
bestDistance = distance;
best = rung;
}
}
return best;
}
/**
* Whether the camera should be handed from one board to another, and to which.
*
* Pure, and it is the whole of the free-camera board rule. Three inputs and no
* state beyond what the caller passes back in:
*
* - `standoffM` the live `cameraStandoffMetres()`, already true metres and
* already free of both scale and exaggeration.
* - `target` where the orbit is pointed, in lat/lng. A board is only a
* candidate if it contains this.
* - `current` the resident board, so the answer can be "stay".
*
* ## The hysteresis band, and why it is not optional
*
* A camera parked exactly on a threshold and nudged by a wheel notch would tear
* down and rebuild a board on every notch. Promotion happens below
* `handover x PROMOTE`, demotion above `handover x DEMOTE`, and the gap between
* them is dead air where the answer is always "stay". `dragging` is the second
* guard: `OrbitControls` fires `change` on every damped frame of a drag, and a
* board swap in the middle of one takes the pointer away from the thing it is
* holding.
*
* ## It has to be allowed to say no
*
* The two metro boards cover 25,884 km² of California's 989,000 about 55% of
* the population and 2.6% of the area. Two wheel notches down from the state
* pose over Fresno lands on 806 m lots and 2 km-wide freeway symbols with
* nothing finer to hand over to, and the honest answer there is to stay on
* California and let the descent look coarse. `null` is that answer and it is
* the common case.
*/
export const PROMOTE = 0.9;
export const DEMOTE = 1.15;
export interface HandoverBoard {
id: string;
bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
/** Metres per scene unit. The finest board wins a tie of candidates. */
metresPerUnit: number;
}
export interface HandoverQuery {
boards: readonly HandoverBoard[];
current: string;
lat: number;
lng: number;
standoffM: number;
dragging?: boolean;
}
export function handover(query: HandoverQuery): string | null {
if (query.dragging === true) return null;
if (!Number.isFinite(query.standoffM) || query.standoffM <= 0) return null;
const contains = (board: HandoverBoard): boolean =>
query.lat >= board.bounds.minLat &&
query.lat <= board.bounds.maxLat &&
query.lng >= board.bounds.minLng &&
query.lng <= board.bounds.maxLng;
const currentBoard = query.boards.find((board) => board.id === query.current);
// Demote first: a camera that has climbed above this board's own handover
// ceiling belongs to whatever contains it at a coarser scale, and asking about
// promotion from a board you have already left is how an oscillation starts.
if (currentBoard !== undefined) {
const ceiling = handoverStandoff(currentBoard.id);
if (Number.isFinite(ceiling) && query.standoffM > ceiling * DEMOTE) {
const coarser = query.boards
.filter(
(board) =>
board.id !== query.current &&
contains(board) &&
board.metresPerUnit > currentBoard.metresPerUnit,
)
.sort((a, b) => a.metresPerUnit - b.metresPerUnit)[0];
return coarser?.id ?? null;
}
}
const candidates = query.boards.filter(
(board) =>
board.id !== query.current &&
contains(board) &&
(currentBoard === undefined || board.metresPerUnit < currentBoard.metresPerUnit) &&
query.standoffM < handoverStandoff(board.id) * PROMOTE,
);
if (candidates.length === 0) return null;
// Finest wins. `sf` and `socal` bounds do not intersect and California
// contains both, so the candidate set here is never larger than one — but the
// rule is written for the general case rather than for today's three packs.
return candidates.sort((a, b) => a.metresPerUnit - b.metresPerUnit)[0]?.id ?? null;
}
+51 -10
View File
@@ -21,11 +21,19 @@
* engine already paid for:
*
* - **Nothing here knows how big a board is.** Every coordinate is derived
* from `city.bounds` through the same `World` the scene uses. `scene.ts`
* from one rectangle `MinimapOptions.frame`, which defaults to
* `city.bounds` through the same `World` the scene uses. `scene.ts`
* documents what the alternative cost: camera limits tuned for San
* Francisco's 230-unit board silently became a property of the engine, and
* the Bay Area's 1003 units could not be framed at all. A minimap with a
* hardcoded extent would fail the same way and look like a rendering bug.
*
* **Amended.** The rule used to say `city.bounds` outright, and that was
* exactly right while a board was one framing and exactly wrong once a board
* became a rung of a ladder a plan that changes shape when the world has
* not is the loudest "you are somewhere else now" signal on the screen. So
* the rectangle is now the caller's to choose and the derivation is
* unchanged. See `MinimapOptions.frame`.
* - **`tick()` runs inside the stage's frame loop**, so it allocates nothing
* and tessellates nothing in the steady state. The map itself coastline,
* relief, roads is rasterised once into an offscreen surface and blitted;
@@ -73,6 +81,34 @@ export interface MinimapOptions {
/** The live orbit controls. `controls.target` is the crosshair. */
controls: OrbitControls;
markerPalette?: MarkerPalette;
/**
* The geographic rectangle the plan **draws**, which is no longer the same
* thing as the board's own bounds. Defaults to `city.bounds`.
*
* ## Why this exists, against this file's own first rule
*
* The header above states the rule "nothing here knows how big a board is
* every coordinate is derived from `city.bounds`", and that rule is exactly
* right while a board is *one framing* and exactly wrong once a board is a
* rung of a ladder. Both metro rectangles are arbitrary crops: the Bay Area's
* is 0.85 by 0.89 degrees and the Southland's is 1.08 by 1.66, so the plan
* came out square on one board and landscape on the other while the world
* underneath had not changed at all. A plan that changes **shape** when the
* world does not is the loudest "you are somewhere else now" signal on the
* screen, and it fires in the same frame the fog dip is trying to hide the
* change.
*
* The rule this replaces it with is narrower rather than looser: the plan is
* still derived entirely from a rectangle handed to it, and it still knows
* nothing about *which* board it is drawing. It has simply stopped assuming
* that rectangle is the board's own.
*
* The frame must **contain** `city.bounds`, or the board runs off its own
* plan. Nothing here enforces that, because a frame that deliberately crops is
* a legitimate thing to want and a runtime assertion would be a second
* opinion about a caller's intent.
*/
frame?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
/** Fires when the user clicks, drags or commits a keyboard seek. */
onSeek?(lat: number, lng: number): void;
/** Fires on hover, and once with `null` when the pointer leaves. */
@@ -183,18 +219,23 @@ export function createMinimap(options: MinimapOptions): Minimap {
// ---- The board ------------------------------------------------------------
/**
* The board in scene units, from the city's own bounds. `projectZ` negates
* latitude north is `-z` so the northern edge is the *smaller* z and the
* corners have to be taken in that order or the map comes out upside down.
* The drawn rectangle in scene units. `projectZ` negates latitude north is
* `-z` so the northern edge is the *smaller* z and the corners have to be
* taken in that order or the map comes out upside down.
*
* `frame`, not `city.bounds`: see `MinimapOptions.frame`. Everything below
* this line is unchanged, because everything below this line was already
* written against these six numbers rather than against a board.
*/
const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
const frame = options.frame ?? city.bounds;
const [westX, northZ] = world.project(frame.maxLat, frame.minLng);
const [eastX, southZ] = world.project(frame.minLat, frame.maxLng);
const boardW = eastX - westX;
const boardH = southZ - northZ;
const boardSpan = Math.max(boardW, boardH);
const latSpan = city.bounds.maxLat - city.bounds.minLat;
const lngSpan = city.bounds.maxLng - city.bounds.minLng;
const latSpan = frame.maxLat - frame.minLat;
const lngSpan = frame.maxLng - frame.minLng;
// Layout, in device pixels. Everything is recomputed by `layout()`.
let dpr = 1;
@@ -401,9 +442,9 @@ export function createMinimap(options: MinimapOptions): Minimap {
const elevation = new Float32Array(shadeCols * shadeRows);
for (let j = 0; j < shadeRows; j++) {
const lat = city.bounds.maxLat - ((j + 0.5) / shadeRows) * latSpan;
const lat = frame.maxLat - ((j + 0.5) / shadeRows) * latSpan;
for (let i = 0; i < shadeCols; i++) {
const lng = city.bounds.minLng + ((i + 0.5) / shadeCols) * lngSpan;
const lng = frame.minLng + ((i + 0.5) / shadeCols) * lngSpan;
elevation[j * shadeCols + i] = world.elevationSampled(lat, lng);
}
}
+20 -1
View File
@@ -442,6 +442,23 @@ export interface SceneOptions {
* parked on the stage defeats that.
*/
signal?: AbortSignal;
/**
* Put this scene on the stage when it is finished. Defaults to **true**.
*
* Construction used to imply presentation: the last statement before the
* handle was returned was `stage.setScene(stageScene)`, which was correct for
* as long as a board was built only when there was nothing else to look at.
* It is not correct once a board is built *behind* the one on screen which
* is the whole of what makes a switch stop being a full-screen card because
* the swap has to happen after the fog has closed over the outgoing board,
* not the instant the incoming one has geometry.
*
* Opt-out rather than removed, so the default stays the thing every existing
* caller and every test already expects, and so "build it but do not show me
* yet" has to be asked for out loud. The caller that asks then owns the
* `stage.setScene(handle.stageScene)` that presents it.
*/
present?: boolean;
}
export interface SceneHandle {
@@ -1309,7 +1326,9 @@ export async function createScene(
});
},
};
stage.setScene(stageScene);
// Construction implies presentation only while nobody says otherwise. See
// `SceneOptions.present` for why that stopped being unconditional.
if (options.present !== false) stage.setScene(stageScene);
return {
world,
+41
View File
@@ -86,7 +86,25 @@ export interface Bridge {
export interface Road {
path: LatLng[];
/**
* Half-width to either side of the centreline, in **scene units** which is
* to say, at whatever this board's metres-per-unit happens to be.
*
* Both metro packs authored roughly true widths here: 1932 m on the Bay Area
* and 3155 m on Southern California. The state board did not, and could not
* sensibly a 40 m freeway is 0.02 units on a board 554 units across so its
* two corridors are authored as 1,919 m and 2,034 m **symbols**, which is why
* they draw as ribbons wider than the cities they join.
*/
width: number;
/**
* What the road really is, in metres, for a pack whose `width` is a symbol.
*
* Read by `cities/reconcile.ts` and by nothing else: the engine draws `width`,
* always. A pack that omits this is taken to have authored true metres in
* `width` already, which both metro packs did.
*/
widthM?: number;
kind: "street" | "freeway";
}
@@ -471,6 +489,19 @@ export interface City {
*/
latScale: number;
/**
* Degrees to scene units for longitude, when the derivation above is not what
* the board wants.
*
* Absent on every authored pack, and present on a pack that
* `cities/reconcile.ts` has rewritten: three boards each squashing longitude
* by the cosine of their own centre latitude disagree about where a place is
* by up to 4 km, and the reconciliation puts all three on one reference
* latitude. Optional rather than required so that "a city pack is a data file
* anybody can review" keeps meaning what it meant.
*/
lngScale?: number;
/**
* How much taller than life the vertical is. Terrain and buildings share it,
* so they stay honest relative to each other.
@@ -513,6 +544,16 @@ export interface City {
/** Palette overrides; every field is optional. */
palette?: Partial<ScenePalette>;
/**
* Set by `cities/reconcile.ts` on a pack it has already rewritten.
*
* `World` posts its pack to the terrain worker as a structured clone and the
* worker builds a second `World` from it. This is what makes that second pass
* a no-op instead of a second reconciliation of an already-reconciled board.
* Never authored by hand.
*/
reconciled?: true;
}
export interface ScenePalette {
+21 -4
View File
@@ -33,6 +33,7 @@
* here, still exact, and are what the Worker itself uses.
*/
import { reconciledCity } from "../cities/reconcile.ts";
import type { City, LatLng } from "./types.ts";
/** The lattice, and the three arrays sampled off it. */
@@ -100,11 +101,27 @@ export class World {
private state: Field | null = null;
private pending: Promise<boolean> | null = null;
/**
* The pack is asked to reconcile itself before anything is derived from it.
*
* With `TERA_RECONCILE` off the default, and every deploy `reconciledCity`
* returns the argument by identity and the three lines below compute exactly
* what they always computed. With it on the pack comes back with one relief
* rule, one road convention, one coastal ramp and one reference latitude, and
* this constructor does not need to know which: it is data, and the whole of
* the engine's involvement is reading `lngScale` off the pack when the pack
* has an opinion. See `cities/reconcile.ts`.
*/
constructor(city: City) {
this.city = city;
this.lngScale = city.latScale * Math.cos((city.center.lat * Math.PI) / 180);
this.metresPerUnit = 111_320 / city.latScale;
this.lngSquash = Math.cos((city.center.lat * Math.PI) / 180);
this.city = reconciledCity(city);
this.lngScale =
this.city.lngScale ?? this.city.latScale * Math.cos((this.city.center.lat * Math.PI) / 180);
this.metresPerUnit = 111_320 / this.city.latScale;
// Not `cos(centre.lat)` any more but the same number whenever `lngScale` is
// derived from it, and the *right* number when it is not: a hill is a circle
// in scene space only if the squash used to measure its radius is the squash
// the projection uses. Reconciling one without the other draws ellipses.
this.lngSquash = this.lngScale / this.city.latScale;
}
// ---- Projection ---------------------------------------------------------
+958 -216
View File
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
/**
* The board cache: what stays in memory, and what gets thrown away first.
*
* This is the eviction policy behind the thing the owner actually feels. A board
* switch used to cover the screen with an opaque card for 1,711 / 1,079 / 997 ms
* while only 604 / 372 / 222 ms of it blocked the main thread so two thirds of
* every pause was a live page with nothing to draw, because `mountCity` disposed
* the outgoing board before the incoming one existed. Keeping it is the fix, and
* keeping it means deciding what may be kept.
*
* The properties worth a test are the ones whose failure is silent. An eviction
* that frees the board on screen is a black canvas. An eviction that frees a
* board and forgets to hand it back is a leak of a whole scene graph, because
* `environmentRig`'s ledger is a strong reference and only the caller can call
* `release`. A `put` that quietly drops an existing record is the same leak with
* a different cause. None of those throw.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createBoardCache, residentCapacity } from "../engine/boards.ts";
interface Board {
id: string;
}
const board = (id: string): Board => ({ id });
describe("resident capacity is a property of the device and nothing else", () => {
it("keeps every board on a desktop and two on a handheld", () => {
// Three is every board this build has, which is what makes the
// Southland-to-Bay rebuild disappear as well as the return to California.
assert.equal(residentCapacity(false), 3);
// `deviceProfile()` changes only the pixel ratio and the shadow map, so a
// phone carries exactly the same resident geometry as a laptop against a
// much smaller WebGL budget. Two is California plus whichever metro is on
// screen — provably enough, because the two metro rectangles do not
// intersect and California contains both.
assert.equal(residentCapacity(true), 2);
});
});
describe("the cache keeps what it is told to keep", () => {
it("returns a stored board without rebuilding anything", () => {
const cache = createBoardCache<Board>({ capacity: 3 });
const sf = board("sf");
assert.deepEqual(cache.put("sf", sf), []);
assert.equal(cache.get("sf"), sf);
assert.equal(cache.has("sf"), true);
assert.equal(cache.get("socal"), null);
assert.equal(cache.size(), 1);
});
it("never evicts a pinned board, even past its ceiling", () => {
// California is pinned because it is the cheapest board (5.86 MB of GPU
// buffers against the Bay Area's 14.12) and is the root of every descent.
const cache = createBoardCache<Board>({ capacity: 2, pinned: ["california"] });
cache.put("california", board("california"));
cache.put("sf", board("sf"));
const evicted = cache.put("socal", board("socal"));
assert.deepEqual(evicted.map((b) => b.id), ["sf"]);
assert.equal(cache.has("california"), true);
assert.equal(cache.has("socal"), true);
});
it("evicts the least recently shown, not the least recently built", () => {
const cache = createBoardCache<Board>({ capacity: 2 });
cache.put("california", board("california"));
cache.put("sf", board("sf"));
// Going back to California makes the Bay Area the stale one.
cache.touch("california");
const evicted = cache.put("socal", board("socal"));
assert.deepEqual(evicted.map((b) => b.id), ["sf"]);
assert.deepEqual(cache.ids(), ["california", "socal"]);
});
it("never evicts the board being put in", () => {
// The failure this rules out is a black canvas: the incoming board is the
// one about to go on the stage, and a capacity of one would otherwise make
// it its own victim.
const cache = createBoardCache<Board>({ capacity: 1 });
cache.put("california", board("california"));
const sf = board("sf");
const evicted = cache.put("sf", sf);
assert.deepEqual(evicted.map((b) => b.id), ["california"]);
assert.equal(cache.get("sf"), sf);
});
it("hands back everything it drops, exactly once", () => {
// `environmentRig.release(scene)` and `SceneHandle.dispose()` are the
// caller's to run, and it can only run them on a record it is given. A
// dropped record that is never returned is the whole scene graph retained.
const cache = createBoardCache<Board>({ capacity: 2 });
const dropped: string[] = [];
for (const id of ["a", "b", "c", "d", "e"]) {
for (const victim of cache.put(id, board(id))) dropped.push(victim.id);
}
assert.deepEqual(dropped, ["a", "b", "c"]);
assert.deepEqual(cache.ids(), ["d", "e"]);
});
it("hands back a record it is replacing in place", () => {
const cache = createBoardCache<Board>({ capacity: 3 });
const first = board("sf");
const second = board("sf");
cache.put("sf", first);
assert.deepEqual(cache.put("sf", second), [first]);
assert.equal(cache.get("sf"), second);
assert.equal(cache.size(), 1);
});
it("does not report a board as evicted when it is put back unchanged", () => {
const cache = createBoardCache<Board>({ capacity: 3 });
const sf = board("sf");
cache.put("sf", sf);
assert.deepEqual(cache.put("sf", sf), []);
assert.equal(cache.get("sf"), sf);
});
it("takes one out on request and leaves the rest alone", () => {
const cache = createBoardCache<Board>({ capacity: 3 });
const sf = board("sf");
cache.put("california", board("california"));
cache.put("sf", sf);
assert.equal(cache.remove("sf"), sf);
assert.equal(cache.remove("sf"), null);
assert.deepEqual(cache.ids(), ["california"]);
});
it("drains everything, including the pinned board", () => {
const cache = createBoardCache<Board>({ capacity: 3, pinned: ["california"] });
cache.put("california", board("california"));
cache.put("sf", board("sf"));
assert.deepEqual(cache.drain().map((b) => b.id), ["california", "sf"]);
assert.equal(cache.size(), 0);
});
it("treats a capacity below one as one rather than throwing", () => {
const cache = createBoardCache<Board>({ capacity: 0 });
const sf = board("sf");
assert.deepEqual(cache.put("sf", sf), []);
assert.equal(cache.get("sf"), sf);
});
});
describe("the two-deep case the product actually runs", () => {
it("never makes the board on screen the victim, whatever the order", () => {
/*
* The bug this is here for was live and is the worst shape retention can
* fail in. With a ceiling of two and California pinned, inserting the
* *incoming* board while the outgoing one was still on the stage left the
* outgoing board as the only legal victim so a Bay-to-Southland switch on
* a phone disposed the picture the visitor was looking at, blanked the
* canvas, and only then swapped. `main.ts` fixes it by writing the cache
* after `stage.setScene`, which makes the least-recently-*shown* board the
* victim; this asserts the property the cache itself guarantees, which is
* that the id being put is never evicted.
*/
const cache = createBoardCache<Board>({
capacity: residentCapacity(true),
pinned: ["california"],
});
cache.put("california", board("california"));
const sf = board("sf");
cache.put("sf", sf);
// The order `main.ts` now uses: the incoming board is shown first, so it is
// the id being put and the outgoing one is the stale entry.
const socal = board("socal");
const evicted = cache.put("socal", socal);
assert.deepEqual(evicted, [sf], "the board that has just been hidden is the victim");
assert.equal(cache.get("socal"), socal, "and the one on screen is still resident");
});
it("rebuilds nothing on a Bay -> Southland -> Bay round trip on a handheld", () => {
// California pinned, one metro slot. Coming back to the Bay Area rebuilds
// it — that is the honest cost of two-deep on a phone — but coming back to
// California, which is the root of every descent, never does.
const cache = createBoardCache<Board>({
capacity: residentCapacity(true),
pinned: ["california"],
});
const california = board("california");
cache.put("california", california);
cache.put("sf", board("sf"));
const droppedForSocal = cache.put("socal", board("socal"));
assert.deepEqual(droppedForSocal.map((b) => b.id), ["sf"]);
// Back to California: resident, so nothing is built and nothing is dropped.
assert.equal(cache.get("california"), california);
cache.touch("california");
assert.equal(cache.has("socal"), true);
});
it("rebuilds nothing at all on a desktop, in any order", () => {
const cache = createBoardCache<Board>({
capacity: residentCapacity(false),
pinned: ["california"],
});
const records = new Map<string, Board>();
let rebuilt = 0;
const visit = (id: string) => {
if (cache.has(id)) {
cache.touch(id);
return;
}
rebuilt++;
const record = board(id);
records.set(id, record);
assert.deepEqual(cache.put(id, record), []);
};
for (const id of ["california", "sf", "socal", "california", "sf", "socal", "sf"]) visit(id);
assert.equal(rebuilt, 3, "three boards exist, so three builds and no more");
for (const [id, record] of records) assert.equal(cache.get(id), record);
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* The chapter strip is a public interface, and this is the fast half of the
* check that says so.
*
* `scripts/check-chapter-identity.mjs` is the whole contract: it boots the built
* bundle under every `?city=` and compares the rendered `#chapters` strip
* `data-view`, `data-view-index`, the printed number and the short label
* against `scripts/fixtures/chapter-identity.json`. That takes a browser and
* about a minute. This file re-states the part of the same fixture that lives in
* the packs, so a reorder fails in `npm test` in a second, and so the negative
* case can be *watched failing*: a guard nobody has seen fail is not a guard.
*
* ## What is actually being protected
*
* Twenty-six aims in `scripts/brand-assets/{shots,films,capture}.mjs` point a
* camera at `#chapters .chapter[N]` a **position** and assert the short
* label they find there; 21 of them at a board a `?city=` reaches. Every one is
* a still, a film or the Open Graph card on lumbridgecorp.com. The
* label assertion was added after a share card shipped for a fortnight showing
* a chase camera on a freeway under the headline "Cities from above", and it is
* a good guard with two holes:
*
* - Short labels are not unique across boards. "Whole Board" is chapter 01 of
* both metro boards and "The Valley" is a different valley on each of them, so
* a reorder that preserves labels passes every one of them and shoots the wrong place.
* - `?city=` falls back to the first board rather than failing, so a board that
* lost its id serves California to a shot aimed at SoCal without an error.
*
* Identity here therefore means `(board, chapter id, position)` together, never
* a label and never a position on its own.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import CALIFORNIA_CITY from "../cities/california.ts";
import SAN_FRANCISCO_CITY from "../cities/sf.ts";
import SOCAL_CITY from "../cities/socal.ts";
import type { Chapter } from "../engine/types.ts";
import { chromeState } from "../ui/chromeState.ts";
import type { ChromeInputs } from "../ui/chromeState.ts";
interface FixtureChapter {
index: number;
dataView: string;
dataViewIndex: string;
number: string;
shortLabel: string;
}
interface Fixture {
takenAt: string;
boards: Record<string, { activeBoard: string; chapters: FixtureChapter[] }>;
}
const fixture = JSON.parse(
readFileSync(new URL("../../scripts/fixtures/chapter-identity.json", import.meta.url), "utf8"),
) as Fixture;
/** `?city=` value to the pack it selects — the same table `main.ts` holds. */
const BOARDS: readonly (readonly [string, { chapters: Chapter[] }])[] = [
["california", CALIFORNIA_CITY],
["sf", SAN_FRANCISCO_CITY],
["socal", SOCAL_CITY],
];
/**
* Every way this list of chapters differs from the one the imagery was aimed at.
*
* A list of sentences rather than an assertion, so that the negative test can
* call the same function on a deliberately broken list and check that it
* *reports* rather than that it *throws*. The two callers below are the whole
* point: one proves the packs are still what the fixture says, the other proves
* this function can tell when they are not.
*/
function drift(boardId: string, chapters: readonly Chapter[]): string[] {
const expected = fixture.boards[boardId];
if (expected === undefined) return [`?city=${boardId}: not in the fixture`];
const problems: string[] = [];
if (expected.chapters.length !== chapters.length) {
problems.push(
`?city=${boardId}: ${chapters.length} chapters, the fixture has ${expected.chapters.length}`,
);
}
for (let i = 0; i < Math.min(expected.chapters.length, chapters.length); i += 1) {
const want = expected.chapters[i];
const got = chapters[i];
if (want === undefined || got === undefined) continue;
// `data-view` is the chapter's id, and `data-view-index` is its position in
// the rendered list — `mount.ts` writes both from the same array. Checking
// them here is checking that the DOM identity and the pack identity are the
// same fact, which is what lets a browser-free test stand in for the DOM one.
if (got.id !== want.dataView) problems.push(`${boardId}[${i}] id ${got.id}${want.dataView}`);
if (String(i) !== want.dataViewIndex) {
problems.push(`${boardId}[${i}] position ${i}${want.dataViewIndex}`);
}
if (got.number !== want.number) {
problems.push(`${boardId}[${i}] number ${got.number}${want.number}`);
}
if (got.shortLabel !== want.shortLabel) {
problems.push(`${boardId}[${i}] label "${got.shortLabel}" ≠ "${want.shortLabel}"`);
}
}
return problems;
}
function inputs(overrides: Partial<ChromeInputs> = {}): ChromeInputs {
return {
mode: "overview",
available: ["overview"],
access: { tier: "anon", signInUrl: "/login.html", subject: null },
inside: false,
officeDepth: null,
viewport: { width: 1440, height: 900, coarsePointer: false },
feeds: { markers: false, weather: false, flights: false },
degraded: [],
devices: [],
firstVisit: false,
panelOpen: true,
planOpen: true,
...overrides,
};
}
describe("chapter identity", () => {
it("matches the fixture the marketing imagery was aimed at", () => {
const problems = BOARDS.flatMap(([id, city]) => drift(id, city.chapters));
assert.deepEqual(
problems,
[],
`The chapter strip moved. Every line below is a capture aim in ` +
`scripts/brand-assets/** that now points somewhere else:\n ${problems.join("\n ")}`,
);
});
it("is 26 rungs over three boards, which is the number the ladder has to carry", () => {
// Not a style assertion: the ladder work re-expresses these 26 as one
// ordered descent, and 24 of them are camera poses — `los-angeles` and
// `san-francisco` on the state board are doors that call `switchCity`
// rather than `flyTo`, which is why two capture presets that claimed to be
// California closeups were in fact photographs of the metro boards.
assert.equal(CALIFORNIA_CITY.chapters.length, 6);
assert.equal(SAN_FRANCISCO_CITY.chapters.length, 12);
assert.equal(SOCAL_CITY.chapters.length, 8);
assert.equal(
BOARDS.reduce((total, [, city]) => total + city.chapters.length, 0),
26,
);
});
it("puts the pack's id and short label straight onto the button", () => {
// The DOM contract, stated where it can be checked without a browser:
// `chromeState` maps a chapter to a row `id`/`label`, and `mount.ts` writes
// that `id` into `data-view` and the row's position into `data-view-index`.
// If this mapping ever stops being the identity function, the fixture above
// stops standing in for the DOM and `check-chapter-identity.mjs` becomes the
// only real guard.
for (const [boardId, city] of BOARDS) {
const state = chromeState(
inputs({ views: city.chapters, activeViewId: city.chapters[0]?.id ?? null }),
);
assert.deepEqual(
state.views.map((view) => view.id),
city.chapters.map((chapter) => chapter.id),
`${boardId}: row ids are not the pack's chapter ids`,
);
assert.deepEqual(
state.views.map((view) => view.label),
city.chapters.map((chapter) => chapter.shortLabel),
`${boardId}: row labels are not the pack's short labels`,
);
assert.deepEqual(
state.views.map((view) => view.number),
city.chapters.map((chapter) => chapter.number),
`${boardId}: printed numbers are not the pack's numbers`,
);
}
});
it("cannot be guarded by short label alone, because labels collide across boards", () => {
// This is the reason the index-and-label aims are not enough, stated
// as a fact about the data rather than as an opinion. If this assertion ever
// fails because the labels became unique, the aims are still aimed by
// position and this file still has to exist — but the failure mode gets
// quieter, which is worth knowing about.
const labels = BOARDS.flatMap(([, city]) => city.chapters.map((chapter) => chapter.shortLabel));
const collisions = labels.filter((label, i) => labels.indexOf(label) !== i);
assert.deepEqual([...new Set(collisions)].sort(), ["The Valley", "Whole Board"]);
});
describe("the guard itself", () => {
// A guard nobody has watched fail is not a guard. Each of these breaks the
// list in one of the three ways a ladder rewrite realistically breaks it,
// and asserts that `drift` says so.
it("fails when two chapters are swapped, even though every label survives", () => {
const swapped = [...SAN_FRANCISCO_CITY.chapters];
const [a, b] = [swapped[2], swapped[3]];
assert.ok(a !== undefined && b !== undefined);
swapped[2] = b;
swapped[3] = a;
const problems = drift("sf", swapped);
assert.ok(problems.length > 0, "a reorder went unnoticed");
assert.ok(
problems.some((line) => line.includes("sf[2] id")),
`expected a complaint about sf[2], got:\n ${problems.join("\n ")}`,
);
});
it("fails when a chapter is inserted, which shifts every index after it", () => {
const inserted: Chapter[] = [...SOCAL_CITY.chapters];
const first = inserted[0];
assert.ok(first !== undefined);
inserted.splice(1, 0, { ...first, id: "san-diego", shortLabel: "San Diego", number: "99" });
const problems = drift("socal", inserted);
assert.ok(problems.length > 0, "an insertion went unnoticed");
assert.ok(
problems.some((line) => line.includes("9 chapters")),
`expected a complaint about the count, got:\n ${problems.join("\n ")}`,
);
});
it("fails when a chapter keeps its place and changes its id", () => {
// The ladder rewrite's most likely shape: same 26 rungs, renamed to a
// single sequence. `data-view` is what a re-pointed harness would key on,
// so a silent rename is the one drift that would otherwise look like
// nothing at all.
const renamed = CALIFORNIA_CITY.chapters.map((chapter, i) =>
i === 1 ? { ...chapter, id: "rung-02" } : chapter,
);
const problems = drift("california", renamed);
assert.deepEqual(problems, ["california[1] id rung-02 ≠ la-sf-us-101"]);
});
});
});
+158
View File
@@ -0,0 +1,158 @@
/**
* The fog dip: the transition that is not a crossfade.
*
* Two boards cannot be drawn at once and that is a measured fact rather than an
* engine limit. Against the caps in `scripts/performance-budgets.json`,
* California + the Bay Area is 2,640,307 triangles against a cap of 2,600,000,
* California + the Southland is 1,805,344 against 1,700,000, and the two metros
* together are 3,694,949 against everything; rendering only the outgoing board's
* terrain still breaks the California direction at 586,535 against 440,000. And
* `performance-budget.mjs` samples eight seconds after a three-second warm-up,
* so a sub-second fade never lands inside the window a transition whose cost
* the gate cannot see is the exact failure this repo argues against.
*
* So the transition is a collapse of the outgoing board's own aerial fog, a
* swap, and a lift of the incoming board's. Zero extra triangles, zero extra
* draw calls, no render target and it is also the *right* transition, because
* a true dissolve at a matched pose would put the seam's 4.17x vertical
* deflation and its up-to-4,025 m registration slide on screen simultaneously
* and in register.
*
* What is testable without a GPU is the arithmetic, and the arithmetic is where
* the two failures live: a dip that does not actually reach either endpoint, and
* a dip whose collapsed pair is shared between boards whose scene units differ
* by a factor of twenty.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
collapsedFog,
dipFog,
FOG_DIP_IN_SECONDS,
FOG_DIP_OUT_SECONDS,
FOG_DIP_REACH_FRACTION,
type AerialFog,
} from "../engine/atmosphere.ts";
/** A clear-day pair of the shape `Atmosphere.aerial` returns for the Bay Area. */
const CLEAR: AerialFog = { near: 1_150, far: 3_900 };
describe("the collapsed pair is measured against the camera, not the board", () => {
it("scales with how far back the camera is standing", () => {
// Fog planes are scene distances and California's unit is twenty times the
// Bay Area's, so one shared pair would leave one board unfogged and wash the
// other out completely.
const wide = collapsedFog(808);
const close = collapsedFog(41);
assert.ok(wide.far > close.far);
assert.equal(wide.far, 808 * FOG_DIP_REACH_FRACTION);
assert.equal(close.far, 41 * FOG_DIP_REACH_FRACTION);
});
it("leaves the near limb of the board faintly readable and everything else gone", () => {
// The failure this replaced: a far plane at 6% of the board's span put every
// fragment past it, and the night board photographed as one flat field of
// #16203a with no coastline in it. `fog_fragment` is linear between the
// planes, so a far plane at 70% of the stand-off means the fragment nearest
// the camera — roughly half a board-span nearer than the target — is heavily
// hazed rather than erased.
const standoff = 808;
const fog = collapsedFog(standoff);
const nearest = standoff - 554 / 2;
const factor = (nearest - fog.near) / (fog.far - fog.near);
assert.ok(factor > 0.8, `the near limb is hidden (${factor})`);
assert.ok(factor < 1, `but it is not erased (${factor})`);
// Everything at or past what the camera is pointed at is gone outright.
assert.ok((standoff - fog.near) / (fog.far - fog.near) >= 1);
});
it("closes to just in front of the camera, and not onto it", () => {
const fog = collapsedFog(808);
assert.ok(fog.near > 0, "a positive near plane is what keeps the whole dip on one curve");
assert.ok(fog.near < fog.far);
});
it("refuses to produce a degenerate pair for a degenerate reach", () => {
for (const reach of [0, -1]) {
const fog = collapsedFog(reach);
assert.ok(fog.far > 0, `reach ${reach} still yields a usable far plane`);
assert.ok(Number.isFinite(fog.far));
}
});
});
describe("a dip reaches both ends and nothing beyond them", () => {
const hidden = collapsedFog(808);
it("is exactly the start at zero and exactly the end at one", () => {
assert.deepEqual(dipFog(CLEAR, hidden, 0), CLEAR);
assert.deepEqual(dipFog(CLEAR, hidden, 1), hidden);
// The lift is the same call with the endpoints swapped, which is why there
// is one function and not two.
assert.deepEqual(dipFog(hidden, CLEAR, 0), hidden);
assert.deepEqual(dipFog(hidden, CLEAR, 1), CLEAR);
});
it("clamps rather than overshooting a caller's clock", () => {
assert.deepEqual(dipFog(CLEAR, hidden, 1.4), hidden);
assert.deepEqual(dipFog(CLEAR, hidden, -0.2), CLEAR);
assert.deepEqual(dipFog(CLEAR, hidden, Number.NaN), CLEAR);
});
it("closes monotonically, and never leaves the board briefly clearer", () => {
// A fog that opened even slightly partway through the collapse would be a
// flash of the outgoing board at exactly the moment the swap is meant to be
// invisible.
let previous = Number.POSITIVE_INFINITY;
for (let t = 0; t <= 1.0001; t += 0.02) {
const far = dipFog(CLEAR, hidden, t).far;
assert.ok(far <= previous + 1e-9, `far grew at t=${t}: ${far} after ${previous}`);
previous = far;
}
});
it("fades in ratios rather than in metres", () => {
// Fog distance is perceived logarithmically. A linear ramp spends the first
// half of the dip doing almost nothing visible and the second half slamming
// shut. The ease is symmetric, so halfway through the dip the far plane is
// exactly the *geometric* mean of the two ends — which is the statement that
// this is a ratio fade and not a distance fade.
const mid = dipFog(CLEAR, hidden, 0.5).far;
assert.ok(
Math.abs(mid - Math.sqrt(CLEAR.far * hidden.far)) < 1e-9,
`midpoint ${mid} is not the geometric mean of ${CLEAR.far} and ${hidden.far}`,
);
assert.ok(mid < (CLEAR.far + hidden.far) / 2, "and it is below the arithmetic mean");
assert.ok(mid > hidden.far, "and it has not arrived early either");
});
it("keeps the pair coherent at every step", () => {
// The failure this catches is real and was found by writing it: a linear
// near plane against a geometric far plane crosses over partway through the
// dip, and `scene.fog` with `near > far` draws nothing coherent.
for (let t = 0; t <= 1.0001; t += 0.1) {
const fog = dipFog(CLEAR, hidden, t);
assert.ok(Number.isFinite(fog.near), `near is finite at t=${t}`);
assert.ok(Number.isFinite(fog.far), `far is finite at t=${t}`);
assert.ok(fog.near >= 0);
assert.ok(fog.far >= fog.near, "a far plane inside the near plane draws nothing");
}
});
});
describe("the two halves are timed differently on purpose", () => {
it("leaves faster than it arrives", () => {
// Leaving is an instruction the visitor just gave and wants obeyed; arriving
// is a picture they are being shown.
assert.ok(FOG_DIP_OUT_SECONDS < FOG_DIP_IN_SECONDS);
});
it("fits inside the pause it is hiding", () => {
// The whole transition is 0.8 s. The switch it covers was measured at
// 997-1,711 ms of opaque card, of which 222-604 ms blocked the main thread —
// so the dip is on the order of the thing it replaces rather than an
// addition to it.
assert.ok(FOG_DIP_OUT_SECONDS + FOG_DIP_IN_SECONDS <= 1.0);
});
});
+358
View File
@@ -0,0 +1,358 @@
/**
* The ladder: one descent over three boards, and the rule that hands the camera
* from one to the next.
*
* Two things are being protected here and only one of them is the arithmetic.
*
* The first is **containment**. The ladder is a new view over the packs, never a
* replacement for them: twenty-nine capture guards in `scripts/brand-assets` aim
* at `#chapters .chapter` by index and assert the button's short label, and every
* still on lumbridgecorp.com is shot that way. So this file asserts that no rung
* carries a short label, a number or an id the pack did not, and that the packs'
* own chapter arrays are untouched a ladder that could only be built by editing
* a pack is a ladder that re-shoots the marketing site.
*
* The second is **oscillation**. `handover` is shipped pure and tested here
* before it is ever allowed to fire on a wheel notch, because a camera parked at
* a threshold without a hysteresis band tears down and rebuilds a board on every
* event, and that is not a bug you find by reading the function.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
activeRung,
BOARD_REGION,
buildLadder,
chapterAltitudeMetres,
chapterStandoffMetres,
DEMOTE,
handover,
handoverStandoff,
PROMOTE,
placesRows,
REGION_LABELS,
type HandoverBoard,
} from "../engine/ladder.ts";
import CALIFORNIA from "../cities/california.ts";
import SAN_FRANCISCO from "../cities/sf.ts";
import SOCAL from "../cities/socal.ts";
const PACKS = [
{ id: "california", city: CALIFORNIA },
{ id: "sf", city: SAN_FRANCISCO },
{ id: "socal", city: SOCAL },
];
const LADDER = buildLadder(PACKS);
/** The three boards as `handover` sees them. Metres per unit is `World`'s own arithmetic. */
const BOARDS: HandoverBoard[] = PACKS.map((pack) => ({
id: pack.id,
bounds: pack.city.bounds,
metresPerUnit: 111_320 / pack.city.latScale,
}));
describe("the ladder is built from the packs and changes none of them", () => {
it("folds the two California doors and keeps every other chapter", () => {
const authored =
CALIFORNIA.chapters.length + SAN_FRANCISCO.chapters.length + SOCAL.chapters.length;
assert.equal(authored, 26, "the three packs author twenty-six chapters between them");
assert.equal(LADDER.length, 24, "the two California doors fold onto the metro rungs");
// The doors are gone as rungs of their own and present as an origin on the
// two rungs they open. They are not camera poses — `main.ts` matches them
// against `CALIFORNIA_DESTINATIONS` and calls `switchCity` — so a rung for
// each would be a duplicate of the board it opens.
assert.equal(LADDER.some((rung) => rung.key === "california:los-angeles"), false);
assert.equal(LADDER.some((rung) => rung.key === "california:san-francisco"), false);
const southland = LADDER.find((rung) => rung.key === "socal:all");
const bay = LADDER.find((rung) => rung.key === "sf:all");
assert.deepEqual(southland?.doorFrom, { board: "california", id: "los-angeles" });
assert.deepEqual(bay?.doorFrom, { board: "california", id: "san-francisco" });
});
it("carries the packs' own ids, numbers and short labels unchanged", () => {
for (const pack of PACKS) {
for (const chapter of pack.city.chapters) {
const rung = LADDER.find((candidate) => candidate.key === `${pack.id}:${chapter.id}`);
// The two folded doors are the only chapters without a rung.
if (rung === undefined) {
assert.ok(
chapter.id === "los-angeles" || chapter.id === "san-francisco",
`${pack.id}:${chapter.id} has no rung and is not a door`,
);
continue;
}
assert.equal(rung.id, chapter.id);
assert.equal(rung.number, chapter.number);
assert.equal(rung.shortLabel, chapter.shortLabel);
assert.equal(rung.description, chapter.description);
}
}
});
it("resolves the label collisions in the ladder rather than in a pack", () => {
// Both metro packs open on a chapter labelled "The Whole Board", and both
// call a chapter "The Valley". Renaming either in its pack would move a
// string twenty-nine index-aimed capture guards assert on.
const labels = LADDER.map((rung) => rung.label);
assert.equal(new Set(labels).size, labels.length, `duplicate ladder label in ${labels}`);
assert.equal(SAN_FRANCISCO.chapters[0]?.shortLabel, "Whole Board");
assert.equal(SOCAL.chapters[0]?.shortLabel, "Whole Board");
assert.equal(LADDER.find((r) => r.key === "socal:all")?.label, "The Southland");
assert.equal(LADDER.find((r) => r.key === "sf:all")?.label, "The Bay Area");
});
it("puts every rung under a region that has a heading", () => {
for (const rung of LADDER) {
assert.equal(rung.region, BOARD_REGION[rung.board]);
assert.equal(typeof REGION_LABELS[rung.region], "string");
}
});
});
describe("stand-off, and not altitude, is the ladder's coordinate", () => {
it("sorts strictly descending by stand-off", () => {
for (let i = 1; i < LADDER.length; i++) {
const above = LADDER[i - 1];
const below = LADDER[i];
assert.ok(above && below);
assert.ok(
above.standoffM >= below.standoffM,
`${above.key} (${above.standoffM}) sorts above ${below.key} (${below.standoffM})`,
);
}
});
it("separates the three boards by stand-off and interleaves them by altitude", () => {
const on = (board: string) => LADDER.filter((rung) => rung.board === board);
const california = on("california");
const socal = on("socal");
const sf = on("sf");
// The measured claim the coordinate choice rests on: by stand-off the three
// boards form three clean bands.
const lowestCalifornia = Math.min(...california.map((r) => r.standoffM));
const highestSocal = Math.max(...socal.map((r) => r.standoffM));
const highestSf = Math.max(...sf.map((r) => r.standoffM));
assert.ok(lowestCalifornia > highestSocal, "California bottoms out above the Southland");
assert.ok(highestSocal > highestSf, "the Southland's whole board stands off further than the Bay's");
assert.ok(Math.round(lowestCalifornia / 1000) === 242, `California bottoms at 242 km, got ${lowestCalifornia}`);
assert.ok(Math.round(highestSocal / 1000) === 109, `the Southland tops at 108.8 km, got ${highestSocal}`);
assert.ok(Math.round(highestSf / 1000) === 71, `the Bay tops at 71.2 km, got ${highestSf}`);
// And by altitude they do not: a SoCal chapter sits inside California's range.
const californiaAltitudes = california.map((r) => r.altitudeM);
const lowestCaliforniaAltitude = Math.min(...californiaAltitudes);
const highestCaliforniaAltitude = Math.max(...californiaAltitudes);
const interleaved = [...socal, ...sf].filter(
(rung) =>
rung.altitudeM > lowestCaliforniaAltitude && rung.altitudeM < highestCaliforniaAltitude,
);
assert.ok(
interleaved.length > 0,
"an altitude ladder would interleave the boards; that is why stand-off is the coordinate",
);
});
it("derives both numbers from the authored focus block alone", () => {
const chapter = SAN_FRANCISCO.chapters[0];
assert.ok(chapter);
const expectedStandoff =
Math.hypot(chapter.focus.distance, chapter.focus.height) * (111_320 / SAN_FRANCISCO.latScale);
assert.equal(chapterStandoffMetres(chapter, SAN_FRANCISCO.latScale), expectedStandoff);
// The vertical exaggeration divides back out, exactly as
// `SceneHandle.cameraAltitudeMetres` does through `World.unitsToMetres`.
assert.equal(
chapterAltitudeMetres(chapter, SAN_FRANCISCO.latScale, SAN_FRANCISCO.verticalExaggeration),
(chapter.focus.height * (111_320 / SAN_FRANCISCO.latScale)) /
SAN_FRANCISCO.verticalExaggeration,
);
});
});
describe("the printed list groups what the array interleaves", () => {
const rows = placesRows(LADDER);
it("holds exactly the same rungs", () => {
assert.equal(rows.length, LADDER.length);
assert.deepEqual(
[...rows].map((r) => r.key).sort(),
[...LADDER].map((r) => r.key).sort(),
);
});
it("prints three region runs rather than nine", () => {
const runs: string[] = [];
for (const rung of rows) if (runs[runs.length - 1] !== rung.region) runs.push(rung.region);
assert.deepEqual(runs, ["state", "southland", "bay"]);
// The thing grouping exists to avoid: a globally sorted list changes region
// far more often than it has regions.
const interleavedRuns: string[] = [];
for (const rung of LADDER) {
if (interleavedRuns[interleavedRuns.length - 1] !== rung.region) {
interleavedRuns.push(rung.region);
}
}
assert.ok(
interleavedRuns.length > 5,
`a global sort changes region ${interleavedRuns.length} times; grouping is why the list is readable`,
);
});
it("still descends inside every group", () => {
for (let i = 1; i < rows.length; i++) {
const above = rows[i - 1];
const below = rows[i];
assert.ok(above && below);
if (above.region !== below.region) continue;
assert.ok(above.standoffM >= below.standoffM, `${above.key} then ${below.key}`);
}
});
});
describe("the lit rung is an altimeter, not a menu", () => {
it("keeps the rung you flew to lit when the camera is on its pose", () => {
const soma = LADDER.find((rung) => rung.key === "sf:soma");
assert.ok(soma);
assert.equal(activeRung(LADDER, "sf", soma.standoffM, "soma")?.key, "sf:soma");
});
it("does not hop between the four Bay rungs that sit inside 12% of each other", () => {
const eastBay = LADDER.find((rung) => rung.key === "sf:east-bay");
const sanJose = LADDER.find((rung) => rung.key === "sf:south-bay");
assert.ok(eastBay && sanJose);
assert.ok(
Math.abs(eastBay.standoffM - sanJose.standoffM) / sanJose.standoffM < 0.12,
"these two are the near-collision the tolerance exists for",
);
// Parked on the East Bay's own pose, the East Bay stays lit even though San
// Jose's number is only two per cent away.
assert.equal(activeRung(LADDER, "sf", eastBay.standoffM, "east-bay")?.key, "sf:east-bay");
});
it("follows the camera once it has left that pose", () => {
const whole = LADDER.find((rung) => rung.key === "sf:all");
const missionBay = LADDER.find((rung) => rung.key === "sf:mission-bay");
assert.ok(whole && missionBay);
// Still nominally "on" the whole-board chapter, but flown down to street
// level: the list has to say where the camera is.
assert.equal(activeRung(LADDER, "sf", missionBay.standoffM, "all")?.key, "sf:mission-bay");
});
it("only ever lights a rung on the resident board", () => {
for (const standoff of [3_000, 30_000, 300_000, 3_000_000]) {
assert.equal(activeRung(LADDER, "socal", standoff, null)?.board, "socal");
}
});
it("answers null for a board with no rungs", () => {
assert.equal(activeRung(LADDER, "paris", 10_000, null), null);
});
});
describe("handover does not oscillate", () => {
/** Downtown San Francisco: inside the sf rectangle, inside California's. */
const SF_TARGET = { lat: 37.7749, lng: -122.4194 };
/** Downtown Los Angeles: inside the socal rectangle, inside California's. */
const LA_TARGET = { lat: 34.05, lng: -118.24 };
/** Fresno: inside California and inside neither metro. */
const FRESNO = { lat: 36.74, lng: -119.78 };
const query = (over: {
current: string;
standoffM: number;
lat: number;
lng: number;
dragging?: boolean;
}) => handover({ boards: BOARDS, ...over });
it("promotes below the authored threshold and stays put above it", () => {
const sf = handoverStandoff("sf");
assert.equal(sf, 71_200);
assert.equal(
query({ current: "california", standoffM: sf * PROMOTE * 0.99, ...SF_TARGET }),
"sf",
);
assert.equal(query({ current: "california", standoffM: sf * PROMOTE * 1.01, ...SF_TARGET }), null);
});
it("promotes at most once and demotes at most once over a full sweep", () => {
const sf = handoverStandoff("sf");
let current = "california";
let promotions = 0;
let demotions = 0;
const step = (standoffM: number) => {
const next = query({ current, standoffM, ...SF_TARGET });
if (next === null) return;
if (next === "sf") promotions++;
else demotions++;
current = next;
};
// Down through the threshold in one per cent steps, and back up again.
for (let f = 1.4; f >= 0.5; f -= 0.01) step(sf * f);
assert.equal(current, "sf", "the sweep down ends on the finer board");
for (let f = 0.5; f <= 1.4; f += 0.01) step(sf * f);
assert.equal(promotions, 1, "one promotion over the whole sweep");
assert.equal(demotions, 1, "one demotion over the whole sweep");
assert.equal(current, "california", "the sweep up ends back on the state");
});
it("has a dead band between the promote and demote thresholds", () => {
const sf = handoverStandoff("sf");
// Inside the band, neither direction fires, from either side of it.
for (const f of [PROMOTE + 0.01, 1.0, DEMOTE - 0.01]) {
assert.equal(query({ current: "california", standoffM: sf * f, ...SF_TARGET }), null);
assert.equal(query({ current: "sf", standoffM: sf * f, ...SF_TARGET }), null);
}
});
it("never fires while a drag is in flight", () => {
const sf = handoverStandoff("sf");
assert.equal(
query({ current: "california", standoffM: sf * 0.2, dragging: true, ...SF_TARGET }),
null,
);
assert.equal(
query({ current: "sf", standoffM: sf * 10, dragging: true, ...SF_TARGET }),
null,
);
});
it("refuses to promote where there is no finer board — 97.4% of the state", () => {
for (const standoffM of [200_000, 60_000, 20_000, 4_000]) {
assert.equal(query({ current: "california", standoffM, ...FRESNO }), null);
}
});
it("promotes into the Southland over Los Angeles and never into the Bay", () => {
assert.equal(
query({ current: "california", standoffM: handoverStandoff("socal") * 0.5, ...LA_TARGET }),
"socal",
);
// The two metro rectangles do not intersect, so the candidate set is never
// larger than one — the assertion is that the *other* one is not a candidate.
assert.equal(query({ current: "sf", standoffM: 10_000, ...LA_TARGET }), null);
});
it("demotes to California and never straight into the other metro", () => {
assert.equal(
query({ current: "sf", standoffM: handoverStandoff("sf") * DEMOTE * 1.01, ...SF_TARGET }),
"california",
);
assert.equal(handoverStandoff("california"), Number.POSITIVE_INFINITY);
// California hands over to nothing, so no stand-off demotes off it.
assert.equal(query({ current: "california", standoffM: 5_000_000, ...SF_TARGET }), null);
});
it("refuses a nonsense stand-off rather than guessing", () => {
for (const standoffM of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
assert.equal(query({ current: "california", standoffM, ...SF_TARGET }), null);
}
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Three packs, one place: do the boards agree about where a thing is?
*
* `World.lngScale` is `latScale × cos(centre.lat)`, and each pack takes the
* cosine at its **own** centre 37.30°, 37.77°, 33.82°. A degree of longitude
* is then worth 88,552 m on the state board and 92,484 m on Southern
* California, so the two boards put the same coordinate in two different places
* and the gap grows with distance from the anchor. At a 109 km stand-off the
* worst of it is about 3% of the frame: a visible sideways slide in any
* transition that shows both boards at once, and a permanent disagreement about
* where Riverside is.
*
* The numbers below are measured on these packs, not quoted from a brief.
*/
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import CALIFORNIA from "../cities/california.ts";
import { REFERENCE_LAT, setReconcile } from "../cities/reconcile.ts";
import SF from "../cities/sf.ts";
import SOCAL from "../cities/socal.ts";
import { World } from "../engine/world.ts";
/** Downtown Los Angeles: inside both southern boards, so a fair shared origin. */
const ANCHOR: [number, number] = [34.05, -118.24];
const PLACES: [string, number, number][] = [
["the shared north-east corner", 34.36, -117.22],
["Riverside", 33.98, -117.37],
["the south-west corner", 33.28, -118.88],
];
/** Where a board puts a place, in true metres from the anchor. */
function offset(world: World, lat: number, lng: number): [number, number] {
const [x0, z0] = world.project(...ANCHOR);
const [x1, z1] = world.project(lat, lng);
return [(x1 - x0) * world.metresPerUnit, (z1 - z0) * world.metresPerUnit];
}
function disagreement(a: World, b: World, lat: number, lng: number): number {
const [ax, az] = offset(a, lat, lng);
const [bx, bz] = offset(b, lat, lng);
return Math.hypot(ax - bx, az - bz);
}
afterEach(() => setReconcile(null));
describe("projection", () => {
it("disagrees by kilometres today", () => {
setReconcile(false);
const california = new World(CALIFORNIA);
const socal = new World(SOCAL);
const worst = Math.max(...PLACES.map(([, lat, lng]) => disagreement(california, socal, lat, lng)));
assert.ok(worst > 3_500, `expected kilometres of drift, measured ${worst.toFixed(0)} m`);
assert.ok(worst < 4_200, `measured ${worst.toFixed(0)} m — the packs moved, re-read this test`);
});
it("agrees within 50 m once every board squashes longitude the same way", () => {
setReconcile(["projection"]);
const worlds = [CALIFORNIA, SF, SOCAL].map((pack) => new World(pack));
for (const [name, lat, lng] of PLACES) {
for (const a of worlds) {
for (const b of worlds) {
const gap = disagreement(a, b, lat, lng);
assert.ok(gap < 50, `${name}: ${a.city.id} vs ${b.city.id} is ${gap.toFixed(0)} m apart`);
}
}
}
});
it("puts every board on the one reference latitude", () => {
setReconcile(["projection"]);
for (const pack of [CALIFORNIA, SF, SOCAL]) {
const world = new World(pack);
const expected = pack.latScale * Math.cos((REFERENCE_LAT * Math.PI) / 180);
assert.ok(Math.abs(world.lngScale - expected) < 1e-9, pack.id);
// And the hill squash follows it, or hills stop being circles in scene
// space on the two boards whose own latitude is not the reference.
assert.ok(Math.abs(world.lngSquash - world.lngScale / pack.latScale) < 1e-15, pack.id);
}
});
/**
* The price, stated so it cannot be discovered later. Southern California is
* projected at 37.3° rather than at its own 33.82°, so it draws narrower in x
* than a projection taken at its own latitude would measured, 4.25% and
* San Francisco 0.64% wider. That is the trade: local aspect, for
* registration that is exact by construction.
*/
it("costs Southern California 4.25% of its width and San Francisco 0.64%", () => {
setReconcile(["projection"]);
const shrink = (pack: typeof SOCAL): number => {
const before = pack.latScale * Math.cos((pack.center.lat * Math.PI) / 180);
return new World(pack).lngScale / before - 1;
};
assert.ok(Math.abs(shrink(SOCAL) + 0.0425) < 0.0005, String(shrink(SOCAL)));
assert.ok(Math.abs(shrink(SF) - 0.0064) < 0.0005, String(shrink(SF)));
assert.equal(shrink(CALIFORNIA), 0);
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* The reconciliation, and the one property that protects the live site: with
* the flag off, nothing anywhere is different.
*
* "Byte-identical with the flag off" is asserted here rather than only in a
* photograph because a photograph proves it for one frame of one board on one
* afternoon, and this proves it for the object the engine reads. `reconciledCity`
* returning its argument **by identity** is the strongest form of that claim
* available: not a copy that happens to carry the same numbers, the same object.
*/
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import CALIFORNIA from "../cities/california.ts";
import {
COAST_RAMP_CELLS,
RELIEF_IN_FRAME,
blendedPeakMetres,
describe as describeReconciliation,
reconciledCity,
reconciledCoastFalloff,
reconciledExaggeration,
reconciledPalette,
reconciledPaletteMix,
setReconcile,
} from "../cities/reconcile.ts";
import SF from "../cities/sf.ts";
import SOCAL from "../cities/socal.ts";
import { DEFAULT_PALETTE } from "../engine/terrain.ts";
import { World } from "../engine/world.ts";
const PACKS = [CALIFORNIA, SF, SOCAL];
afterEach(() => setReconcile(null));
describe("reconcile — the flag off", () => {
it("hands every pack back by identity", () => {
setReconcile(false);
for (const pack of PACKS) assert.equal(reconciledCity(pack), pack);
});
it("leaves the World's derived scalars exactly where they were", () => {
setReconcile(false);
for (const pack of PACKS) {
const world = new World(pack);
assert.equal(world.city, pack);
assert.equal(world.lngScale, pack.latScale * Math.cos((pack.center.lat * Math.PI) / 180));
assert.equal(world.lngSquash, world.lngScale / pack.latScale);
assert.equal(world.metresPerUnit, 111_320 / pack.latScale);
assert.equal(world.city.verticalExaggeration, pack.verticalExaggeration);
assert.equal(world.city.coastFalloff, pack.coastFalloff);
assert.deepEqual(
world.city.roads.map((road) => road.width),
pack.roads.map((road) => road.width),
);
}
});
/**
* `lngSquash` stopped being `cos(centre.lat)` and became `lngScale/latScale`,
* which is the same number for every authored pack and the right one for a
* reconciled one. This is the assertion that the "same number" half is true
* it is what every hill on every shipped board stands on.
*/
it("computes the same hill squash the old expression did", () => {
setReconcile(false);
for (const pack of PACKS) {
const world = new World(pack);
const old = Math.cos((pack.center.lat * Math.PI) / 180);
assert.ok(Math.abs(world.lngSquash - old) < 1e-15, `${pack.id}: ${world.lngSquash} vs ${old}`);
}
});
});
describe("reconcile — one relief rule", () => {
/**
* The whole claim of rule (a), as one number per board.
*
* Relief in the frame is peak scene units over board span ARCHITECTURE
* §12.1's rule, and the one it moved California from 13 to 15 to hold. Today
* the three boards read 7.24%, 4.51% and 6.92%; the rule puts all three on
* `RELIEF_IN_FRAME`.
*/
it("puts all three boards on one relief-in-frame number", () => {
setReconcile(["exaggeration", "projection"]);
const relief = PACKS.map((pack) => {
const world = new World(pack);
const span = Math.max(
(pack.bounds.maxLat - pack.bounds.minLat) * pack.latScale,
(pack.bounds.maxLng - pack.bounds.minLng) * world.lngScale,
);
return world.metres(blendedPeakMetres(pack)) / span;
});
for (const value of relief) assert.ok(Math.abs(value - RELIEF_IN_FRAME) < 1e-6, String(value));
});
/**
* The board ARCHITECTURE §12.1 tuned against a photograph does not move, and
* that is not a coincidence: `RELIEF_IN_FRAME` is California's own number,
* rounded to three significant figures. The 0.1% here is that rounding.
*/
it("leaves California where the photograph put it", () => {
assert.ok(Math.abs(reconciledExaggeration(CALIFORNIA) - 15) < 0.015);
});
/**
* The honest limit of rule (a), asserted so nobody assumes otherwise.
*
* Apparent relief at a *matched* pose the same true-metre stand-off on both
* boards, which is what the seam actually is is proportional to the
* exaggeration itself and not to relief-in-frame. The rule takes the Bay Area
* direction from 4.17× to 2.60× and leaves the Southern California direction
* at 4.4×, because those two boards were already on one number. Only a single
* exaggeration everywhere would make it 1.0×, and that flattens the state to
* 1.7% of its own frame.
*/
it("narrows the seam step without closing it", () => {
setReconcile(["exaggeration", "projection"]);
const ex = (pack: typeof CALIFORNIA): number => new World(pack).city.verticalExaggeration;
assert.ok(ex(CALIFORNIA) / ex(SF) < 4.167, "the Bay Area direction must improve");
assert.ok(Math.abs(ex(CALIFORNIA) / ex(SF) - 2.6) < 0.1);
assert.ok(Math.abs(ex(CALIFORNIA) / ex(SOCAL) - 4.41) < 0.1);
});
});
describe("reconcile — one ground convention", () => {
it("makes the coastal ramp a fixed multiple of the coarse cell", () => {
for (const pack of PACKS) {
const cell = pack.cellLat * (pack.coarseFactor ?? 1);
assert.ok(Math.abs(reconciledCoastFalloff(pack) / cell - COAST_RAMP_CELLS) < 1e-12);
}
});
/**
* The rule is California's own ratio, so the state board's 0.025° comes back
* out of it to within 0.2%. That is what makes it a rule found rather than a
* rule invented.
*/
it("reproduces California's authored falloff", () => {
assert.ok(Math.abs(reconciledCoastFalloff(CALIFORNIA) / CALIFORNIA.coastFalloff - 1) < 0.002);
});
it("only ever widens the metro ramps, which is the direction that hides steps", () => {
for (const pack of [SF, SOCAL]) assert.ok(reconciledCoastFalloff(pack) > pack.coastFalloff);
});
/** The north anchor is `DEFAULT_PALETTE`, copied. This is the copy not drifting. */
it("keeps its copy of DEFAULT_PALETTE honest", () => {
setReconcile(["ground"]);
const north = reconciledPalette({ ...SF, bounds: { ...SF.bounds, minLat: 90, maxLng: -122 } });
for (const [key, value] of Object.entries(DEFAULT_PALETTE)) {
assert.equal(north[key as keyof typeof north], value, key);
}
});
it("gives California its own palette back, exactly", () => {
const mixed = reconciledPalette(CALIFORNIA);
assert.equal(reconciledPaletteMix(CALIFORNIA), 1);
for (const [key, value] of Object.entries(CALIFORNIA.palette ?? {})) {
assert.equal(mixed[key as keyof typeof mixed], value, key);
}
});
/**
* `alpine` is a fact about the board, not a taste: it is the colour of ground
* above the snow line, so a board with no ground up there does not get one.
* San Francisco's tallest is 1,186 m against a 1,900 m threshold.
*/
it("hands out the alpine stop only to a board that reaches the snow line", () => {
assert.equal(reconciledPalette(SF).alpine, undefined);
assert.ok(reconciledPalette(SOCAL).alpine !== undefined);
assert.ok(reconciledPalette(CALIFORNIA).alpine !== undefined);
});
});
describe("reconcile — the mechanics", () => {
it("is idempotent, which is what the terrain worker's second pass depends on", () => {
setReconcile(true);
for (const pack of PACKS) {
const once = reconciledCity(pack);
const clone = JSON.parse(JSON.stringify(once)) as typeof once;
assert.equal(reconciledCity(once), once);
assert.equal(reconciledCity(clone), clone);
assert.equal(clone.reconciled, true);
}
});
it("returns one object per pack and rule set, so a rebuild agrees with itself", () => {
setReconcile(["roads"]);
const roads = reconciledCity(SF);
assert.equal(reconciledCity(SF), roads);
setReconcile(["roads", "ground"]);
assert.notEqual(reconciledCity(SF), roads);
setReconcile(["roads"]);
assert.equal(reconciledCity(SF), roads);
});
it("turns each rule on alone", () => {
setReconcile(["roads"]);
const roadsOnly = reconciledCity(CALIFORNIA);
assert.equal(roadsOnly.verticalExaggeration, CALIFORNIA.verticalExaggeration);
assert.equal(roadsOnly.coastFalloff, CALIFORNIA.coastFalloff);
assert.equal(roadsOnly.lngScale, undefined);
assert.notEqual(roadsOnly.roads[0]?.width, CALIFORNIA.roads[0]?.width);
setReconcile(["exaggeration"]);
const reliefOnly = reconciledCity(SF);
assert.notEqual(reliefOnly.verticalExaggeration, SF.verticalExaggeration);
assert.deepEqual(
reliefOnly.roads.map((r) => r.width),
SF.roads.map((r) => r.width),
);
});
it("reports what it did", () => {
const report = describeReconciliation(SOCAL);
assert.equal(report.id, "socal");
assert.ok(report.lngMetresPerDegree.before > report.lngMetresPerDegree.after);
assert.ok(report.roadMetres.after[0] > 30 && report.roadMetres.after[1] < 56);
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* A road is a road on every board, or it is a symbol on one of them.
*
* Measured on these packs: San Francisco's roads are 1932 m wide and Southern
* California's 3155 m, which is what those roads are. California's US-101 and
* I-5 are **1,919 m and 2,034 m** wider than the cities they join, and the
* most prominent marks on the state board. At the seam one has to become the
* other, and a 2 km ribbon turning into a 30 m line is a change of drawing
* convention rather than an LOD transition.
*
* Rule (b) reads every width as true metres and floors it at the narrowest
* fraction of a board span the two metro packs already agree on. The floor is
* the interesting part: it was measured off the shipped packs rather than
* chosen, and it is what makes "the metro boards do not move" a property of the
* rule rather than a coincidence to be checked.
*/
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import CALIFORNIA from "../cities/california.ts";
import { LEGIBLE_SPAN_FRACTION, reconciledRoadWidth, setReconcile } from "../cities/reconcile.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";
const metresPerUnit = (city: City): number => 111_320 / city.latScale;
/** Every road on a board, in true metres, as the engine will draw it. */
function drawn(city: City): number[] {
const world = new World(city);
return world.city.roads.map((road) => road.width * world.metresPerUnit);
}
afterEach(() => setReconcile(null));
describe("road width", () => {
it("is a symbol two kilometres wide on the state board today", () => {
setReconcile(false);
const widths = drawn(CALIFORNIA);
assert.deepEqual(widths.map(Math.round), [1919, 2034]);
});
it("leaves both metro boards untouched, to the digit", () => {
setReconcile(false);
const before = { sf: drawn(SF), socal: drawn(SOCAL) };
setReconcile(["roads"]);
assert.deepEqual(drawn(SF), before.sf);
assert.deepEqual(drawn(SOCAL), before.socal);
});
/**
* Why the floor is 0.000199 and not a round number: the two metro packs,
* authored separately, put their narrowest road at 0.00019940 and 0.00020356
* of their own span. Two authors agreeing to within 2% about how thin a line
* may get is the measurement the constant is taken from, and the floor sits
* just under the tighter of the two so that neither board moves.
*/
it("sits just under the narrowest road either metro pack authored", () => {
for (const pack of [SF, SOCAL]) {
const world = new World(pack);
const span = Math.max(
(pack.bounds.maxLat - pack.bounds.minLat) * pack.latScale,
(pack.bounds.maxLng - pack.bounds.minLng) * world.lngScale,
);
const narrowest = Math.min(...pack.roads.map((road) => road.width)) / span;
assert.ok(narrowest > LEGIBLE_SPAN_FRACTION, `${pack.id}: ${narrowest}`);
assert.ok(narrowest < LEGIBLE_SPAN_FRACTION * 1.05, `${pack.id}: ${narrowest}`);
}
});
it("collapses California's freeways by 9x, onto the floor", () => {
setReconcile(["roads"]);
const widths = drawn(CALIFORNIA);
for (const metres of widths) {
assert.ok(metres > 200 && metres < 220, `${metres} m`);
}
assert.ok(1919 / (widths[0] as number) > 9);
});
/**
* The floor is a floor, not a width: a road wider in true metres than the
* floor keeps its own width. Nothing on the shipped boards exercises this
* every metro road clears the floor and both California roads fall onto it
* so it is asserted directly rather than left to be discovered by a pack that
* one day authors a 400 m causeway on a small board.
*/
it("is a floor and not a width", () => {
const wide = { ...CALIFORNIA.roads[0], widthM: 400_000 } as (typeof CALIFORNIA.roads)[number];
const units = reconciledRoadWidth(CALIFORNIA, wide);
assert.ok(Math.abs(units * metresPerUnit(CALIFORNIA) - 400_000) < 1);
});
it("reads an undeclared width as true metres already", () => {
const street = SF.roads.find((road) => road.kind === "street");
assert.ok(street !== undefined && street.widthM === undefined);
assert.ok(Math.abs(reconciledRoadWidth(SF, street) - street.width) < 1e-9);
});
});
+135 -4
View File
@@ -43,7 +43,7 @@ import type { DeviceDeclaration } from "../devices/types.ts";
import type { ControlMode } from "../play/controlMode.ts";
import { formatAircraftDetail, formatPlayHud } from "./hud.ts";
import type { AircraftDetailInput, AircraftDetailView, PlayHudView, PlayTelemetry } from "./hud.ts";
import { railHints } from "./shortcuts.ts";
import { railHints, railLabel } from "./shortcuts.ts";
import { BREAKPOINT } from "./tokens.ts";
// ---- Inputs ---------------------------------------------------------------
@@ -122,6 +122,32 @@ export interface ChromeView {
description?: string;
}
/**
* One rung of the places list: a chapter on some board, named so it can be told
* apart from the chapters on the other two.
*
* A separate shape from `ChromeView` on purpose. A view is a row in the *current*
* board's tour and is addressed by its position in that list which is the one
* thing about a chapter this round may not move, because the capture guards aim
* that way. A place is addressed by `key`, carries the board it is on, and is
* ordered by how far the camera stands back. Two different identities, two
* types, and no chance of one silently standing in for the other.
*/
export interface ChromePlace {
/** `board:chapter`. Unique across the whole ladder. */
key: string;
/** The chapter id on its own board. */
id: string;
board: string;
/** The heading this rung sits under: a region, never a board. */
region: string;
/** Unique across the ladder — the two "Whole Board" rungs are named here. */
label: string;
/** True metres from the camera to the target at this rung's authored pose. */
standoffM: number;
description?: string;
}
/**
* What the detail card is showing.
*
@@ -196,6 +222,20 @@ export interface ChromeInputs {
presenceVisible?: boolean;
/** The board strip's entries: cities outside a building, offices inside one. */
boards?: readonly ChromeBoardTab[];
/**
* Whether the board strip is *drawn*. The list above is built either way.
*
* The two are separate because the strip has a second reader: the harnesses
* reach a board with `document.querySelector('[data-board=…]').click()`, and a
* DOM click does not care whether an element is displayed. Emptying the list
* would take the control away from them as well as from the visitor; hiding it
* takes it away from the visitor only, which is what this round is for.
*/
boardsVisible?: boolean;
/** The ladder, in the order the column prints it. Empty hides the section. */
places?: readonly ChromePlace[];
/** The rung the camera is standing on, by `key`. */
activePlaceKey?: string | null;
/** The chapter list: city chapters outside, authored viewpoints inside. */
views?: readonly ChromeView[];
activeViewId?: string | null;
@@ -255,6 +295,28 @@ export interface ChromeViewRow {
active: boolean;
}
export interface ChromePlaceRow {
key: string;
id: string;
board: string;
label: string;
active: boolean;
/** The region heading to print **above** this row, or `null` to print none. */
region: string | null;
/**
* The digit key that reaches this rung, or `null` past the ninth.
*
* The number **is** the key, which is why it stops at nine rather than
* counting to twenty-four: a printed ordinal that no keystroke matches is a
* label pretending to be an affordance. The chapter list gets away with
* numbering everything because a pack's numbers are authored captions; the
* ladder's are not.
*/
key9: string | null;
/** "1,551 km" / "3.9 km". Formatted here so the applier has no decisions left. */
standoff: string;
}
/** The detail card, resolved. `null` when nothing is picked. */
export type ChromeDetailView =
| { kind: "text"; text: string }
@@ -307,8 +369,12 @@ export interface ChromeState {
* consistent frame of chrome.
*/
deviceDeclarations: readonly DeviceDeclaration[];
/** Whether the board strip is worth drawing at all. See `ChromeInputs.boards`. */
boardsVisible: boolean;
chaptersVisible: boolean;
views: readonly ChromeViewRow[];
placesVisible: boolean;
places: readonly ChromePlaceRow[];
blurbVisible: boolean;
blurbText: string;
@@ -478,6 +544,21 @@ export function seedPlanOpen(_width: number): boolean {
// ---- The function ---------------------------------------------------------
/**
* A stand-off as a person reads it, from 3.9 km to 1,551 km.
*
* Kilometres everywhere, because the ladder spans four orders of magnitude and
* switching units partway down a column of numbers turns a scale into a puzzle.
* One decimal below 10 km, where the difference between 3.9 and 4.3 is two
* different neighbourhoods; none above it, where it is noise.
*/
function formatStandoff(metres: number): string {
if (!Number.isFinite(metres) || metres <= 0) return "";
const km = metres / 1000;
if (km < 10) return `${km.toFixed(1)} km`;
return `${Math.round(km).toLocaleString("en-US")} km`;
}
/**
* Resolve the whole interface for one instant. Pure, total, and cheap enough to
* call on every frame if the caller wants to.
@@ -578,8 +659,10 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
keys: entry.keys,
// `rail`, not `meaning`: the sheet gets the sentence, the rail gets the
// glance. See the field's note in `shortcuts.ts` for what reusing the
// sentence here actually looked like on screen.
label: entry.rail ?? entry.meaning,
// sentence here actually looked like on screen. `railLabel` is what makes
// the digits read "Places" on a board and "Viewpoints" in a room without
// binding the same nine keys twice.
label: railLabel(entry, inside),
}));
// ---- The honesty line ---------------------------------------------------
@@ -654,7 +737,45 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
label: view.shortLabel,
active: view.id === inputs.activeViewId,
}));
/**
* The ladder, with a heading printed only where the region actually changes.
*
* The caller hands these over already ordered grouping is a fact about the
* ladder and lives in `engine/ladder.ts`, next to the measurement that says a
* globally interleaved list would print nine headings for three regions. All
* this does is decide which rows carry one.
*/
const placesInput = inputs.places ?? [];
let lastRegion: string | null = null;
const places: readonly ChromePlaceRow[] = placesInput.map((place, i) => {
const region = place.region === lastRegion ? null : place.region;
lastRegion = place.region;
return {
key: place.key,
id: place.id,
board: place.board,
label: place.label,
active: place.key === inputs.activePlaceKey,
region,
key9: i < 9 ? String(i + 1) : null,
standoff: formatStandoff(place.standoffM),
};
});
const activeView = (inputs.views ?? []).find((view) => view.id === inputs.activeViewId);
/**
* The sentence under the list, and it stays pinned to the **chapter** rather
* than to the lit rung.
*
* They are usually the same sentence and deliberately not always. The lit rung
* is an altimeter it follows the camera, so dragging away from a chapter
* moves it and the blurb is a caption for the thing somebody *chose*.
* `scripts/brand-assets/shots.mjs` depends on exactly that: several shots pick
* a chapter so that "the blurb left in the panel is a caption inside the
* frame", and then aim the camera somewhere else through the plan view. A
* blurb that followed the camera would silently recaption those stills.
*/
const blurbText = activeView?.description ?? "";
const detailInput = inputs.detail ?? null;
@@ -744,8 +865,18 @@ export function chromeState(inputs: ChromeInputs): ChromeState {
inputs.devices.length === 1 ? "Studio hardware →" : `Studio hardware · ${inputs.devices.length}`,
deviceDeclarations: inputs.devices,
chaptersVisible: views.length > 0,
boardsVisible: boards.length > 0 && (inputs.boardsVisible ?? true),
/**
* Outside a building the chapter list stays in the document and out of
* sight: the places list is the same journey said better, and the guards
* that reach `#chapters .chapter` do it with a DOM `.click()`, which works
* on a `hidden` element. Inside one it is the viewpoint list and is the
* control.
*/
chaptersVisible: views.length > 0 && (inside || places.length === 0),
views,
placesVisible: !inside && places.length > 0,
places,
// On a phone the panel is a bottom sheet with a thumb-sized list in it; a
// paragraph of description under that list pushes the door off the screen.
blurbVisible: !phone && blurbText !== "",
+103
View File
@@ -62,6 +62,15 @@ import type { ShortcutSheetHandles } from "./shortcuts.ts";
export interface ChromeHandlers {
onSelectBoard?(id: string): void;
onSelectView?(id: string, index: number): void;
/**
* A rung of the places list, by its ladder key.
*
* By key and not by index, unlike `onSelectView` a place is addressed by
* identity because it may be on a board that is not the one on screen, and
* because the list it lives in is ordered by stand-off rather than by the
* order a pack happened to author its chapters in.
*/
onSelectPlace?(key: string): void;
onEnter?(): void;
onWalk?(): void;
onFly?(): void;
@@ -183,7 +192,12 @@ export function mountChrome(
const officeInvite = find("office-invite");
const officeNote = find("office-note");
const chapterNav = find("chapters");
const chapterSection = find("chapters-section");
const boardSection = find("boards-section");
const placeNav = find("places");
const placeSection = find("places-section");
const blurb = find("blurb");
const blurbSection = find("blurb-section");
const tier = find("tier");
const tierLabel = find("tier-label");
@@ -225,6 +239,9 @@ export function mountChrome(
let latest: ChromeState | null = null;
let boardSignature = "";
let viewSignature = "";
let placeSignature = "";
/** The rung that was last scrolled into view, so a steady camera does not scroll. */
let scrolledPlaceKey = "";
let deviceSignature = "";
/**
* Four more signatures, for the four rebuilds that are not lists of buttons.
@@ -307,6 +324,13 @@ export function mountChrome(
if (id !== null && id !== undefined && index >= 0) handlers.onSelectView?.(id, index);
});
on(placeNav, "click", (event) => {
const target = event.target;
if (!(target !== null && typeof target === "object" && "closest" in target)) return;
const key = (target as Element).closest("[data-place]")?.getAttribute("data-place");
if (key !== null && key !== undefined) handlers.onSelectPlace?.(key);
});
// ---- The touch action pad ----------------------------------------------
//
// Held buttons are bound on the pointer rather than on click, because a click
@@ -517,6 +541,84 @@ export function mountChrome(
setHidden(officeNote, !state.officeNoteVisible);
setText(officeNote, state.officeNoteText);
setHidden(boardSection, !state.boardsVisible);
/**
* The places list, rebuilt only when a row's identity or its highlight moves.
*
* The highlight is in the signature deliberately: it is what makes the list
* an altimeter, and it changes as the camera descends rather than only when
* somebody clicks. Everything else about a row is a pure function of the
* ladder, which is built once at module load, so in the steady state this
* comparison is a string equality and nothing else.
*/
setHidden(placeSection, !state.placesVisible);
const nextPlaceSignature = state.places
.map((place) => `${place.key}:${place.region ?? ""}:${place.key9 ?? ""}:${place.active}`)
.join("|");
if (placeNav !== null && nextPlaceSignature !== placeSignature) {
placeSignature = nextPlaceSignature;
const rows: Node[] = [];
for (const place of state.places) {
if (place.region !== null) {
const heading = doc.createElement("p");
heading.className = "places__region";
heading.textContent = place.region;
rows.push(heading);
}
const button = doc.createElement("button");
button.type = "button";
button.className = "place";
// Two attributes, because the two questions are different: which rung
// this is, and which board it would take you to. The second is what lets
// a stylesheet or a test tell a cross-board rung from a local one
// without parsing the first.
button.setAttribute("data-place", place.key);
button.setAttribute("data-place-board", place.board);
button.setAttribute("aria-pressed", String(place.active));
const name = doc.createElement("span");
name.className = "place__name";
name.textContent = place.label;
const standoff = doc.createElement("span");
standoff.className = "place__standoff";
standoff.textContent = place.standoff;
if (place.key9 !== null) {
const key = doc.createElement("span");
key.className = "num";
key.textContent = place.key9;
button.append(key);
}
button.append(name, standoff);
rows.push(button);
}
placeNav.replaceChildren(...rows);
}
/**
* Keep the lit rung in view as the camera descends.
*
* Guarded on the key rather than run every apply, because `scrollIntoView`
* on an element that is already in view still costs a layout and this
* runs from a once-a-second poll. `block: "nearest"` scrolls the list and
* never the page: the panel is a column on a desktop and a bottom sheet on
* a phone, and a rung dragging the whole document sideways is the failure
* that arrangement is most prone to.
*/
const litPlace = state.places.find((place) => place.active) ?? null;
if (placeNav !== null && litPlace !== null && litPlace.key !== scrolledPlaceKey) {
scrolledPlaceKey = litPlace.key;
// Walked rather than selected. A `[data-place="…"]` selector would need
// `CSS.escape` for a key that is caller-supplied text, and `CSS` is a
// browser global this module's tests do not have — the whole point of the
// `FakeElement` harness in `ui/mount.test.ts` is that this file runs
// without a DOM. Twenty-four children is a walk nobody can measure.
const row = [...placeNav.children].find(
(child) => child.getAttribute?.("data-place") === litPlace.key,
);
(row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" });
}
setHidden(chapterSection, !state.chaptersVisible);
setHidden(chapterNav, !state.chaptersVisible);
const nextViewSignature = state.views
.map((view) => `${view.id}:${view.number}:${view.label}:${view.active}`)
@@ -541,6 +643,7 @@ export function mountChrome(
}),
);
}
setHidden(blurbSection, !state.blurbVisible);
setHidden(blurb, !state.blurbVisible);
setText(blurb, state.blurbText);
+41 -5
View File
@@ -110,6 +110,17 @@ export interface Shortcut {
* `railHints` only ever picks from a fixed set of ids.
*/
rail?: string;
/**
* The rail word to use **inside a studio**, when the same binding means
* something else there. Falls back to `rail`.
*
* One key, one row in the `?` sheet, two true glances. `ui/shortcuts.test.ts`
* asserts that no key is bound to two different meanings it is the check
* that caught `G` being both the crow's glide and godmode's drawer so a
* second entry for the digits is not available and would be wrong anyway:
* `1`-`9` is one binding whose list changes with where you are standing.
*/
railOffice?: string;
section: ShortcutSection;
scope: ShortcutScope;
/**
@@ -251,19 +262,38 @@ export const KEYMAP: readonly Shortcut[] = [
{
id: "board",
keys: ["[", "]"],
meaning: "Previous / next board — California, the Bay Area, Southern California",
/*
* It used to read "Previous / next board California, the Bay Area,
* Southern California", which stopped being a true sentence the moment
* boards stopped being modes. There is one list now and it descends: the
* state at 1,551 km of stand-off down to a street in Mission Bay at 3.9,
* with the Southland and the Bay in between. Stepping it crosses from one
* board to the next wherever the list does, which is the whole point.
*/
meaning: "Up / down the list of places — the state, the Southland, the Bay",
section: "world",
scope: "city",
onScreen: "the board tabs in the left column",
onScreen: "the Places list in the left column",
},
{
id: "chapters",
keys: ["1", "…", "9"],
meaning: "Fly to a chapter of the tour, or to a viewpoint inside a studio",
rail: "Chapters",
meaning: "Fly to one of the first nine places, or to a viewpoint inside a studio",
rail: "Places",
/*
* One binding, two true words.
*
* A second KEYMAP entry was the obvious way to say "Viewpoints" in a room
* and `ui/shortcuts.test.ts` refused it, correctly: it asserts that no key
* is bound to two different meanings, which is the check that caught `G`
* being both the crow's glide and godmode's drawer. The keys here are the
* *same* binding said in two places, so the row stays one row and only the
* rail's glance changes.
*/
railOffice: "Viewpoints",
section: "world",
scope: "always",
onScreen: "the numbered list in the left column",
onScreen: "the numbered rows at the top of the Places list",
},
// ---- The view -----------------------------------------------------------
@@ -543,6 +573,12 @@ export function shortcutRows(inputs: ShortcutSheetInputs): readonly ShortcutRow[
* at most two hints and they are about the mode you are actually in; everything
* else is one press of `?` away.
*/
/** The word the rail prints for a shortcut, given where the viewer is standing. */
export function railLabel(entry: Shortcut, inside: boolean): string {
if (inside && entry.railOffice !== undefined) return entry.railOffice;
return entry.rail ?? entry.meaning;
}
export function railHints(mode: ControlMode, coarsePointer: boolean): readonly Shortcut[] {
if (coarsePointer) return [];
const wanted: readonly string[] =