e41c90fe8d
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>
1475 lines
60 KiB
TypeScript
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();
|