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:
+249
-7
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user