/** * 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("#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 | 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("#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("#chapters"); const blurb = document.querySelector("#blurb"); const title = document.querySelector("#title"); const subtitle = document.querySelector("#subtitle"); const enterButton = document.querySelector("#enter"); const cityNav = document.querySelector("#cities"); const source = document.querySelector("#source"); const minimapFrame = document.querySelector("#minimap .minimap-frame"); const minimapReadout = document.querySelector("#minimap-readout"); const tierBadge = document.querySelector("#tier"); const officeBadge = document.querySelector("#office-badge"); const panelToggle = document.querySelector("#panel-toggle"); const panelToggleLabel = document.querySelector("#panel-toggle-label"); const shortcutsCard = document.querySelector("#shortcuts"); const helpButton = document.querySelector("#help"); const planToggle = document.querySelector("#plan-toggle"); const credits = document.querySelector("#credits"); function showDetail(text: string | null) { const card = document.querySelector("#detail"); const body = document.querySelector("#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 = `${number}${view.shortLabel}`; 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("#scrim")?.addEventListener("click", () => { panelOpen = false; applyPanel(); }); document.querySelector("#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("#shortcuts-close")?.focus(); } function closeShortcuts() { if (!shortcutsCard || shortcutsCard.hidden) return; shortcutsCard.hidden = true; helpButton?.focus(); } helpButton?.addEventListener("click", () => openShortcuts()); document.querySelector("#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 `