1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+303 -54
View File
@@ -2,7 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
`viewport-fit=cover` so the canvas reaches the edges of a notched screen;
every fixed card below then pays for it with an `env(safe-area-inset-*)`.
Deliberately **no** `user-scalable=no` and no `maximum-scale`: the map
itself is already protected from browser zoom by `touch-action: none` and
by `scenekit.ts` swallowing WebKit's `gesture*` events, and taking page
zoom away from everyone to protect one element is the accessibility
mistake that rule exists to avoid.
-->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<!--
An empty data URI, not an icon file. `src/` carries no binary assets by
@@ -89,12 +98,29 @@
what people noticed. */
background: #0d1218;
overflow: hidden;
/* The canvas cannot scroll the page — `touch-action: none` sees to that
— but a drag that starts on the panel, the rail or the source line
still rubber-bands the whole document on iOS and can still trigger
pull-to-refresh on Android, which on a full-screen map looks like the
map itself has come loose. */
overscroll-behavior: none;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
#scene { display: block; width: 100vw; height: 100dvh; touch-action: none; }
#scene {
display: block;
width: 100vw;
height: 100dvh;
touch-action: none;
/* A slow orbit on iOS is, to WebKit, a long press on a page: it raises
the selection callout and the magnifier over the city. There is no
text here to select. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* ---- Type scale ------------------------------------------------------
Five sizes. 9px is reserved for uppercase micro-labels, where the caps
@@ -162,22 +188,10 @@
font-variant-numeric: tabular-nums;
}
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; }
.scrub button {
font: inherit;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.scrub button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
/* The `#hour` scrubber and its `now` button used to live here. They were
god-only, and the godmode panel now owns the same override with a date
as well as an hour — two writers for one value, of which this was the
one that silently threw the date away. `main.ts` says the rest. */
.cities { display: flex; gap: var(--s1); }
.city {
@@ -195,7 +209,9 @@
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
@media (hover: hover) {
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
}
.city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); }
.enter {
@@ -212,7 +228,7 @@
box-shadow: var(--shadow);
transition: background var(--t);
}
.enter:hover { background: var(--amber-lit); }
@media (hover: hover) { .enter:hover { background: var(--amber-lit); } }
/* The public-office note. Amber-edged rather than amber-filled: it is an
explanation, not an action, and the one filled amber thing on screen
@@ -255,7 +271,9 @@
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
@media (hover: hover) {
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
}
.chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
@@ -266,7 +284,7 @@
.panel-toggle {
display: none;
position: fixed;
top: var(--s3);
top: calc(var(--s3) + env(safe-area-inset-top));
left: var(--s3);
z-index: 4;
align-items: center;
@@ -287,10 +305,18 @@
}
.panel-toggle .glyph { font-size: 13px; line-height: 1; }
/* ---- Top right: the plan --------------------------------------------- */
/* ---- Top right: who you are, and the plan ------------------------------
The tier badge is its own fixed card rather than the last child of
`.corner`, which it used to be. Two reasons, and the second is a bug:
a phone turns the plan into a bottom sheet and the badge must not go
down there with it, and `M` — which hides the plan — was also hiding
the one line that says whether you are signed in. */
.corner {
position: fixed;
top: var(--s4);
/* Clear of the tier badge above it, which is one line of 9px caps in a
card: 8 + 14 + 8 of padding and leading, two hairlines, and the 4px
rhythm's worth of gap. */
top: calc(var(--s4) + env(safe-area-inset-top) + 2.4rem);
right: var(--s4);
width: 15rem;
display: flex;
@@ -325,7 +351,12 @@
}
.tier {
position: fixed;
top: calc(var(--s4) + env(safe-area-inset-top));
right: var(--s4);
z-index: 3;
margin: 0;
max-width: min(15rem, 50vw);
padding: var(--s2) var(--s3);
display: flex;
align-items: baseline;
@@ -336,7 +367,15 @@
text-transform: uppercase;
color: var(--ink-3);
}
.tier .who { color: var(--ink-2); text-transform: none; letter-spacing: 0.04em; }
.tier .who {
color: var(--ink-2);
text-transform: none;
letter-spacing: 0.04em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
@@ -344,7 +383,10 @@
.rail {
position: fixed;
right: var(--s4);
bottom: var(--s4);
/* The safe-area inset is zero everywhere except the phone it exists for,
where `.rail { bottom: 16px }` puts the `?` button underneath the home
indicator — a control you can see and cannot press. */
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
z-index: 3;
display: flex;
flex-direction: column;
@@ -356,7 +398,32 @@
second panel. */
max-width: min(30rem, calc(100vw - var(--s4) * 2));
}
#detail { max-width: 100%; }
#detail {
max-width: 100%;
display: flex;
align-items: flex-start;
gap: var(--s3);
}
#detail-text { flex: 1; min-width: 0; }
/* A dismiss the thumb can find. On a mouse the detail card is cleared by
moving the pointer off the marker, which is an affordance touch simply
does not have: a tap sets it and nothing takes it away. Tapping empty
map clears it too — this is the version you can see. */
.detail-close {
flex: none;
font: inherit;
font-size: 14px;
line-height: 1;
padding: 0 var(--s1);
margin: -2px 0;
cursor: pointer;
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--ink-3);
transition: color var(--t);
}
@media (hover: hover) { .detail-close:hover { color: var(--ink); } }
.hint {
display: flex;
flex-wrap: wrap;
@@ -377,6 +444,19 @@
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
}
/* The plan toggle is for pointers, and only for pointers.
Where there is a keyboard the `M` hint two elements to the left is the
affordance and a button saying the same thing is a second row of glass
for nothing. Where there is not — a phone, a tablet, a touchscreen
kiosk — the hints are hidden or meaningless and this is the only way in.
Keyed off the pointer rather than off the width because the defect it
fixes is about fingers: an 820px iPad has no `M` either, and it lands on
the desktop layout by design (see `deviceProfile` in `stage.ts`). */
#plan-toggle { display: none; }
@media (pointer: coarse), (max-width: 600px) {
#plan-toggle { display: inline-flex; align-items: center; }
}
.help {
font: inherit;
font-size: 10px;
@@ -389,18 +469,21 @@
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
@media (hover: hover) {
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
}
/* ---- Bottom left: where the numbers came from -------------------------
Shown only when the data is live — see `renderLegend` in main.ts for why
Shown only when the data is live — see `renderSource` in main.ts for why
the sample-data half of this was retired. The disclosure it used to
carry did not go into a README nobody opens: it is on the boot card
everyone passes through and in the `?` card, which is one keypress away
from any state the app can be in. */
from any state the app can be in. The `?` card is also where the live
sources' own credit lines land; see `#credits`. */
.source {
position: fixed;
left: var(--s4);
bottom: var(--s4);
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
margin: 0;
z-index: 3;
max-width: calc(100vw - var(--s4) * 2);
@@ -417,6 +500,18 @@
}
.source.live { color: #8fe89a; }
/* The scrim behind the panel sheet. Declared *before* the phone breakpoint
and not after it: two rules of equal specificity, and the last one in
the file wins whatever the media query says — which is how this spent
an afternoon being permanently invisible. */
#scrim {
display: none;
position: fixed;
inset: 0;
z-index: 5;
background: rgba(4, 7, 11, 0.45);
}
/* ---- Overlays --------------------------------------------------------- */
.overlay {
position: fixed;
@@ -438,6 +533,8 @@
}
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--amber); }
/* The Touch section, under the keyboard one. */
.sheet h2:not(:first-child) { margin-top: var(--s4); }
.keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3);
align-items: baseline; font-size: 11px; }
.keys dt { text-align: right; }
@@ -454,7 +551,7 @@
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
}
.sheet-close:hover { background: rgba(255, 255, 255, 0.16); }
@media (hover: hover) { .sheet-close:hover { background: rgba(255, 255, 255, 0.16); } }
/* ---- Boot -------------------------------------------------------------
The page used to be blank for the ~2.3 s it takes to build the Bay Area
@@ -505,12 +602,22 @@
.boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2);
min-height: 1.4em; }
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
/* One notch up from the note above it, and a hairline off it. A credit
line is somebody's licence term rather than this project's small print,
and two identical grey paragraphs read as one. */
.credits {
margin-top: var(--s3);
padding-top: var(--s3);
border-top: 1px solid var(--hairline);
color: var(--ink-3);
}
/* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture
and becomes a sheet you open; at 600 the plan stops fitting beside the
map at all and lives behind `M`. `main.ts` owns the *state* of both, so
these rules only describe what each state looks like. */
map at all and moves to the "plan view" button on the rail (and `M`,
where there is a keyboard). `main.ts` owns the *state* of both, so these
rules only describe what each state looks like. */
@media (max-width: 900px) {
.panel-toggle { display: flex; }
#panel {
@@ -523,23 +630,131 @@
opacity: 0;
pointer-events: none;
}
.corner { width: 11.5rem; top: var(--s3); right: var(--s3); }
.corner {
width: 11.5rem;
top: calc(var(--s3) + env(safe-area-inset-top) + 2.4rem);
right: var(--s3);
}
.tier { top: calc(var(--s3) + env(safe-area-inset-top)); right: var(--s3); }
.minimap-frame { height: 10rem; }
.rail { right: var(--s3); bottom: var(--s3); }
.source { left: var(--s3); bottom: var(--s3); }
.rail { right: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
.source { left: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
}
/* ---- The phone --------------------------------------------------------
Below 600 this stops being a scaled-down desktop and becomes a different
layout, because the constraints are different in kind and not in degree:
there is no hover, so nothing may depend on it; there is no keyboard, so
a card explaining the keyboard is furniture; and there is one thumb,
which reaches the bottom third of the screen and not the top corners.
The renderer draws the same conclusion from the same numbers —
`deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
stylesheet and the pixel budget cannot drift apart. */
@media (max-width: 600px) {
.rail { max-width: calc(100vw - var(--s3) * 2); }
/* The provenance line and the key hints would collide in the last 40px
of a phone, and the honesty line is the one that has to survive. */
/* Everything hit by a finger clears 44px, which is 11 steps of the 4px
rhythm and the smallest target anyone has managed to defend. `#help`
was 17px tall. */
#panel-toggle,
#enter,
.city,
.chapter,
.sheet-close {
min-height: 44px;
}
.chapter { align-items: center; }
.help,
.detail-close {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border-radius: 999px;
}
/* The rail spans the width so the detail card — now reachable, because
a marker picks on tap — is a readable line rather than a column. */
.rail {
left: var(--s3);
right: var(--s3);
max-width: none;
align-items: stretch;
}
/* No keyboard, so no key hints — and with them gone the card around
them is a full-width bar of glass holding one button. On a phone the
hint *is* the `?` button. */
.hint span { display: none; }
.source { max-width: 60vw; }
.hint {
align-self: flex-end;
padding: 0;
border: 0;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* The honesty line moves out of the thumb's way rather than out of the
layout. It is the one caption that is never allowed to be dropped for
space, so it goes to the quiet corner under the ☰ button and stays to
one ellipsised line. */
.source {
top: calc(var(--s3) + env(safe-area-inset-top) + 3.1rem);
bottom: auto;
max-width: min(60vw, calc(100vw - var(--s3) * 2));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The plan, as a bottom sheet. At 11.5rem in a corner it ate a fifth of
a 375px map and its own affordances were about six pixels across —
a map you cannot read to move a map you can. Full width above the
rail, it is a plan view; and it is still off until asked for, which
on this layout means the "plan view" button on the rail. It was `M`
and only `M` for a while, which meant this block was written for a
device that could never reach it. */
.corner {
top: auto;
left: var(--s3);
right: var(--s3);
width: auto;
bottom: calc(var(--s3) + 3.5rem + env(safe-area-inset-bottom));
}
.minimap-frame { height: min(38dvh, 18rem); }
/* Hover-only text on a device with no hover. */
.minimap-readout { display: none; }
/* The panel, as a bottom sheet. A left drawer on a phone covers the
thing being looked at; a sheet rises from the edge the thumb starts
at, and the scrim behind it means tapping the map closes it. */
#panel {
inset: auto 0 0 0;
width: auto;
max-width: none;
max-height: 72dvh;
padding: var(--s4) var(--s3) calc(var(--s4) + env(safe-area-inset-bottom));
gap: var(--s2);
z-index: 6;
pointer-events: auto;
background: var(--glass-strong);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border-top: 1px solid var(--hairline);
border-radius: var(--r) var(--r) 0 0;
box-shadow: 0 -8px 30px rgba(3, 6, 10, 0.5);
}
body.panel-closed #panel { transform: translateY(100%); opacity: 0; }
#scrim { display: block; }
body.panel-closed #scrim { display: none; }
}
/* The minimap's own visibility is a user decision (`M`), seeded from the
viewport width by `main.ts` rather than by a media query, so that
toggling it on at 375px actually shows it. */
/* The minimap's own visibility is a user decision (`M`, or the "plan view"
button on the rail), seeded from the viewport width by `main.ts` rather
than by a media query, so that toggling it on at 375px actually shows
it. */
body.minimap-off .corner { display: none; }
@media (prefers-reduced-motion: reduce) {
@@ -564,11 +779,6 @@
<h1 id="title">San Francisco</h1>
<p id="subtitle">Tera · Lumbridge Simulate</p>
<p id="clock" class="clock" aria-live="polite"></p>
<div id="scrub" class="scrub" hidden>
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12"
aria-label="Hour of day" />
<button id="now" title="follow the wall clock">now</button>
</div>
</section>
<nav id="cities" class="cities" aria-label="City"></nav>
<button id="enter" class="enter">Enter the office →</button>
@@ -577,27 +787,45 @@
<p class="card" id="blurb"></p>
</div>
<p class="card tier" id="tier" hidden></p>
<aside id="corner" class="corner" aria-label="Plan view">
<div class="card" id="minimap">
<div class="minimap-frame"></div>
<p id="minimap-readout" class="minimap-readout"></p>
</div>
<p class="card tier" id="tier" hidden></p>
</aside>
<div class="rail" id="rail">
<div class="card" id="detail" aria-live="polite" hidden></div>
<div class="card" id="detail" aria-live="polite" hidden>
<span id="detail-text"></span>
<button id="detail-close" class="detail-close" aria-label="Dismiss">×</button>
</div>
<div class="card hint" id="hint">
<span><kbd>1</kbd><kbd>9</kbd> chapters</span>
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
<span><kbd>O</kbd> office</span>
<span><kbd>M</kbd> plan</span>
<!-- The plan view's tap target, for the devices where `M` is not a
thing that exists. Until this button did, the plan was seeded off
below 600px and the key was the only way to turn it on, so the
bottom-sheet layout designed for it further up this file was
unreachable on the device it was drawn for. Hidden where the key
hint beside it is true; see `#plan-toggle` in the stylesheet.
`aria-pressed` and no second label, because the state it reports is
the map appearing next to it. -->
<button id="plan-toggle" class="help" aria-pressed="true">plan view</button>
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
</div>
</div>
<p id="source" class="source"></p>
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
scrim leaves no way to dismiss it but the same button that opened it,
which is the wrong half of the screen for a thumb. -->
<div id="scrim"></div>
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
aria-labelledby="shortcuts-title" hidden>
<div class="card sheet">
@@ -607,15 +835,36 @@
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
<dt><kbd>?</kbd></dt><dd>This card</dd>
<dt id="key-overlays"><kbd>?</kbd></dt><dd>This card</dd>
<dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd>
<dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd>
</dl>
<p class="boot-note">On the plan: click or drag to move the view, scroll to zoom,
arrow keys to aim and <kbd>Enter</kbd> to go.</p>
<h2 id="touch-title">Touch</h2>
<dl class="keys">
<dt>One finger</dt><dd>Orbit</dd>
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
</dl>
<!-- Stated as the rule rather than as a claim about this page, because
this file is static and the answer is not: one build serves a
deployment with a weather station and one with nothing at all. -->
<p class="boot-note">The markers on this map are fabricated. No real company data
ships in this build; a deployment wired to a live source says <em>live data</em>
in the corner, and this one does not.</p>
ships in this build. A deployment wired to a live source names it in the corner —
<em>live weather</em>, <em>live traffic</em>, <em>live markers</em>, or
<em>live data</em> when all three are. Nothing in the corner means everything you
are looking at came out of this bundle.</p>
<!-- Credit for whatever live source is answering right now, written by
`renderCredits` in main.ts. Empty and hidden on a build with no
server, which is most of them — and not decoration when it is not:
MET Norway and Open-Meteo are CC BY 4.0 and the API forwards the
line each of them asks for. This card is where it is shown because
it is reachable from every state on both layouts, and because the
corner line is clamped to one ellipsised phrase on a phone. -->
<p class="boot-note credits" id="credits" hidden></p>
<button class="sheet-close" id="shortcuts-close">Close</button>
</div>
</div>