1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/main.ts
T
karti e41c90fe8d 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>
2026-08-06 03:25:31 -07:00

1475 lines
60 KiB
TypeScript

/**
* The demo: two cities under a real sun and moon, and one office you can step
* into.
*
* It ships **no real company data**. The markers are fabricated — see
* `src/adapters/sample.ts`, which says so loudly — because real positions are
* geocoded and real pipeline status is private, and neither belongs in this
* repo. When a Tera API is present the same markers arrive from it instead, and
* the UI says which of the two it is showing.
*
* It also runs with no server at all: the sun and moon are computed locally,
* the traffic is simulated, the office is a data file. Clone it and it works.
*/
import {
createAtmosphere,
observe,
PACIFIC_MARINE_LAYER,
type WeatherObservation,
} from "./engine/atmosphere.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import { regionOf, SimulatedFlights } from "./engine/flights.ts";
import type { Pose } from "./engine/scenekit.ts";
import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.ts";
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts";
import {
createTeraClient,
describeLiveness,
type TrafficSource,
type WeatherWatch,
} from "./adapters/http.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, sampleRoutesFor } from "./adapters/sample.ts";
import { authFetch } from "./session.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
/**
* Three type-only imports and not one value among them, which is what keeps the
* office and the instruments out of the entry chunk.
*
* `import type` is erased before Rollup ever sees it, so none of these three
* files is an edge in the module graph and none of them lands in the 780 kB
* everybody downloads. The office arrives through the `await import()` in
* `loadOffice()`, the tools through the one in `boot()`, and the rule that says
* so for `src/tools/` is written out at the top of `tools/index.ts`. Turning any
* of these into a value import silently undoes the split, and nothing fails —
* the bundle just gets big again.
*/
import type { OfficeScene } from "./interiors/officeScene.ts";
import type { Office } from "./interiors/types.ts";
import type { MaterialRegistry } from "./assets/materials.ts";
import type { Godmode, GodmodePlace } from "./tools/index.ts";
import type { PoseEditor } from "./tools/poseEditor.ts";
const CITIES: { id: string; label: string; city: City }[] = [
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
{ id: "socal", label: "SoCal", city: SOCAL },
];
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
/**
* One renderer, one loop, for as long as this page is open.
*
* Built here rather than inside `createScene` because a `WebGLRenderer` is a
* property of the *canvas* and not of the city drawn on it. When each city
* built its own, every switch between the Bay Area and SoCal abandoned a
* renderer on the one GL context this page has, and abandoned renderers do not
* give their textures back: `WebGLRenderer.dispose()` frees no texture at all,
* so ten switches measured 88 live GPU textures against zero `deleteTexture`
* calls, 16.8 MB of orphaned shadow map at a time. `stage.ts` has the numbers
* and the reading of three's source that they come from.
*
* Nothing disposes this. It outlives every city and every office on the page,
* and the page unload takes it — the same arrangement, and the same reasoning,
* as `officeMaterials` below.
*/
const stage = createStage(canvas);
// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
const tera = createTeraClient({ fetch: authFetch });
let city: SceneHandle | null = null;
let cityId = "sf";
/**
* The city the user last *asked* for, which is not the same as the one that is
* mounted or even the one that is being built.
*
* `building()` defers its work by two animation frames so the boot card can
* paint, and a frame under load is not 16 ms — measured at 250 ms on a software
* rasteriser. Clicking SoCal and then changing your mind inside that window
* used to hit `if (id === cityId) return` against a `cityId` the deferred
* `mountCity` had not written yet, so the second click was discarded as
* redundant and you arrived at the city you had just cancelled. The guard has
* to be against the intention, and the intention is recorded synchronously in
* the click handler.
*/
let wantedCity = "sf";
let office: OfficeScene | null = null;
let inside = false;
let markers: Marker[] = SAMPLE_MARKERS;
let palette: MarkerPalette = SAMPLE_PALETTE;
let liveData = false;
/**
* What this visitor may do. Resolved once in `boot()`; every gate below reads
* `access.can.*` and nothing else.
*
* The pre-boot value is the **closed** one, deliberately. A handler that
* somehow fires before `resolveAccess()` has settled — a keystroke on a slow
* connection, a click on a control that is in the document from first paint —
* should offer a visitor less than they are entitled to and never more. The
* rule that produces this value, and the SSO bug that once produced it wrongly,
* are written out in `src/access.ts`.
*/
let access: Access = {
tier: "anon",
subject: null,
signInUrl: null,
can: capabilitiesFor("anon"),
feeds: null,
};
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
let minimap: Minimap | null = null;
/**
* The sky over the city currently on screen, polled while it is on screen.
*
* One watch at a time and it belongs to the board, not to the page. Stopping it
* at the top of `mountCity` is the whole of the cancel-on-switch rule: a
* `/weather` request for San Francisco that lands after the user has moved to
* SoCal would otherwise put the marine layer over Long Beach, and it is a
* request in flight for most of the second in which somebody clicks.
*/
let weatherWatch: WeatherWatch | null = null;
/**
* The live traffic source, when there is one, kept so the corner label can ask
* it whether the aircraft on screen were observed. `null` means the simulator,
* which is never live and does not need asking.
*/
let cityFlights: TrafficSource | null = null;
/**
* The build in progress. Aborting it is what makes a second click on the other
* city cheap: `createScene` drops the heightfield, resolves `null`, and has
* allocated no WebGL context for the abandoned board.
*/
let mounting: AbortController | null = null;
let godmode: Godmode | null = null;
let poseEditor: PoseEditor | null = null;
/**
* The office, once somebody has asked for it. Both are `null` until the first
* `loadOffice()` because both live in a chunk this page does not fetch until
* then — see `loadOffice` for the arithmetic, and the import block above for
* what keeps them out of the entry chunk.
*
* The registry is one texture set for every office this page ever builds.
* Signing in while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"` — cheap only if both are handed the same
* registry, because drawing the textures is the expensive part and a registry
* draws them once. Owned here and disposed nowhere: it outlives every scene
* that borrows it, and the page teardown takes the process with it.
*/
let officePack: Office | null = null;
let officeMaterials: MaterialRegistry | null = null;
/**
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
* two names, and which name you arrived at is the whole difference: one is a
* map with an office in it, the other is an office with a map behind it. The
* city is still built underneath either way — that is what makes "← Back to the
* city" work from the office front door — so this is a policy about where boot
* *stops*, not about what boot builds.
*/
const OPENS_IN_OFFICE =
location.hostname.split(".")[0] === "office" ||
new URLSearchParams(location.search).get("view") === "office";
// ---- Time -----------------------------------------------------------------
/**
* `null` follows the wall clock. The override exists because the honest answer
* at 2 a.m. is a very dark city — correct, and not what you want to be looking
* at while judging whether the sun is in the right place.
*
* A whole `Date` and not an hour, which is the change the godmode panel forced
* and the right shape anyway. The old scrubber wrote an hour onto *today*, so
* there was no way to ask for the December solstice, and any control that could
* set a date would have had it silently discarded on the next scrub. One
* override, one writer, one type that can carry everything the sun depends on.
*/
let instantOverride: Date | null = null;
/**
* A fabricated sky, or `null` for whatever the deployment reports.
*
* Kept next to the instant because it is the same kind of thing — a god-only
* lie about the inputs, told to see what the renderer does with it — and it
* takes precedence over the live observation for exactly as long as it is set.
*/
let weatherOverride: WeatherObservation | null = null;
function currentInstant(): Date {
return instantOverride ?? new Date();
}
/**
* What the sky is doing, in the order the answers are trusted.
*
* The override wins because somebody typed it. Otherwise the live observation,
* and `null` — nobody was asked — when there is no watch or it has not landed
* yet. `null` is not "clear": `atmosphere.ts` treats a *reported* clear sky as
* authority that suppresses the modelled marine layer, so handing it an
* invented clear day on every failed poll would permanently kill San
* Francisco's fog on the zero-config box where the local model is all there is.
* See `WeatherFeed` in `adapters/http.ts`, which is careful about the same
* distinction from the other side.
*/
function currentWeather(): WeatherObservation | null {
return weatherOverride ?? weatherWatch?.current().value ?? null;
}
function updateSun() {
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
if (!city || !atmosphere) return;
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
city.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
// The plan view follows the same day the map does. It computes its own
// palette from this one number rather than reading the rig, because a rig is
// a set of three.js lights and the minimap has none.
minimap?.setSolarElevation(env.sun.elevation);
const clock = document.querySelector<HTMLElement>("#clock");
if (!clock) return;
const el = env.sun.elevation;
const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
}
// ---- Cities ---------------------------------------------------------------
/**
* Switching city tears the old one down completely.
*
* Unlike the city↔office move — where the city is paused and kept, because you
* are coming straight back — nobody flips between metros often enough to
* justify holding two heightfields and 140k building instances at once.
*/
async function mountCity(id: string) {
const entry = CITIES.find((c) => c.id === id);
if (!entry || !canvas) return;
/**
* Abandon whatever is still building before touching anything else.
*
* Two clicks on the city buttons a second apart used to run two heightfields
* to completion and race to assign `city`; now the first `createScene` sees
* its signal go and resolves `null` without ever allocating a renderer. The
* controller is replaced rather than reused because an aborted signal stays
* aborted, and the new build must not be born cancelled.
*/
mounting?.abort();
const mount = new AbortController();
mounting = mount;
wantedCity = id;
weatherWatch?.stop();
weatherWatch = null;
poseEditor?.destroy();
poseEditor = null;
office?.dispose();
office = null;
inside = false;
minimap?.dispose();
minimap = null;
city?.dispose();
// Not merely tidy. `city` is read by the frame pump, by `updateSun` and by
// every render function, and the gap between the dispose above and the
// assignment below is now an `await` wide rather than a statement — long
// enough for all three to run against a torn-down scene.
city = null;
cityId = id;
/**
* The sky and the traffic are per-city and are chosen here, before the build,
* because `flights` is fixed at scene construction.
*
* Two gates, and both are needed. `can.liveData` is the tier — an anonymous
* visitor must not be firing requests the server is going to refuse — and
* `feeds` is the deployment, which is what stops a member on the ordinary
* box, where every source is `none`, from polling two endpoints forever for
* a 404. The old code gated the flights on `liveData`, the markers flag,
* which is a different feed entirely: a deployment with a real ADS-B receiver
* and no marker file flew the simulator.
*/
const region = regionOf(entry.city);
// The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed
// unconditionally and all of it is over San Francisco, so the SoCal board's
// entire sky projected ~590 km off the world and rendered as nothing at all.
const routes = sampleRoutesFor(entry.city);
const traffic =
access.can.liveData && access.feeds?.flights ? tera.flights(region, routes) : null;
cityFlights = traffic;
const handle = await createScene(stage, {
city: entry.city,
markerPalette: palette,
flights: traffic ?? new SimulatedFlights(routes),
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? `${m.blurb}` : ""}` : null),
signal: mount.signal,
// An abandoned build keeps its worker running for a tick or two after the
// abort; its percentages must not land on the card the new city is using.
onProgress: (p) => {
if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
},
});
if (!handle) {
/**
* Superseded. `scene.dispose()` is what normally cancels the traffic
* source, and there is no scene — so the polling this call started would
* otherwise outlive the board it was started for, and keep a request in
* the air for a city nobody is looking at.
*/
traffic?.dispose();
if (cityFlights === traffic) cityFlights = null;
return;
}
city = handle;
/**
* The weather, started only now that the board exists.
*
* Deliberately after the build rather than beside it: a poll issued at the
* top of a two-second heightfield is a request for a city the user may
* already have left, and the first thing `stop()` would do is throw the
* answer away. Nothing on screen is waiting for it — `updateSun` runs
* immediately with `null`, which is climatology, and the observation
* replaces it when it lands.
*/
weatherWatch =
access.can.liveData && access.feeds?.weather
? tera.watchWeather(entry.city.center, () => {
updateSun();
renderSource();
})
: null;
// Fog distances are scene units, so they have to follow the board — 210/460
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
// out on the whole-board view: a fog starting nearer than that is behind the
// viewer's own shoulder, and every pixel in frame is then at full fog.
//
// That last failure used to be catastrophic and is now only bad, and the
// difference is worth recording because the comment used to claim the worse
// version. The night fog colour is derived from the sky, the night sky was
// nearly black, and so a fog plane behind the camera turned the entire map
// off. `atmosphere.ts` now floors the night ground rig and stops the
// obscuration convergence subtracting it again, and the night fog here lands
// around #16203a — aerial perspective that lifts distance rather than a
// blackout. The clearance is still required: a board flattened to one uniform
// value is unreadable at any brightness. It is no longer the difference
// between a map and a black rectangle.
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
atmosphere = createAtmosphere({
lng: entry.city.center.lng,
metresPerUnit: city.world.metresPerUnit,
clearFog: { near: span * 1.15, far: span * 2.8 },
// The floor on how far you can see, and it has to know how big the board
// is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
// completely wrong here: this board is ninety-four kilometres across, so
// real visibility correctly hides three quarters of it and the night view
// renders as a black rectangle. A map is looked at from outside the
// atmosphere it is depicting.
minVisibilityM: span * city.world.metresPerUnit * 1.6,
// The marine layer is a fact about the eastern Pacific at this latitude,
// not a decoration. LA gets its own weather, not San Francisco's fog.
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
});
city.setMarkers(id === "sf" ? markers : []);
city.onChapterChange(() => renderLegend());
/**
* The plan view, built last, because it reads the finished `World` — the
* heightfield the terrain has already paid for — and the live camera and
* controls the scene has just made. It is torn down and rebuilt with the
* city for the same reason the city is: nothing in it survives a change of
* board, and it holds a `World` that would otherwise leak.
*/
minimap = createMinimap({
world: city.world,
city: entry.city,
camera: city.stageScene.camera,
controls: city.stageScene.controls,
markerPalette: palette,
// The plan view is a 2D canvas the same size as a phone's thumb, and on a
// handheld it is drawn at the same ceiling the WebGL renderer uses. One
// definition of "phone", in `stage.ts`, read by both.
maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(lat, lng) {
if (!city) return;
/**
* Slide the orbit target and carry the camera with it, keeping the offset
* between them. A seek is "look over there", not "go to chapter three":
* snapping to a chapter pose throws away the angle and the distance the
* user spent the last minute choosing, and doing it from a click on a map
* is the kind of surprise that stops people clicking on the map.
*
* No easing, deliberately. `flyTo` would need a pose, which is the thing
* being avoided, and an instant move is also the correct answer under
* `prefers-reduced-motion`.
*/
const { camera, controls } = city.stageScene;
const [x, z] = city.world.project(lat, lng);
const y = city.world.groundAt(lat, lng);
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
controls.target.set(x, y, z);
camera.position.set(x + dx, y + dy, z + dz);
},
onHover(info) {
if (!minimapReadout) return;
minimapReadout.textContent = info
? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
: "";
},
});
minimapFrame?.replaceChildren(minimap.canvas);
minimap.setMarkers(id === "sf" ? markers : []);
// The instruments, for the one visitor in a deployment who has them. The pose
// editor holds a `World`, a camera and a controls, so it belongs to the board
// and dies with it — the same reason the plan view does.
mountPoseEditor(city);
refreshGodmodePlace();
updateSun();
renderLegend();
}
/**
* The minimap's own frame pump.
*
* `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
* the alternative is adding an `onTick` to the scene handle for exactly one
* call site. This is the smaller change and it costs nothing measurable: an
* idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the
* loop keeps running unchanged across a city swap, across the office swap, and
* during the window where there is no minimap at all.
*/
requestAnimationFrame(function pumpMinimap() {
requestAnimationFrame(pumpMinimap);
minimap?.tick();
// The pose editor is on the same pump for the same reasons, and is `null` for
// everyone who is not god, so this is one property read per frame on a
// public page.
poseEditor?.tick();
pollLiveness();
});
/**
* Whether the corner label is still telling the truth, once a second.
*
* The two live feeds settle on their own schedule and neither has an event to
* subscribe to: `TrafficSource.live()` flips when a `/flights` body lands
* inside the region, which is somewhere in the first fifteen seconds, and the
* weather watch fires its own callback but only when a *poll* settles. A
* one-second sample is late by nothing anybody can perceive and costs a
* subtraction on the frames it skips.
*/
let livenessCheckedAt = 0;
function pollLiveness() {
const now = performance.now();
if (now - livenessCheckedAt < 1000) return;
livenessCheckedAt = now;
renderSource();
}
// ---- Office ---------------------------------------------------------------
/**
* Everyone gets in. The tier picks which building they get, not whether the
* door opens.
*
* The office used to be members-only, and the anonymous view of this site was a
* map with a greyed-out button on it — the single most interesting thing the
* project does, visible only as something you cannot have. At `"public"` depth
* the same shell, the same furniture and the same named viewpoints are built,
* and the only thing missing is the people. That is withheld because the API
* refuses occupancy to an anonymous caller, not because this function declined
* to draw it.
*/
async function enterOffice() {
if (!city) return;
if (!office) {
const built = await loadOffice();
// The chunk arrived after the user had already left for the other city, or
// it did not arrive at all. Either way there is no room to walk into and
// `loadOffice` has already said so on the button.
if (!built || !city) return;
const { createOfficeScene, pack, materials } = built;
const depth = access.can.officeDepth;
office = createOfficeScene(pack, {
dom: city.stage.renderer.domElement,
background: 0x11161c,
depth,
materials,
// Two different questions, so two different callbacks. `onPresencePick`
// answers "who is at this desk"; `onPlacePick` answers only "this is a
// desk, and it is the fourteenth one" — which is all a stranger is told.
...(depth === "full"
? { onPresencePick: (p) => showDetail(p ? p.label : null) }
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
});
office.onViewChange(() => renderLegend());
}
city.stage.setScene(office);
inside = true;
showDetail(null);
refreshGodmodePlace();
renderLegend();
}
function leaveOffice() {
if (!city) return;
city.stage.setScene(city.stageScene);
inside = false;
showDetail(null);
refreshGodmodePlace();
renderLegend();
}
/**
* Fetch Spaces.
*
* The office is the largest thing in this build that most visitors never open:
* the interior, the furniture catalogue, the material registry and the
* floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be
* downloaded, parsed and executed on every load of a map page by people who
* came to look at a city. Behind these three `await import()`s Vite gives them
* chunks of their own and the door fetches them on the way through. Measured,
* entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after —
* the difference is smaller than the chunks because three.js is shared and
* stays where it was.
*
* All three in one `Promise.all` because they are one arrival: the pack without
* the builder is a data file nobody can draw, so the fetches overlap rather
* than queue. Rollup happens to emit them as three chunks the browser asks for
* together; awaiting them in sequence would make that three round trips on a
* slow link for no reason at all.
*
* There is deliberately no retry and no cache-busting. A failed chunk fetch is
* a deploy that moved the file under an open tab; the honest answer is to say
* the door did not open and let the next click try again, which it will,
* because a rejected dynamic import is not memoised by the browser.
*/
async function loadOffice(): Promise<{
createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene;
pack: Office;
materials: MaterialRegistry;
} | null> {
try {
const [interiors, pack, assets] = await Promise.all([
import("./interiors/officeScene.ts"),
import("./offices/lumbridge-hq.ts"),
import("./assets/materials.ts"),
]);
officePack ??= pack.default;
officeMaterials ??= new assets.MaterialRegistry({ quality: "high" });
return {
createOfficeScene: interiors.createOfficeScene,
pack: officePack,
materials: officeMaterials,
};
} catch {
showDetail("The office did not load. Check the connection and try the door again.");
return null;
}
}
/** The office's name, for the two bits of chrome that say where you are. */
function officeName(): string {
return officePack?.name ?? "Spaces";
}
// ---- Chrome ---------------------------------------------------------------
const nav = document.querySelector<HTMLElement>("#chapters");
const blurb = document.querySelector<HTMLElement>("#blurb");
const title = document.querySelector<HTMLElement>("#title");
const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
const tierBadge = document.querySelector<HTMLElement>("#tier");
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
const helpButton = document.querySelector<HTMLButtonElement>("#help");
const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
const credits = document.querySelector<HTMLElement>("#credits");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
const body = document.querySelector<HTMLElement>("#detail-text");
if (!card || !body) return;
card.hidden = text === null;
// The text, and not the card: the card also holds the dismiss button, which
// `textContent` on the card would delete the first time a marker was picked.
body.textContent = text ?? "";
}
function renderCityPicker() {
if (!cityNav) return;
cityNav.replaceChildren();
for (const c of CITIES) {
const b = document.createElement("button");
// `aria-pressed` rather than a class, because that is what these are: two
// buttons of which exactly one is on. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart.
b.className = "city";
b.type = "button";
b.setAttribute("aria-pressed", String(c.id === cityId && !inside));
b.textContent = c.label;
b.addEventListener("click", () => switchCity(c.id));
cityNav.append(b);
}
}
/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
function renderLegend() {
renderCityPicker();
if (!nav || !city) return;
const views: View[] = inside && office ? office.views : city.chapters;
const activeId = inside && office ? office.current() : city.current();
nav.replaceChildren();
views.forEach((view, i) => {
const button = document.createElement("button");
button.className = "chapter";
button.type = "button";
button.setAttribute("aria-pressed", String(view.id === activeId));
const number = view.number ?? String(i + 1).padStart(2, "0");
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
button.addEventListener("click", () => flyToIndex(i));
nav.append(button);
});
const active = views.find((v) => v.id === activeId);
if (blurb) {
blurb.textContent = active?.description ?? "";
blurb.hidden = !active?.description;
}
const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
if (title) title.textContent = inside ? officeName() : cityLabel;
if (subtitle) {
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
}
if (enterButton) {
// One label for everyone. The door is open at both tiers; what differs is
// what is behind it, and that is the badge's job to say, not the button's.
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
}
renderSource();
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
canvas.setAttribute(
"aria-label",
inside
? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
);
}
minimap?.setChapters(city.chapters, city.current());
renderOfficeBadge();
}
/**
* The corner label, which now names the parts rather than claiming the whole.
*
* Only the *positive* case gets a permanent label. This used to read "sample
* data · fabricated, not real companies" on every frame of every load, which is
* the overwhelmingly common case — no deployment has a markers source wired by
* default — so the disclosure was on screen approximately always and had become
* furniture. A caption nobody reads is not disclosure, it is a watermark. The
* fact still has to be somewhere on the same screen as the map, so it is stated
* on the boot card everyone passes through and again in the `?` card, one
* keypress away and permanently reachable.
*
* What is left is the informative signal, and it is three signals rather than
* one. The markers, the weather and the traffic arrive from three different
* places and every combination of them is a deployment that exists; a single
* flag has to pick one to be about and then lie about the other two. The
* particular lie this closes is "live data" printed over invented companies
* because a weather station answered — which is precisely the claim the `live`
* flag was introduced to prevent. `describeLiveness` in `adapters/http.ts` owns
* the wording; all three live is the only case that still says "live data".
*
* Called from `renderLegend` and once a second from the frame pump, because the
* feeds settle after the legend has been drawn.
*/
function renderSource() {
if (!source) return;
const label = describeLiveness({
markers: liveData,
// An override is a sky somebody invented, so it retires the claim for as
// long as it is up — the label is about what is on screen, not about what
// the deployment could have shown.
weather: weatherOverride === null && (weatherWatch?.current().live ?? false),
flights: cityFlights?.live() ?? false,
});
if (source.textContent !== label) source.textContent = label;
source.hidden = label === "";
/**
* The green. `.source.live` in `index.html` is the whole visual difference
* between this line and the rest of the chrome, and the rewrite that replaced
* `source.className = "source live"` with a `textContent`/`hidden` pair
* dropped it — so every live label rendered at `--ink-3`, the same muted grey
* as a key hint, and the stylesheet rule could no longer match anything. This
* label is only ever on screen when it has something to say; the colour is
* how it says it is worth reading.
*/
source.classList.toggle("live", label !== "");
renderCredits();
}
/**
* Who to thank for what is on screen, in the `?` card.
*
* MET Norway and Open-Meteo publish under CC BY 4.0 and the server emits the
* credit line each of them asks for — `server/README.md` says in as many words
* that the consumer is expected to display it — and adsb.lol asks to be named
* for the positions. All of it arrived, was parsed into `WeatherFeed.attribution`
* and `FlightsBody.attribution`, and was then read by nobody: a licence
* obligation plumbed to within one line of being met.
*
* It goes in the `?` card rather than on the `#source` line, and that is a
* choice rather than convenience. The corner label is one short phrase and on a
* phone it is explicitly clamped to a single ellipsised line, so a licence
* sentence appended to it would be *truncated* — the one outcome worse than
* putting it a keypress away. The `?` card is reachable from every state the
* app can be in, on both layouts, and already carries the sentence about the
* markers being fabricated; the provenance of the map belongs in one place.
*
* Empty when nothing live is on screen, because a credit for data nobody is
* looking at is noise, and because the zero-config build owes nobody anything.
*/
function renderCredits() {
if (!credits) return;
const lines: string[] = [];
// The weather override is somebody's invention; it is not MET Norway's sky
// and must not be attributed to them.
if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? []));
lines.push(...(cityFlights?.attribution() ?? []));
const unique = [...new Set(lines.filter((line) => line !== ""))];
credits.textContent = unique.join(" · ");
credits.hidden = unique.length === 0;
}
/**
* The one thing a public visitor is actually missing, said in the place where
* they would notice it missing.
*
* An empty office with no explanation reads as a bug — a floor that failed to
* load — and the fix for that is a sentence, not a disabled button. The
* sign-in link is offered *beside* the office rather than in front of it, so it
* is an upgrade and never a toll gate.
*/
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
officeBadge.hidden = !publicOffice;
if (!publicOffice) return;
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
);
if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in for the live floor";
officeBadge.append(link, document.createTextNode("."));
} else {
officeBadge.append(document.createTextNode("Sign in to see who's in."));
}
}
/**
* Who the site thinks you are, in the corner, always. Three words and a name.
*
* It is here rather than buried in a menu because every other difference on
* this page — an empty office, sample markers, no godmode tab — is a
* *silence*, and a silence you cannot attribute is indistinguishable from a
* fault. This is the line that tells you which of the two you are looking at.
*/
function renderTierBadge() {
if (!tierBadge) return;
tierBadge.className = `card tier ${access.tier}`;
const label = document.createElement("span");
/**
* The label names what you *get*, not who you are, and that is deliberate.
* "Signed in" was the first draft and it is a lie in the commonest case:
* a clean clone with no API at all resolves to `member`, and telling someone
* they are signed in to a server that does not exist is the sort of small
* dishonesty that makes the rest of the interface untrustworthy. "Full view"
* is true whether the tier came from a session or from there being nothing to
* have a session with; the subject, when there is one, says the rest.
*/
label.textContent =
access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
tierBadge.replaceChildren(label);
if (access.subject !== null) {
const who = document.createElement("span");
who.className = "who";
who.textContent = access.subject;
tierBadge.append(who);
} else if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in";
tierBadge.append(link);
}
tierBadge.hidden = false;
}
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
function currentViews(): View[] {
if (inside && office) return office.views;
return city?.chapters ?? [];
}
function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
}
function switchCity(id: string) {
if (inside) leaveOffice();
if (id === wantedCity) return;
wantedCity = id;
const label = CITIES.find((c) => c.id === id)?.label ?? id;
void building(`Building ${label}`, () => mountCity(id));
}
function stepCity(delta: number) {
const at = CITIES.findIndex((c) => c.id === wantedCity);
const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
if (next) switchCity(next.id);
}
/**
* Guards the door against the second click.
*
* Entering now begins with a network fetch for the Spaces chunk, so the window
* between the click and the room is wide enough to click in again — and two
* `enterOffice()` calls in that window build two office scenes, park the second
* on the stage and leak the first, textures and all. One flag, cleared in a
* `finally` so a failed fetch does not wedge the door shut.
*/
let entering = false;
async function toggleOffice() {
if (inside) {
leaveOffice();
return;
}
if (entering) return;
// Only the first entry fetches or builds anything; after that the office is
// parked in memory next to the paused city and the swap is a pointer.
if (office) {
void enterOffice();
return;
}
entering = true;
/**
* Say so on the button before anything else happens.
*
* The boot card comes up too, but it comes up on the *next* frame at the
* earliest, and on a slow connection the chunk is the long pole rather than
* the build. A door that does nothing visible for half a second gets clicked
* again; a door that says "Opening…" gets waited for.
*/
if (enterButton) {
enterButton.textContent = "Opening the office…";
enterButton.setAttribute("aria-busy", "true");
}
try {
await building("Fetching the office…", () => enterOffice());
} finally {
entering = false;
enterButton?.removeAttribute("aria-busy");
// `renderLegend` writes the real label whichever way it went — "← Back to
// the city" if we are in, the door again if the fetch failed.
renderLegend();
}
}
enterButton?.addEventListener("click", () => void toggleOffice());
// ---- Panels, plan and overlays ----------------------------------------------
/**
* Two pieces of chrome are a *user* decision rather than a media query, and the
* distinction matters: a media query that hides the plan below 600px also makes
* `M` do nothing there, which is the width where a plan view is most useful and
* least affordable. So the width only seeds the initial state, and the moment
* someone presses the key the viewport stops having an opinion.
*/
let panelOpen = window.innerWidth > 900;
let planOpen = window.innerWidth > 600;
let planChosen = false;
function applyPanel() {
document.body.classList.toggle("panel-closed", !panelOpen);
panelToggle?.setAttribute("aria-expanded", String(panelOpen));
}
function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen);
planToggle?.setAttribute("aria-pressed", String(planOpen));
}
/**
* One body, two ways in, and the second one is the point.
*
* This lived inline in the `M` branch of the keydown handler and was reachable
* from nowhere else, which made the plan view **unreachable on any touch
* device**: `planOpen` is seeded `window.innerWidth > 600`, so a phone starts
* with it off, and a phone has no `M`. Every visible control at 390px was
* enumerated and none of them could turn it on. `index.html` has carried a
* designed phone layout for `.corner` — a bottom sheet above the rail, at
* `min(38dvh, 18rem)` — that no visitor to that layout could ever see, under a
* comment saying it "costs nothing until it is asked for". There was no way to
* ask. `#plan-toggle` is that way, shown wherever the pointer is coarse.
*
* So the key and the button call this, and `planChosen` is set by both for the
* same reason it always was: once somebody has an opinion, the viewport stops
* having one.
*/
function togglePlan() {
planOpen = !planOpen;
planChosen = true;
applyPlan();
}
panelToggle?.addEventListener("click", () => {
panelOpen = !panelOpen;
applyPanel();
});
planToggle?.addEventListener("click", () => togglePlan());
/**
* The scrim behind the phone's panel sheet. It is `display: none` above 600px,
* so this listener is only ever reachable where the sheet exists.
*/
document.querySelector<HTMLElement>("#scrim")?.addEventListener("click", () => {
panelOpen = false;
applyPanel();
});
document.querySelector<HTMLElement>("#detail-close")?.addEventListener("click", () => {
showDetail(null);
});
window.addEventListener("resize", () => {
if (!planChosen) {
planOpen = window.innerWidth > 600;
applyPlan();
}
});
function openShortcuts() {
if (!shortcutsCard || !shortcutsCard.hidden) return;
shortcutsCard.hidden = false;
document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
}
function closeShortcuts() {
if (!shortcutsCard || shortcutsCard.hidden) return;
shortcutsCard.hidden = true;
helpButton?.focus();
}
helpButton?.addEventListener("click", () => openShortcuts());
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
shortcutsCard?.addEventListener("click", (event) => {
// The backdrop, not the sheet. Clicking the card itself must not close it.
if (event.target === shortcutsCard) closeShortcuts();
});
/**
* Keyboard access to everything the mouse can reach.
*
* Bound to `window` rather than to the canvas, because the canvas is only
* focusable by accident and a shortcut that stops working when you tab to the
* legend is worse than no shortcut. The guard is the usual one: a keystroke
* that lands in a text field or on the plan view's own arrow-key handler
* belongs to that control, not to this.
*/
window.addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === "Escape") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else if (inside) leaveOffice();
else showDetail(null);
return;
}
if (event.key === "?") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else openShortcuts();
event.preventDefault();
return;
}
if (event.key >= "1" && event.key <= "9") {
flyToIndex(Number(event.key) - 1);
return;
}
if (event.key === "[") {
stepCity(-1);
return;
}
if (event.key === "]") {
stepCity(1);
return;
}
const lower = event.key.toLowerCase();
if (lower === "m") {
togglePlan();
return;
}
if (lower === "o") void toggleOffice();
});
// ---- Time -------------------------------------------------------------------
/**
* The `#hour` slider and its `now` button are gone, replaced rather than kept.
*
* They were a second writer for one override, and the weaker of the two:
* `capabilitiesFor` hands `timeControl` and `debug` to exactly the same tier, so
* there was never an audience for the simple case — the only person who could
* see the scrubber was the same person who can open the godmode panel. Keeping
* both meant the slider wrote an hour onto *today* and silently discarded
* whatever date the panel had set, which is a bug with no upside.
*
* `#clock` stays exactly as it was, and stays visible to everyone: a map that
* will not say what time it is showing is worse than one you cannot scrub.
*
* What is left of the gate is one line, and it is belt and braces — the only
* writer of `instantOverride` is the panel, and the panel is not constructed
* unless `can.debug`. It stays because "no control" and "no override" are two
* different facts, and the second is the one the renderer depends on.
*/
function applyTimeControl() {
if (!access.can.timeControl) instantOverride = null;
}
// ---- Instruments ------------------------------------------------------------
/**
* The godmode panel and the pose editor, for the one visitor in a deployment
* who has them.
*
* **Constructed, not hidden.** Everything in this section is behind
* `access.can.debug`, and for a member or an anonymous visitor the result is
* not a panel with `display: none` on it — it is no element, no `<style>`, no
* key binding, and no bytes: `src/tools/` is reached only through the
* `await import()` below, so a non-god browser never fetches the chunk. A
* hidden instrument is still an instrument you shipped to a stranger, and the
* tier that gets these is the tier that can already read the source.
*
* That rule is also why the dock's geometry is set as element styles instead of
* a rule in `index.html`. Every visitor downloads that stylesheet; a
* `#pose-dock { … }` sitting in it that can never match is the one trace this
* arrangement would otherwise leave behind, in the bytes if not in the DOM.
*/
/** Cached across boards: the module is fetched once, the tool is rebuilt per city. */
let poseEditorFactory: typeof import("./tools/poseEditor.ts").createPoseEditor | null = null;
let poseDockBody: HTMLElement | null = null;
let poseDockOpen = true;
/**
* Where the panel thinks it is standing.
*
* `null` while there is no board — between a `dispose()` and the next
* `createScene`, which is now an `await` wide. The stage travels with the place
* even though there is only ever one of them, because what the panel is being
* told is *which board these counters are about*: `label`, the centre, the
* scale and the renderer's ledger are one reading, and handing the panel the
* stage separately would let it print SoCal's draw calls under the Bay Area's
* name for the length of a switch.
*
* `marineStrength` is deliberately not wired. It is private to `atmosphere.ts`
* today, and the panel prints "no hook wired" rather than a guess — see the
* handoff note; one `export` keyword upstream turns the readout on.
*/
function godmodePlace(): GodmodePlace | null {
if (!city) return null;
const entry = CITIES.find((c) => c.id === cityId);
const active = entry?.city ?? SAN_FRANCISCO;
const world = city.world;
const label = entry?.label ?? cityId;
return {
// The office is a different board for accounting: it is the ledger key, and
// its draw calls are not the city's.
label: inside ? `${label} · office` : label,
lat: active.center.lat,
lng: active.center.lng,
stage: city.stage,
metresPerUnit: world.metresPerUnit,
unproject: (x, z) => world.unproject(x, z),
};
}
/** `setPlace` when there is a place. Called on every board and room change. */
function refreshGodmodePlace() {
const place = godmodePlace();
if (place) godmode?.setPlace(place);
}
async function mountGodmode() {
if (!access.can.debug || godmode) return;
const place = godmodePlace();
if (!place) return;
const [tools, poses] = await Promise.all([
import("./tools/index.ts"),
import("./tools/poseEditor.ts"),
]);
poseEditorFactory = poses.createPoseEditor;
godmode = tools.createGodmode({
container: document.body,
initial: { instant: currentInstant(), place },
onTimeChange(instant) {
instantOverride = instant;
updateSun();
},
onWeatherOverride(w) {
weatherOverride = w;
updateSun();
// The corner label has to stop claiming live weather the moment the sky
// on screen is one somebody typed.
renderSource();
},
});
// The city was built before this chunk arrived, so its pose editor is built
// here; every later board gets one from `mountCity`.
if (city) mountPoseEditor(city);
addGodmodeShortcut();
}
/**
* The pose editor, rebuilt with the board.
*
* It holds a `World`, a camera and a controls, all three of which die with the
* city — the same reason the plan view is rebuilt rather than re-pointed.
*/
function mountPoseEditor(handle: SceneHandle) {
if (!access.can.debug || !poseEditorFactory) return;
const dock = instrumentDock();
poseEditor = poseEditorFactory({
container: dock,
allowed: access.can.debug,
world: handle.world,
camera: handle.stageScene.camera,
controls: handle.stageScene.controls,
/**
* A cut rather than a flight, for now.
*
* The tool is forbidden from writing the camera itself and asks for
* `SceneKit.flyTo`, which `SceneHandle` does not expose — it offers
* `flyTo(chapterId)` and nothing that takes a pose. Moving both ends of the
* orbit at once is exactly what the plan view's `onSeek` does a few hundred
* lines up and it lands on the right pose; what it does not do is ease, so
* re-flying a captured chapter jumps. `scene.ts` is another agent's file
* this week; the two-line addition that upgrades this is in the handoff.
*/
flyTo: (pose: Pose) => {
handle.stageScene.camera.position.copy(pose.position);
handle.stageScene.controls.target.copy(pose.target);
handle.stageScene.controls.update();
},
existingChapters: handle.chapters,
});
poseEditor.setVisible(poseDockOpen);
}
/**
* The dock, built once and reused across boards.
*
* Bottom right, above the key-hint rail and below the plan view, which is the
* one column of this layout with room in it: the left panel owns the left edge
* top to bottom, the godmode drawer opens bottom *centre*, and the plan view
* stops around 20rem down. The height is capped against both neighbours rather
* than at a flat `dvh`, so a short window shrinks the dock instead of sliding
* it under the plan view.
*/
function instrumentDock(): HTMLElement {
if (poseDockBody) return poseDockBody;
const dock = document.createElement("aside");
dock.id = "pose-dock";
dock.setAttribute("aria-label", "Pose editor");
Object.assign(dock.style, {
position: "fixed",
right: "var(--s4, 16px)",
bottom: "calc(var(--s4, 16px) + 5rem)",
zIndex: "5",
width: "min(20rem, calc(100vw - var(--s4, 16px) * 2))",
maxHeight: "min(52dvh, calc(100dvh - 26rem))",
display: "flex",
flexDirection: "column",
alignItems: "stretch",
gap: "var(--s1, 4px)",
});
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "help";
toggle.setAttribute("aria-expanded", "true");
Object.assign(toggle.style, { alignSelf: "flex-end", fontFamily: "inherit" });
const body = document.createElement("div");
Object.assign(body.style, { flex: "1", minHeight: "0", display: "flex" });
function applyDock() {
toggle.textContent = poseDockOpen ? "poses ▾" : "poses ▴";
toggle.setAttribute("aria-expanded", String(poseDockOpen));
body.hidden = !poseDockOpen;
// Collapsed is genuinely idle: `setVisible(false)` is what stops the tool
// reading the camera on every frame of a map nobody is authoring against.
poseEditor?.setVisible(poseDockOpen);
}
toggle.addEventListener("click", () => {
poseDockOpen = !poseDockOpen;
applyDock();
});
dock.append(toggle, body);
document.body.append(dock);
poseDockBody = body;
applyDock();
return body;
}
/**
* `G` in the `?` card, added from here rather than typed into `index.html`.
*
* The card is the one place a visitor goes to find out what this page can do,
* so a key that exists must be in it — and a key that does not exist must not.
* Hard-coding the row would advertise an instrument nine visitors in ten have
* no way to open.
*/
function addGodmodeShortcut() {
const keys = document.querySelector<HTMLElement>("#shortcuts .keys");
const before = document.querySelector<HTMLElement>("#key-overlays");
if (!keys || document.querySelector("#key-godmode")) return;
const dt = document.createElement("dt");
dt.id = "key-godmode";
dt.innerHTML = "<kbd>G</kbd>";
const dd = document.createElement("dd");
dd.textContent = "Godmode: the clock, the weather and the counters";
keys.insertBefore(dt, before);
keys.insertBefore(dd, before);
}
// ---- The boot card ----------------------------------------------------------
const bootCard = document.querySelector<HTMLElement>("#boot");
const bootStep = document.querySelector<HTMLElement>("#boot-step");
/**
* Wait until the browser has actually put pixels on the glass.
*
* Writing to `textContent` and then immediately building a heightfield paints
* nothing: the style change and the two seconds of synchronous work are in the
* same task, so the frame the user sees is the one *after* the work. Two
* `requestAnimationFrame`s straddle a paint, which is the whole trick — a
* single one still runs before it.
*/
function painted(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
/**
* Wait until the renderer has actually drawn the thing that was being built.
*
* `painted()` is not enough any more and the difference is visible. The
* heightfield now builds in a Worker, so `createScene` resolves at a moment
* when the main thread is *idle* and two animation frames go by in 33 ms —
* long before the terrain mesh, the 140k building instances and the first
* WebGL draw have happened. The card faded on an empty canvas.
*
* `renderer.info.render.frame` is the renderer counting its own draws, which is
* the only witness that cannot be fooled by a fast frame: it advances exactly
* once per `render()`, so an increment means a frame of the new scene has been
* submitted, and the extra `painted()` after it puts that frame on the glass
* before the fade starts.
*
* The timeout is not a fallback to a timer, it is a bound on a promise that
* would otherwise never settle — an abandoned build never draws anything, and
* the boot card must not be the thing that outlives it.
*/
function drawn(timeoutMs = 4000): Promise<void> {
const from = stage.renderer.info.render.frame;
const deadline = performance.now() + timeoutMs;
return new Promise((resolve) => {
requestAnimationFrame(function wait() {
if (stage.renderer.info.render.frame > from || performance.now() > deadline) {
void painted().then(resolve);
return;
}
requestAnimationFrame(wait);
});
});
}
/**
* Which `building()` call the boot card belongs to.
*
* A generation counter rather than a boolean because two builds can overlap:
* clicking SoCal while the Bay Area is still building leaves the first
* `building()` running, and it must not fade a card the second one is using.
* Last one in owns it.
*/
let bootGeneration = 0;
/**
* Run something slow with the boot card up and a sentence saying what it is.
*
* Naming the work is most of the value: a blank page for two seconds reads as
* broken, and "Building the Bay Area…" for two seconds reads as busy. The
* percentage on top of it comes from `bootProgress`, which the heightfield
* drives directly from the Worker.
*
* `work` may be synchronous or not. It became "or not" when `createScene` did,
* and the flattening matters: without the `await` here the card faded about
* 800 ms in, while the city was still building, because a promise is a truthy
* value that returns instantly.
*/
async function building<T>(label: string, work: () => T | Promise<T>): Promise<T> {
const generation = ++bootGeneration;
if (bootStep) bootStep.textContent = label;
if (bootCard) {
bootCard.hidden = false;
bootCard.classList.remove("done");
}
await painted();
const result = await work();
await drawn();
// Superseded while we were building: the card belongs to a later call now and
// fading it would uncover a city that does not exist yet.
if (generation !== bootGeneration) return result;
bootCard?.classList.add("done");
window.setTimeout(() => {
if (bootCard?.classList.contains("done")) bootCard.hidden = true;
}, 300);
return result;
}
/**
* The heightfield's progress, on the card that is already on screen.
*
* Nothing is written while the build is on the main thread, and that is not an
* oversight: `onMainThread` means the page is frozen for the duration, so every
* one of these writes would land in the same task and exactly one of them —
* the last — would ever be seen. The fallback build is the case where the
* static label is all the honesty available.
*
* The caller is responsible for not reporting an abandoned build; see the
* `onProgress` passed by `mountCity`.
*/
function bootProgress(label: string, fraction: number, onMainThread: boolean) {
if (!bootStep || onMainThread || bootCard?.hidden !== false) return;
bootStep.textContent = `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
}
// ---- Boot -----------------------------------------------------------------
/**
* Access first, then data, then the board.
*
* The order is load-bearing in both directions and it used to be wrong. Markers
* were fetched before the tier was known, which is a request an anonymous
* visitor should not be making; and both decisions have to be settled before
* the *first* `mountCity`, because `markerPalette` is fixed at scene
* construction — the sample palette's keys are not the API's — and the flight
* source is chosen in the same call.
*
* Everything about the API remains optional. No server means the bundled sample
* set, the simulated traffic, and a label at the bottom of the screen saying
* which of the two you are looking at.
*/
async function boot() {
applyPanel();
applyPlan();
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
access = await resolveAccess();
applyTimeControl();
renderTierBadge();
// Both gates, for the reason `mountCity` gives at length: the tier says
// whether this visitor may ask, `feeds` says whether there is anything to
// ask. A box with `markers: "none"` serves the public empty body to everyone,
// so the request buys a round trip and lands on the same sample set.
if (access.can.liveData && access.feeds?.markers) {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
}
const first = CITIES[0];
await building(`Building ${first?.label ?? "the city"}`, () => mountCity(first?.id ?? "sf"));
// The instruments, after the first board, because the panel reads a live
// stage and there is not one before this line.
await mountGodmode();
// The `office.` front door. The city is already standing behind this, so the
// back button is a scene swap and not a rebuild.
if (OPENS_IN_OFFICE) await building("Fetching the office…", () => enterOffice());
/**
* The wall clock, once a minute.
*
* Skipped entirely while an override is up — the whole point of an override
* is that the map has stopped following the clock — and the panel is told the
* new instant only when it is not the one choosing it, so its own readouts
* stay pinned to what is being rendered rather than fighting it.
*/
window.setInterval(() => {
if (instantOverride !== null) return;
updateSun();
godmode?.setInstant(new Date());
}, 60_000);
}
void boot();