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
+249 -7
View File
@@ -12,12 +12,46 @@
* the sun — `Atmosphere` for a city, a fixed constant for an office — computes
* the state and hands it over, and nothing writes back. That is the one
* direction CONTRACT.md §4 asks for.
*
* It is also where a finger meets the map. `OrbitControls` gives one gesture
* vocabulary to both a mouse and a thumb, and the two want different answers —
* so the kit swaps a small input profile on every `pointerdown` according to
* `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop
* changes; the touch values are only ever installed by a touch.
*
* The one thing that is *not* here is `touch-action`. `OrbitControls.connect()`
* sets `touchAction = "none"` on the element it is handed, and `index.html`
* also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule
* is what covers the second or two between first paint and this module
* existing, and a drag on the canvas in that window would otherwise scroll and
* rubber-band the page instead.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";
/**
* How much slower one finger turns the camera than one mouse.
*
* `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes,
* and it has one `rotateSpeed` for both, so this is a compromise between them.
* Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall
* phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle`
* leaves about 85° of usable travel against a mapping that spends 360° over a
* screen height, so a tilt hits its clamp in the first 200 px and the camera
* feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px
* and costs the azimuth a swing it can afford — the vertical axis is the
* binding constraint, and there is only one dial.
*/
const TOUCH_ROTATE_SCALE = 0.7;
/** How far a finger may wander and still be a tap, in CSS px. */
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
position: THREE.Vector3;
@@ -101,16 +135,108 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
const controls = new OrbitControls(camera, dom);
controls.enableDamping = true;
controls.dampingFactor = options.dampingFactor ?? 0.07;
const baseDamping = options.dampingFactor ?? 0.07;
controls.dampingFactor = baseDamping;
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = options.minDistance ?? 12;
controls.maxDistance = options.maxDistance ?? 340;
// ---- Input --------------------------------------------------------------
/*
* The gesture map is three.js's default and it is already the right one:
* `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers
* pinch and drag *at the same time*, which is how every map on a phone
* behaves and is why it is not split into separate two-finger modes here.
*
* Zoom needs nothing scaled to the board, and it is worth saying why, because
* `scene.ts` records what happened the last time a distance was treated as a
* constant. A pinch dollies by `(endSeparation / startSeparation) ^
* zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both
* multiply the camera's current distance, so SoCal's 393-unit board and the
* Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with
* no knowledge of either number. The only board-sized values in the gesture
* path are `minDistance` and `maxDistance`, which the caller already derives.
*/
/**
* A mouse and a thumb are given different values for the three settings where
* one answer cannot serve both, swapped in on `pointerdown` by `pointerType`.
*
* The alternative — pick the values once from a device probe — is wrong on
* every laptop with a touchscreen, where both inputs are live at once and the
* user switches between them mid-session. Keying off the event that is
* actually happening is both simpler and correct, and it means the desktop
* path is bit-for-bit what it was: the touch values do not exist until a
* touch installs them.
*
* - **`screenSpacePanning`** is three's default `true`, which pans along the
* camera's own up vector. On a map seen from above that lifts the target
* off the ground as you drag, and the board slides away underneath. For two
* fingers it goes to `false`: pan in the ground plane, so the board tracks
* the fingers. Left alone for the mouse, where right-drag pan is
* long-standing behaviour and someone would notice it change.
* - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point
* between the fingers, which is the whole reason people pinch a particular
* neighbourhood. It moves `controls.target` as well as the camera, so the
* orbit centre drifts toward whatever was pinched — accepted deliberately,
* because on a map that drift *is* the interaction. The wheel keeps zooming
* to the centre of the view.
* - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`.
*/
const mouseInput = {
rotateSpeed: controls.rotateSpeed,
screenSpacePanning: controls.screenSpacePanning,
zoomToCursor: controls.zoomToCursor,
};
function applyPointerProfile(pointerType: string) {
const touch = pointerType === "touch";
controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1);
controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning;
controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor;
}
/**
* A wheel arrives with no pointer, so it cannot announce its own type. Any
* wheel at all means a mouse or a trackpad is in the room, and without this a
* hybrid laptop that was last touched keeps the touch profile — and scrolls
* toward wherever the finger happened to be, once, for no visible reason.
*
* `OrbitControls` registered its own wheel handler first, so the notch that
* performs the reset is itself still anchored to the old point and only the
* next one is centred. One notch, on a machine that has both inputs and used
* both in the same breath; the fix for that costs finger-counting state and
* buys a frame.
*/
function onWheel() {
applyPointerProfile("mouse");
}
dom.addEventListener("wheel", onWheel, { passive: true });
/**
* iOS pinches the *page* as well as the map.
*
* `touch-action: none` stops Safari's double-tap zoom and its scroll, but
* WebKit's own `gesture*` events are not covered by it, and a two-finger
* pinch that begins on the canvas can still scale the whole document —
* leaving the UI enormous, half off-screen, and with no gesture left that
* undoes it. Refusing the three of them costs nothing anywhere else: no other
* engine implements the events at all.
*/
const preventGesture = (event: Event) => event.preventDefault();
dom.addEventListener("gesturestart", preventGesture);
dom.addEventListener("gesturechange", preventGesture);
dom.addEventListener("gestureend", preventGesture);
// ---- Light rig ----------------------------------------------------------
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.castShadow = true;
const mapSize = options.shadowMapSize ?? 2048;
// The default is the device's, not a constant: a phone gets a smaller map for
// the reasons written out in `stage.ts`. A caller that knows better — an
// office, at a hundredth of the city's scale — passes its own.
const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize;
sun.shadow.mapSize.set(mapSize, mapSize);
sun.shadow.camera.near = options.shadowNear ?? 10;
sun.shadow.camera.far = options.shadowFar ?? 520;
@@ -175,6 +301,19 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
let flying = false;
let flightT = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
// Mid-flight when the preference flips: land now rather than finish the arc.
if (reducedMotion && flying) setPose(to);
}
motionQuery?.addEventListener("change", onMotionChange);
function setPose(pose: Pose) {
flying = false;
camera.position.copy(pose.position);
@@ -182,7 +321,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
controls.update();
}
/**
* A chapter flight is the largest motion this app makes: the whole field of
* view sweeps and rotates for a second and a half, unrequested by anyone who
* only clicked a name in a list. That is the case `prefers-reduced-motion`
* exists for, so under it the flight becomes a cut. `main.ts` already reached
* the same conclusion for a minimap seek and says so there.
*
* Damping is left alone, and the distinction is worth stating: damping only
* ever follows a finger or a mouse that is currently moving, and it settles
* in a few frames after it stops. It is the response to a gesture, not motion
* the interface started on its own.
*/
function flyTo(pose: Pose) {
if (reducedMotion) {
setPose(pose);
return;
}
from.position.copy(camera.position);
from.target.copy(controls.target);
to.position.copy(pose.position);
@@ -203,14 +358,68 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
// thrown away.
let pointerDirty = false;
function onPointerMove(event: PointerEvent) {
function aimAt(clientX: number, clientY: number) {
const rect = dom.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
pointerDirty = true;
}
// A moving finger is not hovering; see the tap block below.
function onPointerMove(event: PointerEvent) {
if (event.pointerType === "touch") return;
aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointermove", onPointerMove);
/**
* There is no hover on a touch screen, and pretending otherwise is how a map
* ends up flashing a detail card for every marker a thumb happens to sweep
* across on its way to turning the board. A finger only reports where it is
* *while it is pressed*, which is exactly when it is doing something else.
*
* So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and
* `TAP_MS`, and that point is picked. Anything longer or further is a gesture
* and picks nothing. The pick then survives the finger leaving the glass — a
* card raised by a tap has to stay up to be read — and is cleared by the next
* touch anywhere, which is what makes tapping empty water the way to dismiss
* it.
*
* 12 px of slop, not zero: a thumb pivots while it presses, and a tap that
* wandered a millimetre is still a tap. Past that the camera has visibly
* moved, and something that moved the map should not also have selected
* something on it.
*/
/** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */
let tapPointer = -1;
let tapX = 0;
let tapY = 0;
let tapAt = 0;
function onPointerDown(event: PointerEvent) {
applyPointerProfile(event.pointerType);
if (event.pointerType !== "touch") return;
resetPick();
tapPointer = tapPointer === -1 ? event.pointerId : -2;
tapX = event.clientX;
tapY = event.clientY;
tapAt = event.timeStamp;
}
dom.addEventListener("pointerdown", onPointerDown);
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP;
tapPointer = -1;
if (wasTap) aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointerup", onPointerUp);
dom.addEventListener("pointercancel", onPointerUp);
function resetPick() {
pointerDirty = false;
if (picked === null) return;
@@ -219,7 +428,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
dom.style.cursor = "";
wasPicking?.onChange(null);
}
dom.addEventListener("pointerleave", resetPick);
// Not for touch. A finger lifting fires `pointerleave` immediately after
// `pointerup`, so honouring it here would wipe the pick a tap had just made,
// in the same frame, every time.
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
resetPick();
}
dom.addEventListener("pointerleave", onPointerLeave);
function repick() {
if (!picking || !pointerDirty) return;
@@ -253,6 +470,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
},
resetPick,
tick(dt) {
/**
* `OrbitControls` damps per *frame*, not per second: every `update()`
* moves the camera `dampingFactor` of the way to where the input asked
* for. So the same 0.07 is a different feel on every refresh rate — twice
* as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a
* 144 Hz monitor, which is why the settle on a laptop and the settle on a
* handset never matched.
*
* Re-deriving it from the frame time fixes both ends with the same line.
* At exactly 60 fps this returns `baseDamping` unchanged, so the desktop
* default it was tuned at is preserved to the digit; away from 60 it
* holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to
* 50 ms, so the exponent cannot run away after a stall and snap the
* camera.
*/
controls.dampingFactor =
dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping;
if (flying) {
flightT = Math.min(1, flightT + dt * flightSpeed);
// easeInOutCubic — a flight that starts and lands gently
@@ -268,7 +502,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
},
dispose() {
dom.removeEventListener("pointermove", onPointerMove);
dom.removeEventListener("pointerleave", resetPick);
dom.removeEventListener("pointerdown", onPointerDown);
dom.removeEventListener("pointerup", onPointerUp);
dom.removeEventListener("pointercancel", onPointerUp);
dom.removeEventListener("pointerleave", onPointerLeave);
dom.removeEventListener("wheel", onWheel);
dom.removeEventListener("gesturestart", preventGesture);
dom.removeEventListener("gesturechange", preventGesture);
dom.removeEventListener("gestureend", preventGesture);
motionQuery?.removeEventListener("change", onMotionChange);
dom.style.cursor = "";
picking = null;
controls.dispose();