ce85867de9
The relief ramp scales the ground group to 5.78/15 when you are in a city, so Twin Peaks is not 4 km tall at FiDi. Camera targets lived in world space and were still written from baked 15x groundAt, so the look-at hung 2.6x above downtown and every tower read as a needle. chapterPose, the aircraft, and seek now use the scaled surface. When the ramp moves, camera and look-at Y scale with it from sea level so height-above-surface stays authored.
6474 lines
271 KiB
TypeScript
6474 lines
271 KiB
TypeScript
/**
|
||
* The demo: two cities under a real sun and moon, and two studios 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, the studio hardware is a
|
||
* fixed-step simulator running in this tab. Clone it and it works.
|
||
*
|
||
* ### What this file is, after the interface moved out
|
||
*
|
||
* This file used to hold about forty imperative `element.hidden = condition`
|
||
* decisions with the condition and the write on the same line, spread through a
|
||
* 655-line "Chrome" section, and none of it was testable without a WebGL
|
||
* context. That is now two modules: `ui/chromeState.ts` decides and `ui/mount.ts`
|
||
* writes, and what is left here is the one call between them —
|
||
* `chrome.apply(chromeState(chromeInputs()))` — plus the thing this file is
|
||
* actually for, which is **assembly**: it owns the clock, the access tier, which
|
||
* board is mounted, which building is open, and which of the delivered modules
|
||
* is handed to which scene.
|
||
*
|
||
* The rule that keeps it that way: nothing below this line may write to a DOM
|
||
* node that `mount.ts` owns. Where you want a piece of chrome to change, add a
|
||
* field to `ChromeInputs` and call `renderChrome()`.
|
||
*/
|
||
|
||
import {
|
||
collapsedFog,
|
||
createAtmosphere,
|
||
dipFog,
|
||
FOG_DIP_IN_SECONDS,
|
||
FOG_DIP_OUT_SECONDS,
|
||
observe,
|
||
PACIFIC_MARINE_LAYER,
|
||
type AerialFog,
|
||
type Atmosphere,
|
||
type WeatherObservation,
|
||
} from "./engine/atmosphere.ts";
|
||
import {
|
||
createBoardCache,
|
||
prefetchTarget,
|
||
residentCapacity,
|
||
type BoardCache,
|
||
type PrefetchTier,
|
||
} from "./engine/boards.ts";
|
||
import {
|
||
activeRung,
|
||
buildLadder,
|
||
handover,
|
||
HANDOVER_STANDOFF_M,
|
||
placesRows,
|
||
REGION_LABELS,
|
||
type LadderRung,
|
||
} from "./engine/ladder.ts";
|
||
import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts";
|
||
import { detailLotMetres } from "./engine/blocks.ts";
|
||
import { createScene, type SceneHandle } from "./engine/scene.ts";
|
||
import { createEnvironmentRig } from "./engine/environmentRig.ts";
|
||
import {
|
||
aircraftDetail,
|
||
regionOf,
|
||
SimulatedFlights,
|
||
withTrafficDial,
|
||
type TrafficDial,
|
||
} from "./engine/flights.ts";
|
||
import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites.ts";
|
||
import type { Pose } from "./engine/scenekit.ts";
|
||
import { createStage, deviceProfile } from "./engine/stage.ts";
|
||
import { daylightPhase } from "./engine/solar.ts";
|
||
import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts";
|
||
import CALIFORNIA from "./cities/california.ts";
|
||
import { UNIFIED_SOURCES, unifiedCalifornia } from "./cities/unify.ts";
|
||
import { reconciledCity } from "./cities/reconcile.ts";
|
||
import SAN_FRANCISCO from "./cities/sf.ts";
|
||
import SOCAL from "./cities/socal.ts";
|
||
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
|
||
import {
|
||
aircraftActionsFromPlay,
|
||
cameraRelativePlanar,
|
||
crowActionsFromPlay,
|
||
groundActorActionsFromPlay,
|
||
PlayInputRouter,
|
||
sampleStandardPlayGamepad,
|
||
vehicleActionsFromPlay,
|
||
type StandardPlayGamepadButtons,
|
||
} from "./input/play.ts";
|
||
import {
|
||
createTeraClient,
|
||
type FireWatch,
|
||
type PresenceWatch,
|
||
mergedTraffic,
|
||
type TrafficSource,
|
||
type WeatherWatch,
|
||
} from "./adapters/http.ts";
|
||
import { promote, type FireBounds } from "./server/fires.ts";
|
||
import type { BirdsBody, FiresBody, RadarBody, VesselsBody } from "./server/wire.ts";
|
||
import { createFireLayer } from "./engine/fires.ts";
|
||
import { createPortLayer } from "./engine/ports.ts";
|
||
import { createVesselLayer } from "./engine/vessels.ts";
|
||
import { boardCarriesRaster, precipFactoryFor } from "./engine/precip.ts";
|
||
import { createMigrationLayer } from "./engine/migration.ts";
|
||
import {
|
||
berthAnchors,
|
||
modelHarbour,
|
||
promoteVessels,
|
||
vesselSummary,
|
||
type BerthAnchor,
|
||
type VesselBounds,
|
||
} from "./server/vessels.ts";
|
||
import { promoteRadar } from "./server/radar.ts";
|
||
import { promoteBirds } from "./server/birds.ts";
|
||
import { mountFirePanel, type FirePanelHandle } from "./ui/firePanel.ts";
|
||
import {
|
||
SAMPLE_MARKERS,
|
||
SAMPLE_PALETTE,
|
||
SAMPLE_PRESENCE_PALETTE,
|
||
samplePresenceForOfficeAt,
|
||
sampleRoutesFor,
|
||
} from "./adapters/sample.ts";
|
||
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
|
||
import { authFetch } from "./session.ts";
|
||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||
import { randomId } from "./ids.ts";
|
||
import { createMinimap, type Minimap } from "./engine/minimap.ts";
|
||
import {
|
||
createJourney,
|
||
journeyReducer,
|
||
loadJourneySession,
|
||
saveJourneySession,
|
||
type JourneyCity,
|
||
type JourneyEvent,
|
||
type JourneyState,
|
||
} from "./journey/index.ts";
|
||
import {
|
||
actorKindForPresence,
|
||
createProfileEditor,
|
||
createDefaultLocalProfile,
|
||
createWebcamCapture,
|
||
createWebcamFaceConsent,
|
||
createWebcamFacePanel,
|
||
createWebcamFaceTexture,
|
||
loadLocalProfile,
|
||
resolveHumanoidAppearance,
|
||
saveLocalProfile,
|
||
type LocalProfile,
|
||
type ProfileEditor,
|
||
type WebcamCaptureController,
|
||
type WebcamFacePanel,
|
||
type WebcamFaceTextureAdapter,
|
||
} from "./profile/index.ts";
|
||
import type { ActorIdentity } from "./actors/controller.ts";
|
||
import { InstancedMesh, SRGBColorSpace, Vector3, VideoTexture } from "three";
|
||
import {
|
||
CALIFORNIA_AIR_ROUTE,
|
||
createAircraftPoseSnapshot,
|
||
} from "./aircraft/index.ts";
|
||
import {
|
||
cityControlMode,
|
||
createControlModeState,
|
||
transitionControlMode,
|
||
type ControlMode,
|
||
} from "./play/controlMode.ts";
|
||
import {
|
||
createOfficeScreenPanel,
|
||
type MediaSurfaceDescriptor,
|
||
type OfficeScreenPanel,
|
||
type OfficeScreenRemoteStatus,
|
||
} from "./media/index.ts";
|
||
import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts";
|
||
/**
|
||
* The interface, as two modules and one call.
|
||
*
|
||
* `chromeState` is pure — no DOM, no THREE, no clock — and `mountChrome` is the
|
||
* only thing in the product that writes to the page. Both are value imports and
|
||
* both belong in the entry chunk: the chrome is on screen before anything else
|
||
* is, including the boot card's own successor.
|
||
*/
|
||
import {
|
||
chromeState,
|
||
seedPanelOpen,
|
||
seedPlanOpen,
|
||
type ChromeDetail,
|
||
type ChromeInputs,
|
||
type ChromeLayout,
|
||
} from "./ui/chromeState.ts";
|
||
import { mountChrome, type ChromeHandle } from "./ui/mount.ts";
|
||
import { controlForKey, edgeForKey } from "./ui/shortcuts.ts";
|
||
import { browserStorage, hasSeenOnboarding } from "./ui/onboarding.ts";
|
||
import { playHudKindFor, type AircraftDetailInput, type PlayTelemetry } from "./ui/hud.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 sees it, so none of these files is an
|
||
* edge in the module graph. The office arrives through `loadOffice()`, the tools
|
||
* through the `await import()` in `boot()`. Turning any of them into a value
|
||
* import silently undoes the split and nothing fails — the bundle just gets big
|
||
* again. The device and vehicle simulators travel with the office for the same
|
||
* reason: renderer-free, but with nothing to say until you are in a studio.
|
||
*/
|
||
import type { OfficeScene } from "./interiors/officeScene.ts";
|
||
import type { Office, Presence } from "./interiors/types.ts";
|
||
import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts";
|
||
import type { MaterialQuality, MaterialRegistry } from "./assets/materials.ts";
|
||
import type { DeviceSource } from "./devices/adapter.ts";
|
||
import type { DeviceCommand, DeviceDeclaration, DeviceState } from "./devices/types.ts";
|
||
import type { SimulatedVehicleTelemetry } from "./transport/vehicleTelemetry.ts";
|
||
// Type-only for the reason above, and it matters more here than it looks:
|
||
// `officeMinimap.ts` imports the asset registry as a *value*, to read prop
|
||
// footprints, so a value import of it here would drag the furniture catalogue
|
||
// back into the entry chunk and quietly undo the split this block exists to
|
||
// protect. It arrives with the office, in `loadOffice()`, because it is only
|
||
// ever drawn once you are standing in one.
|
||
import type { OfficeMinimap } from "./engine/officeMinimap.ts";
|
||
import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts";
|
||
import type { PoseEditor } from "./tools/poseEditor.ts";
|
||
import type {
|
||
EntityPoseSnapshot,
|
||
InterestCell,
|
||
RealtimeClient,
|
||
ServerRealtimeMessage,
|
||
} from "./realtime/index.ts";
|
||
import type { PresenceIndicator } from "./realtime/presenceIndicator.ts";
|
||
|
||
/**
|
||
* One full California, as **one pack** — and now the default.
|
||
*
|
||
* The first attempt at this nested each metro's built scene inside the state
|
||
* board's under a similarity transform. The transform was right and the result
|
||
* was not: two independently built terrains occupied the same ground and
|
||
* interpenetrated. `cities/unify.ts` is the answer that has no seam to stitch —
|
||
* one `City` carrying the state's landform and both metros' detail, so the
|
||
* engine builds one world, one lattice and one terrain mesh. See that file for
|
||
* the measured lattice cost and for the two merges that are not a union.
|
||
*
|
||
* ## Why it is no longer behind a flag
|
||
*
|
||
* Because a flag is a second product, and the whole point was to stop having
|
||
* three. Shipped behind `?one=1` the merged board was something a visitor could
|
||
* not find and did not get: `tera.lumbridgecorp.com` still landed on the Bay
|
||
* Area, the left column still offered two other boards, and clicking one still
|
||
* tore the board down and built another. The board existed and the product did
|
||
* not change.
|
||
*
|
||
* ## The three ways this resolves, and why the escape hatches are shaped so
|
||
*
|
||
* Default — no `?city=`, or `?city=california` — is **one California**.
|
||
*
|
||
* `?city=sf` and `?city=socal` still get the dedicated boards, unchanged. That
|
||
* is deliberate and it is not hedging: twenty-nine capture guards in
|
||
* `scripts/brand-assets` and the `bay-area` and `socal` cells of
|
||
* `scripts/performance-budget.mjs` aim at those boards by name and assert the
|
||
* `data-board` they land on. Making a metro id fall back to the merged board
|
||
* would not degrade those, it would make them silently measure and photograph
|
||
* something else — which is the exact failure the budget harness's own comment
|
||
* says the `data-board` assertion exists to catch. A named board stays a named
|
||
* board.
|
||
*
|
||
* `?one=0` forces the three-board product whatever the city is. One consumer
|
||
* needs it — the `california` budget cell, which is written against the *plain*
|
||
* state board's 440,000 cap and would otherwise measure the merged board that
|
||
* `california-one` already covers — and it is the honest way to say "the old
|
||
* arrangement" without spelling a board id.
|
||
*/
|
||
const ONE_PARAM = new URLSearchParams(location.search).get("one");
|
||
const CITY_PARAM = new URLSearchParams(location.search).get("city");
|
||
const ONE_CALIFORNIA =
|
||
ONE_PARAM === "1" ||
|
||
(ONE_PARAM !== "0" && CITY_PARAM !== "sf" && CITY_PARAM !== "socal");
|
||
|
||
/**
|
||
* The boards on offer — three, or **one**.
|
||
*
|
||
* With `?one=1` this is a single entry, and that is the whole of what makes the
|
||
* merged board a *board* rather than a nicer statewide tier on a three-board
|
||
* product. Everything downstream that decides "is this somewhere else" reads
|
||
* this array:
|
||
*
|
||
* - `LADDER` is built from it, so every rung's `board` is `california` and
|
||
* `goToPlace`'s `rung.board !== cityId` switch never fires. Clicking FiDi
|
||
* moves the camera instead of tearing the board down and building another.
|
||
* - `PREFETCH_TIERS` and the handover rule get one tier, so there is no
|
||
* handover: nothing to promote to, nothing to demote from, and no cut to
|
||
* conceal with a fog dip.
|
||
* - The office return links and `?city=` fall back to the one board that
|
||
* exists rather than to a board the visitor was never on.
|
||
*
|
||
* The metro packs are still imported and still merged — `unifiedCalifornia()`
|
||
* reads both — they simply stop being *destinations*. Without the flag nothing
|
||
* here moves, which is what keeps the twenty-nine capture guards and every cell
|
||
* of `scripts/performance-budget.mjs` aiming at the boards they were written
|
||
* against.
|
||
*/
|
||
const CITIES: { id: string; label: string; city: City }[] = ONE_CALIFORNIA
|
||
? [{ id: "california", label: "California", city: unifiedCalifornia().city }]
|
||
: [
|
||
{ id: "california", label: "California", city: CALIFORNIA },
|
||
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
|
||
{ id: "socal", label: "SoCal", city: SOCAL },
|
||
];
|
||
|
||
/**
|
||
* The board a bare URL lands on. **The detailed one, not the state tier.**
|
||
*
|
||
* ## Why this is not `CITIES[0]`
|
||
*
|
||
* It used to be, implicitly, in three places — a `wantedCity` initialiser, the
|
||
* office's return-link builder and the boot fallback — and the board it named
|
||
* was the 1,919 m-per-unit state tier. So the first thing every visitor saw was
|
||
* the coarsest representation the product owns: seventeen districts over
|
||
* 1063x930 km, no city legible, and a left column whose first offer is a list of
|
||
* somewhere else to go. The detailed boards carry 52 and 47 districts at 94 and
|
||
* 391 m per unit, and they are what the engine is actually for.
|
||
*
|
||
* With the free-camera handover on, the state tier has stopped being a
|
||
* destination and become **what you get when you pull back** — which is the role
|
||
* it is good at, since it is the only board that draws 97.4% of California.
|
||
* Landing on the Bay and zooming out to the state is the same map at two
|
||
* scales. Landing on the state and hunting for a city is three products.
|
||
*
|
||
* ## Why the constant rather than reordering `CITIES`
|
||
*
|
||
* Because `CITIES` order is load-bearing elsewhere: twenty-nine capture guards
|
||
* in `scripts/brand-assets` and every cell in `scripts/performance-budget.mjs`
|
||
* aim with an explicit `?city=`, and the budget harness binds each scene to the
|
||
* `data-board` it asserts precisely because an unknown `?city=` falls back to
|
||
* the first entry rather than failing. Reordering the array to change a landing
|
||
* page would silently re-point that fallback and every consumer that reads the
|
||
* array positionally. A named constant changes the one thing intended.
|
||
*
|
||
* `?city=california` still goes straight to the state board, and nothing about
|
||
* the deep link changed.
|
||
*/
|
||
/*
|
||
* With one board there is nothing to choose: the argument above is entirely
|
||
* about which of three products a bare URL lands on, and `?one=1` is the answer
|
||
* that there are not three. The landing *pose* is a separate question: the
|
||
* pack still opens its chapter list on `california-overview` so capture and
|
||
* `?city=california` frame the state, and a bare URL passes `openingChapter:
|
||
* "fidi"` so a visitor's first frame is a city they can read.
|
||
*/
|
||
const DEFAULT_CITY_ID = ONE_CALIFORNIA ? "california" : "sf";
|
||
|
||
/** The corridor's scale doors, and the office each detailed board arrives near. */
|
||
const CALIFORNIA_DESTINATIONS = new Map<string, { cityId: string; officeId: string }>([
|
||
["los-angeles", { cityId: "socal", officeId: "mateo-court" }],
|
||
["san-francisco", { cityId: "sf", officeId: "lumbridge-hq" }],
|
||
]);
|
||
|
||
/**
|
||
* The three boards' twenty-six authored chapters as one ordered descent.
|
||
*
|
||
* Built once, at module load, from the same packs `CITIES` holds — so the ladder
|
||
* cannot disagree with the boards about what a chapter is. `PLACES` is the same
|
||
* twenty-four rungs in the order the left column prints them; see
|
||
* `engine/ladder.ts` for why the printed list is grouped by region and the array
|
||
* is not.
|
||
*
|
||
* Neither of these replaces `city.chapters`. The per-board chapter list still
|
||
* exists, still renders into `#chapters`, and still carries the ids, the order,
|
||
* the short labels and the `data-view` attributes that twenty-nine capture
|
||
* guards in `scripts/brand-assets` aim at. The ladder is a **view** over that
|
||
* data and nothing more.
|
||
*/
|
||
/**
|
||
* The ladder is built from the **reconciled** packs, because `World` is.
|
||
*
|
||
* `world.ts:116` is `this.city = reconciledCity(city)`, so every number the
|
||
* engine draws with — `lngScale`, `verticalExaggeration`, road widths — is the
|
||
* reconciled one. The ladder reads `focus` to derive each rung's stand-off, and
|
||
* a ladder built from the raw packs is a ladder measuring a world nobody is
|
||
* looking at. With every rule off that is the same object by identity and this
|
||
* line is a no-op; with a rule on it is the difference between the rail agreeing
|
||
* with the camera and quietly disagreeing with it.
|
||
*
|
||
* Deliberately calling `reconciledCity` here rather than reading it off a
|
||
* `World`: the ladder is built at module load, before any board is mounted, and
|
||
* it must be — `PLACES` is the navigation and it exists before the first frame.
|
||
*/
|
||
const LADDER = buildLadder(
|
||
CITIES.map((entry) => ({ id: entry.id, city: reconciledCity(entry.city) })),
|
||
);
|
||
const PLACES = placesRows(LADDER);
|
||
|
||
const JOURNEY_SESSION_KEY = "tera:journey:v1";
|
||
const restoredJourney = loadJourneySession(sessionStorage, JOURNEY_SESSION_KEY);
|
||
let journey: JourneyState = restoredJourney.status === "loaded"
|
||
? restoredJourney.state
|
||
: createJourney({
|
||
actor: {
|
||
id: "anonymous",
|
||
kind: "crow",
|
||
signedIn: false,
|
||
profile: { displayName: "Guest" },
|
||
},
|
||
});
|
||
const LOCAL_PROFILE_KEY = "tera:profile:v2";
|
||
let localProfile: LocalProfile | null = null;
|
||
|
||
function humanoidAppearance() {
|
||
return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
|
||
}
|
||
|
||
function signedInActorIdentity(profile = localProfile): ActorIdentity {
|
||
const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null;
|
||
return {
|
||
id: access.subject ?? "anonymous",
|
||
displayName: profile?.displayName ?? access.subject ?? "Guest",
|
||
authenticated: access.subject !== null,
|
||
profile: {
|
||
appearance: appearance
|
||
? {
|
||
skinTone: appearance.skinTone,
|
||
primaryColor: appearance.outfitColor,
|
||
accentColor: appearance.accentColor,
|
||
hairColor: appearance.hairColor,
|
||
bodyShape: appearance.bodyShape,
|
||
}
|
||
: { primaryColor: "#151a20", accentColor: "#f2b134" },
|
||
},
|
||
};
|
||
}
|
||
|
||
function dispatchJourney(event: JourneyEvent): boolean {
|
||
const next = journeyReducer(journey, event);
|
||
if (next === journey) return false;
|
||
journey = next;
|
||
saveJourneySession(sessionStorage, JOURNEY_SESSION_KEY, journey);
|
||
return true;
|
||
}
|
||
|
||
function journeyToCity(city: JourneyCity): void {
|
||
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
|
||
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
|
||
dispatchJourney({ type: "navigate-to-city", city });
|
||
}
|
||
|
||
/**
|
||
* The buildings this page can walk into.
|
||
*
|
||
* Three, deliberately unalike — a tower above Transbay, a hangar at Alameda
|
||
* Point, an Arts District courtyard — because the thing worth showing is that
|
||
* one engine and one format render all three, and that `OfficeSite` is what
|
||
* makes them feel like different places rather than the same room with
|
||
* different furniture.
|
||
*
|
||
* The loaders stay lazy: a pack eagerly imported would put its furniture in the
|
||
* entry bundle for every visitor who never opens that door.
|
||
*/
|
||
const OFFICE_LOADERS: Readonly<Record<ShippedOfficeId, () => Promise<{ default: Office }>>> = {
|
||
"lumbridge-hq": () => import("./offices/lumbridge-hq.ts"),
|
||
"frontier-valley": () => import("./offices/frontier-valley.ts"),
|
||
"mateo-court": () => import("./offices/mateo-court.ts"),
|
||
};
|
||
|
||
const OFFICE_OPERATION_LOADERS: Readonly<Partial<Record<
|
||
ShippedOfficeId,
|
||
() => Promise<{ default: RobotOperationsDefinition }>
|
||
>>> = {
|
||
"lumbridge-hq": async () => ({
|
||
default: (await import("./offices/operations/lumbridge-hq.ts")).LUMBRIDGE_HQ_ROBOT_OPERATIONS,
|
||
}),
|
||
"mateo-court": async () => ({
|
||
default: (await import("./offices/operations/mateo-court.ts")).MATEO_COURT_ROBOT_OPERATIONS,
|
||
}),
|
||
};
|
||
|
||
const OFFICES = OFFICE_SITES.map((entry) => ({
|
||
...entry,
|
||
label: entry.name,
|
||
load: OFFICE_LOADERS[entry.id],
|
||
loadOperations: OFFICE_OPERATION_LOADERS[entry.id],
|
||
}));
|
||
|
||
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 abandoned a renderer on the one GL context this page
|
||
* has — and `WebGLRenderer.dispose()` frees no texture at all. `stage.ts` has
|
||
* the measurement and the reading of three's source it comes from.
|
||
*
|
||
* Nothing disposes this: it outlives every city and every office on the page,
|
||
* the same arrangement as `officeMaterials` below.
|
||
*/
|
||
const stage = createStage(canvas);
|
||
|
||
/**
|
||
* One environment map for the page, beside the one renderer, for the same
|
||
* reason there is one renderer.
|
||
*
|
||
* A `PMREMGenerator` compiles three shader programs and each cached environment
|
||
* is a half-float render target; both are properties of the GL context, not of
|
||
* the board drawn on it. Building a rig inside `createScene` would allocate a
|
||
* fresh chain per city and orphan the previous one on every switch, which is
|
||
* precisely the arithmetic `stage.ts` records for the renderer itself. So it is
|
||
* built here, handed to both scenes, and each scene calls `release()` on its way
|
||
* out so the rig's ledger does not retain a floor plate nobody can see.
|
||
*
|
||
* Nothing about it is eager: it allocates on the first `apply()`, and the two
|
||
* kinds — a sky for the city, a room for an office — are cached separately, so a
|
||
* page that never opens a door never builds the office blur chain.
|
||
*/
|
||
const environment = createEnvironmentRig(stage.renderer);
|
||
|
||
// `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 });
|
||
|
||
/**
|
||
* Everything a board owns, bundled so a board can be **kept**.
|
||
*
|
||
* `mountCity` used to tear sixteen module-level variables down before it built
|
||
* the next board, which is why a switch was a full-screen card: the outgoing
|
||
* board was already gone, so there was nothing to draw for the whole build.
|
||
* Measured, that is 65-78% of every pause — 1,711 / 1,079 / 997 ms of cover
|
||
* against only 604 / 372 / 222 ms of blocked main thread, with 68 / 45 / 49
|
||
* frames drawn into the gap. Retention converts a modal into a live picture
|
||
* that freezes briefly, and it costs no triangles, because `Stage` retains and
|
||
* pauses by contract and a paused scene is zero draw calls.
|
||
*
|
||
* The split between what is in here and what is *not* is the whole design:
|
||
*
|
||
* - **In here** is everything expensive and everything derived from the pack:
|
||
* the scene handle, the plan view, the atmosphere, the board span, the
|
||
* harbour model, the traffic dial. All of it survives a switch away and
|
||
* comes back instantly.
|
||
* - **Not in here** is everything that is a *subscription*: the weather watch,
|
||
* the fire watch, the fire panel, the camera's fog listener and the pose
|
||
* editor. Those belong to the board that is on screen, not to the board that
|
||
* exists — a `/weather` poll for a city nobody is looking at is a request
|
||
* nobody needs, and a fire panel mounted per resident board would put two
|
||
* copies of it in a piece of chrome this file does not own. They are stopped
|
||
* on the way out of `presentBoard` and started again on the way in.
|
||
*
|
||
* The module-level `city`, `atmosphere`, `minimap` and the rest below are now
|
||
* the **visible** board's, projected out of the record by `adoptBoard`. That is
|
||
* deliberate rather than lazy: it keeps eighty-five `city.` call sites, six
|
||
* render functions and the frame pump reading exactly what they read before, so
|
||
* retention is a change to the lifetime of a board and not to the shape of this
|
||
* file.
|
||
*/
|
||
interface MountedBoard {
|
||
id: string;
|
||
entry: { id: string; label: string; city: City };
|
||
handle: SceneHandle;
|
||
minimap: Minimap | null;
|
||
atmosphere: Atmosphere;
|
||
/** The board's largest edge in scene units. The fog dip's collapse is a fraction of it. */
|
||
span: number;
|
||
/** Live traffic when the deployment has it; `null` means the simulator. */
|
||
flights: TrafficSource | null;
|
||
trafficDial: TrafficDial;
|
||
/** The rectangle a fire poll is clipped to, or `null` on a board that draws none. */
|
||
fireBounds: FireBounds | null;
|
||
carriesSky: boolean;
|
||
vesselsBody: VesselsBody | null;
|
||
vesselBounds: VesselBounds | null;
|
||
vesselBerths: readonly BerthAnchor[];
|
||
vesselPorts: readonly Port[];
|
||
harbourAtMs: number;
|
||
/** The markers this board was built with, for the plan view on the way back. */
|
||
markers: Marker[];
|
||
/** False until the first time it has been on screen; see `presentBoard`. */
|
||
arrived: boolean;
|
||
}
|
||
|
||
/**
|
||
* The resident boards, least recently shown first.
|
||
*
|
||
* California is pinned because it is the cheapest (5.86 MB of GPU buffers
|
||
* against the Bay Area's 14.12) and the root of every descent. The ceiling is
|
||
* three on a desktop — every board this build has — and two on a handheld,
|
||
* because `deviceProfile()` changes only the pixel ratio and the shadow map, so
|
||
* a phone carries exactly the same resident geometry as a laptop against a much
|
||
* smaller budget. `sf` and `socal` bounds do not intersect and California
|
||
* contains both, so two is provably enough to describe any camera position.
|
||
*/
|
||
const boards: BoardCache<MountedBoard> = createBoardCache<MountedBoard>({
|
||
pinned: ["california"],
|
||
capacity: residentCapacity(deviceProfile().handheld),
|
||
});
|
||
|
||
/**
|
||
* The board on screen, or `null` before the first one and between a dispose and
|
||
* the next swap. The record behind the `city` handle below.
|
||
*/
|
||
let visibleBoard: MountedBoard | null = null;
|
||
let city: SceneHandle | null = null;
|
||
let cityId = "california";
|
||
/**
|
||
* 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 = DEFAULT_CITY_ID;
|
||
let office: OfficeScene | null = null;
|
||
/** The pack used by `office`; kept separate from the currently selected door. */
|
||
let builtOfficeId: string | null = null;
|
||
let inside = false;
|
||
let controlModeState = createControlModeState();
|
||
const playInput = new PlayInputRouter();
|
||
let markers: Marker[] = SAMPLE_MARKERS;
|
||
let realtimeClient: RealtimeClient | null = null;
|
||
let presenceIndicator: PresenceIndicator | null = null;
|
||
let stopRealtimeSubscription: (() => void) | null = null;
|
||
let realtimeOperation = 0;
|
||
let lastRealtimePublishAt = 0;
|
||
let realtimePageActive = true;
|
||
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */
|
||
const realtimeActorId = randomId();
|
||
const realtimeVehicleId = randomId();
|
||
const realtimeAircraftId = randomId();
|
||
|
||
/**
|
||
* The buildings you can walk into, as procedural glyphs on the city.
|
||
*
|
||
* The one thing that makes Tera and Spaces read as one product rather than two
|
||
* views sharing a bundle: both packs carry a real `site`, and until these
|
||
* existed that coordinate was known to the lighting and to nothing else.
|
||
*
|
||
* `OFFICE_SITES` rather than the packs, deliberately — a pack is a lazy chunk
|
||
* and the city wants these the instant the board appears. `colorKey` and `glyph`
|
||
* stay opaque to the engine, so a door looks different from a company without
|
||
* the renderer knowing what either means.
|
||
*/
|
||
const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
|
||
id: `office:${entry.id}`,
|
||
label: entry.name,
|
||
colorKey: "office",
|
||
blurb: `${entry.entryCopy} · ${entry.status}`,
|
||
lat: entry.site.lat,
|
||
lng: entry.site.lng,
|
||
// Hand-typed from the street grid, like every other coordinate here. Not a
|
||
// placeholder, so it is drawn as a real address.
|
||
located: true,
|
||
...(entry.site.exterior ? { glyph: entry.site.exterior } : {}),
|
||
}));
|
||
|
||
/**
|
||
* Whether a marker is a door rather than a company.
|
||
*
|
||
* The id prefix is the whole test, and it is deliberately something no marker
|
||
* off the wire can collide with: `markers/gate.ts` serves rows from a synced
|
||
* database and none of them are namespaced this way.
|
||
*/
|
||
function officeIdOf(marker: Marker): string | null {
|
||
return marker.id.startsWith("office:") ? marker.id.slice("office:".length) : null;
|
||
}
|
||
|
||
/**
|
||
* Whatever the pointer is over, so a click knows what it clicked.
|
||
*
|
||
* The engine reports picks by hover rather than by click — that is what drives
|
||
* the detail card — so the click handler has no argument of its own and reads
|
||
* this instead. `null` whenever the pointer is over open ground, which is what
|
||
* makes a click on the terrain do nothing.
|
||
*/
|
||
let hoveredMarker: Marker | null = null;
|
||
/**
|
||
* The colour a door is drawn in, which no marker feed knows about.
|
||
*
|
||
* Merged in **here** rather than added to `SAMPLE_PALETTE`, because it is not a
|
||
* sample of anything: a deployment that replaces the whole marker feed with its
|
||
* own palette must still get doors it can see. Amber, so a door reads as this
|
||
* application's navigation rather than as another marker hue.
|
||
*/
|
||
const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f };
|
||
|
||
let palette: MarkerPalette = { ...SAMPLE_PALETTE, ...OFFICE_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;
|
||
/**
|
||
* What is burning, polled while a board that draws fire is on screen.
|
||
*
|
||
* A board watch like the weather's, and stopped in the same place for a
|
||
* different reason. The body is not per-region — one statewide answer, one
|
||
* cache key, every viewer — so a late arrival cannot describe the wrong place
|
||
* the way a stale `/weather` can. What it can do is arrive for a board that has
|
||
* been torn down, and `promote()` needs *bounds* to say anything at all.
|
||
*
|
||
* `null` on the Bay Area board and on every keyless clone. See `firesFor`.
|
||
*/
|
||
let fireWatch: FireWatch | null = null;
|
||
/**
|
||
* Stops this board's camera listening to the fog, or `null` between boards.
|
||
*
|
||
* The rig follows the clock at about one hertz, which was fine while every term
|
||
* in it was a function of time. Aerial perspective is a function of where the
|
||
* camera is, and a camera can cross a board in a second — so a fog recomputed
|
||
* only on the clock steps four times during a chapter flight and pops each
|
||
* time. `OrbitControls` fires `change` on every update that actually moved
|
||
* something, including damping and including `setPose`, which is exactly the
|
||
* event this needs and is already the event the minimap redraws on.
|
||
*/
|
||
let cameraFogWatch: (() => void) | null = null;
|
||
/** The panel that says what the board is showing, and what it is not. */
|
||
let firePanel: FirePanelHandle | null = null;
|
||
/** The board's own rectangle, held so a poll landing later can be clipped to it. */
|
||
let fireBounds: FireBounds | null = null;
|
||
/**
|
||
* The last body, kept for the whole page rather than for the board.
|
||
*
|
||
* This is the one place the "not per-region" property of `/fires` pays a
|
||
* visible dividend. The same statewide body answers for every board, so a
|
||
* visitor who switches from California to the Southland can be shown the
|
||
* correct — and correctly *empty* — answer in the same frame as the switch,
|
||
* re-clipped to the new rectangle, instead of reading "nothing has answered"
|
||
* for as long as a fresh request takes. `undefined` means no watch has ever
|
||
* settled; `null` means one settled and the feed refused.
|
||
*/
|
||
let firesBody: FiresBody | null | undefined;
|
||
/**
|
||
* The harbour on the board, and the three sentences beside the three feeds.
|
||
*
|
||
* All of it is per-board and all of it is cleared on the way out of `mountCity`,
|
||
* because — unlike `firesBody`, which is one statewide answer for every board —
|
||
* a harbour is a fact about a rectangle and a raster is clipped to one.
|
||
*
|
||
* `vesselsBody` is **modelled**, not observed: `modelHarbour` builds it from the
|
||
* board's own authored berths and channels, and `promoteVessels` reads it
|
||
* through exactly the gate a live AIS body would go through. The day
|
||
* `GET /vessels` exists, the only line that changes is where this comes from.
|
||
* `harbourAtMs` is the clock that body was built for, so the next refresh is a
|
||
* new *fix* rather than a nudge — see `refreshHarbour`.
|
||
*/
|
||
let vesselsBody: VesselsBody | null = null;
|
||
let vesselBounds: VesselBounds | null = null;
|
||
let vesselBerths: readonly BerthAnchor[] = [];
|
||
let vesselPorts: readonly Port[] = [];
|
||
let harbourAtMs = 0;
|
||
/** Whether the board on screen is one a statewide instrument can describe. */
|
||
let boardCarriesSky = false;
|
||
/**
|
||
* The two sky bodies, and the one request that fetches them.
|
||
*
|
||
* `undefined` is "not asked yet", `null` is "asked and the box refused", and a
|
||
* body carrying `source: "none"` is "asked and the box serves no such feed" —
|
||
* three states with three different sentences, which is the whole reason the
|
||
* gates take a nullable body rather than a field. `skyAskedAtMs` throttles the
|
||
* refetch to the body's own TTL off the once-a-minute clock, so there is no
|
||
* second poller in this file.
|
||
*/
|
||
let radarBody: RadarBody | null | undefined;
|
||
let birdsBody: BirdsBody | null | undefined;
|
||
let skyAskedAtMs = 0;
|
||
let skyInFlight = false;
|
||
/** The three board notes, written by the gates and drawn by `showBoardNotes`. */
|
||
let seaNote = "";
|
||
let radarNote = "";
|
||
let birdsNote = "";
|
||
/**
|
||
* The satellite element sets, fetched once for the page rather than once per city.
|
||
*
|
||
* The asymmetry with every other feed here is the physics: an aircraft at
|
||
* 10,000 m is visible for tens of kilometres and the two boards are six hundred
|
||
* apart, but a satellite at 550 km is above the horizon for a circle two
|
||
* thousand kilometres across. So the elements are shared — and the *catalogue*
|
||
* is not, because a `SatelliteCatalogue` is built around an observer, and
|
||
* reusing one would compute the Southland's sky from San Francisco with nothing
|
||
* on screen to say so.
|
||
*
|
||
* A promise rather than a value, so a second city mounted while the first fetch
|
||
* is in the air waits for it instead of starting another. `null` forever on the
|
||
* majority of deployments: `TERA_SATELLITES_SOURCE` is off by default.
|
||
*/
|
||
let satelliteElements: Promise<SatelliteElements[]> | null = null;
|
||
/**
|
||
* The fabricated-traffic dial for the board on screen, rebuilt with every city.
|
||
*
|
||
* Held here rather than inside the godmode closure because the panel is mounted
|
||
* once and the board is not: a dial captured when the panel opened would keep
|
||
* pushing aircraft at a scene that had been disposed two city switches ago.
|
||
*/
|
||
let trafficDial: TrafficDial | null = null;
|
||
/**
|
||
* Whether the satellite layer is drawn, remembered across city switches.
|
||
*
|
||
* A new board builds a new layer, which starts visible, so a god who turned the
|
||
* sky off and then changed city would have it come back on — a setting that
|
||
* quietly undoes itself is worse than one that is not there.
|
||
*/
|
||
let satellitesVisible = true;
|
||
/**
|
||
* 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;
|
||
/**
|
||
* The build nobody asked for.
|
||
*
|
||
* A strictly lower-priority second lane. It exists because "one map" is a claim
|
||
* about continuity, and continuity is paid for by having the detail already on
|
||
* the GPU when the camera gets there: CONTRACT §1.1 keeps three boards resident
|
||
* on a desktop and pins California, and until now nothing ever *filled* that
|
||
* residency except a visitor waiting through a build.
|
||
*
|
||
* Held separately from `mounting` rather than reusing it, because the two have
|
||
* opposite rights. A foreground build may cancel a background one; a background
|
||
* build may never cancel or queue in front of a foreground one, and it must be
|
||
* abandonable the instant somebody touches anything. Keeping one controller for
|
||
* both would make "abort the build" ambiguous at exactly the moment it matters.
|
||
*/
|
||
let prefetching: 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;
|
||
/**
|
||
* Which building the door leads to. Changed by the picker while you are inside.
|
||
*
|
||
* `officePack` is the pack for *this* id and is rebuilt on a switch rather than
|
||
* memoised forever, which is the one-line difference from the arrangement that
|
||
* could only ever hold one office.
|
||
*/
|
||
let officeId: string = OFFICES[0]?.id ?? "lumbridge-hq";
|
||
/**
|
||
* The office's own sky, when its pack says where it stands.
|
||
*
|
||
* A second `Atmosphere` rather than the city's, because the two are at different
|
||
* scales and in different places: the city's is built with `metresPerUnit` near
|
||
* 94 and a fog measured in board spans, and an office runs at 1 m per unit
|
||
* fifty metres across. `null` for a pack with no `site`, which keeps the fixed
|
||
* interior rig and is a supported state rather than a gap.
|
||
*/
|
||
let officeAtmosphere: Atmosphere | null = null;
|
||
let officeScreenPanel: OfficeScreenPanel | null = null;
|
||
let sharedScreen: {
|
||
screenId: string;
|
||
stream: MediaStream;
|
||
video: HTMLVideoElement;
|
||
texture: VideoTexture;
|
||
remote: RemoteOfficeMedia | null;
|
||
} | null = null;
|
||
let remoteViewedScreen: {
|
||
screenId: string;
|
||
officeId: string;
|
||
video: HTMLVideoElement;
|
||
texture: VideoTexture;
|
||
remote: RemoteOfficeMedia;
|
||
bound: boolean;
|
||
} | null = null;
|
||
let remoteMediaOperation = 0;
|
||
let remoteViewerRequestScreenId: string | null = null;
|
||
/**
|
||
* The plan panel's other occupant.
|
||
*
|
||
* There are two of these because there are two places, and the panel shows the
|
||
* one you are standing in. Holding the city plan up while the scene in front of
|
||
* it is a floor plate was the bug this pair exists to fix: the single piece of
|
||
* chrome whose whole job is "where am I" was answering about another county.
|
||
*
|
||
* It is built with the office and dies with it, so the office's `Plan`, camera
|
||
* and controls — all three of which it holds by reference — cannot outlive the
|
||
* scene they came from. Which of the two is *mounted* is a separate question
|
||
* from which exist, and `showPlan()` is the only thing that answers it.
|
||
*/
|
||
let officePlan: OfficeMinimap | null = null;
|
||
/**
|
||
* Whether the people currently on the floor came from the deployment or from
|
||
* `sample.ts`.
|
||
*
|
||
* Kept because the difference has to be *said*. Every other gap on this page is
|
||
* a silence — an empty office, a missing scrubber — and a silence you cannot
|
||
* attribute is indistinguishable from a fault; here the failure is the opposite
|
||
* and worse, because fabricated people are not silent. Twenty-five invented
|
||
* names at real desks is a screenshot somebody will take, and it must not be
|
||
* possible to take it without the caption.
|
||
*/
|
||
let presenceIsSample = false;
|
||
/**
|
||
* The deployment's own roster, or `null` while the sample one stands in for it.
|
||
*
|
||
* Held separately from what is on screen because the two are refreshed by
|
||
* different things: a real roster arrives from the API on its own timer, and the
|
||
* sample one is a function of the clock and has to be recomputed whenever the
|
||
* clock moves. Collapsing them would mean either re-rendering a live roster on
|
||
* every scrub or freezing the sample one.
|
||
*/
|
||
let livePresence: Presence[] | null = null;
|
||
/**
|
||
* The running poll of who is in, or `null` when nobody is standing in the room.
|
||
*
|
||
* It belongs to the visit and not to the page, which is the same rule
|
||
* `weatherWatch` follows one line up and for a sharper reason: a roster is
|
||
* requested with a credential and describes people, so a watch left running
|
||
* after somebody stepped back out to the city is a page quietly asking about a
|
||
* room nobody is looking at. Started by `enterOffice`, stopped by `leaveOffice`
|
||
* and by `mountCity`, and there is never more than one.
|
||
*/
|
||
let presenceWatch: PresenceWatch | 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 OFFICE_HOST = location.hostname.split(".")[0] === "office";
|
||
|
||
const OPENS_IN_OFFICE =
|
||
OFFICE_HOST || new URLSearchParams(location.search).get("view") === "office";
|
||
|
||
/**
|
||
* Where the city is, when this page is the office front door.
|
||
*
|
||
* Leaving the office used to swap the scene and leave the address bar saying
|
||
* `office.`, so the URL you would copy and send disagreed with what you were
|
||
* looking at. The city door is therefore this hostname with its first label
|
||
* swapped — a guess only ever made when that label is literally `office`, which
|
||
* is the same comparison that made this the office door. Same registrable
|
||
* domain, same static root, same API; a deployment serving one name and not the
|
||
* other has half-configured itself, and a 404 is the honest failure for that.
|
||
*
|
||
* Anything else — a bare domain, `?view=office` on the city door — derives
|
||
* nothing and keeps the in-page swap.
|
||
*/
|
||
function cityDoorUrl(cityWanted?: string): string | null {
|
||
if (!OFFICE_HOST) return null;
|
||
const url = new URL(location.href);
|
||
const labels = url.hostname.split(".");
|
||
labels[0] = "tera";
|
||
url.hostname = labels.join(".");
|
||
url.search = "";
|
||
url.hash = "";
|
||
// Carried so that stepping out of the office into SoCal lands in SoCal. The
|
||
// page load is the cost of telling the truth about where you are; arriving in
|
||
// the wrong metro on top of it would not be.
|
||
if (cityWanted !== undefined && cityWanted !== DEFAULT_CITY_ID) {
|
||
url.searchParams.set("city", cityWanted);
|
||
}
|
||
return url.href;
|
||
}
|
||
|
||
|
||
// ---- The interface's own state --------------------------------------------
|
||
|
||
/**
|
||
* The applier, once the page has one.
|
||
*
|
||
* `null` for the handful of statements between this module's first line and
|
||
* `boot()`, which is why every call site is optional-chained rather than
|
||
* asserted: a keystroke that lands in that window should do nothing, not throw.
|
||
*/
|
||
let chrome: ChromeHandle | null = null;
|
||
|
||
/**
|
||
* 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 or the button the viewport stops having an opinion.
|
||
*
|
||
* The seeds themselves live in `ui/chromeState.ts` next to the breakpoints they
|
||
* read, because that is where the reasoning about what a phone can afford is —
|
||
* and because the old inline `window.innerWidth > 600` for the plan was the
|
||
* whole of live defect 10, "the minimap disappears entirely" on a phone.
|
||
*/
|
||
let panelOpen = seedPanelOpen(window.innerWidth);
|
||
let planOpen = seedPlanOpen(window.innerWidth);
|
||
let panelChosen = false;
|
||
let planChosen = false;
|
||
|
||
/**
|
||
* Whether this browser has been offered the first-run coach.
|
||
*
|
||
* Read **once**, here, before anything mounts: `mountOnboarding` marks the flag
|
||
* as it appears rather than as it completes, so asking `localStorage` a second
|
||
* time after the coach is up would answer "seen" and take it straight back off
|
||
* the screen. The write belongs to the coach; this is the read, and it is the
|
||
* only one.
|
||
*/
|
||
let firstVisit = !hasSeenOnboarding(browserStorage());
|
||
|
||
/**
|
||
* What the detail card is showing: an authored sentence, an observed aircraft,
|
||
* or nothing.
|
||
*
|
||
* A union rather than a string, because the two are not the same kind of thing
|
||
* and flattening the aircraft to a sentence is exactly what used to happen —
|
||
* `showDetail(\`${m.label} — ${m.blurb}\`)` for everything, including a track
|
||
* with five fields and a licence credit on it.
|
||
*/
|
||
let detail: ChromeDetail | null = null;
|
||
|
||
// ---- Studio hardware -------------------------------------------------------
|
||
|
||
/**
|
||
* Where the readings for the room you are standing in come from.
|
||
*
|
||
* One at a time and it belongs to the visit, like `presenceWatch` above it. The
|
||
* choice between the deployment's own device route and the fixed-step simulator
|
||
* in this tab is made inside `devices/adapter.ts` and deliberately not here —
|
||
* that is the one place the anon-first fallback decision lives, and a second
|
||
* copy of it in the app is how the two drift apart.
|
||
*/
|
||
let deviceSource: DeviceSource | null = null;
|
||
/** The latest readings, so the panel and the hardware in the room agree. */
|
||
let deviceStates: readonly DeviceState[] = [];
|
||
|
||
/**
|
||
* The car outside, as a state machine.
|
||
*
|
||
* Seeded from the office id, so a studio's Model X has the same charge, the same
|
||
* cabin temperature and the same parking jitter on every machine and on every
|
||
* reload — a car whose battery was different every time you opened the door
|
||
* would read as noise rather than as a vehicle.
|
||
*/
|
||
let vehicleTelemetry: SimulatedVehicleTelemetry | null = null;
|
||
|
||
/**
|
||
* The clock line, already formatted, because this file owns the clock.
|
||
*
|
||
* `instantOverride` is the one thing on the page that can make the rendered
|
||
* instant disagree with the wall clock, and it lives here — so a formatter
|
||
* downstream would have to be handed the override as well as the time, which is
|
||
* two facts to keep in step for one string. Written by `updateSun`, which is
|
||
* also the only thing that reads the sun.
|
||
*/
|
||
let clockLabel = "";
|
||
// ---- 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;
|
||
/**
|
||
* Where the office's house lights take their level from. Godmode's switch, and
|
||
* it survives the office being rebuilt because the panel re-asserts it.
|
||
*/
|
||
let houseLights: GodmodeHouseLights = "sun";
|
||
|
||
/**
|
||
* The sun's height as the *fittings* are told it, which is the real one unless
|
||
* somebody has a hand on the switch.
|
||
*
|
||
* Forcing the level by lying about the elevation rather than opening a second
|
||
* path into `luminaires.ts`, and that is the design rather than a shortcut:
|
||
* `OfficeScene.setSolarElevation` feeds the fittings and nothing else, while
|
||
* the rig outside is computed from the real `env` a line later. So this moves
|
||
* the *interior* and leaves the sky, the key light and the fog exactly where the
|
||
* clock put them — which is the whole point of a control that lets you look at
|
||
* the night office in daylight. ±90° is far outside the 0°–6° ramp in
|
||
* `luminaires.ts`, and stays outside it if that band is ever widened.
|
||
*/
|
||
function houseElevation(actual: number): number {
|
||
return houseLights === "sun" ? actual : houseLights === "on" ? -90 : 90;
|
||
}
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* The office's rig for the instant being rendered, in the building's own frame.
|
||
*
|
||
* Same clock, same weather and same `Atmosphere` machinery the city runs on —
|
||
* which is the entire point. An office that dimmed on a schedule of its own
|
||
* would be a second sun, and CONTRACT.md §4 exists to say there is one.
|
||
*
|
||
* `officeDaylight` does the two things a room needs and a map does not: it turns
|
||
* the sun into the building's frame using `site.heading`, so the light comes
|
||
* through the windows the pack actually has, and it moves the fog outdoors.
|
||
*/
|
||
function officeLighting(site: NonNullable<Office["site"]>) {
|
||
const env = observe(site.lat, site.lng, currentInstant(), currentWeather());
|
||
// `officeAtmosphere` is built alongside the pack; falling back to the fixed
|
||
// rig here would be a flicker rather than a fix, so this is only ever called
|
||
// where one exists.
|
||
const state = officeAtmosphere?.apply(env);
|
||
if (!state) return undefined;
|
||
|
||
/**
|
||
* The building's own lights, on top of whatever is left of the sun.
|
||
*
|
||
* The order matters and is the only subtle thing here: the daylight
|
||
* adaptation runs first, because it is about the *sun* — which way the
|
||
* building faces and where the weather starts — and the house lights are
|
||
* added to the result, because they are about the building. Doing it the
|
||
* other way round would rotate the interior lighting by the building's
|
||
* heading, which is meaningless: a ceiling does not face a compass point.
|
||
*
|
||
* `office` may not exist yet — this is called once at construction, before
|
||
* there is a scene to ask — in which case the elevation is fed straight to the
|
||
* ramp so the first frame is already correct rather than a lit room fading
|
||
* down or a dark one fading up.
|
||
*/
|
||
office?.setSolarElevation(houseElevation(env.sun.elevation));
|
||
const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation));
|
||
/**
|
||
* And the smoke, which is the third parameter and the only one that comes
|
||
* from another building's problem.
|
||
*
|
||
* It is adapted here, once, for the reason CONTRACT §4 gives: `Atmosphere`
|
||
* owns the rig and `daylight.ts` adapts what it returned, so a room may not
|
||
* reach into the fire layer and form a second opinion about its own sky. The
|
||
* scalar is the drawn set weighted by acreage, distance and wind alignment —
|
||
* `smokeLoadAt` in `engine/fires.ts` — and it is zero whenever nothing is
|
||
* burning, which is most days and is today on the Southland board.
|
||
*
|
||
* Costs no geometry and no draw call: it moves the haze colour, the haze
|
||
* near-distance and the sun's tint, three numbers `officeDaylight` already
|
||
* computed.
|
||
*/
|
||
return withHouseLights(officeDaylight(state, site, smokeLoadAtSite(site)), house);
|
||
}
|
||
|
||
/**
|
||
* The lights-on ramp, for the one moment there is no office to ask.
|
||
*
|
||
* Duplicating the two bounds from `luminaires.ts` is a smell and is the lesser
|
||
* of the two available ones: the alternative is building the office scene with
|
||
* a rig computed from a light level it cannot report yet, which means the room
|
||
* is visibly wrong for exactly one frame at every entry. Kept in step by being
|
||
* four lines long and named after the thing it mirrors.
|
||
*/
|
||
function houseLevelFor(solarElevationDeg: number): number {
|
||
return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6));
|
||
}
|
||
|
||
/**
|
||
* The fog alone, for the camera's clock rather than the sun's.
|
||
*
|
||
* ## The split, and why it is a split rather than a cheaper `setLighting`
|
||
*
|
||
* Two things move at completely different rates and were being served by one
|
||
* call. The **sun** moves on the wall clock, which this file steps once a
|
||
* minute. The **camera** moves on a gesture: `change` fires every frame under
|
||
* damping, and even throttled to 2% of altitude a single drag gets through a few
|
||
* dozen times. This function is the second of those, and it now sends the only
|
||
* thing that is actually a fact about the camera — how far you can see from
|
||
* where it is standing.
|
||
*
|
||
* What it used to send was the whole rig, and the whole rig is a fan-out: six
|
||
* layer setters, a dirtied `MeshLambertMaterial` in the port yard, a rebuild of
|
||
* the vessel wake instances, and the PMREM environment's fingerprint in
|
||
* `environmentRig.ts`. Not one of those reads a fog distance. Measured over a
|
||
* dolly and a drag on both metro boards, the old path cost 0.14-0.19 ms a step
|
||
* and this one costs about 0.02.
|
||
*
|
||
* ## The trap it also disarms, which is the better reason
|
||
*
|
||
* `environmentRig.ts` decides whether to re-render and re-convolve the sky
|
||
* cubemap by fingerprinting the rig's colours — `sky.horizon` among them — and
|
||
* `interiors/daylight.ts` pins that horizon stop to the fog colour so the sky
|
||
* dome and the haze meet without a seam. Aerial perspective moves distances and
|
||
* no colours, so it does not trip that today; the point of routing the camera
|
||
* through a setter that *cannot carry a colour* is that the next altitude-driven
|
||
* term cannot trip it either. The alternative fix — coarsening the fingerprint
|
||
* until the rebuild stops — hides one instance and leaves the mechanism armed.
|
||
*
|
||
* `updateSun` still passes the camera's view to `apply`, so a clock tick on a
|
||
* board nobody is touching still gets the right fog. `Atmosphere` remains the
|
||
* sole owner of both numbers (CONTRACT §4); this file only chooses which of them
|
||
* a gesture is allowed to move.
|
||
*/
|
||
function applyCameraFog(): void {
|
||
if (!city || !atmosphere) return;
|
||
// A dip owns this board's fog for the ~0.8 s it runs. A camera event landing
|
||
// inside that window would snap the haze back open mid-transition, which is
|
||
// precisely the seam the dip exists to cover.
|
||
if (fogDip !== null && fogDip.record.handle === city) return;
|
||
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
|
||
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
|
||
city.setAerialFog(
|
||
atmosphere.aerial(env, {
|
||
altitudeMetres: city.cameraAltitudeMetres(),
|
||
standoffMetres: city.cameraStandoffMetres(),
|
||
}),
|
||
);
|
||
}
|
||
|
||
function updateSun() {
|
||
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
|
||
|
||
// The office follows the same clock, and follows it whether or not you are
|
||
// standing in it — walking back in to a room lit for an hour ago is the
|
||
// failure this avoids.
|
||
const site = officePack?.site;
|
||
if (office && site && officeAtmosphere) {
|
||
const state = officeLighting(site);
|
||
if (state) office.setLighting(state);
|
||
}
|
||
|
||
// The roster follows the same clock the sun does. Never over a live answer.
|
||
if (livePresence === null) applyPresence();
|
||
|
||
if (!city || !atmosphere) return;
|
||
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
|
||
/**
|
||
* The camera's height is the second argument to the rig now, and the reason
|
||
* it comes from the scene rather than from here is that only the scene knows
|
||
* this board's metres per unit and its vertical exaggeration.
|
||
*
|
||
* `atmosphere.ts` turns it into aerial perspective — how far you can see
|
||
* through the air you are above — clamped so the clear-day pair below is the
|
||
* ceiling and never exceeded. At the whole-board pose it saturates and every
|
||
* board renders exactly as it did before, which is why the Bay Area's stills
|
||
* are unaffected by construction. Low down it is what puts haze on a range
|
||
* eighty kilometres up the valley.
|
||
*/
|
||
const view = {
|
||
altitudeMetres: city.cameraAltitudeMetres(),
|
||
standoffMetres: city.cameraStandoffMetres(),
|
||
};
|
||
city.setLighting(atmosphere.apply(env, view));
|
||
// `LightingState` carries a fog pair, so a clock tick inside a dip would undo
|
||
// it. Re-asserted rather than suppressed, because the *rest* of the rig — the
|
||
// sun, the sky, the environment map — is still exactly what the clock says.
|
||
if (fogDip !== null && fogDip.record.handle === city) {
|
||
const dip = fogDip;
|
||
const t = dip.seconds <= 0 ? 1 : Math.min(1, dip.elapsed / dip.seconds);
|
||
city.setAerialFog(dipFog(dip.from, dip.to, t));
|
||
}
|
||
city.setSolarElevation(env.sun.elevation);
|
||
/**
|
||
* The board's other three feeds, on the same clock and from the same sun.
|
||
*
|
||
* `env.sun.elevation` is handed to the migration gate rather than letting it
|
||
* ask an ephemeris of its own. A second opinion about where the sun is, taken
|
||
* from a clock the scrubber does not own, is how a night board and a night
|
||
* layer end up disagreeing — and `LightingState.hemisphere` is not the signal
|
||
* either, because `atmosphere.ts` raises the fill after dark and it reads
|
||
* *higher* at midnight than at noon.
|
||
*
|
||
* Both are no-ops on a board without them: `tickHarbour` returns on a board
|
||
* with no port, and `setPrecip`/`setMigration` are no-ops without a layer.
|
||
*/
|
||
tickHarbour(currentInstant().getTime());
|
||
if (boardCarriesSky) applySky(env.sun.elevation, Date.now());
|
||
/**
|
||
* `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`.
|
||
*
|
||
* `null` weather is "nobody was asked", which is not an edge case — it is the
|
||
* default deployment and the configuration this repo is held to. Falling back
|
||
* to zero meant a clean clone's sky was permanently, silently empty.
|
||
* `atmosphere` models a sky when nobody has observed one, and an observed
|
||
* cover still wins outright when there is one.
|
||
*/
|
||
city.setCloudCover(atmosphere.cloudCover(env));
|
||
city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null);
|
||
// The override itself, not `currentInstant()`. Handing over a resolved date
|
||
// would peg the sky to whatever second this ran in, and this runs about once a
|
||
// second — so an unscrubbed sky would advance in visible steps while the
|
||
// aircraft beside it moved smoothly. `null` means "read the clock yourself".
|
||
city.setSkyInstant(instantOverride);
|
||
// 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 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)}%` : "";
|
||
// Formatted here rather than in the chrome, because the clock is this file's
|
||
// — `instantOverride` is the one thing on the page that can make it disagree
|
||
// with the wall clock, and a formatter downstream would have to be told.
|
||
clockLabel = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
|
||
renderChrome();
|
||
}
|
||
|
||
// ---- Fire -----------------------------------------------------------------
|
||
|
||
/**
|
||
* The boards that draw fire.
|
||
*
|
||
* Named rather than derived, and deliberately not "every board": the Bay Area
|
||
* rectangle has never contained a live incident, so a layer there would be a
|
||
* frame-time allowance spent on an empty view every night. California and the
|
||
* Southland are the two rectangles the upstream store actually populates.
|
||
*/
|
||
const FIRE_BOARDS: ReadonlySet<string> = new Set(["california", "socal"]);
|
||
|
||
function drawsFire(id: string): boolean {
|
||
return FIRE_BOARDS.has(id) && access.feeds?.fires === true;
|
||
}
|
||
|
||
/**
|
||
* One body, turned into the three things that read it.
|
||
*
|
||
* **The gate is not here and must never be copied here.** `promote()` in
|
||
* `src/server/fires.ts` is the only place that decides what is drawn — the ten
|
||
* acres, the eighty per cent, the prescribed burns, and the twenty-one stale
|
||
* rows the collector never deletes — and this function's whole job is to hand
|
||
* its answer to three consumers unchanged. On today's live store that answer is
|
||
* five marks on California and *nothing at all* on the Southland, from the same
|
||
* body on the same second, and both are correct.
|
||
*
|
||
* `null` is a fourth state and it is the one that has to survive: nothing has
|
||
* answered. The panel says so out loud rather than showing an all-clear, which
|
||
* is the same argument `health.ts` makes for `degraded[]` — a board with no feed
|
||
* behind it and a board with nothing burning on it are indistinguishable without
|
||
* a timestamp.
|
||
*/
|
||
function applyFires(body: FiresBody | null): void {
|
||
firesBody = body;
|
||
if (fireBounds === null) return;
|
||
const promotion = promote(body, fireBounds);
|
||
city?.setFires(promotion);
|
||
firePanel?.apply(body === null ? null : promotion);
|
||
// The courtyard the fires are actually over. Cheap — `smokeLoadAt` reads the
|
||
// drawn set, which is at most sixty-four rows and is usually none — and only
|
||
// ever a repaint of a rig that was going to be recomputed on the next clock
|
||
// tick anyway.
|
||
if (inside) updateSun();
|
||
renderChrome();
|
||
}
|
||
|
||
/**
|
||
* How much smoke is in the air at a building, from the fires on the board.
|
||
*
|
||
* Zero with no fire layer, zero with nothing drawn, and zero on a board that
|
||
* does not draw fire at all — so the courtyard is bit-identical to today
|
||
* everywhere except under a real fire, which is the property
|
||
* `interiors/daylight.ts` was given a third argument for.
|
||
*/
|
||
function smokeLoadAtSite(site: NonNullable<Office["site"]>): number {
|
||
return city?.fireSmokeLoadAt(site.lat, site.lng) ?? 0;
|
||
}
|
||
|
||
/**
|
||
* The load below which the building says nothing about smoke.
|
||
*
|
||
* `smokeLoadAt` is continuous and its floor is a real zero, so "greater than
|
||
* zero" looked like the honest test and is not. On today's live board the two
|
||
* ten-acre fires in San Bernardino are 101 km from Mateo Court — inside the
|
||
* layer's 120 km reach — and contribute a load of about **0.0025**: a quarter of
|
||
* one per cent of a haze change, which is nothing anybody can see, under a
|
||
* sentence that would have read "Thin smoke". That is a caption describing
|
||
* something the picture does not contain, which is the failure this whole round
|
||
* is arranged against.
|
||
*
|
||
* So the **rig** still gets the true scalar — continuous, no pop, and at 0.0025
|
||
* indistinguishable from zero — and the **words** wait until there is something
|
||
* to say. Calibrated from the layer's own model rather than by eye: a
|
||
* 500-acre fire 60 km away lands at 0.14 and a 3,600-acre fire 20 km away at
|
||
* 0.60, so this threshold sits an order of magnitude under the smallest fire
|
||
* anybody would call smoke and an order of magnitude over the largest one
|
||
* nobody would.
|
||
*/
|
||
const SMOKE_STATED_LOAD = 0.08;
|
||
|
||
/** The room's smoke disclosure, or `null` on a day the room cannot tell. */
|
||
function officeSmokeNote(): string | null {
|
||
const site = officePack?.site;
|
||
if (site === undefined) return null;
|
||
const load = smokeLoadAtSite(site);
|
||
return load >= SMOKE_STATED_LOAD ? smokeCaption(load) : null;
|
||
}
|
||
|
||
/**
|
||
* Whether the board's fire panel is on screen right now.
|
||
*
|
||
* Two rules, and the second one is a judgement made from a picture. Outside a
|
||
* building the panel is always up on a board that draws fire, because "nothing
|
||
* is burning here" is a fact about that board and the whole point is that it is
|
||
* *stated* rather than inferred from an absence.
|
||
*
|
||
* **Inside a building it appears only when the fires are actually in that
|
||
* building's sky.** A five-item list of incidents three hundred kilometres away
|
||
* pushed "Back to the city" below the fold of the LA office's panel on the first
|
||
* frame a visitor sees of the room — a board-level instrument crowding out the
|
||
* room's own controls. When the courtyard really does go brown, the list is the
|
||
* explanation for what is on screen and it belongs there; `smokeCaption` in the
|
||
* office note says the same thing in one sentence either way.
|
||
*
|
||
* `#fire-section` is this file's element, not `mount.ts`'s. See where it is
|
||
* mounted.
|
||
*/
|
||
function showFireSection(): void {
|
||
const section = document.querySelector<HTMLElement>("#fire-section");
|
||
if (section === null) return;
|
||
const site = officePack?.site;
|
||
const wanted =
|
||
firePanel !== null &&
|
||
(!inside || (site !== undefined && smokeLoadAtSite(site) >= SMOKE_STATED_LOAD));
|
||
if (section.hidden === !wanted) return;
|
||
section.hidden = !wanted;
|
||
}
|
||
|
||
// ---- The sea, and the sky over it -----------------------------------------
|
||
|
||
/**
|
||
* The seed the harbour is modelled from.
|
||
*
|
||
* Fixed, and that is the whole point: two visitors on two continents see the
|
||
* same ships in the same berths, and a capture script that shoots the Southland
|
||
* twice gets the same photograph. Everything downstream of it is a hash of this
|
||
* and a stable string — a berth id, a port id — and never `Math.random`.
|
||
*/
|
||
const HARBOUR_SEED = 115;
|
||
|
||
/**
|
||
* The harbour, rebuilt as a **new fix** rather than nudged along.
|
||
*
|
||
* `modelHarbour` stands in for a feed this deployment does not have, and the
|
||
* feed's shape is what sets the cadence here. Upstream listens for thirty
|
||
* seconds every fifteen minutes, so a hull under way has moved about five
|
||
* kilometres between two reports; `engine/vessels.ts` is licensed to dead-reckon
|
||
* along the reported course for exactly that long and then stops. Rebuilding on
|
||
* the same interval is therefore not a smoothing trick — it is the feed's own
|
||
* behaviour — and the small jump a moving hull makes when a new body lands is
|
||
* the jump a real fix makes. Splining it away would be inventing the positions
|
||
* in between, which is the one thing this layer refuses to do.
|
||
*
|
||
* The clock is the app's instant rather than the wall clock, so a scrubbed sky
|
||
* and the harbour under it describe the same moment, and `promoteVessels` is
|
||
* handed the same number so the body's age is what it actually is: zero.
|
||
*/
|
||
function refreshHarbour(atMs: number): void {
|
||
if (vesselBounds === null) return;
|
||
vesselsBody = modelHarbour(vesselPorts, { seed: HARBOUR_SEED, atMs });
|
||
harbourAtMs = atMs;
|
||
/**
|
||
* Through the gate, never around it.
|
||
*
|
||
* The modelled body is a `VesselsBody` and it goes through the same
|
||
* `promoteVessels` a live AIS body would: the three sentinels are re-checked,
|
||
* the hulls are clipped to this board, a moored one takes its bearing from the
|
||
* berth it is lying on, and the four suppression counters are what
|
||
* `vesselSummary` writes its sentence from. The day `/vessels` exists, the
|
||
* only line in this file that changes is the one above.
|
||
*/
|
||
const promotion = promoteVessels(vesselsBody, vesselBounds, vesselBerths, atMs);
|
||
city?.setVessels(promotion.drawn);
|
||
seaNote = vesselSummary(promotion);
|
||
}
|
||
|
||
/** A new fix when the declared interval has passed, in either direction. */
|
||
function tickHarbour(atMs: number): void {
|
||
if (vesselBounds === null) return;
|
||
const intervalMs = Math.max(60, vesselsBody?.intervalSeconds ?? 900) * 1000;
|
||
// Absolute, because the clock on this page can be dragged backwards.
|
||
if (Math.abs(atMs - harbourAtMs) < intervalMs) return;
|
||
refreshHarbour(atMs);
|
||
}
|
||
|
||
/**
|
||
* Ask for the rain and the birds, at most one request each per TTL.
|
||
*
|
||
* Gated on `/health`'s `sources`, exactly as the fire watch is: on a deployment
|
||
* that serves neither — which is this repo's default and every clone — nothing
|
||
* is requested at all and the two gates still have something true to say, so the
|
||
* panel reads "no radar feed is configured" rather than an ambiguous silence.
|
||
*
|
||
* There is no watcher class behind this on purpose. Radar is a five-minute
|
||
* composite and BirdCast a ten-minute one; the page already has a once-a-minute
|
||
* clock, and a fourth polling ladder in `adapters/http.ts` to re-ask a question
|
||
* whose answer changes at most twelve times an hour would be machinery bought
|
||
* for nothing.
|
||
*/
|
||
async function askSky(): Promise<void> {
|
||
if (skyInFlight) return;
|
||
const wantsRadar = access.feeds?.radar === true;
|
||
const wantsBirds = access.feeds?.birds === true;
|
||
if (!wantsRadar && !wantsBirds) return;
|
||
skyInFlight = true;
|
||
skyAskedAtMs = Date.now();
|
||
try {
|
||
const [radar, birds] = await Promise.all([
|
||
wantsRadar ? tera.radar() : Promise.resolve(null),
|
||
wantsBirds ? tera.birds() : Promise.resolve(null),
|
||
]);
|
||
if (wantsRadar) radarBody = radar;
|
||
if (wantsBirds) birdsBody = birds;
|
||
} finally {
|
||
skyInFlight = false;
|
||
}
|
||
// Straight onto the board rather than at the next minute: a scan that landed
|
||
// is the only thing on this page that can change what the sky looks like
|
||
// without the clock moving.
|
||
updateSun();
|
||
}
|
||
|
||
/**
|
||
* Both sky gates, run against whatever has answered.
|
||
*
|
||
* `nowMs` is the **wall** clock and not the app's instant, because `fetchedAt`
|
||
* is a real timestamp and "last scan 5 minutes old" has to stay true when
|
||
* somebody scrubs the sky to midnight. `solarElevationDeg` is the opposite: it
|
||
* is the elevation the board is *lit* by, because the migration gate is a
|
||
* daylight gate and a layer that disagreed with the sky about whether it is
|
||
* night is precisely the failure that made the first version of it draw nothing
|
||
* at 04:35 while passing its own tests.
|
||
*
|
||
* Both messages are always full sentences, including — especially — on the day
|
||
* nothing is falling and nothing is aloft, which is most days.
|
||
*/
|
||
function applySky(solarElevationDeg: number, nowMs: number): void {
|
||
const radar = promoteRadar(radarBody, nowMs);
|
||
const birds = promoteBirds(birdsBody, { nowMs, solarElevationDeg });
|
||
city?.setPrecip(radar.field);
|
||
city?.setMigration(birds.field);
|
||
radarNote = radar.message;
|
||
birdsNote = birds.message;
|
||
// The next scan, when this one has expired. `askSky` stamps its own clock
|
||
// before the request, so this cannot re-enter while one is in the air.
|
||
const ttlMs = Math.max(60, radarBody?.ttlSeconds ?? 0, birdsBody?.ttlSeconds ?? 0) * 1000;
|
||
if (nowMs - skyAskedAtMs >= ttlMs) void askSky();
|
||
}
|
||
|
||
/**
|
||
* The two board notes, drawn the way `showFireSection` draws the fire panel.
|
||
*
|
||
* `#sea-section` and `#sky-section` are this file's elements rather than
|
||
* `mount.ts`'s, the same arrangement `#fire-section` and `#presence-host` have:
|
||
* their content is a board's own instrument reading, they are rebuilt per board,
|
||
* and they are hidden outright on a board that has no harbour and no statewide
|
||
* raster. Hidden inside a building for the reason the fire list is: a room's
|
||
* panel belongs to the room.
|
||
*/
|
||
function showBoardNotes(): void {
|
||
const sea = !inside && vesselBounds !== null ? seaNote : "";
|
||
writeBoardNote("sea-section", [["sea-note", sea]]);
|
||
const sky = !inside && boardCarriesSky;
|
||
writeBoardNote("sky-section", [
|
||
["radar-note", sky ? radarNote : ""],
|
||
["birds-note", sky ? birdsNote : ""],
|
||
]);
|
||
}
|
||
|
||
/** One section, its paragraphs, and the rule that an empty one is not shown. */
|
||
function writeBoardNote(
|
||
sectionId: string,
|
||
notes: readonly (readonly [string, string])[],
|
||
): void {
|
||
const section = document.querySelector<HTMLElement>(`#${sectionId}`);
|
||
if (section === null) return;
|
||
let any = false;
|
||
for (const [id, text] of notes) {
|
||
if (text !== "") any = true;
|
||
const line = document.querySelector<HTMLElement>(`#${id}`);
|
||
if (line === null) continue;
|
||
// Compared before it is written, like `mount.ts`'s applier: this runs on
|
||
// every `renderChrome` and a `textContent` write is a layout invalidation.
|
||
if (line.textContent !== text) line.textContent = text;
|
||
if (line.hidden === (text !== "")) line.hidden = text === "";
|
||
}
|
||
if (section.hidden === !any) return;
|
||
section.hidden = !any;
|
||
}
|
||
|
||
// ---- Cities ---------------------------------------------------------------
|
||
|
||
/**
|
||
* Switching boards no longer tears the old one down.
|
||
*
|
||
* The board on screen stays on screen, fully interactive, for the whole of the
|
||
* next board's build; the swap happens afterwards, under a fog dip, and the
|
||
* outgoing board is **kept** so coming back to it costs nothing. See
|
||
* `MountedBoard` for what is retained and what is not, and `engine/boards.ts`
|
||
* for the measurements that say two resident boards are free and three are
|
||
* provably enough.
|
||
*
|
||
* The opaque boot card is now the *first* mount of a session only. A switch
|
||
* after that shows a small non-modal pill and leaves the picture alone, which is
|
||
* the whole of what the owner can feel.
|
||
*/
|
||
async function mountCity(id: string): Promise<void> {
|
||
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 board 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.
|
||
*
|
||
* It does **not** touch a board that is already resident. An abort cancels a
|
||
* build, and a built board belongs to the cache from the moment it lands in
|
||
* it.
|
||
*/
|
||
/*
|
||
* The background lane goes first, and the order is the whole guard.
|
||
*
|
||
* `createScene` awaits a heightfield the terrain worker is building; if the
|
||
* prefetch is still holding that worker when a real request arrives, the
|
||
* foreground build queues behind work nobody asked for. Aborting it here —
|
||
* before `mounting?.abort()`, before anything else — is what makes a visitor's
|
||
* click always the most important thing in the process.
|
||
*/
|
||
cancelPrefetch();
|
||
mounting?.abort();
|
||
const mount = new AbortController();
|
||
mounting = mount;
|
||
wantedCity = id;
|
||
|
||
const resident = boards.get(id);
|
||
if (resident !== null) {
|
||
// Nothing to build and nothing to wait for: the shader programs, the
|
||
// heightfield and every instance buffer are still on the GPU. Measured, a
|
||
// second visit to the Bay Area used to cost 1,664 ms against the first
|
||
// mount's 1,779 — three refcounts programs per material and deletes at
|
||
// zero, so disposal was relinking every shader on the way back.
|
||
await presentBoard(resident);
|
||
return;
|
||
}
|
||
|
||
showSwitchProgress(entry.label, null);
|
||
try {
|
||
const record = await buildBoard(entry, mount);
|
||
if (record === null) {
|
||
pendingPlace = null;
|
||
hideSwitchProgress();
|
||
return;
|
||
}
|
||
if (mount.signal.aborted || wantedCity !== id) {
|
||
// Somebody changed their mind while this was in the air. It is a complete,
|
||
// correct board — but nothing asked for it, and parking an unrequested
|
||
// board in a cache with a ceiling would evict one somebody did ask for.
|
||
disposeBoard(record);
|
||
pendingPlace = null;
|
||
hideSwitchProgress();
|
||
return;
|
||
}
|
||
await presentBoard(record);
|
||
} finally {
|
||
/*
|
||
* A finished mount is not a mount in flight.
|
||
*
|
||
* `mounting` used to be replaced and never cleared, which was harmless while
|
||
* the only thing that read it was the next `mountCity` wanting something to
|
||
* abort — an already-resolved controller aborts nothing and costs nothing.
|
||
* It stopped being harmless the moment a second lane asked the obvious
|
||
* question "is a foreground build happening right now?", because the honest
|
||
* answer after the first mount of the session was permanently "yes". The
|
||
* background lane armed exactly never, and the only symptom was a feature
|
||
* that silently did nothing.
|
||
*
|
||
* Guarded on identity: a build that was superseded must not clear the
|
||
* controller belonging to the build that superseded it.
|
||
*/
|
||
if (mounting === mount) mounting = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build a board without touching the one on screen.
|
||
*
|
||
* Everything in here is either expensive or derived from the pack, which is the
|
||
* test for what belongs to a `MountedBoard`. Nothing in here reads `cityId`,
|
||
* `city`, `atmosphere` or `minimap` — those are the *visible* board's and the
|
||
* visible board is still the outgoing one for the whole of this function. That
|
||
* separation is what makes building behind a live picture possible at all, and
|
||
* it is the one invariant to keep if this function grows.
|
||
*/
|
||
async function buildBoard(
|
||
entry: { id: string; label: string; city: City },
|
||
mount: AbortController,
|
||
options: {
|
||
/**
|
||
* Build without saying so.
|
||
*
|
||
* The background lane needs this and nothing else does. `onProgress` below
|
||
* ends in `bootProgress`, which raises the switch pill — so a board loaded
|
||
* ahead of the camera would put "Building The Bay Area… terrain 42%" on
|
||
* screen over a visitor who did nothing, which is exactly the "you are
|
||
* somewhere else now" chrome that removing the tab strip and the boot card
|
||
* was for. A prefetch that announces itself is worse than no prefetch.
|
||
*/
|
||
quiet?: boolean;
|
||
} = {},
|
||
): Promise<MountedBoard | null> {
|
||
const id = entry.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 only one of them is about the visitor. `can.liveEnvironment`
|
||
* is true for everybody — the sky is not a thing an account can grant. What is
|
||
* load-bearing is `feeds`, the *deployment*, which stops the ordinary box
|
||
* where every source is `none` polling two endpoints forever for a 404.
|
||
*
|
||
* These used to be gated on the *markers* flag, which is a different feed
|
||
* entirely, so a deployment with a real ADS-B receiver and no marker file flew
|
||
* the simulator.
|
||
*/
|
||
/*
|
||
* One sky region per detailed source, not one circle around the board.
|
||
*
|
||
* `regionOf` derives its circle from the board's bounds, which is right for a
|
||
* metro board and wrong for the merged one: 402 nautical miles centred on the
|
||
* middle of the state, which the API refuses with `400 bad_request, "Nothing
|
||
* this deployment serves is near 37.3,-119.25"` — correctly, since what it
|
||
* serves is San Francisco and the Southland, five hundred and sixty
|
||
* kilometres apart. `region` stays the board's own circle for everything that
|
||
* wants one shape (the traffic dial, the sample generator); `skyRegions` is
|
||
* what the live feed is actually asked for.
|
||
*/
|
||
const region = regionOf(entry.city);
|
||
const skyRegions =
|
||
ONE_CALIFORNIA && entry.id === "california"
|
||
? UNIFIED_SOURCES.map((source) => regionOf(source))
|
||
: [region];
|
||
// 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);
|
||
// The public traffic API is region-oriented and intentionally capped around
|
||
// one metro. A state-wide request would either be rejected or become a data
|
||
// vacuum, so California keeps the honest deterministic sky while its two
|
||
// detailed boards continue to use live ADS-B when available.
|
||
const traffic =
|
||
carriesMetroDetail(entry.city) && access.can.liveEnvironment && access.feeds?.flights
|
||
? skyRegions.length === 1
|
||
? tera.flights(region, routes)
|
||
: mergedTraffic(skyRegions.map((r) => tera.flights(r, routes)))
|
||
: null;
|
||
|
||
/**
|
||
* Started here and awaited below, so the fetch overlaps the heightfield build
|
||
* rather than following it. Gated on the deployment for the same reason the
|
||
* traffic is: a box with no satellite source answers with an empty catalogue,
|
||
* and asking it once per page load for that is a request nobody needs.
|
||
*
|
||
* Not gated on the visitor. There is no `can.` check because there is nothing
|
||
* to grant — the objects in this catalogue broadcast their positions to
|
||
* anybody with a radio, and every element set in it is a US Government work.
|
||
*/
|
||
if (satelliteElements === null && access.feeds?.satellites) {
|
||
satelliteElements = tera.satellites();
|
||
}
|
||
const elements = (await satelliteElements) ?? [];
|
||
// Rebuilt per board: the observer is this city's centre. See the note on
|
||
// `satelliteElements` for why only the elements are shared.
|
||
const catalogue =
|
||
elements.length === 0 ? undefined : new SatelliteCatalogue(elements, entry.city.center);
|
||
// The build may have been abandoned while that was in the air.
|
||
if (mount.signal.aborted) {
|
||
traffic?.dispose();
|
||
return null;
|
||
}
|
||
|
||
// Wrapped, not replaced: the dial passes the real sky through untouched and
|
||
// concatenates fabricated aircraft after it, so it composes with a live ADS-B
|
||
// feed as readily as with the simulator. `cityFlights` stays the unwrapped
|
||
// source — the corner label asks it whether what is on screen was observed,
|
||
// and the answer is about the feed rather than about the dial.
|
||
const dial = withTrafficDial(traffic ?? new SimulatedFlights(routes), region);
|
||
// Carried across the switch, so a dial somebody set on the last board is still
|
||
// set on this one. The dial it copies from is the *visible* board's, which is
|
||
// still on screen and still interactive while this build runs.
|
||
dial.setExtra(trafficDial?.extra() ?? 0);
|
||
|
||
/**
|
||
* Stable destinations are handed to the scene at construction time, not a
|
||
* frame later. A building glyph needs that head start so `blocks.ts` can
|
||
* reserve its footprint before the anonymous one-draw-call skyline is
|
||
* emitted; otherwise both buildings occupy the same address and the useful
|
||
* one is usually hidden inside the random one.
|
||
*
|
||
* A door belongs to the board it stands on. This used to be gated on a
|
||
* hard-coded `id === "sf"`; using the board's own bounds is what keeps Mateo
|
||
* Court on the Southland board and off the Bay Area one. Sample companies
|
||
* stay SF-only because that sample feed is about one city and always was.
|
||
*/
|
||
const bounds = entry.city.bounds;
|
||
const doors = OFFICE_MARKERS.filter(
|
||
(m) =>
|
||
m.lat >= bounds.minLat &&
|
||
m.lat <= bounds.maxLat &&
|
||
m.lng >= bounds.minLng &&
|
||
m.lng <= bounds.maxLng,
|
||
);
|
||
/*
|
||
* Bounds **and** metro detail — the doors' rule, plus the feeds' rule.
|
||
*
|
||
* The sample companies are San Francisco's, so on San Francisco's own board
|
||
* they appear and on the Southland's they do not; on a board that contains
|
||
* San Francisco *and draws it at metro fidelity* they appear too, which is
|
||
* what `cities/unify.ts` needed and what `id === "sf"` silently refused.
|
||
*
|
||
* Bounds alone is not enough, and the budget said so within one run: the
|
||
* coarse statewide board's rectangle contains San Francisco, so it picked up
|
||
* forty-four markers it has no business drawing at 1,919 m to the unit and
|
||
* went from 373 draw calls to 417. `carriesMetroDetail` is the same predicate
|
||
* the live feeds use and it is the same question — is this a board on which a
|
||
* building-sized thing means anything.
|
||
*/
|
||
const sampleMarkers = (carriesMetroDetail(entry.city) ? markers : []).filter(
|
||
(m) =>
|
||
m.lat >= bounds.minLat &&
|
||
m.lat <= bounds.maxLat &&
|
||
m.lng >= bounds.minLng &&
|
||
m.lng <= bounds.maxLng,
|
||
);
|
||
const initialMarkers = [...sampleMarkers, ...doors];
|
||
|
||
/**
|
||
* The two sky layers, decided here so the options object below reads as four
|
||
* sentences rather than as four nested conditionals.
|
||
*
|
||
* Two gates each, and they are the pair `drawsFire` uses. The **board** gate
|
||
* is `boardCarriesRaster`: both feeds are statewide instruments quantised to a
|
||
* quarter of a degree — sixteen WSR-88Ds and fifty-eight counties — and a
|
||
* rectangle ninety kilometres across cannot be described by a cell twenty-seven
|
||
* kilometres wide. It answers true for California and false for the Southland
|
||
* and the Bay, which is the same answer an `id === "california"` would have
|
||
* given and is a fact about the geometry rather than about a name.
|
||
*
|
||
* The **deployment** gate is `access.feeds`, read from `/health`'s `sources`.
|
||
* With no projection configured there is no body, no honest caption beyond the
|
||
* one the gate already writes, and nothing to draw — so the layer is withheld
|
||
* entirely rather than built and left empty, exactly as `fires` is. This repo's
|
||
* own default is `none` for both, which is why the panel sentence matters more
|
||
* than the layer.
|
||
*/
|
||
const carriesSky = boardCarriesRaster(entry.city.bounds);
|
||
const precipFactory = carriesSky && access.feeds?.radar === true
|
||
? precipFactoryFor(entry.city.bounds)
|
||
: null;
|
||
const drawsMigration = carriesSky && access.feeds?.birds === true;
|
||
const handle = await createScene(stage, {
|
||
city: entry.city,
|
||
// Bare URL on one California: land in the city, not on 1,551 km of state.
|
||
// `?city=california` (and every capture shot) still opens on chapters[0].
|
||
...(ONE_CALIFORNIA && CITY_PARAM === null ? { openingChapter: "fidi" } : {}),
|
||
// The page's one rig, shared with the office. Handed in rather than built
|
||
// per board; see the note where it is constructed.
|
||
environment,
|
||
actor: {
|
||
kind: actorKindForPresence(access.subject !== null, "outdoors"),
|
||
identity: access.subject === null
|
||
? {
|
||
id: "anonymous",
|
||
displayName: "Guest",
|
||
authenticated: false,
|
||
profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
|
||
}
|
||
: signedInActorIdentity(),
|
||
mode: access.subject === null ? "flight" : "ground",
|
||
position: access.subject === null ? { y: 1_200 } : { y: 0 },
|
||
minFlightAltitude: 20,
|
||
maxFlightAltitude: 1_500,
|
||
...(access.subject === null
|
||
? { camera: { distance: 2.55, height: 1.2, targetHeight: 0.1, lookAhead: 1.3 } }
|
||
: {}),
|
||
},
|
||
...(id === "california"
|
||
? { actorAnchor: { lat: 35.5, lng: -119.5 } }
|
||
: {}),
|
||
markerPalette: palette,
|
||
markers: initialMarkers,
|
||
...(id === "california"
|
||
? {
|
||
roadTraffic: {
|
||
pack: CALIFORNIA_TRANSPORT,
|
||
routeId: "la-sf-us-101",
|
||
count: 14,
|
||
seed: 115,
|
||
},
|
||
aircraft: {
|
||
route: CALIFORNIA_AIR_ROUTE,
|
||
initialPosition: CALIFORNIA_AIR_ROUTE[0],
|
||
initialAltitudeM: CALIFORNIA_AIR_ROUTE[0]?.altitudeM ?? 1_350,
|
||
initialHeadingDeg: 320,
|
||
assistedAltitudeM: 1_500,
|
||
// Corridor altitude is an atlas glyph just like the black cars:
|
||
// literal metres would put the chase camera inside the coarse hills.
|
||
altitudeSceneUnitsPerMetre: 0.01,
|
||
visualSceneUnitsPerMetre: 0.22,
|
||
camera: { distance: 52, height: 18, lookAhead: 18 },
|
||
},
|
||
}
|
||
: {}),
|
||
flights: dial.source,
|
||
...(catalogue ? { satellites: catalogue } : {}),
|
||
/**
|
||
* Fire, on the two boards where fire happens and nowhere else.
|
||
*
|
||
* Two gates and, unusually here, neither is about the visitor. `drawsFire`
|
||
* is about the *map*: the Bay Area rectangle has held zero incidents on
|
||
* every day this store has existed, and it is the board already spending
|
||
* the largest frame-time allowance in the product, so it does not pay for a
|
||
* layer to draw nothing. `feeds.fires` is about the *deployment*: with no
|
||
* projection configured there is no body to draw and no honest caption to
|
||
* write, and a layer with an empty view is a layer allocated for nothing.
|
||
*
|
||
* Withheld rather than passed-and-emptied, because `scene.ts` builds no
|
||
* group, no material and no draw call when this is absent — see
|
||
* `SceneOptions.fires`.
|
||
*/
|
||
...(drawsFire(id) ? { fires: createFireLayer } : {}),
|
||
/**
|
||
* The port kit, on the one board that declares a port.
|
||
*
|
||
* The ports are `city.ports` — plain authored data, closed over here rather
|
||
* than passed through `SceneOptions`, because `scene.ts` must not have to
|
||
* know what a quay is. Withheld entirely on a board with none, so
|
||
* `engine/ports.ts` never enters the graph of a build that draws no port and
|
||
* a portless board costs no group, no material and no draw call.
|
||
*/
|
||
...(entry.city.ports?.length
|
||
? { ports: (world, options) => createPortLayer(world, entry.city.ports ?? [], options) }
|
||
: {}),
|
||
/**
|
||
* The hulls, on the same boards as the quays and for the same reason.
|
||
*
|
||
* Gated on the *ports*, not on a feed: a ship is drawn against a berth, a
|
||
* channel and a breakwater, and a hull on a board with none of them is a
|
||
* white shape on open water with nothing to say what it is doing. Withheld
|
||
* rather than passed-and-emptied, like every layer above it.
|
||
*
|
||
* What fills it is `refreshHarbour`, below — a modelled body through the
|
||
* live gate, never a hull invented at the renderer.
|
||
*/
|
||
...(entry.city.ports?.length ? { vessels: createVesselLayer } : {}),
|
||
/** Reflectivity, on a board a quarter-degree cell can describe. */
|
||
...(precipFactory ? { precip: precipFactory } : {}),
|
||
/** Tonight's migration, on the same board and under the same argument. */
|
||
...(drawsMigration ? { migration: createMigrationLayer } : {}),
|
||
/**
|
||
* A pin is a hover *and* a click, and an office pin is a door.
|
||
*
|
||
* `onMarkerPick` fires for both — `scenekit`'s picking calls it on hover
|
||
* with the marker and on leave with `null` — so this cannot simply open a
|
||
* building on every call or the office would fly open the moment the pointer
|
||
* crossed a tower. The hover shows the card; the click is a separate
|
||
* listener below, which reads whatever the hover last resolved.
|
||
*/
|
||
onMarkerPick: (m) => {
|
||
hoveredMarker = m;
|
||
showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null);
|
||
},
|
||
/**
|
||
* The other thing worth pointing at, and the one the owner asked for by
|
||
* name: a card per aeroplane, for anybody who lands on this page.
|
||
*
|
||
* Hover opens it and hover away closes it, which is the same contract the
|
||
* marker card has had since picking existed — there is no click handler
|
||
* here because there is nothing extra a click could mean. See
|
||
* `showAircraftDetail` for where the provenance comes from.
|
||
*/
|
||
onAircraftPick: (a) => showAircraftDetail(a),
|
||
signal: mount.signal,
|
||
/**
|
||
* Built, not shown. `presentBoard` puts it on the stage once the fog has
|
||
* closed over the board the visitor is still looking at — see
|
||
* `SceneOptions.present`.
|
||
*/
|
||
present: false,
|
||
// 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 (options.quiet) return;
|
||
if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
|
||
},
|
||
...(access.subject !== null
|
||
? { realtimePeers: { localSceneUnitsPerMetre: entry.city.latScale / 111_320 } }
|
||
: {}),
|
||
});
|
||
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();
|
||
return null;
|
||
}
|
||
// A new board builds a new layer, and a new layer starts visible. Reapply
|
||
// whatever the panel last said, or the setting silently undoes itself on the
|
||
// first board switch.
|
||
handle.setSatellitesVisible(satellitesVisible);
|
||
|
||
if (carriesSky) void askSky();
|
||
|
||
// 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] = handle.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
|
||
const [ex, sz] = handle.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
|
||
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
|
||
|
||
const atmosphere = createAtmosphere({
|
||
lng: entry.city.center.lng,
|
||
metresPerUnit: handle.world.metresPerUnit,
|
||
// Pushed out with the camera. `scene.ts` now lets the orbit reach 2.0 spans
|
||
// so the viewer can get above the satellite dome, and at the old far of 2.8
|
||
// the board sat at 51% fog from that pose — the whole city washing out at
|
||
// exactly the moment the shot is meant to be the city under the
|
||
// constellation. 3.9 keeps it near a fifth, which is the haze it had at the
|
||
// old limit.
|
||
clearFog: { near: span * 1.15, far: span * 3.9 },
|
||
// 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 * handle.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,
|
||
});
|
||
/**
|
||
* Recompute the **fog** when the camera moves, not only when the clock does.
|
||
*
|
||
* Throttled on the altitude itself rather than on time: `change` fires every
|
||
* frame under damping and building a rig walks a keyframe table and a lunar
|
||
* ephemeris, which is not work to do sixty times a second for a number that
|
||
* has not moved. Two per cent of the current altitude is under the threshold
|
||
* at which the fog planes visibly shift, and it collapses a whole chapter
|
||
* flight to a few dozen recomputations.
|
||
*
|
||
* `applyCameraFog` and not `updateSun`, and not `setLighting` either. The
|
||
* clock tick ends in `renderChrome()`, a DOM pass with no business running
|
||
* because somebody dragged the board; and the full rig ends in six layer
|
||
* setters and the environment rig's fingerprint, none of which read a
|
||
* distance. See `applyCameraFog` for the whole argument.
|
||
*/
|
||
|
||
handle.onChapterChange(() => renderChrome());
|
||
handle.onControlModeChange((mode) => adoptCityControlMode(mode));
|
||
|
||
const minimap = createMinimap({
|
||
world: handle.world,
|
||
// The pack the *World* is drawing, not the one the module imported. These
|
||
// are the same object whenever no reconciliation rule is on, and when one
|
||
// is, the plan view drawn from the raw pack is a plan of a different
|
||
// California than the one under the camera.
|
||
city: handle.world.city,
|
||
// The plan stops changing shape when the board does. See `planFrame`.
|
||
frame: planFrame(handle.world.city),
|
||
camera: handle.stageScene.camera,
|
||
controls: handle.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 || city.controlMode() !== "overview") 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}` : ""}`
|
||
: "";
|
||
},
|
||
});
|
||
/**
|
||
* The plan's markers, and why this line is easy to lose.
|
||
*
|
||
* While the minimap was a module-level singleton this was `setMarkers` on the
|
||
* one plan there was. Making it per-board moved the construction into this
|
||
* function and the call went with it — except it did not, and the plan shipped
|
||
* with an empty marker layer on every board. It is invisible to every test and
|
||
* to the console: the plan still draws, still shades, still tracks the camera,
|
||
* and simply has no doors on it. The photographs are what found it — the
|
||
* Southland's orange Mateo Court dot and the Bay Area's cluster of sample
|
||
* companies, both present at `bcac6aa` and both gone.
|
||
*
|
||
* A door is one of the two ways anybody finds a studio, so this is the layer
|
||
* that matters most of the three. `MountedBoard.markers` keeps the same array
|
||
* for a board that is evicted and rebuilt.
|
||
*/
|
||
minimap.setMarkers(initialMarkers);
|
||
|
||
return {
|
||
id,
|
||
entry,
|
||
handle,
|
||
minimap,
|
||
atmosphere,
|
||
span,
|
||
flights: traffic,
|
||
trafficDial: dial,
|
||
fireBounds: drawsFire(id) ? entry.city.bounds : null,
|
||
carriesSky,
|
||
vesselsBody: null,
|
||
vesselBounds: entry.city.ports?.length ? entry.city.bounds : null,
|
||
vesselBerths: entry.city.ports?.length ? berthAnchors(entry.city.ports) : [],
|
||
vesselPorts: entry.city.ports ?? [],
|
||
harbourAtMs: 0,
|
||
markers: initialMarkers,
|
||
arrived: false,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Make a resident board the visible one, hiding the seam inside a fog dip.
|
||
*
|
||
* ## The order, which is the whole of it
|
||
*
|
||
* The outgoing board's aerial fog collapses toward the camera over
|
||
* `FOG_DIP_OUT_SECONDS`, so the last frame anybody sees of it is a flat field of
|
||
* its own horizon colour. The swap happens in that frame. The incoming board's
|
||
* fog then lifts from the same collapsed pair to whatever `Atmosphere` says the
|
||
* weather is, over `FOG_DIP_IN_SECONDS`.
|
||
*
|
||
* A **fog dip and not a crossfade**, and the argument is measured rather than
|
||
* aesthetic — see `FOG_DIP_OUT_SECONDS` in `engine/atmosphere.ts`, which carries
|
||
* both halves of it: every both-boards-live pairing breaks a desktop triangle
|
||
* cap, and a true dissolve is the one transition that puts the seam's 4.17x
|
||
* vertical deflation and its up-to-4,025 m registration slide on screen
|
||
* simultaneously and in register.
|
||
*
|
||
* `prefers-reduced-motion` collapses the whole thing to a cut, exactly as
|
||
* `arrive()` and `beginOfficeArrival` already do.
|
||
*/
|
||
async function presentBoard(record: MountedBoard): Promise<void> {
|
||
const outgoing = visibleBoard;
|
||
if (outgoing === record) {
|
||
hideSwitchProgress();
|
||
return;
|
||
}
|
||
|
||
const dips = outgoing !== null && !prefersReducedMotion();
|
||
if (dips && outgoing !== null) await runFogDip(outgoing, "out");
|
||
|
||
deactivateBoard();
|
||
// The office belongs to the board it was opened from. Kept until here rather
|
||
// than dropped at the top of the switch, so the outgoing board stays whole for
|
||
// the whole of the build behind it.
|
||
disposeLoadedOffice();
|
||
inside = false;
|
||
clearPublishedPlayInput();
|
||
controlModeState = createControlModeState();
|
||
|
||
adoptBoard(record);
|
||
stage.setScene(record.handle.stageScene);
|
||
|
||
/**
|
||
* The cache is written **here**, at the moment a board becomes the visible
|
||
* one, and not when its build finished.
|
||
*
|
||
* The ordering is load-bearing on a handheld and it was wrong once. With a
|
||
* ceiling of two and California pinned, inserting the incoming board while the
|
||
* outgoing one was still on the stage made the outgoing board the only legal
|
||
* victim — so a Bay-Area-to-Southland switch on a phone disposed the picture
|
||
* the visitor was looking at, blanked the canvas, and then swapped, which is
|
||
* precisely the teardown this whole workstream exists to remove. Writing the
|
||
* cache after `stage.setScene` means the least-recently-*shown* board is the
|
||
* one that has just been hidden, which is both the correct victim and a board
|
||
* nothing is drawing.
|
||
*/
|
||
for (const evicted of boards.put(record.id, record)) disposeBoard(evicted);
|
||
|
||
activateBoard(record);
|
||
hideSwitchProgress();
|
||
publishCameraHook(record);
|
||
/*
|
||
* Arriving is itself a reason to look ahead.
|
||
*
|
||
* The camera listeners below arm the lane as the visitor moves, but landing on
|
||
* a board is a pose change nothing fired a `change` event for — and it is the
|
||
* single most likely moment for the next board to already be worth having. The
|
||
* old prefetch, if any, was cancelled by `mountCity` before this build began.
|
||
*/
|
||
maybePrefetch(record);
|
||
|
||
// Before the lift, so the incoming board's first drawn frame is already inside
|
||
// the haze the outgoing one ended in rather than snapping to clear and then
|
||
// fogging up.
|
||
if (dips) record.handle.setAerialFog(collapsedFogFor(record));
|
||
|
||
attachCurrentWebcamFace();
|
||
moveRealtimePresence();
|
||
showPlan();
|
||
mountPoseEditor(record.handle);
|
||
refreshGodmodePlace();
|
||
updateSun();
|
||
renderChrome();
|
||
|
||
/**
|
||
* The opening move, on the first showing of a board and not on a return.
|
||
*
|
||
* Arriving somewhere is what a first sight of a board is, and it is why every
|
||
* mount used to play one. A *return* is a different thing: the point of
|
||
* keeping a board is that you come back to the pose you left it at, and an
|
||
* arrival would throw that away to re-stage a shot the visitor has already
|
||
* seen. Captures are unaffected — every harness loads a fresh page with
|
||
* `?city=`, which is always a first showing.
|
||
*
|
||
* After `updateSun`, so the first frame is lit by the real sky rather than by
|
||
* the opening climatology the scene was built with, and after `showPlan` and
|
||
* `renderChrome` so the move plays over a finished page.
|
||
*/
|
||
const queued = pendingPlace;
|
||
pendingPlace = null;
|
||
if (queued !== null && queued.board === record.id) {
|
||
// A rung on this board was what the switch was *for*, so it replaces the
|
||
// opening move rather than following it — an arrival and then a flight to
|
||
// somewhere else is two camera moves for one click.
|
||
record.arrived = true;
|
||
flyToChapter(queued.id);
|
||
} else if (!record.arrived) {
|
||
record.arrived = true;
|
||
record.handle.arrive();
|
||
}
|
||
|
||
if (dips) await runFogDip(record, "in");
|
||
}
|
||
|
||
/**
|
||
* The fog dip in progress, or `null`. Driven by the frame pump.
|
||
*
|
||
* A promise rather than a callback because `presentBoard` reads as a sequence —
|
||
* collapse, swap, lift — and that is exactly what it is.
|
||
*/
|
||
let fogDip:
|
||
| {
|
||
record: MountedBoard;
|
||
from: AerialFog;
|
||
to: AerialFog;
|
||
elapsed: number;
|
||
seconds: number;
|
||
settle: () => void;
|
||
}
|
||
| null = null;
|
||
|
||
function runFogDip(record: MountedBoard, phase: "out" | "in"): Promise<void> {
|
||
// Whoever was dipping is superseded, and has to be *told* — a second click
|
||
// inside the first switch's 0.8 s replaces `fogDip`, and an orphaned promise
|
||
// leaves the first `presentBoard` suspended at its own `await` for the rest of
|
||
// the page's life. Settling it lets that call run to its end and find nothing
|
||
// left to do, which is the correct outcome of "last click wins".
|
||
endFogDip();
|
||
const clear = aerialFogFor(record);
|
||
const hidden = collapsedFogFor(record);
|
||
const from = phase === "out" ? clear : hidden;
|
||
const to = phase === "out" ? hidden : clear;
|
||
record.handle.setAerialFog(from);
|
||
return new Promise<void>((resolve) => {
|
||
fogDip = {
|
||
record,
|
||
from,
|
||
to,
|
||
elapsed: 0,
|
||
seconds: phase === "out" ? FOG_DIP_OUT_SECONDS : FOG_DIP_IN_SECONDS,
|
||
settle: resolve,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** One frame of the dip. Called from the app's pump; a no-op when none is running. */
|
||
function stepFogDip(dt: number): void {
|
||
const dip = fogDip;
|
||
if (dip === null) return;
|
||
dip.elapsed += dt;
|
||
const t = dip.seconds <= 0 ? 1 : Math.min(1, dip.elapsed / dip.seconds);
|
||
dip.record.handle.setAerialFog(dipFog(dip.from, dip.to, t));
|
||
if (t < 1) return;
|
||
endFogDip();
|
||
}
|
||
|
||
/** Finish whatever dip is running, wherever it had got to. Idempotent. */
|
||
function endFogDip(): void {
|
||
const dip = fogDip;
|
||
if (dip === null) return;
|
||
fogDip = null;
|
||
dip.settle();
|
||
}
|
||
|
||
/**
|
||
* How far this board's camera is standing back, in this board's scene units,
|
||
* floored so a camera sitting on its own target still has a fog to collapse to.
|
||
*
|
||
* The floor is a twentieth of the board's span rather than a constant, because a
|
||
* constant would be a distance in whichever board's units it was written for.
|
||
*/
|
||
function collapsedFogFor(record: MountedBoard): AerialFog {
|
||
const reach =
|
||
record.handle.cameraStandoffMetres() / record.handle.world.metresPerUnit;
|
||
return collapsedFog(Math.max(record.span * 0.05, reach));
|
||
}
|
||
|
||
/**
|
||
* The fog this board would have if nothing were dipping — the pair the clock and
|
||
* the camera agree on.
|
||
*
|
||
* Routed through the board's own `Atmosphere` rather than through the visible
|
||
* one's, because `createAtmosphere` takes `metresPerUnit` and a span-derived
|
||
* clear pair and both are per-board. A board that came back under the wrong
|
||
* atmosphere would render with fog planes tuned for a board twenty times its
|
||
* size, which on California is the difference between a map and a uniform wash.
|
||
*/
|
||
function aerialFogFor(record: MountedBoard): AerialFog {
|
||
const env = observe(
|
||
record.entry.city.center.lat,
|
||
record.entry.city.center.lng,
|
||
currentInstant(),
|
||
currentWeather(),
|
||
);
|
||
return record.atmosphere.aerial(env, {
|
||
altitudeMetres: record.handle.cameraAltitudeMetres(),
|
||
standoffMetres: record.handle.cameraStandoffMetres(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Project a record onto the module-level "the visible board" variables.
|
||
*
|
||
* The one function that decides what `city`, `cityId`, `atmosphere`, `minimap`
|
||
* and the six board readings mean. Everything else in this file reads those and
|
||
* is unchanged by retention, which is the point.
|
||
*/
|
||
function adoptBoard(record: MountedBoard): void {
|
||
visibleBoard = record;
|
||
city = record.handle;
|
||
cityId = record.id;
|
||
atmosphere = record.atmosphere;
|
||
minimap = record.minimap;
|
||
trafficDial = record.trafficDial;
|
||
cityFlights = record.flights;
|
||
fireBounds = record.fireBounds;
|
||
boardCarriesSky = record.carriesSky;
|
||
vesselsBody = record.vesselsBody;
|
||
vesselBounds = record.vesselBounds;
|
||
vesselBerths = record.vesselBerths;
|
||
vesselPorts = record.vesselPorts;
|
||
harbourAtMs = record.harbourAtMs;
|
||
seaNote = "";
|
||
radarNote = "";
|
||
birdsNote = "";
|
||
}
|
||
|
||
/**
|
||
* Stop everything the *visible* board was subscribed to, and leave the board
|
||
* itself alone.
|
||
*
|
||
* The distinction is the reason retention is safe. A `/weather` request for San
|
||
* Francisco that lands after the visitor has moved to the Southland would put
|
||
* the marine layer over Long Beach; the fire panel is mounted into chrome this
|
||
* file does not own and would otherwise accumulate one copy per resident board;
|
||
* and the camera fog listener is bound to one board's `OrbitControls`. None of
|
||
* those are things a paused board should keep doing. Its geometry, its
|
||
* heightfield, its shader programs and its pose all stay exactly where they are.
|
||
*/
|
||
function deactivateBoard(): void {
|
||
const record = visibleBoard;
|
||
if (record !== null) {
|
||
record.vesselsBody = vesselsBody;
|
||
record.harbourAtMs = harbourAtMs;
|
||
}
|
||
weatherWatch?.stop();
|
||
weatherWatch = null;
|
||
fireWatch?.stop();
|
||
fireWatch = null;
|
||
firePanel?.dispose();
|
||
firePanel = null;
|
||
cameraFogWatch?.();
|
||
cameraFogWatch = null;
|
||
poseEditor?.destroy();
|
||
poseEditor = null;
|
||
}
|
||
|
||
/** Subscribe the board that has just come on screen. The inverse of `deactivateBoard`. */
|
||
function activateBoard(record: MountedBoard): void {
|
||
const { entry } = record;
|
||
const id = record.id;
|
||
|
||
/**
|
||
* The weather, started only now that the board is on screen.
|
||
*
|
||
* 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.
|
||
*/
|
||
// Weather observations are metro-scoped for the same reason live aircraft
|
||
// are: one honest station cannot describe a 600 km corridor. California uses
|
||
// the local climatology model; the detailed SF and SoCal boards keep their
|
||
// live observations.
|
||
//
|
||
/*
|
||
* And the merged board uses climatology too, which took a 400 in production
|
||
* to notice.
|
||
*
|
||
* `carriesMetroDetail` is true of it — it carries both metros' districts —
|
||
* so it asked, at `entry.city.center`, which is the middle of the *state*:
|
||
* `GET /api/v1/weather?lat=37.30&lng=-119.25` → `400 bad_request, "Nothing
|
||
* this deployment serves is near 37.3,-119.25"`. The API is right. What it
|
||
* serves is San Francisco and the Southland, five hundred and sixty
|
||
* kilometres apart, and the point midway between them is farm country in the
|
||
* Sierra foothills that no station on this deployment describes.
|
||
*
|
||
* The sky already had this problem and solved it by asking per source
|
||
* (`skyRegions`, above). Weather does not get the same answer, because the
|
||
* two feeds are not the same shape: aircraft from two regions *merge* — they
|
||
* are disjoint sets of objects in one sky — and two observations do not. One
|
||
* board cannot be simultaneously foggy at the Golden Gate and clear over
|
||
* Riverside, and picking either one and applying it statewide is a worse lie
|
||
* than the climatology model, which at least varies with latitude and season.
|
||
*
|
||
* So this follows the rule the comment above already states rather than
|
||
* inventing an exception to it: a board that spans more than one served
|
||
* region takes the model. The metros keep their live observations on their
|
||
* own boards, and the day the atmosphere is regional rather than per board,
|
||
* this is the line that changes.
|
||
*/
|
||
const spansManyWeatherRegions = ONE_CALIFORNIA && id === "california";
|
||
weatherWatch =
|
||
carriesMetroDetail(entry.city) &&
|
||
!spansManyWeatherRegions &&
|
||
access.can.liveEnvironment &&
|
||
access.feeds?.weather
|
||
? tera.watchWeather(entry.city.center, () => {
|
||
updateSun();
|
||
renderChrome();
|
||
})
|
||
: null;
|
||
|
||
/**
|
||
* And the fire, started the same way and for the same reason.
|
||
*
|
||
* The panel is mounted first and applied with `null` immediately, so the very
|
||
* first frame of a fire board already carries a sentence — "no fire feed has
|
||
* answered" rather than a blank space that a viewer would read as an
|
||
* all-clear. The watch then replaces it within a request.
|
||
*
|
||
* `entry.city.bounds` is handed to both halves and they use it for different
|
||
* things: `promote()` clips the drawn set to the frame, and the panel turns an
|
||
* off-board fire into "342 km north of this frame" instead of "somewhere
|
||
* else". One rectangle, so the picture and the caption cannot disagree.
|
||
*/
|
||
// `#fire-section` and `#fire-host` are this file's, not `mount.ts`'s — the
|
||
// same arrangement `#presence-host` has, and the reason the header's rule
|
||
// about not writing to the chrome's DOM is not broken here. `showFireSection`
|
||
// owns the visibility and runs on every `renderChrome`.
|
||
if (drawsFire(id)) {
|
||
fireBounds = entry.city.bounds;
|
||
const fireHost = document.querySelector<HTMLElement>("#fire-host");
|
||
firePanel = fireHost === null ? null : mountFirePanel(fireHost, { bounds: fireBounds });
|
||
// Whatever the page already knows, re-clipped to this board. `undefined` is
|
||
// the first board of the session and is the only case that shows the fault
|
||
// sentence, which is correct: nothing has answered yet.
|
||
if (firesBody === undefined) firePanel?.apply(null);
|
||
else applyFires(firesBody);
|
||
fireWatch = tera.watchFires((body) => applyFires(body));
|
||
}
|
||
showFireSection();
|
||
|
||
/**
|
||
* The sea and the sky, in the frame the board comes on screen in.
|
||
*
|
||
* `refreshHarbour` is synchronous — the modelled body is arithmetic over
|
||
* fifteen berths — so the first visible frame of the Southland already has its
|
||
* hulls on it, on a first mount and on a return alike. On a return it is a new
|
||
* *fix* rather than a replay: the clock has moved on while the board was
|
||
* paused, and the harbour is modelled from the instant, not from a tick count.
|
||
*/
|
||
if (record.vesselBounds !== null) refreshHarbour(currentInstant().getTime());
|
||
// Asked once per board and then only when the body's own TTL has expired; the
|
||
// promotion that draws it runs on the clock, in `updateSun`, which is also
|
||
// where the sun's elevation comes from.
|
||
if (record.carriesSky) void askSky();
|
||
|
||
{
|
||
const controls = record.handle.stageScene.controls;
|
||
let lastAltitude = -1;
|
||
const onCameraMoved = () => {
|
||
const altitude = record.handle.cameraAltitudeMetres();
|
||
// Two per cent of the current altitude, floored at a metre so a camera
|
||
// resting exactly on the ground does not recompute on every event.
|
||
const moved = Math.abs(altitude - lastAltitude);
|
||
if (lastAltitude >= 0 && moved < Math.max(1, lastAltitude * 0.02)) return;
|
||
lastAltitude = altitude;
|
||
applyCameraFog();
|
||
maybeHandover(record);
|
||
maybePrefetch(record);
|
||
};
|
||
const onDragStart = () => { dragging = true; };
|
||
const onDragEnd = () => {
|
||
dragging = false;
|
||
// The notch that ended the drag is the one that may promote: the rule
|
||
// refuses while a pointer is down, so without this a descent that ends
|
||
// below a threshold would sit there until the next event.
|
||
maybeHandover(record);
|
||
maybePrefetch(record);
|
||
};
|
||
controls.addEventListener("change", onCameraMoved);
|
||
controls.addEventListener("start", onDragStart);
|
||
controls.addEventListener("end", onDragEnd);
|
||
cameraFogWatch = () => {
|
||
controls.removeEventListener("change", onCameraMoved);
|
||
controls.removeEventListener("start", onDragStart);
|
||
controls.removeEventListener("end", onDragEnd);
|
||
dragging = false;
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a pointer is currently on the board.
|
||
*
|
||
* `OrbitControls` fires `start` on the first pointer-down, the first wheel notch
|
||
* and the first pinch, and `end` when it lets go. Swapping a board out from
|
||
* under a finger that is holding it is the one thing a free-camera handover must
|
||
* never do.
|
||
*/
|
||
let dragging = false;
|
||
|
||
/**
|
||
* Free-camera promotion across the seam. **On, and `?handover=0` turns it off.**
|
||
*
|
||
* ## Why it is on now
|
||
*
|
||
* Because the owner asked three times why there are three boards, and this is
|
||
* the answer to that question rather than a nicety. A wheel notch over the Bay
|
||
* now promotes to the detailed Bay Area board and a notch back out demotes to
|
||
* the state tier: one map, three scales, entered by moving rather than by
|
||
* picking a product off a strip of tabs. Measured on the deployed bundle before
|
||
* this default changed, promotion fired at the sixteenth notch in from the state
|
||
* pose and the arrival was clean — no boot card, no tab, no click on a name.
|
||
*
|
||
* The two reservations below were real and both are now discharged. The
|
||
* oscillation one had a *cause* rather than a risk — see the arrival guard in
|
||
* `maybeHandover`, which is the whole of it. The coarse-descent one is a
|
||
* property of the packs and not of this rule, and it is recorded as such:
|
||
*
|
||
* ## What is still true, and is now a decision rather than a doubt
|
||
*
|
||
* 97.4% of California's area has no board below 242 km of stand-off. Zooming in
|
||
* over Fresno lands on 806 m lots and 2 km-wide freeway symbols with nothing to
|
||
* hand over to, and the rule correctly refuses. That picture has now been
|
||
* looked at — it is a dark, coarse blur, and it is what the state tier honestly
|
||
* is at that range. The judgement made here is that a visitor who deliberately
|
||
* zooms into the empty middle of the state and finds it coarse has learned
|
||
* something true, and that this costs less than a tab strip that tells every
|
||
* visitor there are three products before they touch anything. Authoring
|
||
* Sacramento, Fresno and the Central Valley is what retires it.
|
||
*
|
||
* ## The original argument, kept because it is the reason the guard exists
|
||
*
|
||
* The rule itself — `handover` in `engine/ladder.ts` — is pure, tested and
|
||
* shipped. What is deliberately not shipped is letting a wheel notch fire it,
|
||
* and the reasons are specific rather than cautious. A camera parked at a
|
||
* threshold oscillates without a hysteresis band and a drag guard, both of which
|
||
* are in the rule and neither of which has been photographed at every pose yet.
|
||
* And 97.4% of California's area has no board below 242 km of stand-off: two
|
||
* notches down from the state pose over Fresno lands on 806 m lots and 2 km-wide
|
||
* freeway symbols with nothing to hand over to, so the rule correctly refuses
|
||
* and the descent has to be *allowed* to look coarse. That is a picture somebody
|
||
* should look at before it is on by default.
|
||
*
|
||
* `?handover=1` turns it on. Every promotion goes through `switchCity`, which is
|
||
* what dispatches the journey event — a board that changed without one leaves
|
||
* the door into Mateo Court rejecting with `wrong-city` while the Southland is
|
||
* visibly on screen, and nothing throws.
|
||
*/
|
||
const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") !== "0";
|
||
|
||
/**
|
||
* Whether a board may be built before anybody asks for it.
|
||
*
|
||
* On, and this is the single constant that turns it off — the rollback for the
|
||
* whole background lane, per the plan this was built from. It is a constant and
|
||
* not a query flag deliberately: a query flag is a thing that ships in one state
|
||
* and is measured in another, and every number quoted for this lane was measured
|
||
* with it in the state it ships in.
|
||
*/
|
||
const PREFETCH_ENABLED = true;
|
||
|
||
/**
|
||
* Does this pack carry metro detail, as opposed to being the coarse state board?
|
||
*
|
||
* **Asked of the pack, not of its id**, and that distinction is a bug this
|
||
* function exists to have fixed. Live ADS-B and live weather observations were
|
||
* gated on `id !== "california"`, which meant "not the coarse statewide board"
|
||
* on the day it was written and was correct then: live aircraft over a board at
|
||
* 1,919 m to the unit are a glyph problem, and an observation from one station
|
||
* cannot speak for a thousand kilometres of coast.
|
||
*
|
||
* `cities/unify.ts` then made a board that is *both* — the whole state, at metro
|
||
* fidelity, keeping the `california` id deliberately so that the fire gate, the
|
||
* ladder's region table and every `?city=` deep link keep working. It therefore
|
||
* inherited a gate meant for something else and shipped with live traffic and
|
||
* live weather silently off, which is exactly the class of defect that reads as
|
||
* "it feels less alive" and never as an error.
|
||
*
|
||
* `focusRegions` is the honest predicate and needs no new field: the coarse
|
||
* state pack declares none, and every pack that has ground worth drawing at
|
||
* metro resolution declares at least one.
|
||
*/
|
||
/**
|
||
* A camera the capture harness can plant, exposed on `globalThis`.
|
||
*
|
||
* **Why this exists.** Every instrument in this repo aims through a control a
|
||
* reader also uses — `look.mjs --chapter` clicks a chapter button, `shots.mjs`
|
||
* drives the mode dock — and that is the right default, because a harness with
|
||
* a back door photographs a state no visitor can reach. But it means a board
|
||
* can only be photographed *where a chapter already points*, and the merged
|
||
* board carries California's six: the whole state, the north, two corridors and
|
||
* two doors that leave the board. There is no rung anywhere near a city on it.
|
||
*
|
||
* The cost of that was three failed attempts to photograph the very thing the
|
||
* board exists for — its cities — by clicking the minimap and guessing wheel
|
||
* notches, each of which landed in open ocean or on empty coast. A picture is
|
||
* this project's acceptance instrument; a board that cannot be pointed at is a
|
||
* board whose work cannot be accepted.
|
||
*
|
||
* So: a seek, in the same coordinates a pack is authored in. It moves the
|
||
* camera and nothing else — no mode change, no board change, no state a visitor
|
||
* could not also reach by dragging. It is `globalThis` rather than a module
|
||
* export because the harness talks to a built bundle across a page boundary,
|
||
* and it is deliberately not wired to any UI.
|
||
*/
|
||
function publishCameraHook(record: MountedBoard): void {
|
||
(globalThis as { __teraCamera?: unknown }).__teraCamera = {
|
||
/**
|
||
* Look at a place from a distance, in the pack's own units: degrees for the
|
||
* target, true metres for the stand-off and the height.
|
||
*
|
||
* `metresPerUnit` for the horizontal and `metresPerUnit / exaggeration` for
|
||
* the vertical, because a stand-off is a plan measurement and a height is
|
||
* not — the same distinction `chapterStandoffMetres` and
|
||
* `chapterAltitudeMetres` are built on, and getting it wrong is a camera
|
||
* under the ground.
|
||
*/
|
||
seek(at: { lat: number; lng: number; standoffM?: number; heightM?: number; azimuth?: number }) {
|
||
const world = record.handle.world;
|
||
const scene = record.handle.stageScene;
|
||
const [x, z] = world.project(at.lat, at.lng);
|
||
const ground = record.handle.groundWorldY(at.lat, at.lng);
|
||
const standoff = (at.standoffM ?? 20_000) / world.metresPerUnit;
|
||
const lift =
|
||
((at.heightM ?? (at.standoffM ?? 20_000) * 0.6) / world.metresPerUnit) *
|
||
world.city.verticalExaggeration * record.handle.reliefScale();
|
||
const azimuth = at.azimuth ?? 0.6;
|
||
scene.controls.target.set(x, ground, z);
|
||
scene.camera.position.set(
|
||
x + Math.sin(azimuth) * standoff,
|
||
ground + lift,
|
||
z + Math.cos(azimuth) * standoff,
|
||
);
|
||
scene.controls.update();
|
||
return { x, z, ground, standoff, lift };
|
||
},
|
||
/**
|
||
* What the city layer is currently drawing: how many lots are packed, and
|
||
* which rung of `DETAIL_LOT_TIERS` they are lotted at.
|
||
*
|
||
* The same argument as `seek`, one floor down. Lot size follows the camera
|
||
* now, and "did the board coarsen when I pulled back" is not a question a
|
||
* triangle total can answer — a total is the terrain and the water and the
|
||
* roads as well, and at the poses that matter those are more than half of
|
||
* it. This reads the answer off the mesh instead. It observes and changes
|
||
* nothing.
|
||
*/
|
||
lots() {
|
||
const found: InstancedMesh[] = [];
|
||
record.handle.stageScene.scene.traverse((child) => {
|
||
if (child instanceof InstancedMesh && child.name === "blocks") found.push(child);
|
||
});
|
||
const mesh = found[0];
|
||
if (mesh === undefined) return null;
|
||
const store = mesh.userData.detail as
|
||
| { tier?: number; detailTier?: number | null }
|
||
| undefined;
|
||
/*
|
||
* `detailTier` and not `tier`, because they answer different questions and
|
||
* only one of them is the one being asked. `tier` is an index and is
|
||
* always valid; `detailTier` is `null` at a pose where no metro detail is
|
||
* drawn at all. Reporting `tier` printed "40 m" at the whole-board pose,
|
||
* on a board drawing nothing but 806 m base lots — an instrument lying in
|
||
* the direction of "the fine rung is cheap", which is precisely the
|
||
* direction that would get somebody to spend a budget they do not have.
|
||
*/
|
||
const drawn = store?.detailTier ?? null;
|
||
return {
|
||
packed: mesh.count,
|
||
capacity: (mesh.instanceMatrix.array.length / 16) | 0,
|
||
tier: drawn,
|
||
lotMetres: drawn === null ? null : detailLotMetres(drawn),
|
||
};
|
||
},
|
||
board: record.id,
|
||
};
|
||
}
|
||
|
||
function carriesMetroDetail(city: City): boolean {
|
||
return (city.focusRegions?.length ?? 0) > 0;
|
||
}
|
||
|
||
function maybeHandover(record: MountedBoard): void {
|
||
if (!FREE_HANDOVER) return;
|
||
if (inside || fogDip !== null || record !== visibleBoard) return;
|
||
/*
|
||
* The opening move is not a camera position anybody asked for, and reading a
|
||
* stand-off out of it is how this feature spent a round looking broken.
|
||
*
|
||
* `arrivalStart` begins every board's arrival at 1.5x its resting stand-off
|
||
* and `ladder.ts` demotes above 1.15x the handover ceiling, so the first
|
||
* second of *any* board is outside that board's own retention band. Landing on
|
||
* the Bay Area with the flag on therefore bounced straight back to the state
|
||
* board with no input at all — measured, twice, before this line existed. It
|
||
* is not an sf quirk and it is not a tuning problem: 1.5 > 1.15 holds for
|
||
* every board there will ever be, which is why the fix is a guard rather than
|
||
* a number. `handoverArrivalGuard.test.ts` pins that inequality so the guard
|
||
* cannot be deleted as redundant.
|
||
*/
|
||
if (record.handle.arriving()) return;
|
||
if (record.handle.controlMode() !== "overview") return;
|
||
const target = record.handle.stageScene.controls.target;
|
||
const [lat, lng] = record.handle.world.unproject(target.x, target.z);
|
||
const next = handover({
|
||
boards: CITIES.map((entry) => ({
|
||
id: entry.id,
|
||
bounds: entry.city.bounds,
|
||
metresPerUnit: 111_320 / entry.city.latScale,
|
||
})),
|
||
current: record.id,
|
||
lat,
|
||
lng,
|
||
standoffM: record.handle.cameraStandoffMetres(),
|
||
dragging,
|
||
});
|
||
if (next !== null && next !== wantedCity) switchCity(next);
|
||
}
|
||
|
||
/**
|
||
* The metro tiers the background lane may load, and the stand-off each takes
|
||
* over at. Derived from the ladder's own handover table, so there is exactly one
|
||
* place in the product that says where a board stops being the right one.
|
||
*/
|
||
const PREFETCH_TIERS: readonly PrefetchTier[] = CITIES.flatMap((entry) => {
|
||
const handoverStandoffM = HANDOVER_STANDOFF_M[entry.id];
|
||
return handoverStandoffM === undefined
|
||
? []
|
||
: [{ id: entry.id, handoverStandoffM, bounds: entry.city.bounds }];
|
||
});
|
||
|
||
/**
|
||
* Drop whatever the background lane is doing.
|
||
*
|
||
* Called before every foreground abort and on every board present, because a
|
||
* prefetch that outlives the reason it started is a build competing with a
|
||
* visitor for one terrain worker. `createScene` honours the signal by dropping
|
||
* the heightfield and resolving `null` without allocating a renderer, so an
|
||
* abandoned prefetch costs nothing to abandon.
|
||
*/
|
||
/**
|
||
* How long a board must be on screen and undisturbed before anything loads
|
||
* behind it, in milliseconds.
|
||
*
|
||
* The board that just arrived is still linking shaders, uploading buffers and
|
||
* settling its first frames; starting another build inside that window is the
|
||
* one way this feature can make the product measurably worse. Deliberately not
|
||
* `requestIdleCallback`: Safari did not ship it until recently and it is exactly
|
||
* the browser most likely to be on the tightest budget, so the deferral is a
|
||
* timer everywhere rather than a good mechanism on some platforms and none on
|
||
* others.
|
||
*/
|
||
const PREFETCH_IDLE_MS = 2_000;
|
||
|
||
let prefetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function cancelPrefetch(): void {
|
||
if (prefetchTimer !== null) {
|
||
clearTimeout(prefetchTimer);
|
||
prefetchTimer = null;
|
||
}
|
||
prefetching?.abort();
|
||
prefetching = null;
|
||
}
|
||
|
||
/**
|
||
* Load the board the camera is heading for, before it is asked for.
|
||
*
|
||
* ## Why this is the whole of "loads in the background"
|
||
*
|
||
* Everything else was already there. `present: false` already exists on
|
||
* `SceneOptions` and is already honoured; `boards` already holds three on a
|
||
* desktop with California pinned; `presentBoard` already runs a fog dip over a
|
||
* cache hit at zero shader links and zero blocked milliseconds. The only missing
|
||
* piece was something that decides to build a board nobody has clicked on, and
|
||
* the reason it was missing is that it is the piece that can hurt: it competes
|
||
* for the terrain worker and it can relight the board on screen.
|
||
*
|
||
* Both hazards are closed rather than hoped about. The competition is closed by
|
||
* `cancelPrefetch()` running before every foreground abort. The relighting is
|
||
* closed in `environmentRig.ts` — an off-stage apply borrows the cached probe
|
||
* instead of convolving a new one and disposing the visible board's — and by
|
||
* `scene.ts` passing `offstage: !presented` on every clock-driven apply.
|
||
*
|
||
* ## Why the decision is not made here
|
||
*
|
||
* `prefetchTarget` is pure, takes scalars, and lives in `boards.ts` with the
|
||
* eviction policy it has to agree with. This function is the effects half: read
|
||
* three numbers off the camera, ask, and act. Keeping the two apart is what lets
|
||
* `prefetchPolicy.test.ts` assert against the real pack bounds — including that
|
||
* the two metros' trigger discs provably cannot overlap — with no DOM, no WebGL
|
||
* and no `three` anywhere near it.
|
||
*/
|
||
function maybePrefetch(record: MountedBoard): void {
|
||
if (!PREFETCH_ENABLED) return;
|
||
// Never behind a visitor. A foreground build, an office, or a dip in progress
|
||
// all mean something is already happening that matters more than this.
|
||
if (inside || mounting !== null || fogDip !== null) return;
|
||
if (record !== visibleBoard) return;
|
||
if (prefetching !== null) return;
|
||
|
||
const target = record.handle.stageScene.controls.target;
|
||
const [lat, lng] = record.handle.world.unproject(target.x, target.z);
|
||
const id = prefetchTarget({
|
||
tiers: PREFETCH_TIERS,
|
||
lat,
|
||
lng,
|
||
standoffM: record.handle.cameraStandoffMetres(),
|
||
resident: boards.ids(),
|
||
visible: visibleBoard?.id ?? null,
|
||
capacity: residentCapacity(deviceProfile().handheld),
|
||
pinned: ["california"],
|
||
});
|
||
if (id === null) return;
|
||
const entry = CITIES.find((candidate) => candidate.id === id);
|
||
if (entry === undefined) return;
|
||
|
||
/*
|
||
* Armed now, started later.
|
||
*
|
||
* The timer is what separates "the camera is somewhere a load would be useful"
|
||
* from "and it has been there long enough that nothing else needs the machine".
|
||
* Every gesture cancels it through `cancelPrefetch`, so a visitor sweeping the
|
||
* camera across the state re-arms it repeatedly and starts nothing until they
|
||
* stop — which is the behaviour a background lane has to have to be invisible.
|
||
*/
|
||
if (prefetchTimer !== null) clearTimeout(prefetchTimer);
|
||
prefetchTimer = setTimeout(() => {
|
||
prefetchTimer = null;
|
||
startPrefetch(entry);
|
||
}, PREFETCH_IDLE_MS);
|
||
}
|
||
|
||
/** The build itself, once the deferral above has decided it is safe to start. */
|
||
function startPrefetch(entry: { id: string; label: string; city: City }): void {
|
||
const id = entry.id;
|
||
// Re-checked after the wait, because two seconds is long enough for every one
|
||
// of these to have changed.
|
||
if (inside || mounting !== null || fogDip !== null) return;
|
||
if (prefetching !== null || boards.has(id) || visibleBoard?.id === id) return;
|
||
|
||
const controller = new AbortController();
|
||
prefetching = controller;
|
||
void (async () => {
|
||
const built = await buildBoard(entry, controller, { quiet: true });
|
||
// Four ways this stops mattering between the ask and the answer, and all
|
||
// four have to be checked *after* the await rather than before it: the
|
||
// visitor may have clicked something, the lane may have been cancelled, the
|
||
// board may have arrived by the front door in the meantime, or the cache may
|
||
// no longer have room for it without evicting what is on screen.
|
||
if (built === null) {
|
||
if (prefetching === controller) cancelPrefetch();
|
||
return;
|
||
}
|
||
const stale =
|
||
controller.signal.aborted ||
|
||
prefetching !== controller ||
|
||
boards.has(id) ||
|
||
visibleBoard?.id === id;
|
||
if (stale) {
|
||
disposeBoard(built);
|
||
if (prefetching === controller) prefetching = null;
|
||
return;
|
||
}
|
||
/*
|
||
* Parked, not presented.
|
||
*
|
||
* `presentBoard` writes the cache itself at the moment a board becomes
|
||
* visible; this one never becomes visible, so it writes its own entry — and
|
||
* it goes in as the *least* recently shown, which is what `put` already does
|
||
* for a board that has never been shown. Anything evicted to make room is
|
||
* disposed here, exactly as `presentBoard` does, because the rig's ledger is
|
||
* a strong reference and a board left in it retains the whole scene graph.
|
||
*/
|
||
for (const evicted of boards.put(id, built)) disposeBoard(evicted);
|
||
prefetching = null;
|
||
})();
|
||
}
|
||
|
||
/**
|
||
* The rectangle the plan view draws, which is no longer the board's own.
|
||
*
|
||
* ## The problem, stated as a measurement
|
||
*
|
||
* Three boards, three arbitrary rectangles. California's is 9.55 by 10.5
|
||
* degrees, the Bay Area's 0.85 by 0.89, the Southland's 1.08 by 1.66 — which on
|
||
* screen, once longitude is squashed by the cosine of each board's own centre
|
||
* latitude, is a **0.87 portrait, a 0.83 portrait and a 1.28 landscape**. So the
|
||
* plan in the corner was turning through ninety degrees between the Southland
|
||
* and the other two while the world underneath it had not moved at all. That is
|
||
* the loudest "you are somewhere else now" signal on the screen and it fires in
|
||
* the same frame the fog dip is trying to hide everything else.
|
||
*
|
||
* ## The rule, and what it is not
|
||
*
|
||
* Every board is drawn inside a rectangle with **California's proportions**,
|
||
* grown around the board's own bounds until the aspect matches and centred on
|
||
* it. California is the reference because its four edges are the only ones in
|
||
* the product that are a *place* rather than a crop: the two metro rectangles
|
||
* are framings somebody chose, and the state's are the state's.
|
||
*
|
||
* The cost of the rule is honest and small: the Bay Area's frame widens by 5.7%
|
||
* and the Southland's grows 46% taller, so the Southland sits in a band across
|
||
* the middle of its plan with water above and below it — which is, to be fair
|
||
* to it, where the Southland actually is.
|
||
*
|
||
* **This is the shape pinned, not the extent.** The design this comes from asks
|
||
* for the plan to be pinned to California's *bounds* on every board, with a zoom
|
||
* that follows the camera's ground footprint and the relief sourced from
|
||
* whichever board is resident. That is a larger change — the relief raster is
|
||
* built once into an offscreen surface and blitted under a 30 Hz cap and a
|
||
* skip-when-nothing-moved guard, and a zoom means re-rasterising against both —
|
||
* and it is not what shipped here. What shipped is the half of it that answers
|
||
* the complaint: the widget is the same shape on all three boards.
|
||
*/
|
||
const PLAN_ASPECT = planAspect(CALIFORNIA);
|
||
|
||
function planAspect(pack: City): number {
|
||
const lat = (pack.bounds.maxLat - pack.bounds.minLat);
|
||
const lng = (pack.bounds.maxLng - pack.bounds.minLng) * Math.cos((pack.center.lat * Math.PI) / 180);
|
||
return lng / lat;
|
||
}
|
||
|
||
function planFrame(pack: City): {
|
||
minLat: number;
|
||
maxLat: number;
|
||
minLng: number;
|
||
maxLng: number;
|
||
} {
|
||
const cos = Math.cos((pack.center.lat * Math.PI) / 180);
|
||
let latSpan = pack.bounds.maxLat - pack.bounds.minLat;
|
||
let lngSpan = pack.bounds.maxLng - pack.bounds.minLng;
|
||
const aspect = (lngSpan * cos) / latSpan;
|
||
// Grow the short axis. Never shrink either one: a frame that cropped would put
|
||
// part of the board off its own plan, which is a worse failure than an empty
|
||
// margin and a much less obvious one.
|
||
if (aspect < PLAN_ASPECT) lngSpan = (latSpan * PLAN_ASPECT) / cos;
|
||
else latSpan = (lngSpan * cos) / PLAN_ASPECT;
|
||
const midLat = (pack.bounds.maxLat + pack.bounds.minLat) / 2;
|
||
const midLng = (pack.bounds.maxLng + pack.bounds.minLng) / 2;
|
||
return {
|
||
minLat: midLat - latSpan / 2,
|
||
maxLat: midLat + latSpan / 2,
|
||
minLng: midLng - lngSpan / 2,
|
||
maxLng: midLng + lngSpan / 2,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Free a board for good.
|
||
*
|
||
* The order matters and `scene.ts` documents why: `SceneHandle.dispose()` takes
|
||
* the scene off the stage first and then walks it, so the renderer still has the
|
||
* bookkeeping that makes `gl.deleteProgram` actually run. It also calls
|
||
* `environmentRig.release(scene)` before anything else frees a texture — the
|
||
* rig's ledger is a **strong** reference, and a disposed board left in it is the
|
||
* whole scene graph retained, which is the exact leak that doc comment was
|
||
* written for. Retention makes this path rarer and therefore easier to get
|
||
* wrong, so it has one caller and one place to look.
|
||
*/
|
||
function disposeBoard(record: MountedBoard): void {
|
||
// A dip drives `setAerialFog` from the pump every frame, and a handle whose
|
||
// materials have been disposed is not a thing to keep writing to.
|
||
if (fogDip?.record === record) endFogDip();
|
||
if (visibleBoard === record) {
|
||
deactivateBoard();
|
||
visibleBoard = null;
|
||
city = null;
|
||
atmosphere = null;
|
||
minimap = null;
|
||
}
|
||
record.minimap?.dispose();
|
||
record.handle.dispose();
|
||
}
|
||
|
||
/**
|
||
* The opening move for a **room**, and the one place the app owns a camera.
|
||
*
|
||
* The city's arrival lives in `scene.ts`, where the scene owns its own kit. An
|
||
* office has no equivalent seam — `OfficeScene` hands out `camera` and
|
||
* `controls` and offers `flyTo(viewId)` and nothing that takes a pose — so the
|
||
* move is driven from here, off the pump this file already runs.
|
||
*
|
||
* Two rules, and both are about not breaking something that works:
|
||
*
|
||
* **The resting pose is whatever the room already chose.** It is read off the
|
||
* camera at the moment the door opens rather than computed, so it is the pack's
|
||
* `viewpoints[0]` on a first entry and *where you left it* on a second. A
|
||
* viewpoint's `focus.at` is also the walk spawn — `startDeviceFeed`'s
|
||
* neighbour above reads it to place a walker — and the LA office has already
|
||
* been bitten by an arrival shot that stood the camera inside a three-metre
|
||
* entry passage framing two brick walls. The target is not touched here. Only
|
||
* the camera moves, and only away from a pose somebody already checked.
|
||
*
|
||
* **A low pose has nowhere to fly from.** Below `OFFICE_ARRIVAL_MIN_ELEVATION`
|
||
* the viewpoint is an eye-level shot standing in a room, and lifting a camera
|
||
* that is inside a building puts the first second of the arrival inside the
|
||
* ceiling slab. Those rooms simply do not get one.
|
||
*/
|
||
let officeArrival: { from: Pose; to: Pose; elapsed: number } | null = null;
|
||
|
||
/**
|
||
* Seconds. The same length as a board's, so the two doors feel like one product
|
||
* — and bounded by the same thing: `scripts/performance-budget.mjs` measures the
|
||
* `office` scene from a three-second warm-up, and a move still running inside
|
||
* the sample window makes its geometry counts stop reproducing. See
|
||
* `ARRIVAL_SECONDS` in `scene.ts` for the argument in full.
|
||
*/
|
||
const OFFICE_ARRIVAL_SECONDS = 2.5;
|
||
/** Below this, in radians, the viewpoint is inside a room and gets no move. */
|
||
const OFFICE_ARRIVAL_MIN_ELEVATION = (15 * Math.PI) / 180;
|
||
/** How much higher the camera starts, in radians, and the ceiling on where that lands. */
|
||
const OFFICE_ARRIVAL_RISE = (11 * Math.PI) / 180;
|
||
const OFFICE_ARRIVAL_MAX_ELEVATION = (58 * Math.PI) / 180;
|
||
/** How much further out it starts, as a multiple of the resting stand-off. */
|
||
const OFFICE_ARRIVAL_STANDOFF = 1.32;
|
||
/** How far round the room it swings, in radians. */
|
||
const OFFICE_ARRIVAL_YAW = -0.4;
|
||
|
||
function beginOfficeArrival(scene: OfficeScene): void {
|
||
officeArrival = null;
|
||
if (prefersReducedMotion()) return;
|
||
const target = scene.controls.target.clone();
|
||
const rest = { position: scene.camera.position.clone(), target };
|
||
const dx = rest.position.x - target.x;
|
||
const dy = rest.position.y - target.y;
|
||
const dz = rest.position.z - target.z;
|
||
const reach = Math.hypot(dx, dy, dz);
|
||
if (reach <= 0) return;
|
||
const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
|
||
if (elevation < OFFICE_ARRIVAL_MIN_ELEVATION) return;
|
||
const raised = Math.min(elevation + OFFICE_ARRIVAL_RISE, OFFICE_ARRIVAL_MAX_ELEVATION);
|
||
const flat = Math.hypot(dx, dz);
|
||
const cos = Math.cos(OFFICE_ARRIVAL_YAW);
|
||
const sin = Math.sin(OFFICE_ARRIVAL_YAW);
|
||
const ax = flat > 0 ? (dx * cos - dz * sin) / flat : 0;
|
||
const az = flat > 0 ? (dx * sin + dz * cos) / flat : 1;
|
||
const startReach = Math.min(reach * OFFICE_ARRIVAL_STANDOFF, scene.controls.maxDistance * 0.995);
|
||
const horizontal = Math.cos(raised) * startReach;
|
||
const from: Pose = {
|
||
target: target.clone(),
|
||
position: new Vector3(
|
||
target.x + ax * horizontal,
|
||
target.y + Math.sin(raised) * startReach,
|
||
target.z + az * horizontal,
|
||
),
|
||
};
|
||
scene.camera.position.copy(from.position);
|
||
scene.controls.target.copy(from.target);
|
||
scene.controls.update();
|
||
officeArrival = { from, to: rest, elapsed: 0 };
|
||
}
|
||
|
||
/** Any input, any view, any change of mode: the move is over. */
|
||
function cancelOfficeArrival(): void {
|
||
officeArrival = null;
|
||
}
|
||
|
||
function stepOfficeArrival(dt: number): void {
|
||
if (officeArrival === null) return;
|
||
if (!inside || !office || controlModeState.mode !== "office-overview") {
|
||
officeArrival = null;
|
||
return;
|
||
}
|
||
officeArrival.elapsed += dt;
|
||
const t = Math.min(1, officeArrival.elapsed / OFFICE_ARRIVAL_SECONDS);
|
||
const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
|
||
office.camera.position.lerpVectors(officeArrival.from.position, officeArrival.to.position, e);
|
||
office.controls.target.lerpVectors(officeArrival.from.target, officeArrival.to.target, e);
|
||
office.controls.update();
|
||
if (t >= 1) officeArrival = null;
|
||
}
|
||
|
||
/**
|
||
* Whether this visitor has asked the platform for less movement.
|
||
*
|
||
* The same question `scene.ts` and `scenekit.ts` ask, asked here because this
|
||
* file drives a camera of its own. Read at the moment it is needed rather than
|
||
* cached: the only callers ask once per door, and the query is a property read.
|
||
*/
|
||
function prefersReducedMotion(): boolean {
|
||
if (typeof window.matchMedia !== "function") return false;
|
||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
}
|
||
|
||
/**
|
||
* The app's own frame pump, beside the renderer's.
|
||
*
|
||
* `Stage` owns the render loop and neither scene handle exposes a per-frame
|
||
* hook, so the alternative is adding an `onTick` to both for a handful of call
|
||
* sites. 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 scene at all.
|
||
*
|
||
* What lives on it, and why each is here rather than in a scene:
|
||
*
|
||
* - the two plan widgets, which are 2-D canvases the engine draws;
|
||
* - the pose editor, which is `null` for everyone who is not god;
|
||
* - the studio hardware and the car outside, which are **simulations** rather
|
||
* than layers — both are fixed-step state machines that the arena also drives,
|
||
* and putting their clocks in a scene would mean the arena and the renderer
|
||
* stepping two different copies;
|
||
* - the chrome, but only while a body is under control. See below.
|
||
*/
|
||
let lastPumpAt = performance.now();
|
||
requestAnimationFrame(function pump(now) {
|
||
requestAnimationFrame(pump);
|
||
/**
|
||
* Clamped, because a backgrounded tab comes back with a `dt` of minutes and
|
||
* neither simulator should be asked to catch up on a room nobody was in. Both
|
||
* of them drop the excess internally as well; this is the cheaper half of the
|
||
* same decision, made before the call rather than inside it.
|
||
*/
|
||
const dt = Math.min(0.1, Math.max(0, (now - lastPumpAt) / 1000));
|
||
lastPumpAt = now;
|
||
|
||
syncJourneyVehicle(now);
|
||
stepFogDip(dt);
|
||
stepOfficeArrival(dt);
|
||
updateLocalPlayerMaps();
|
||
// Only the mounted one. The other is still constructed and still holds a live
|
||
// camera, but its canvas is out of the document, so `clientWidth` is 0, every
|
||
// `resize()` puts it back to `ready = false`, and ticking it would be a
|
||
// function call that exists to do nothing sixty times a second.
|
||
if (inside) officePlan?.tick();
|
||
else minimap?.tick();
|
||
if (inside) {
|
||
// A no-op on the API strategy, which is driven by its own poll — see
|
||
// `devices/adapter.ts`, which owns that choice and nothing above it knows
|
||
// which of the two it got.
|
||
deviceSource?.tick(dt);
|
||
stepVehicleTelemetry(dt);
|
||
}
|
||
poseEditor?.tick();
|
||
publishRealtimePresence(now);
|
||
pollLiveness();
|
||
/**
|
||
* The chrome, on the frames where something in it actually moves.
|
||
*
|
||
* `chromeState` is pure and `mount.apply` compares before it writes, so
|
||
* calling this unconditionally would be correct — and it would also rebuild
|
||
* nothing, sixty times a second, for the ninety-odd percent of a session
|
||
* spent in an orbit camera. The one part of the interface that changes per
|
||
* frame is the play HUD's speed and altitude, and `playHudKindFor` is exactly
|
||
* the test for "is a controller reporting numbers right now". Everything else
|
||
* calls `renderChrome()` at the moment it changes, which is what makes this
|
||
* an optimisation rather than a second source of truth.
|
||
*/
|
||
if (playHudKindFor(controlModeState.mode) !== null) renderChrome();
|
||
});
|
||
|
||
function updateLocalPlayerMaps(): void {
|
||
if (!city) return;
|
||
if (inside) {
|
||
minimap?.setPlayer(null);
|
||
const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null;
|
||
officePlan?.setPlayer(walker
|
||
? {
|
||
levelId: walker.levelId,
|
||
x: walker.position.x,
|
||
z: walker.position.z,
|
||
headingRad: Math.atan2(-walker.facing.x, -walker.facing.z),
|
||
kind: walker.actor,
|
||
}
|
||
: null);
|
||
return;
|
||
}
|
||
officePlan?.setPlayer(null);
|
||
if (city.controlMode() === "drive") {
|
||
const state = city.vehicleState();
|
||
minimap?.setPlayer(state
|
||
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" }
|
||
: null);
|
||
return;
|
||
}
|
||
if (city.controlMode() === "aircraft") {
|
||
const state = city.aircraftState();
|
||
minimap?.setPlayer(state
|
||
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" }
|
||
: null);
|
||
return;
|
||
}
|
||
if (city.controlMode() === "actor") {
|
||
const state = city.actorState();
|
||
const pack = CITIES.find((candidate) => candidate.id === cityId)?.city;
|
||
if (!state || !pack) {
|
||
minimap?.setPlayer(null);
|
||
return;
|
||
}
|
||
const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center;
|
||
const [originX, originZ] = city.world.project(anchor.lat, anchor.lng);
|
||
const [lat, lng] = city.world.unproject(
|
||
originX + state.x / city.world.metresPerUnit,
|
||
originZ + state.z / city.world.metresPerUnit,
|
||
);
|
||
minimap?.setPlayer({
|
||
lat,
|
||
lng,
|
||
headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360,
|
||
kind: "actor",
|
||
});
|
||
return;
|
||
}
|
||
minimap?.setPlayer(null);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
let journeySyncedAt = 0;
|
||
let previousVehicleProgress: number | null = null;
|
||
function pollLiveness() {
|
||
const now = performance.now();
|
||
if (now - livenessCheckedAt < 1000) return;
|
||
livenessCheckedAt = now;
|
||
renderChrome();
|
||
}
|
||
|
||
/** Persist route progress cheaply and turn the hero's wrap into a real scale door. */
|
||
function syncJourneyVehicle(now: number): void {
|
||
if (!routeDriveIsActive() || !city || journey.route === null || journey.vehicle === null) {
|
||
previousVehicleProgress = null;
|
||
return;
|
||
}
|
||
const vehicle = city.vehicleState();
|
||
if (!vehicle) return;
|
||
const progress = vehicle.progress;
|
||
const arrived = journey.route.direction === 1
|
||
? previousVehicleProgress !== null && previousVehicleProgress > 0.94 && progress < 0.06
|
||
: previousVehicleProgress !== null && previousVehicleProgress < 0.06 && progress > 0.94;
|
||
previousVehicleProgress = progress;
|
||
|
||
if (arrived) {
|
||
const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles";
|
||
dispatchJourney({ type: "reach-route-endpoint", endpoint });
|
||
dispatchJourney({ type: "exit-vehicle" });
|
||
switchCity(endpoint === "san-francisco" ? "sf" : "socal");
|
||
return;
|
||
}
|
||
if (now - journeySyncedAt < 500) return;
|
||
journeySyncedAt = now;
|
||
dispatchJourney({ type: "update-route-progress", progress });
|
||
}
|
||
|
||
// ---- 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;
|
||
// A city can have more than one authored door. Leaving one office parks its
|
||
// scene for a cheap re-entry, but clicking a different door must not reopen
|
||
// that cached building under the new office id. Tear down every resource
|
||
// owned by the old room before loading the requested one; the parked city
|
||
// actor, journey identity and local profile deliberately live elsewhere.
|
||
if (office && builtOfficeId !== officeId) disposeLoadedOffice();
|
||
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, createOfficeMinimap, pack, materials, robotOperations } = built;
|
||
const depth = access.can.officeDepth;
|
||
// Keep the remote-avatar renderer behind the authenticated boundary. The
|
||
// public Office door can build its full local scene without downloading it.
|
||
const createOfficePeers = access.subject !== null
|
||
? (await import("./realtime/scenePeers.ts")).createScenePeers
|
||
: null;
|
||
|
||
/**
|
||
* The building's own sky, built before the scene because the scene wants the
|
||
* opening rig and the horizon drop at construction.
|
||
*
|
||
* `metresPerUnit: 1` — an office is authored in metres, and this is the only
|
||
* thing `Atmosphere` needs to know about scale. The fog it computes from
|
||
* that is then thrown away and replaced by `officeDaylight`, because a fog
|
||
* sized for a fifty-metre room is a fog inside the room.
|
||
*/
|
||
officeAtmosphere = pack.site
|
||
? createAtmosphere({
|
||
lng: pack.site.lng,
|
||
metresPerUnit: 1,
|
||
/**
|
||
* No marine layer, deliberately — unlike the city, which gets one.
|
||
*
|
||
* The model is a fact about sea level on this coast, and both of these
|
||
* buildings look *out over* it: one from 188 m up a tower, one across
|
||
* an estuary. Handing it to the office put the observer inside the fog
|
||
* it describes, which greyed the sky to near-white at one in the
|
||
* afternoon and took the view with it. The city keeps its fog; the
|
||
* rooms that look at the city do not stand in it.
|
||
*/
|
||
marineLayer: null,
|
||
})
|
||
: null;
|
||
|
||
office = createOfficeScene(pack, {
|
||
dom: city.stage.renderer.domElement,
|
||
// The page's one environment map, the same object the city holds. Indoors
|
||
// it is the difference between `deviceMesh`, `chairBase` and the Model X's
|
||
// clearcoat reading as metal and reading as grey plastic.
|
||
environment,
|
||
/**
|
||
* The same aeroplanes the board outside is drawing.
|
||
*
|
||
* `dial.source` rather than the raw feed, so a sky somebody turned up in
|
||
* godmode is the sky in both places; and the *city's* source rather than a
|
||
* second subscription, so the office is a second reader of one feed rather
|
||
* than a second poller of a volunteer-funded API. The office never
|
||
* disposes it — see the note on `OfficeSceneOptions.flights`.
|
||
*/
|
||
...(trafficDial ? { flights: trafficDial.source } : {}),
|
||
/**
|
||
* The car on the apron. `corridor` detail because it is parked and the
|
||
* arrival viewpoint looks at it from across a courtyard: the 22 extra draw
|
||
* calls `follow` buys are mirrors, glass frames and brake calipers, none
|
||
* of which resolve at that range.
|
||
*
|
||
* The seed is the office id rather than a constant, so the two studios do
|
||
* not park identical cars at identical angles — which is the single
|
||
* clearest tell that a scene was generated.
|
||
*/
|
||
exteriorVehicle: { detail: "corridor", seed: seedForOffice(officeId) },
|
||
// A visitor owns one local actor. Anonymous visitors are the promised
|
||
// office dog; a signed-in visitor gets the procedural humanoid. The
|
||
// arrival viewpoint is already a pack-authored clear point on a floor,
|
||
// which makes it the honest spawn and keeps coordinates out of the app.
|
||
walker: {
|
||
levelId: pack.viewpoints[0]?.levelId ?? pack.levels[0]?.id ?? "level-1",
|
||
position: pack.viewpoints[0]?.focus.at ?? { x: 0, z: 0 },
|
||
facing: pack.viewpoints[0]
|
||
? {
|
||
x: -Math.sin(pack.viewpoints[0].focus.rotation),
|
||
z: -Math.cos(pack.viewpoints[0].focus.rotation),
|
||
}
|
||
: { x: 0, z: -1 },
|
||
actor: actorKindForPresence(access.subject !== null, "office") === "dog"
|
||
? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
|
||
: { kind: "humanoid", ...(humanoidAppearance() ?? { outfitColor: 0x151a20, accentColor: 0xf2b134 }) },
|
||
},
|
||
// Only when there is no sky to put behind it. A sited office computes a
|
||
// gradient and a horizon; painting the old flat colour over that is the
|
||
// bug that looks exactly like the sky not working.
|
||
...(pack.site ? {} : { background: 0x11161c }),
|
||
...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
|
||
...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
|
||
// Only offices with explicit, validated simulated operations get robots.
|
||
// No geometry-derived random errands and no implication of live work.
|
||
...(robotOperations ? { robotOperations } : {}),
|
||
depth,
|
||
materials,
|
||
// Ignored entirely at `"public"` depth, where no layer is built to colour.
|
||
presencePalette: SAMPLE_PRESENCE_PALETTE,
|
||
// 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) }),
|
||
...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}),
|
||
});
|
||
builtOfficeId = officeId;
|
||
office.onViewChange(() => renderChrome());
|
||
// Registered here rather than on every entry, because this block runs once
|
||
// per office and the scene is retained between visits. The first drag,
|
||
// wheel or pinch ends the opening move wherever it has got to — a camera
|
||
// that finished its arc anyway would be arguing with somebody who has
|
||
// already started using it.
|
||
office.controls.addEventListener("start", cancelOfficeArrival);
|
||
officePlan = buildOfficePlan(createOfficeMinimap, office);
|
||
startDeviceFeed(built.createDeviceSource, office.devices);
|
||
startVehicleTelemetry(built.createSimulatedVehicleTelemetry);
|
||
}
|
||
requestControlMode("overview");
|
||
city.stage.setScene(office);
|
||
inside = true;
|
||
controlModeState = {
|
||
mode: "office-overview",
|
||
revision: controlModeState.revision + 1,
|
||
};
|
||
attachCurrentWebcamFace();
|
||
moveRealtimePresence();
|
||
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
|
||
journeyToCity(desiredCity);
|
||
dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
|
||
showPlan();
|
||
showDetail(null);
|
||
refreshGodmodePlace();
|
||
renderChrome();
|
||
// Once the room is up and the panel beside it is drawn. See
|
||
// `beginOfficeArrival` for the two rules it obeys.
|
||
beginOfficeArrival(office);
|
||
// After the room is on screen, not before. The first ask is a second request
|
||
// and the building is worth looking at while it is in flight; awaiting it here
|
||
// would hold the door shut on a network round trip to draw people into a scene
|
||
// the user cannot see yet.
|
||
watchOccupancy();
|
||
}
|
||
|
||
/**
|
||
* The plan panel, for the office.
|
||
*
|
||
* Built once alongside the scene rather than on every entry, because it reads
|
||
* that scene's camera and controls by reference — a second one built against a
|
||
* disposed office would draw a footprint from a camera nobody is moving.
|
||
*
|
||
* The constructor is passed in rather than imported, for the reason written at
|
||
* the import block: naming the module here as a value would put the furniture
|
||
* catalogue back in the entry chunk.
|
||
*/
|
||
function buildOfficePlan(
|
||
create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap,
|
||
scene: OfficeScene,
|
||
): OfficeMinimap {
|
||
const panel = create({
|
||
plan: scene.plan,
|
||
camera: scene.camera,
|
||
controls: scene.controls,
|
||
// The same ceiling the city plan takes, from the same place. Two 2D canvases
|
||
// in one corner of one page disagreeing about what a phone can afford is the
|
||
// exact drift `deviceProfile` exists in one file to prevent.
|
||
maxPixelRatio: deviceProfile().maxPixelRatio,
|
||
onSeek(x, z) {
|
||
if (controlModeState.mode !== "office-overview") return;
|
||
/**
|
||
* The same move the city plan makes, and for the same reason: slide the
|
||
* orbit target and carry the camera with it, keeping the offset between
|
||
* them. A seek is "look over there", not "go to viewpoint three".
|
||
*
|
||
* The height is deliberately untouched. Indoors that matters more than it
|
||
* does outdoors — the difference between a viewpoint two metres off the
|
||
* floor and one fourteen metres up is the difference between standing in
|
||
* the room and looking down into it, and clicking the plan should not
|
||
* silently move you between those two.
|
||
*/
|
||
const { camera, controls } = scene;
|
||
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, controls.target.y, z);
|
||
camera.position.set(x + dx, controls.target.y + dy, z + dz);
|
||
},
|
||
onHover(info) {
|
||
if (!minimapReadout) return;
|
||
if (!info) {
|
||
minimapReadout.textContent = "";
|
||
return;
|
||
}
|
||
// Metres, not degrees. The city widget prints a latitude and a longitude
|
||
// because that is the address of a place on a map; the address of a place
|
||
// in a building is where it is on the floor, in the units the pack was
|
||
// authored in, so a self-hoster can read a number off this and type it
|
||
// into their own office file.
|
||
const where = `${info.x.toFixed(1)}, ${info.z.toFixed(1)} m`;
|
||
// The storey is named only when there is more than one to be on. On a
|
||
// single-level pack — which is the reference office and will be most
|
||
// packs — "Level 1" on every hover is a word that never changes and
|
||
// therefore never informs.
|
||
const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : "";
|
||
// The person last, and after the room, because the readout is read left to
|
||
// right as an address getting more specific: the floor, then the room,
|
||
// then who is in it.
|
||
const room = info.room ? ` · ${info.room}` : "";
|
||
const person = info.person ? ` · ${info.person}` : "";
|
||
minimapReadout.textContent = `${where}${level}${room}${person}`;
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Once, not per frame.
|
||
*
|
||
* `scene.robots()` hands back a stable array of vectors the robot layer
|
||
* mutates in place, so the plan reads this frame's positions through a
|
||
* reference taken here — the same handshake `officeScene` already makes with
|
||
* `luminaires.setWalkers`. Polling it every frame would allocate nothing and
|
||
* would imply the array were a snapshot, which it is not.
|
||
*/
|
||
panel.setRobots(scene.robots());
|
||
return panel;
|
||
}
|
||
|
||
/**
|
||
* Put the plan for wherever you are into the panel.
|
||
*
|
||
* One function rather than a `replaceChildren` at each of the call sites,
|
||
* because the failure it prevents is silent: the panel keeps showing a widget
|
||
* that is still ticking and still correct *about somewhere else*, which looks
|
||
* exactly like a working map.
|
||
*/
|
||
function showPlan() {
|
||
const widget = inside ? officePlan : minimap;
|
||
if (!minimapFrame || !widget) return;
|
||
minimapFrame.replaceChildren(widget.canvas);
|
||
// The canvas has just re-entered the document with a real box for the first
|
||
// time, or the first time since it left it. Nothing else tells it that: the
|
||
// `ResizeObserver` fires on a change of size, and going from detached to
|
||
// attached at the same size is not one.
|
||
widget.resize();
|
||
}
|
||
|
||
function leaveOffice() {
|
||
if (!city) return;
|
||
// Before the scene swap, so the last thing the watch can do is abort a request
|
||
// rather than publish into a room the user has already left.
|
||
stopWatchingOccupancy();
|
||
disposeOfficeScreenUi();
|
||
clearPublishedPlayInput();
|
||
office?.walker?.setActive(false);
|
||
dispatchJourney({ type: "leave-office" });
|
||
city.stage.setScene(city.stageScene);
|
||
inside = false;
|
||
controlModeState = {
|
||
mode: "overview",
|
||
revision: controlModeState.revision + 1,
|
||
};
|
||
city.setControlMode("overview");
|
||
attachCurrentWebcamFace();
|
||
moveRealtimePresence();
|
||
showPlan();
|
||
showDetail(null);
|
||
refreshGodmodePlace();
|
||
renderChrome();
|
||
}
|
||
|
||
/** Dispose everything whose coordinates or subscriptions belong to one office. */
|
||
function disposeLoadedOffice(): void {
|
||
stopWatchingOccupancy();
|
||
disposeOfficeScreenUi();
|
||
// Before the scene, so a reading in flight cannot land on a disposed layer.
|
||
// `stop()` is idempotent and is the only thing that owns a timer down there.
|
||
deviceSource?.stop();
|
||
deviceSource = null;
|
||
deviceStates = [];
|
||
vehicleTelemetry = null;
|
||
officePlan?.dispose();
|
||
officePlan = null;
|
||
office?.dispose();
|
||
office = null;
|
||
builtOfficeId = null;
|
||
officeAtmosphere = null;
|
||
}
|
||
|
||
/**
|
||
* A stable integer per building, for the things that must look the same twice.
|
||
*
|
||
* FNV-1a over the id, which is the same hash the arena's checksums use and is
|
||
* chosen for the same property: it is short, it has no dependencies, and it
|
||
* gives two ids that differ by one character completely different seeds — so
|
||
* `lumbridge-hq` and `mateo-court` genuinely park different cars rather than
|
||
* two that differ in the third decimal.
|
||
*/
|
||
function seedForOffice(id: string): number {
|
||
let hash = 0x811c9dc5;
|
||
for (let i = 0; i < id.length; i += 1) {
|
||
hash ^= id.charCodeAt(i);
|
||
hash = Math.imul(hash, 0x01000193);
|
||
}
|
||
return hash >>> 0;
|
||
}
|
||
|
||
/**
|
||
* Point the hardware in this room at whatever will answer for it.
|
||
*
|
||
* The choice — the deployment's device route, or the fixed-step simulator in
|
||
* this tab — is made inside `createDeviceSource` and deliberately not here.
|
||
* That is the one place the anon-first fallback lives, and the reason it is not
|
||
* a branch in this file is that the two halves would drift: the route refuses an
|
||
* anonymous read, which is correct, and an anonymous visitor is the audience
|
||
* this product is designed for, so the refusal must produce a working
|
||
* instrument rather than a dead one.
|
||
*
|
||
* `serverHasDevices` is the same short-circuit `access.feeds` performs for
|
||
* weather and markers: a box with no device source answers with an empty body,
|
||
* and asking it once per TTL per tab forever to be told nothing is a request
|
||
* nobody needs.
|
||
*
|
||
* **It is two questions and it used to ask only one.** `access.feeds.devices`
|
||
* says the deployment has a source; `access.can.liveDevices` says this viewer
|
||
* may read it. On cloud-2 the first is true and, for the visitor this product
|
||
* is designed for, the second is false — the route is members-only and answers
|
||
* 401 — so passing the deployment's answer alone sent every anonymous viewer
|
||
* down the API strategy to be refused. `apiSource` then renders `atRest()`
|
||
* forever: a rack of powered-off instruments, in a room the panel beside them
|
||
* describes as running, with an exponential back-off quietly retrying a request
|
||
* that cannot succeed. Both halves, and the fallback is the simulator that was
|
||
* always meant to serve this case.
|
||
*/
|
||
function startDeviceFeed(
|
||
create: typeof import("./devices/adapter.ts").createDeviceSource,
|
||
declarations: readonly DeviceDeclaration[],
|
||
): void {
|
||
deviceSource?.stop();
|
||
deviceStates = [];
|
||
if (declarations.length === 0) {
|
||
deviceSource = null;
|
||
return;
|
||
}
|
||
deviceSource = create({
|
||
declarations,
|
||
client: tera,
|
||
officeId: officePack?.id ?? officeId,
|
||
serverHasDevices: access.feeds?.devices !== false && access.can.liveDevices,
|
||
/**
|
||
* And the tier itself, which is belt and braces on purpose.
|
||
*
|
||
* `can.liveDevices` above is *this app's* answer to "may this viewer read
|
||
* the route", and it is the one CONTRACT §6 names. `viewerTier` is the
|
||
* *fact*, and `adapter.ts` gates on it independently, so a future call site
|
||
* that forgets the capability still cannot ship an anonymous visitor a rack
|
||
* of dead instruments. Two gates for one decision is the right number when
|
||
* one of them is a bug that already shipped once.
|
||
*/
|
||
viewerTier: access.tier,
|
||
seed: seedForOffice(officeId),
|
||
onReading: (reading) => {
|
||
deviceStates = reading.states;
|
||
// The hardware in the room and the panel beside it, from one reading, so
|
||
// a lit indicator and a lit control can never disagree about a mic.
|
||
office?.setDeviceStates(reading.states);
|
||
chrome?.applyDeviceStates(reading.states);
|
||
},
|
||
});
|
||
const opening = deviceSource.current();
|
||
deviceStates = opening.states;
|
||
office?.setDeviceStates(opening.states);
|
||
chrome?.applyDeviceStates(opening.states);
|
||
}
|
||
|
||
/**
|
||
* Send one command, and say nothing if it is refused.
|
||
*
|
||
* `normalizeDeviceCommand` has already run inside the panel, so anything that
|
||
* reaches here is well formed for a device this office declared. A `null` back
|
||
* is a *deployment* refusal — an anonymous caller writing to a real room — and
|
||
* the panel is left showing the last reading rather than a control that appears
|
||
* to have worked. The refusal is not narrated because the sign-in offer is
|
||
* already on the same screen.
|
||
*/
|
||
function sendDeviceCommand(command: DeviceCommand): void {
|
||
void deviceSource?.command(command).then((state) => {
|
||
if (state === null) return;
|
||
deviceStates = deviceStates.map((current) => (current.id === state.id ? state : current));
|
||
office?.setDeviceStates(deviceStates);
|
||
chrome?.applyDeviceStates(deviceStates);
|
||
});
|
||
}
|
||
|
||
/** Bring the studio hardware panel into view, opening the column if it is shut. */
|
||
function openDevicePanel(): void {
|
||
panelOpen = true;
|
||
panelChosen = true;
|
||
renderChrome();
|
||
document.querySelector<HTMLElement>("#device-section")?.scrollIntoView({
|
||
block: "nearest",
|
||
// A jump rather than a glide, and not only for `prefers-reduced-motion`: the
|
||
// panel it is scrolling inside has just been opened in the same frame.
|
||
behavior: "auto",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The car outside, as its own fixed-step simulation.
|
||
*
|
||
* Seeded per office and stepped from the frame pump rather than from the office
|
||
* scene, because this is the *same* module the arena drives — a state machine
|
||
* with a snapshot and a restore, not a render layer. Keeping its clock here is
|
||
* what lets the renderer and the arena hold two independent instances of one
|
||
* implementation instead of two implementations.
|
||
*
|
||
* The ambient temperature is a constant and that is a gap rather than a choice:
|
||
* `WeatherObservation` carries cloud, precipitation, visibility and wind, and no
|
||
* temperature at all, so there is nothing live to couple to. Inventing one from
|
||
* the sun's elevation would be publishing a reading nobody took, which is the
|
||
* one thing the whole telemetry layer is arranged not to do.
|
||
*/
|
||
function startVehicleTelemetry(
|
||
create: typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry,
|
||
): void {
|
||
vehicleTelemetry = create({
|
||
seed: seedForOffice(officeId),
|
||
fixedStepSeconds: VEHICLE_STEP_SECONDS,
|
||
ambientC: DEFAULT_AMBIENT_C,
|
||
});
|
||
office?.setVehicleTelemetry(vehicleTelemetry.current());
|
||
}
|
||
|
||
/** Seconds of simulated vehicle time per fixed step. */
|
||
const VEHICLE_STEP_SECONDS = 0.5;
|
||
/**
|
||
* The ambient temperature the parked car sits in, in Celsius.
|
||
*
|
||
* A mild Californian afternoon, which is what both studios stand in. See
|
||
* `startVehicleTelemetry` for why it is not read off the weather feed.
|
||
*/
|
||
const DEFAULT_AMBIENT_C = 19;
|
||
|
||
let vehicleStepDebt = 0;
|
||
/**
|
||
* Advance the car by whole fixed steps, and only ever by whole ones.
|
||
*
|
||
* A fixed-step simulator that is handed a variable `dt` stops being
|
||
* reproducible, and reproducibility is the entire reason this module is shared
|
||
* with the arena. So the frame's elapsed time is accumulated and spent in whole
|
||
* steps, with the remainder carried; `apply` is signature-guarded downstream, so
|
||
* the frames that spend no step cost a comparison.
|
||
*/
|
||
function stepVehicleTelemetry(dt: number): void {
|
||
const source = vehicleTelemetry;
|
||
if (!source || !office) return;
|
||
vehicleStepDebt = Math.min(vehicleStepDebt + dt, VEHICLE_STEP_SECONDS * 4);
|
||
while (vehicleStepDebt >= VEHICLE_STEP_SECONDS) {
|
||
vehicleStepDebt -= VEHICLE_STEP_SECONDS;
|
||
source.stepFixed();
|
||
}
|
||
office.setVehicleTelemetry(source.current());
|
||
}
|
||
|
||
/**
|
||
* Fetch Spaces.
|
||
*
|
||
* The office is the largest thing in this build that most visitors never open,
|
||
* so it is a chunk the door fetches on the way through rather than bytes every
|
||
* map visitor downloads. Measured entry chunk: 780.18 kB before the split,
|
||
* 720.89 after — smaller than the chunks themselves because three.js is shared
|
||
* and stays where it was.
|
||
*
|
||
* One `Promise.all` because they are one arrival: a pack without its builder is
|
||
* a data file nobody can draw, so the fetches overlap rather than queue.
|
||
*
|
||
* No retry and no cache-busting, deliberately. 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 can, because a
|
||
* rejected dynamic import is not memoised by the browser.
|
||
*/
|
||
async function loadOffice(): Promise<{
|
||
createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene;
|
||
createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
|
||
createDeviceSource: typeof import("./devices/adapter.ts").createDeviceSource;
|
||
createSimulatedVehicleTelemetry:
|
||
typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry;
|
||
pack: Office;
|
||
materials: MaterialRegistry;
|
||
robotOperations: RobotOperationsDefinition | null;
|
||
} | null> {
|
||
try {
|
||
// More modules and still one arrival. The plan renderer reads prop
|
||
// footprints off the asset registry, so it is already downstream of the
|
||
// furniture catalogue this chunk exists to hold back — asking for it here
|
||
// costs nothing beyond the module itself, and asking for it anywhere else
|
||
// would cost the whole catalogue in the entry chunk.
|
||
const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
|
||
if (!entry) return null;
|
||
const [interiors, pack, assets, plan, operations, devices, telemetry] = await Promise.all([
|
||
import("./interiors/officeScene.ts"),
|
||
entry.load(),
|
||
import("./assets/materials.ts"),
|
||
import("./engine/officeMinimap.ts"),
|
||
entry.loadOperations?.() ?? Promise.resolve(null),
|
||
// Neither of these imports THREE, the DOM or the network, and neither has
|
||
// anything to say until somebody is standing in a studio — so both travel
|
||
// with the furniture rather than in the bundle every map visitor fetches.
|
||
import("./devices/adapter.ts"),
|
||
import("./transport/vehicleTelemetry.ts"),
|
||
]);
|
||
// Assigned rather than memoised with `??=`: the memo was what made this
|
||
// single-office forever, quietly serving the first pack fetched for every
|
||
// later request whatever id was asked for.
|
||
officePack = pack.default;
|
||
officeMaterials ??= new assets.MaterialRegistry({ quality: officeMaterialQuality() });
|
||
return {
|
||
createOfficeScene: interiors.createOfficeScene,
|
||
createOfficeMinimap: plan.createOfficeMinimap,
|
||
createDeviceSource: devices.createDeviceSource,
|
||
createSimulatedVehicleTelemetry: telemetry.createSimulatedVehicleTelemetry,
|
||
pack: officePack,
|
||
materials: officeMaterials,
|
||
robotOperations: operations?.default ?? null,
|
||
};
|
||
} catch {
|
||
showDetail("The office did not load. Check the connection and try the door again.");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How much texture a studio may draw, decided by the device rather than by a
|
||
* constant.
|
||
*
|
||
* This said `"high"` for every visitor, which meant `materials.ts` had a fully
|
||
* implemented `low` and `medium` path — different roughness handling, a cheaper
|
||
* glazing fallback, and 256px textures instead of 512 — that **no caller could
|
||
* ever reach**. The documented mobile escape hatch had never actually existed,
|
||
* and a phone was being handed desktop-grade texture memory for a canvas at
|
||
* 1.5× pixel ratio.
|
||
*
|
||
* `medium` rather than `low` on a handheld: `low` drops to flat Lambert with no
|
||
* maps at all, which is the setting that makes an office open on an integrated
|
||
* GPU and is far more than a modern phone needs. `medium` is the same physically
|
||
* shaded materials at half the texture resolution, which is the actual
|
||
* difference a phone can feel.
|
||
*
|
||
* `deviceProfile()` is the one definition of "phone" on this page — the pixel
|
||
* ratio cap, the shadow map size and now the texture budget all read it, so they
|
||
* cannot disagree about what a phone is.
|
||
*/
|
||
function officeMaterialQuality(): MaterialQuality {
|
||
return deviceProfile().handheld ? "medium" : "high";
|
||
}
|
||
|
||
/** The office's name, for the two bits of chrome that say where you are. */
|
||
function officeName(): string {
|
||
return officePack?.name ?? "Spaces";
|
||
}
|
||
|
||
/**
|
||
* Who is in the building, asked for once per entry.
|
||
*
|
||
* Only at full depth, and that is not an optimisation: `createOfficeScene` at
|
||
* `"public"` builds no presence layer at all, so there is nothing to populate
|
||
* and the request would be one the session is going to be refused anyway.
|
||
*
|
||
* The fallback is `markers`' fallback, one room in. An API that answers is
|
||
* believed, including when it answers with nobody — an office where everyone has
|
||
* gone home is a real fact about an office, and overwriting it with invented
|
||
* people to liven up the demo is the one thing this file must never do.
|
||
*/
|
||
function watchOccupancy() {
|
||
stopWatchingOccupancy();
|
||
const scene = office;
|
||
if (!scene || scene.depth !== "full") return;
|
||
presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => {
|
||
livePresence = body?.people ?? null;
|
||
presenceIsSample = false;
|
||
// The scene may have been torn down between a request going out and coming
|
||
// back — a city switch disposes the office — and writing people into a
|
||
// disposed layer is a use-after-free with a friendly name. The watch's own
|
||
// `stop()` already drops late answers; this is the belt to that brace,
|
||
// because the office can also be *replaced* (signing in rebuilds it at full
|
||
// depth) without the watch having been stopped in between.
|
||
if (office !== scene) return;
|
||
applyPresence();
|
||
renderChrome();
|
||
});
|
||
}
|
||
|
||
function stopWatchingOccupancy() {
|
||
presenceWatch?.stop();
|
||
presenceWatch = null;
|
||
// Or the next building — or the next entry into this one — opens wearing the
|
||
// previous deployment's roster while its own request is still in the air.
|
||
livePresence = null;
|
||
}
|
||
|
||
/**
|
||
* Put whoever is in the building on screen.
|
||
*
|
||
* The live roster if there is one, and otherwise the sample one **as it would be
|
||
* at the instant being rendered**. That second half is what stops the office
|
||
* showing a full complement of seated people at one in the morning, under house
|
||
* lights that came on because the sun is down — which was the least believable
|
||
* thing left in the room once the clock became real.
|
||
*
|
||
* A live answer always wins. An API that says the building is empty is telling
|
||
* the truth about the building, and dressing it with invented people would be
|
||
* the one lie this whole layer is arranged to avoid.
|
||
*/
|
||
function applyPresence() {
|
||
if (!office || office.depth !== "full") return;
|
||
const fallback = samplePresenceForOfficeAt(officePack?.id ?? officeId, currentInstant());
|
||
const people = livePresence ?? fallback;
|
||
presenceIsSample = livePresence === null && fallback.length > 0;
|
||
office.setPresence(people);
|
||
officePlan?.setPresence(people);
|
||
}
|
||
|
||
// ---- Chrome ---------------------------------------------------------------
|
||
//
|
||
// Six DOM handles and no more. Everything else on this page belongs to
|
||
// `ui/mount.ts`, which is the only module in the product that writes to it —
|
||
// these six are the hosts of things `mount` deliberately does not own: two
|
||
// widgets built by the engine, one overlay built by `profile/`, one by
|
||
// `media/`, and one indicator built by `realtime/`.
|
||
|
||
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
|
||
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
|
||
const presenceHost = document.querySelector<HTMLElement>("#presence-host");
|
||
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
|
||
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
|
||
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
|
||
let profileEditor: ProfileEditor | null = null;
|
||
let webcamFacePanel: WebcamFacePanel | null = null;
|
||
let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
|
||
let webcamFaceConsent = createWebcamFaceConsent();
|
||
let webcamCapture: WebcamCaptureController | null = null;
|
||
/** Whether hosted presence has a widget on screen; the chrome makes room for it. */
|
||
let presenceMounted = false;
|
||
|
||
function availableControlModes() {
|
||
const route = city?.current();
|
||
return {
|
||
insideOffice: inside,
|
||
drive: !inside && city?.vehicleState() !== null &&
|
||
(route === "la-sf-us-101" || route === "la-sf-i-5"),
|
||
actor: !inside && city?.actorState() !== null,
|
||
aircraft: !inside && city?.aircraftState() !== null,
|
||
officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The same availability, as the list the chrome takes.
|
||
*
|
||
* Two shapes for one fact is a smell and is the lesser one here:
|
||
* `transitionControlMode` was written against the record and is tested against
|
||
* it, and `chromeState` takes a list because a list is what a dock renders. This
|
||
* is the one conversion, in one place, rather than each caller deriving its own.
|
||
*/
|
||
function availableModes(): ControlMode[] {
|
||
const can = availableControlModes();
|
||
const modes: ControlMode[] = [inside ? "office-overview" : "overview"];
|
||
if (can.drive) modes.push("drive");
|
||
if (can.actor) modes.push("actor");
|
||
if (can.aircraft) modes.push("aircraft");
|
||
if (can.officeWalk) modes.push("office-walk");
|
||
return modes;
|
||
}
|
||
|
||
function clearPublishedPlayInput(): void {
|
||
playInput.clearAll();
|
||
city?.setVehicleActions({});
|
||
city?.setActorActions({});
|
||
city?.setAircraftActions({});
|
||
office?.walker?.setAction({ x: 0, z: 0 });
|
||
}
|
||
|
||
function adoptCityControlMode(mode: ReturnType<typeof cityControlMode>): void {
|
||
if (inside) return;
|
||
const previous = controlModeState.mode;
|
||
if (controlModeState.mode !== mode) {
|
||
clearPublishedPlayInput();
|
||
controlModeState = { mode, revision: controlModeState.revision + 1 };
|
||
}
|
||
if (previous === "overview" && mode !== "overview") closePanelForPlay();
|
||
renderChrome();
|
||
}
|
||
|
||
/** One transaction updates Journey, simulation ownership, input and chrome. */
|
||
function requestControlMode(requested: ControlMode): boolean {
|
||
const transition = transitionControlMode(controlModeState, requested, availableControlModes());
|
||
if (transition.changed) clearPublishedPlayInput();
|
||
controlModeState = transition.state;
|
||
const next = transition.state.mode;
|
||
|
||
if (inside) {
|
||
city?.setControlMode("overview");
|
||
const walking = next === "office-walk";
|
||
office?.walker?.setActive(walking);
|
||
if (!walking) office?.walker?.setAction({ x: 0, z: 0 });
|
||
} else {
|
||
office?.walker?.setActive(false);
|
||
city?.setControlMode(cityControlMode(next));
|
||
}
|
||
|
||
if (next === "drive") {
|
||
const routeId = city?.current();
|
||
if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") {
|
||
dispatchJourney({ type: "set-mode", mode: "play" });
|
||
if (journey.route?.routeId !== routeId) {
|
||
dispatchJourney({ type: "select-route", routeId, direction: 1 });
|
||
}
|
||
if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
|
||
}
|
||
} else if (journey.vehicle) {
|
||
dispatchJourney({ type: "exit-vehicle" });
|
||
}
|
||
if (next !== "overview" && next !== "office-overview") {
|
||
dispatchJourney({ type: "set-mode", mode: "play" });
|
||
} else if (!journey.vehicle) {
|
||
dispatchJourney({ type: "set-mode", mode: "observe" });
|
||
}
|
||
if (
|
||
transition.changed &&
|
||
(transition.previous === "overview" || transition.previous === "office-overview") &&
|
||
next !== "overview" && next !== "office-overview"
|
||
) {
|
||
closePanelForPlay();
|
||
}
|
||
|
||
renderChrome();
|
||
publishPlayActions();
|
||
return transition.accepted;
|
||
}
|
||
|
||
/** One authored sentence in the detail card, or nothing. */
|
||
function showDetail(text: string | null) {
|
||
detail = text === null ? null : { kind: "text", text };
|
||
renderChrome();
|
||
}
|
||
|
||
/**
|
||
* One aeroplane in the detail card, for anybody at all.
|
||
*
|
||
* Owner decision 2: the flight card is part of the demo an anonymous visitor
|
||
* gets, and there is nothing here an account could grant — an ADS-B position is
|
||
* broadcast unencrypted by the aircraft to anybody with a receiver. What the
|
||
* *deployment* knows and the engine does not is provenance, which is why the
|
||
* live source is asked first: `TrafficSource.detail` carries the transponder
|
||
* address off the wire body and the credit line the feed asks to be shown
|
||
* beside its data, and neither is recoverable from the position alone.
|
||
*
|
||
* The fallback is not a degraded case. It is the zero-config clone, the
|
||
* California corridor board and every aircraft the godmode dial invented, and
|
||
* for all three the honest answer is a card that says "simulated track" rather
|
||
* than a hex address nobody transmitted. `aircraftDetail` defaults `observed` to
|
||
* false precisely so that forgetting to say so cannot produce the other claim.
|
||
*/
|
||
function showAircraftDetail(aircraft: Aircraft | null): void {
|
||
if (aircraft === null) {
|
||
if (detail?.kind === "aircraft") showDetail(null);
|
||
return;
|
||
}
|
||
const centre = CITIES.find((c) => c.id === cityId)?.city.center;
|
||
const resolved = cityFlights?.detail(aircraft.id)
|
||
?? aircraftDetail(aircraft, { observed: false, ...(centre ? { from: centre } : {}) });
|
||
const card: AircraftDetailInput = {
|
||
// The hex when the feed gave one; the source's own id otherwise, which the
|
||
// card only ever shows for a track it has already labelled as simulated.
|
||
id: resolved.icao24 ?? resolved.id,
|
||
callsign: resolved.callsign,
|
||
lat: resolved.lat,
|
||
lng: resolved.lng,
|
||
altitude: resolved.altitudeM,
|
||
heading: resolved.headingDeg,
|
||
// The four the feed carries and the simulator does not. All four are `null`
|
||
// for a synthetic track and for a server one version behind, and the card
|
||
// drops the row rather than printing "unknown" — so there is no degraded
|
||
// state to handle here, only a shorter card.
|
||
type: resolved.type,
|
||
registration: resolved.registration,
|
||
groundSpeedKt: resolved.groundSpeedKt,
|
||
verticalRateFpm: resolved.verticalRateFpm,
|
||
synthetic: !resolved.observed,
|
||
attribution: resolved.attribution.length > 0 ? resolved.attribution.join(" · ") : null,
|
||
};
|
||
detail = { kind: "aircraft", aircraft: card };
|
||
renderChrome();
|
||
}
|
||
|
||
/** The board strip: worlds outside a building, buildings inside one. */
|
||
function boardTabs() {
|
||
return inside
|
||
? OFFICES.map((o) => ({ id: o.id, label: o.label, status: o.status }))
|
||
: CITIES.map((c) => ({ id: c.id, label: c.label, status: null }));
|
||
}
|
||
|
||
/**
|
||
* Whether the three-board tab strip is on screen.
|
||
*
|
||
* Off for everybody by default, which is the point of this round: a strip of
|
||
* three tabs naming three *modes* is the most direct statement the interface can
|
||
* make that this is three products rather than one place, and the places list is
|
||
* what replaced it. It is kept rather than deleted because it is still the only
|
||
* control that addresses a board by id, which is exactly what somebody working
|
||
* on a board wants — so `?boards=1` brings it back, and godmode has it always.
|
||
*
|
||
* Inside a building the same strip is the *studio* picker and there is nothing
|
||
* to hide: three studios are three places, and the list below them is the room's
|
||
* viewpoints rather than a second tour.
|
||
*/
|
||
const BOARD_TABS_FORCED = new URLSearchParams(location.search).get("boards") === "1";
|
||
|
||
function boardTabsVisible(): boolean {
|
||
return inside || BOARD_TABS_FORCED || access.can.debug;
|
||
}
|
||
|
||
/**
|
||
* The ladder, in the shape the left column takes it.
|
||
*
|
||
* Every rung of all three boards, every time — the list does not change when the
|
||
* board does, because that is the whole idea. What changes is which row is lit.
|
||
*/
|
||
function placeRows() {
|
||
return PLACES.map((rung) => ({
|
||
key: rung.key,
|
||
id: rung.id,
|
||
board: rung.board,
|
||
region: REGION_LABELS[rung.region],
|
||
label: rung.label,
|
||
standoffM: rung.standoffM,
|
||
description: rung.description,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Whatever the mode's controller is reporting, in the shape the HUD formats.
|
||
*
|
||
* Keyed off `playHudKindFor` rather than off a second `switch` on the mode, so
|
||
* the two orbit states are excluded once, in the module that also decides the
|
||
* card's visibility. `null` whenever the controller has nothing to say — a
|
||
* scene that has not built an aircraft cannot report on one — and the HUD is
|
||
* hidden rather than blank in that case.
|
||
*/
|
||
function playTelemetry(): PlayTelemetry | null {
|
||
switch (playHudKindFor(controlModeState.mode)) {
|
||
case "drive": {
|
||
const state = city?.vehicleState();
|
||
if (!state) return null;
|
||
return {
|
||
kind: "drive",
|
||
speedMps: state.speedMps,
|
||
roadName: state.roadName,
|
||
driveMode: state.mode,
|
||
progress: state.progress,
|
||
camera: city?.vehicleCamera() ?? "chase",
|
||
guardrailContact: state.guardrailContact,
|
||
collisionRisk: state.collisionRisk,
|
||
};
|
||
}
|
||
case "actor": {
|
||
const state = city?.actorState();
|
||
if (!state) return null;
|
||
return {
|
||
kind: "actor",
|
||
actor: state.kind,
|
||
speedMps: state.speedMps,
|
||
altitudeM: Math.max(0, state.y),
|
||
distanceM: state.distanceM,
|
||
pose: state.kind === "crow" ? state.crowPose : state.mode,
|
||
flightEnergy: state.flightEnergy,
|
||
displayName: state.identity.displayName,
|
||
atAltitudeBound: state.altitudeBoundContact !== "none",
|
||
};
|
||
}
|
||
case "aircraft": {
|
||
const state = city?.aircraftState();
|
||
if (!state) return null;
|
||
return {
|
||
kind: "aircraft",
|
||
speedMps: state.speedMps,
|
||
altitudeM: state.altitudeM,
|
||
flightMode: state.mode,
|
||
batteryWh: state.batteryWh,
|
||
stalled: state.stalled,
|
||
hardLanding: state.hardLanding,
|
||
envelopeContact: state.envelopeContact,
|
||
};
|
||
}
|
||
case "office-walk": {
|
||
const state = office?.walker?.state();
|
||
if (!state) return null;
|
||
return {
|
||
kind: "office-walk",
|
||
officeLabel: officeName(),
|
||
distanceM: state.distance,
|
||
x: state.position.x,
|
||
z: state.position.z,
|
||
};
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** The body under control, for the labels that name it. */
|
||
function chromeActor() {
|
||
if (inside) {
|
||
const walker = office?.walker?.state();
|
||
if (!walker) return {};
|
||
return { kind: walker.actor === "anonymous-dog" ? ("dog" as const) : ("humanoid" as const) };
|
||
}
|
||
if (controlModeState.mode === "aircraft") return { flying: true };
|
||
const state = city?.actorState();
|
||
if (!state) return {};
|
||
return { kind: state.kind, flying: state.mode === "flight" };
|
||
}
|
||
|
||
/**
|
||
* Everybody who is owed a credit for what is currently on screen.
|
||
*
|
||
* MET Norway and Open-Meteo publish under CC BY 4.0 and the ADS-B feeds ask to
|
||
* be named for the positions; all of it arrived, and until the `?` sheet started
|
||
* printing it, all of it was read by nobody — a licence obligation plumbed to
|
||
* within one line of being met. The weather override is deliberately excluded:
|
||
* a sky somebody typed is not MET Norway's sky and must not be attributed to
|
||
* them.
|
||
*/
|
||
function creditLines(): string[] {
|
||
const lines: string[] = [];
|
||
if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? []));
|
||
lines.push(...(cityFlights?.attribution() ?? []));
|
||
/**
|
||
* The three newest feeds, each named by whatever answered for it.
|
||
*
|
||
* The modelled harbour is in this list for the opposite of the usual reason:
|
||
* its attribution line exists to say that nothing out there is being claimed —
|
||
* "not an observation of any vessel" — and the sheet that prints licence
|
||
* obligations is the right place for a disclaimer of one. Radar and birds
|
||
* carry NWS and BirdCast the day a projection is configured, and carry nothing
|
||
* before that, which is why this reads the body rather than a constant.
|
||
*/
|
||
if (vesselBounds !== null) lines.push(...(vesselsBody?.attribution ?? []));
|
||
if (boardCarriesSky) {
|
||
lines.push(...(radarBody?.attribution ?? []));
|
||
lines.push(...(birdsBody?.attribution ?? []));
|
||
}
|
||
return lines.filter((line) => line.trim() !== "");
|
||
}
|
||
|
||
/**
|
||
* Everything the interface needs to know, as one plain object.
|
||
*
|
||
* This function is the whole of the coupling between the application and its
|
||
* chrome. It reads module state and returns data; it writes nothing and it
|
||
* decides nothing — every "should this be visible" question in the product is
|
||
* answered by `chromeState`, and every answer is applied by `mount`. Adding a
|
||
* piece of chrome is a field here, a decision there and a write in the applier,
|
||
* in that order, and never a fortieth `element.hidden =` in this file.
|
||
*/
|
||
function chromeInputs(): ChromeInputs {
|
||
const entry = CITIES.find((c) => c.id === cityId);
|
||
const selectedOffice = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
|
||
const mediaSurfaces = inside && office?.depth === "full" ? office.listMediaSurfaces() : [];
|
||
const robotActivity = inside ? office?.robotActivityInfo() ?? null : null;
|
||
const views: View[] = inside && office ? office.views : city?.chapters ?? [];
|
||
const activeViewId = inside && office ? office.current() : city?.current() ?? null;
|
||
|
||
return {
|
||
mode: controlModeState.mode,
|
||
available: availableModes(),
|
||
access: {
|
||
tier: access.tier,
|
||
signInUrl: access.signInUrl,
|
||
subject: access.subject,
|
||
displayName: localProfile?.displayName ?? null,
|
||
hasProfile: localProfile !== null,
|
||
},
|
||
inside,
|
||
officeDepth: inside ? office?.depth ?? null : null,
|
||
viewport: {
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
// The class is set by the first touch this page ever sees and never
|
||
// removed, because a laptop with a touchscreen has both and the one the
|
||
// person actually used is the one worth believing.
|
||
coarsePointer:
|
||
document.body.classList.contains("touch-capable") ||
|
||
window.matchMedia?.("(pointer: coarse)").matches === true,
|
||
},
|
||
feeds: {
|
||
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,
|
||
weatherOverridden: weatherOverride !== null,
|
||
sampleOccupancy: presenceIsSample,
|
||
credits: creditLines(),
|
||
},
|
||
degraded: access.degraded ?? [],
|
||
devices: inside ? office?.devices ?? [] : [],
|
||
firstVisit,
|
||
panelOpen,
|
||
planOpen,
|
||
|
||
board: {
|
||
cityId,
|
||
cityLabel: entry?.city.name ?? "",
|
||
officeId,
|
||
officeLabel: inside ? officeName() : selectedOffice?.label ?? "the studio",
|
||
officeStatus: selectedOffice?.status ?? "building",
|
||
isCalifornia: cityId === "california",
|
||
},
|
||
actor: chromeActor(),
|
||
office: {
|
||
mediaSurfaceCount: mediaSurfaces.length,
|
||
mediaSurfaceActiveCount: mediaSurfaces.filter((surface) => surface.bound).length,
|
||
robotDisclosure: robotActivity?.disclosure ?? null,
|
||
// Recomputed here rather than cached beside the promotion, because the
|
||
// load is a function of *where the building is* as well as what is
|
||
// burning, and this is the one place that knows both.
|
||
smokeNote: officeSmokeNote(),
|
||
walkable: office?.walker != null,
|
||
},
|
||
cameraLive: webcamCapture?.status() === "active",
|
||
presenceVisible: presenceMounted,
|
||
boards: boardTabs(),
|
||
boardsVisible: boardTabsVisible(),
|
||
places: inside ? [] : placeRows(),
|
||
activePlaceKey: activePlace()?.key ?? null,
|
||
views: views.map((view) => ({
|
||
id: view.id,
|
||
number: view.number ?? null,
|
||
shortLabel: view.shortLabel,
|
||
...(view.description === undefined ? {} : { description: view.description }),
|
||
})),
|
||
activeViewId,
|
||
telemetry: playTelemetry(),
|
||
detail,
|
||
clockLabel,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Draw the interface. One call, and it replaces forty.
|
||
*
|
||
* Cheap enough to run from the frame pump — `chromeState` is pure arithmetic and
|
||
* string building, and the applier compares before it writes and rebuilds a
|
||
* subtree only when that subtree's signature moves — but it is still called on
|
||
* *changes* rather than unconditionally, and the pump only joins in while a body
|
||
* is under control. See `pumpChrome`.
|
||
*
|
||
* The two plan widgets are updated here rather than inside the applier because
|
||
* neither is chrome: they are canvases the engine draws, holding a live camera,
|
||
* and `mount.ts` is deliberately ignorant of THREE.
|
||
*/
|
||
function renderChrome(): void {
|
||
// The one place the mode the app *thinks* it is in is reconciled with the mode
|
||
// the scene is actually in. A scene can change it on its own — a chapter that
|
||
// is a driving route puts the city into `drive` — and a dock that disagreed
|
||
// with the camera was the oldest bug in this file.
|
||
if (city) {
|
||
const walking = inside && (office?.walker?.active() ?? false);
|
||
const actual: ControlMode = inside
|
||
? walking ? "office-walk" : "office-overview"
|
||
: city.controlMode();
|
||
if (controlModeState.mode !== actual) {
|
||
controlModeState = { mode: actual, revision: controlModeState.revision + 1 };
|
||
}
|
||
}
|
||
chrome?.apply(chromeState(chromeInputs()));
|
||
showFireSection();
|
||
showBoardNotes();
|
||
// Each plan rings the entry its own legend is showing as current. Both are
|
||
// updated whichever one is mounted, so the hidden one is already right when it
|
||
// comes back rather than correcting itself a frame after it appears.
|
||
if (city) minimap?.setChapters(city.chapters, city.current());
|
||
officePlan?.setActiveView(office?.current() ?? null);
|
||
}
|
||
|
||
/**
|
||
* Walk out of one building and into another.
|
||
*
|
||
* A full teardown and rebuild rather than a swap, because everything an office
|
||
* scene holds is derived from its pack: the shell, the plan panel, the camera
|
||
* limits, the horizon drop, the light rig, the hardware on the desks and the car
|
||
* on the apron. The expensive part — the texture registry — is deliberately
|
||
* *not* rebuilt, which is the same trick that makes signing in cheap:
|
||
* `officeMaterials` outlives every scene that borrows it, so a switch costs
|
||
* geometry and not the thing that draws the wood grain.
|
||
*/
|
||
async function switchOffice(id: string) {
|
||
// `entering` is the same guard `toggleOffice` uses, and this has to share it.
|
||
// Without it, two clicks inside the loading window build two office scenes and
|
||
// the first is parked on the stage with nothing holding a reference to it —
|
||
// a whole `OfficeScene`, its geometry and its plan panel, leaked per click.
|
||
if (entering || id === officeId || !OFFICES.some((o) => o.id === id)) return;
|
||
const previous = officeId;
|
||
officeId = id;
|
||
|
||
// The roster, screen session, hardware feed, plan and scene all belong to the
|
||
// building being left.
|
||
disposeLoadedOffice();
|
||
|
||
entering = true;
|
||
try {
|
||
await building("Opening the door…", () => enterOffice());
|
||
} finally {
|
||
entering = false;
|
||
}
|
||
|
||
/**
|
||
* The old room is already gone by the time we find out whether the new one
|
||
* arrived, and a failed chunk fetch is a real case — `loadOffice` says so on
|
||
* the card and returns null.
|
||
*
|
||
* So there is no room on the stage and `inside` still claims there is. Put the
|
||
* viewer back in the city rather than in an empty scene, and put the door back
|
||
* on the building they came from, or the picker is wedged pointing at an
|
||
* office that will not open.
|
||
*/
|
||
if (office || !city) return;
|
||
officeId = previous;
|
||
inside = false;
|
||
city.stage.setScene(city.stageScene);
|
||
showPlan();
|
||
renderChrome();
|
||
}
|
||
|
||
// ---- Hosted realtime presence --------------------------------------------
|
||
|
||
function realtimeInterest(): InterestCell {
|
||
if (inside) return { kind: "office", officeId };
|
||
if (cityId === "california") return { kind: "california-tile", x: 0, y: 0, level: 0 };
|
||
return { kind: "city", cityId: cityId === "socal" ? "socal" : "bay-area" };
|
||
}
|
||
|
||
function degrees(radians: number): number {
|
||
return ((radians * 180 / Math.PI + 540) % 360) - 180;
|
||
}
|
||
|
||
function realtimePose(): EntityPoseSnapshot | null {
|
||
if (access.subject === null) return null;
|
||
const timestampMs = Date.now();
|
||
|
||
if (inside) {
|
||
const walker = office?.walker;
|
||
if (!walker) return null;
|
||
const state = walker.state();
|
||
const headingDeg = -degrees(Math.atan2(-state.facing.x, -state.facing.z));
|
||
return {
|
||
entity: "actor",
|
||
actorId: realtimeActorId,
|
||
kind: state.actor === "anonymous-dog" ? "dog" : "humanoid",
|
||
sequence: 0,
|
||
timestampMs,
|
||
pose: {
|
||
space: "local",
|
||
cell: { kind: "office", officeId },
|
||
xM: state.position.x,
|
||
yM: office?.plan.level(state.levelId)?.floorY ?? 0,
|
||
zM: state.position.z,
|
||
headingDeg,
|
||
pitchDeg: 0,
|
||
},
|
||
velocity: {
|
||
xMps: state.action.x * 1.6,
|
||
yMps: 0,
|
||
zMps: state.action.z * 1.6,
|
||
yawDegPerSec: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
const aircraft = city?.aircraftActive() ? city.aircraftState() : null;
|
||
if (aircraft) {
|
||
return createAircraftPoseSnapshot(
|
||
{ aircraftId: realtimeAircraftId, pilotActorId: realtimeActorId },
|
||
aircraft,
|
||
0,
|
||
timestampMs,
|
||
);
|
||
}
|
||
|
||
const vehicle = journey.vehicle !== null ? city?.vehicleState() : null;
|
||
if (vehicle) {
|
||
const heading = vehicle.headingDeg * Math.PI / 180;
|
||
return {
|
||
entity: "vehicle",
|
||
vehicleId: realtimeVehicleId,
|
||
kind: "model-x",
|
||
driverActorId: realtimeActorId,
|
||
sequence: 0,
|
||
timestampMs,
|
||
pose: {
|
||
space: "geographic",
|
||
lat: vehicle.lat,
|
||
lng: vehicle.lng,
|
||
altitudeM: 0,
|
||
headingDeg: vehicle.headingDeg,
|
||
pitchDeg: 0,
|
||
},
|
||
velocity: {
|
||
xMps: Math.sin(heading) * vehicle.speedMps,
|
||
yMps: 0,
|
||
zMps: Math.cos(heading) * vehicle.speedMps,
|
||
yawDegPerSec: 0,
|
||
},
|
||
steering: vehicle.steering,
|
||
wheelRadians: vehicle.wheelRadians,
|
||
};
|
||
}
|
||
|
||
const actor = city?.actorState();
|
||
if (!actor || !city) return null;
|
||
const headingDeg = -degrees(actor.yaw);
|
||
const velocity = {
|
||
xMps: -Math.sin(actor.yaw) * actor.speedMps,
|
||
yMps: actor.verticalSpeedMps,
|
||
// Geographic velocity is east/up/north; scene +Z is south.
|
||
zMps: Math.cos(actor.yaw) * actor.speedMps,
|
||
yawDegPerSec: 0,
|
||
};
|
||
const origin = cityId === "california" ? { lat: 35.5, lng: -119.5 } : city.world.city.center;
|
||
const [originX, originZ] = city.world.project(origin.lat, origin.lng);
|
||
const [lat, lng] = city.world.unproject(
|
||
originX + actor.x / city.world.metresPerUnit,
|
||
originZ + actor.z / city.world.metresPerUnit,
|
||
);
|
||
return {
|
||
entity: "actor",
|
||
actorId: realtimeActorId,
|
||
kind: actor.kind,
|
||
sequence: 0,
|
||
timestampMs,
|
||
pose: {
|
||
space: "geographic",
|
||
lat,
|
||
lng,
|
||
altitudeM: actor.y,
|
||
headingDeg,
|
||
pitchDeg: degrees(actor.pitch),
|
||
},
|
||
velocity,
|
||
};
|
||
}
|
||
|
||
function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean {
|
||
if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId;
|
||
if (snapshot.entity === "vehicle") {
|
||
return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
|
||
}
|
||
return snapshot.pilotActorId === realtimeClient?.state().actorId ||
|
||
snapshot.aircraftId === realtimeAircraftId;
|
||
}
|
||
|
||
function clearRealtimePeers(): void {
|
||
city?.clearRemoteEntities();
|
||
office?.clearRemoteEntities();
|
||
updatePresenceIndicator();
|
||
}
|
||
|
||
function applyRealtimeMessage(message: ServerRealtimeMessage): void {
|
||
if (message.type === "membership-revoked") {
|
||
stopWebcamFace(true);
|
||
clearRealtimePeers();
|
||
return;
|
||
}
|
||
const updates = message.type === "pose-delta"
|
||
? message.updates
|
||
: message.type === "join-grant"
|
||
? message.initial
|
||
: message.type === "resume-grant"
|
||
? message.snapshot
|
||
: [];
|
||
const target = inside ? office : city;
|
||
for (const snapshot of updates) {
|
||
if (!ownRealtimeEntity(snapshot)) target?.upsertRemoteSnapshot(snapshot);
|
||
}
|
||
if (message.type === "pose-delta") {
|
||
for (const id of message.removedEntityIds) {
|
||
if (
|
||
id !== `actor:${realtimeClient?.state().actorId}` &&
|
||
id !== `vehicle:${realtimeVehicleId}` &&
|
||
id !== `aircraft:${realtimeAircraftId}`
|
||
) {
|
||
target?.removeRemoteEntity(id);
|
||
}
|
||
}
|
||
}
|
||
updatePresenceIndicator();
|
||
}
|
||
|
||
function updatePresenceIndicator(state = realtimeClient?.state()): void {
|
||
if (!presenceIndicator || access.subject === null) return;
|
||
const connection: "connecting" | "live" | "reconnecting" | "offline" =
|
||
state?.status === "streaming" ? "live"
|
||
: state?.status === "reconnecting" ? "reconnecting"
|
||
: state?.status === "left" || state?.status === "disposed" ? "offline"
|
||
: "connecting";
|
||
const nearbyPeerCount = connection === "live"
|
||
? (inside ? office?.remoteEntityCount() : city?.remoteEntityCount()) ?? 0
|
||
: 0;
|
||
presenceIndicator.update({ signedIn: true, connection, nearbyPeerCount });
|
||
}
|
||
|
||
async function joinRealtimePresence(operation: number): Promise<void> {
|
||
const client = realtimeClient;
|
||
const pose = realtimePose();
|
||
if (!client || !pose) return;
|
||
try {
|
||
const grant = await client.join(realtimeInterest(), pose);
|
||
if (operation !== realtimeOperation) return;
|
||
applyRealtimeMessage(grant);
|
||
} catch {
|
||
if (operation === realtimeOperation) {
|
||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||
}
|
||
}
|
||
}
|
||
|
||
function moveRealtimePresence(): void {
|
||
clearRealtimePeers();
|
||
const client = realtimeClient;
|
||
if (!client || access.subject === null) return;
|
||
const operation = ++realtimeOperation;
|
||
if (client.state().sessionId === null) {
|
||
void joinRealtimePresence(operation);
|
||
return;
|
||
}
|
||
updatePresenceIndicator({ ...client.state(), status: "reconnecting" });
|
||
void client.moveInterest(realtimeInterest())
|
||
.then((grant) => {
|
||
if (operation === realtimeOperation) applyRealtimeMessage(grant);
|
||
})
|
||
.catch(() => {
|
||
if (operation === realtimeOperation) {
|
||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||
}
|
||
});
|
||
}
|
||
|
||
function publishRealtimePresence(now: number): void {
|
||
if (!realtimeClient || now - lastRealtimePublishAt < 100) return;
|
||
const pose = realtimePose();
|
||
if (!pose || realtimeClient.state().sessionId === null) return;
|
||
lastRealtimePublishAt = now;
|
||
try {
|
||
realtimeClient.publishPose(pose);
|
||
} catch {
|
||
presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
|
||
}
|
||
}
|
||
|
||
async function initializeRealtimePresence(): Promise<void> {
|
||
if (!realtimePageActive || access.subject === null || !presenceHost || realtimeClient) return;
|
||
const [{ createRealtimeClient }, { createPresenceIndicator }] = await Promise.all([
|
||
import("./realtime/client.ts"),
|
||
import("./realtime/presenceIndicator.ts"),
|
||
]);
|
||
// `pagehide` can overtake the lazy chunks. Never construct a fresh client
|
||
// after the page has already run its terminal cleanup.
|
||
if (!realtimePageActive || access.subject === null || realtimeClient) return;
|
||
presenceHost.hidden = false;
|
||
// The class that makes room for it is `chromeState`'s to add, from
|
||
// `presenceVisible` — one place decides what the top-right column contains.
|
||
presenceMounted = true;
|
||
presenceIndicator = createPresenceIndicator({
|
||
container: presenceHost,
|
||
onRetry: () => moveRealtimePresence(),
|
||
});
|
||
presenceIndicator.update({ signedIn: true, connection: "connecting", nearbyPeerCount: 0 });
|
||
realtimeClient = createRealtimeClient({
|
||
actorId: realtimeActorId,
|
||
authenticatedFetch: authFetch,
|
||
sendIntervalMs: 100,
|
||
});
|
||
stopRealtimeSubscription = realtimeClient.subscribe({
|
||
onMessage: applyRealtimeMessage,
|
||
onStateChange: updatePresenceIndicator,
|
||
onError: () => presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }),
|
||
});
|
||
moveRealtimePresence();
|
||
}
|
||
|
||
window.addEventListener("pagehide", () => {
|
||
realtimePageActive = false;
|
||
stopWebcamFace(true);
|
||
disposeOfficeScreenUi();
|
||
realtimeOperation += 1;
|
||
stopRealtimeSubscription?.();
|
||
stopRealtimeSubscription = null;
|
||
clearRealtimePeers();
|
||
// Page teardown cannot await the leave round trip. Contain a rotated-token
|
||
// or offline failure so navigation never produces an unhandled rejection;
|
||
// the server's disconnect grace still removes the ephemeral session.
|
||
void realtimeClient?.dispose().catch(() => undefined);
|
||
realtimeClient = null;
|
||
presenceIndicator?.dispose();
|
||
presenceIndicator = null;
|
||
// The one thing on this page that owns GPU memory neither scene created. It
|
||
// is disposed by whoever owns the `Stage`, which is this file, and never by a
|
||
// scene — the two kinds of environment are shared between every city and every
|
||
// office that ever existed on this page.
|
||
environment.dispose();
|
||
});
|
||
|
||
function applyProfilePreview(profile: LocalProfile): void {
|
||
if (access.subject === null) return;
|
||
if (inside) {
|
||
const appearance = resolveHumanoidAppearance(profile.appearance);
|
||
office?.walker?.setAppearance({
|
||
kind: "humanoid",
|
||
skinTone: appearance.skinTone,
|
||
outfitColor: appearance.outfitColor,
|
||
accentColor: appearance.accentColor,
|
||
hairColor: appearance.hairColor,
|
||
bodyShape: appearance.bodyShape,
|
||
});
|
||
} else {
|
||
city?.setActorIdentity(signedInActorIdentity(profile));
|
||
}
|
||
}
|
||
|
||
function cameraSupported(): boolean {
|
||
return typeof navigator.mediaDevices?.getUserMedia === "function";
|
||
}
|
||
|
||
/** Attach the one ephemeral texture to whichever signed-in humanoid is current. */
|
||
function attachCurrentWebcamFace(): void {
|
||
const texture = webcamFaceTexture?.texture() ?? null;
|
||
if (!texture || access.subject === null) return;
|
||
city?.clearActorFaceTexture();
|
||
office?.walker?.clearFaceTexture();
|
||
if (inside) office?.walker?.attachFaceTexture(texture);
|
||
else city?.attachActorFaceTexture(texture);
|
||
}
|
||
|
||
/**
|
||
* Stop app-owned capture. The texture adapter deliberately never owns tracks,
|
||
* so this boundary must stop every one before dropping the stream reference.
|
||
*/
|
||
function stopWebcamFace(revoke = false): void {
|
||
city?.clearActorFaceTexture();
|
||
office?.walker?.clearFaceTexture();
|
||
webcamFaceTexture?.clear();
|
||
webcamCapture?.stop();
|
||
if (revoke) webcamFaceConsent.revoke();
|
||
else webcamFaceConsent.stop();
|
||
webcamFaceTexture?.sync();
|
||
webcamFacePanel?.update(cameraSupported() ? "off" : "unsupported");
|
||
}
|
||
|
||
async function startWebcamFace(): Promise<void> {
|
||
if (access.subject === null || !localProfile || !cameraSupported() || webcamCapture?.status() === "active" ||
|
||
webcamCapture?.status() === "requesting") return;
|
||
webcamFacePanel?.update("requesting");
|
||
webcamFaceConsent.requestStart();
|
||
webcamCapture ??= createWebcamCapture({
|
||
acquire: () => navigator.mediaDevices.getUserMedia({
|
||
audio: false,
|
||
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } },
|
||
}),
|
||
createVideo: () => {
|
||
const video = document.createElement("video");
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.autoplay = true;
|
||
video.hidden = true;
|
||
document.body.append(video);
|
||
return video;
|
||
},
|
||
});
|
||
let binding: Awaited<ReturnType<WebcamCaptureController["start"]>>;
|
||
try {
|
||
binding = await webcamCapture.start();
|
||
} catch (error) {
|
||
webcamFaceConsent.stop();
|
||
const denied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
|
||
webcamFacePanel?.update(
|
||
"error",
|
||
denied ? "Camera permission was denied. Nothing was captured." : "Camera could not start. Check the device and try again.",
|
||
);
|
||
return;
|
||
}
|
||
// Null means Stop/pagehide overtook an asynchronous permission or play step.
|
||
if (!binding) return;
|
||
if (access.subject === null) {
|
||
stopWebcamFace(true);
|
||
return;
|
||
}
|
||
try {
|
||
const consent = webcamFaceConsent.start(true);
|
||
if (!consent.accepted) throw new Error("camera consent was no longer active");
|
||
webcamFaceTexture ??= createWebcamFaceTexture({ consent: webcamFaceConsent });
|
||
webcamFaceTexture.bind(binding);
|
||
for (const track of binding.stream.getVideoTracks()) {
|
||
track.addEventListener("ended", () => {
|
||
if (webcamCapture?.binding()?.stream === binding.stream) stopWebcamFace();
|
||
}, { once: true });
|
||
}
|
||
attachCurrentWebcamFace();
|
||
webcamFacePanel?.update("active");
|
||
// Native permission UI can restore focus to the page after the promise
|
||
// resolves. Put it back inside the still-open modal on the next frame.
|
||
requestAnimationFrame(() => {
|
||
if (profileEditor?.state().open) webcamFacePanel?.focusStop();
|
||
});
|
||
} catch {
|
||
stopWebcamFace();
|
||
webcamFacePanel?.update("error", "Camera preview could not start. Nothing was retained.");
|
||
}
|
||
}
|
||
|
||
function ensureProfileEditor(): ProfileEditor | null {
|
||
if (profileEditor) return profileEditor;
|
||
if (!profileOverlay || !localProfile || access.subject === null) return null;
|
||
profileEditor = createProfileEditor({
|
||
container: profileOverlay,
|
||
profile: localProfile,
|
||
identityId: access.subject,
|
||
onPreview: applyProfilePreview,
|
||
onSave(profile) {
|
||
localProfile = profile;
|
||
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile);
|
||
dispatchJourney({
|
||
type: "sign-in-actor-swap",
|
||
actor: {
|
||
id: access.subject ?? "anonymous",
|
||
kind: "humanoid",
|
||
signedIn: true,
|
||
profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor },
|
||
},
|
||
});
|
||
city?.setActorIdentity(signedInActorIdentity(profile));
|
||
applyProfilePreview(profile);
|
||
renderChrome();
|
||
profileOverlay.hidden = true;
|
||
},
|
||
onCancel() {
|
||
profileOverlay.hidden = true;
|
||
},
|
||
});
|
||
profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
|
||
if (webcamFaceIndicator) {
|
||
const host = document.createElement("div");
|
||
profileEditor.root.insertBefore(host, profileEditor.root.querySelector("form")?.nextSibling ?? null);
|
||
webcamFacePanel = createWebcamFacePanel({
|
||
container: host,
|
||
indicatorContainer: webcamFaceIndicator,
|
||
supported: cameraSupported(),
|
||
onStart: startWebcamFace,
|
||
onStop: () => stopWebcamFace(),
|
||
});
|
||
if (webcamCapture?.status() === "active") webcamFacePanel.update("active");
|
||
profileEditor.registerFocusables(webcamFacePanel.focusables);
|
||
}
|
||
return profileEditor;
|
||
}
|
||
|
||
function openProfileEditor(): void {
|
||
if (!localProfile || !profileOverlay) return;
|
||
const editor = ensureProfileEditor();
|
||
if (!editor) return;
|
||
editor.update(localProfile);
|
||
profileOverlay.hidden = false;
|
||
editor.open();
|
||
}
|
||
|
||
function screenBinding(surface: MediaSurfaceDescriptor) {
|
||
return {
|
||
officeId: surface.officeId,
|
||
levelId: surface.levelId,
|
||
roomId: surface.roomId,
|
||
screenId: surface.screenId,
|
||
};
|
||
}
|
||
|
||
function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void {
|
||
officeScreenPanel?.setRemoteStatus(screenId, status);
|
||
renderChrome();
|
||
}
|
||
|
||
function isRemoteAuthorizationError(error: unknown): boolean {
|
||
return error instanceof Error && /\((?:401|403)\)/.test(error.message);
|
||
}
|
||
|
||
function releaseRemoteViewer(
|
||
status: OfficeScreenRemoteStatus = "off",
|
||
action: "revoke" | "dispose" = "revoke",
|
||
): void {
|
||
const viewing = remoteViewedScreen;
|
||
const pendingScreenId = remoteViewerRequestScreenId;
|
||
remoteMediaOperation += 1;
|
||
remoteViewerRequestScreenId = null;
|
||
if (pendingScreenId && pendingScreenId !== viewing?.screenId) {
|
||
remotePanelStatus(pendingScreenId, status);
|
||
}
|
||
if (!viewing) return;
|
||
remoteViewedScreen = null;
|
||
office?.clearMediaSurface(viewing.screenId);
|
||
// `OfficeScreenPanel` owns defensive descriptor snapshots rather than a
|
||
// live view of OfficeScene. Refresh after clear just as the bind path does,
|
||
// otherwise the panel can keep saying "Media active" after remote teardown.
|
||
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
|
||
viewing.texture.dispose();
|
||
viewing.video.pause();
|
||
viewing.video.srcObject = null;
|
||
remotePanelStatus(viewing.screenId, status);
|
||
const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose();
|
||
void finish.catch(() => undefined);
|
||
}
|
||
|
||
function syncRemoteViewerState(
|
||
viewing: NonNullable<typeof remoteViewedScreen>,
|
||
state: RemoteMediaState,
|
||
): void {
|
||
if (remoteViewedScreen !== viewing) return;
|
||
if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting");
|
||
else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting");
|
||
else if (state.status === "live") remotePanelStatus(viewing.screenId, "live");
|
||
if (state.hasRemoteVideo && !viewing.bound) {
|
||
// Playback is a consequence of the user's screen-specific opt-in click;
|
||
// the transport itself deliberately never calls `play()`.
|
||
void viewing.video.play().then(() => {
|
||
if (remoteViewedScreen !== viewing || !office || !inside) return;
|
||
viewing.bound = office.bindMediaSurface(
|
||
viewing.screenId,
|
||
{ canView: true, optedIn: true },
|
||
viewing.texture,
|
||
);
|
||
officeScreenPanel?.update(office.listMediaSurfaces());
|
||
remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error");
|
||
}).catch(() => {
|
||
if (remoteViewedScreen === viewing) {
|
||
showDetail("The remote screen arrived, but this browser could not start video playback.");
|
||
releaseRemoteViewer("error", "dispose");
|
||
}
|
||
});
|
||
}
|
||
if (state.status === "stopped" || state.status === "revoked") {
|
||
showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped.");
|
||
releaseRemoteViewer("stopped", "dispose");
|
||
}
|
||
}
|
||
|
||
async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void> {
|
||
if (access.subject === null || !inside || !office || office.depth !== "full") return;
|
||
releaseRemoteViewer("off");
|
||
const operation = ++remoteMediaOperation;
|
||
remoteViewerRequestScreenId = surface.screenId;
|
||
remotePanelStatus(surface.screenId, "connecting");
|
||
showDetail(`Connecting to ${surface.screenId}…`);
|
||
try {
|
||
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||
const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
|
||
if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
|
||
office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
|
||
const video = document.createElement("video");
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.autoplay = false;
|
||
const texture = new VideoTexture(video);
|
||
texture.colorSpace = SRGBColorSpace;
|
||
texture.generateMipmaps = false;
|
||
let viewing: NonNullable<typeof remoteViewedScreen>;
|
||
const remote = createRemoteOfficeMedia({
|
||
role: "viewer",
|
||
binding: screenBinding(surface),
|
||
authenticatedFetch: authFetch,
|
||
onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
|
||
onError: (error) => {
|
||
if (remoteViewedScreen !== viewing) return;
|
||
if (isRemoteAuthorizationError(error)) {
|
||
showDetail("Remote screen authorization ended.");
|
||
releaseRemoteViewer("unavailable", "dispose");
|
||
} else {
|
||
remotePanelStatus(surface.screenId, "reconnecting");
|
||
}
|
||
},
|
||
});
|
||
viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false };
|
||
remoteViewerRequestScreenId = null;
|
||
remoteViewedScreen = viewing;
|
||
await remote.startViewer({ viewerOptIn: true, video });
|
||
if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`);
|
||
} catch (error) {
|
||
if (operation !== remoteMediaOperation) return;
|
||
remoteViewerRequestScreenId = null;
|
||
releaseRemoteViewer("unavailable", "dispose");
|
||
remotePanelStatus(surface.screenId, "unavailable");
|
||
showDetail(isRemoteAuthorizationError(error)
|
||
? "Remote screen authorization ended. Sign in again to reconnect."
|
||
: "No authorized remote share is available for that screen.");
|
||
}
|
||
}
|
||
|
||
async function startRemotePresenter(
|
||
surface: MediaSurfaceDescriptor,
|
||
shared: NonNullable<typeof sharedScreen>,
|
||
): Promise<void> {
|
||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||
remotePanelStatus(surface.screenId, "connecting");
|
||
try {
|
||
const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
|
||
if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
|
||
const remote = createRemoteOfficeMedia({
|
||
role: "presenter",
|
||
binding: screenBinding(surface),
|
||
authenticatedFetch: authFetch,
|
||
onStateChange(state) {
|
||
if (sharedScreen !== shared) return;
|
||
if (state.status === "live") remotePanelStatus(surface.screenId, "live");
|
||
else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting");
|
||
else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting");
|
||
else if (state.status === "stopped" || state.status === "revoked") {
|
||
showDetail("The hosted screen share ended.");
|
||
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
if (sharedScreen !== shared) return;
|
||
if (isRemoteAuthorizationError(error)) {
|
||
showDetail("Screen sharing stopped because authorization ended.");
|
||
queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
|
||
} else {
|
||
remotePanelStatus(surface.screenId, "reconnecting");
|
||
}
|
||
},
|
||
});
|
||
shared.remote = remote;
|
||
await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video });
|
||
if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`);
|
||
} catch (error) {
|
||
if (sharedScreen !== shared) return;
|
||
if (isRemoteAuthorizationError(error)) {
|
||
showDetail("Screen sharing stopped because authorization ended. Sign in again to share.");
|
||
stopLocalScreenShare();
|
||
return;
|
||
}
|
||
void shared.remote?.dispose().catch(() => undefined);
|
||
shared.remote = null;
|
||
remotePanelStatus(surface.screenId, "unavailable");
|
||
showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`);
|
||
}
|
||
}
|
||
|
||
function stopLocalScreenShare(): void {
|
||
const shared = sharedScreen;
|
||
if (!shared) return;
|
||
sharedScreen = null;
|
||
remoteMediaOperation += 1;
|
||
office?.clearMediaSurface(shared.screenId);
|
||
remotePanelStatus(shared.screenId, "stopped");
|
||
void shared.remote?.stop().catch(() => undefined);
|
||
for (const track of shared.stream.getTracks()) track.stop();
|
||
shared.texture.dispose();
|
||
shared.video.pause();
|
||
shared.video.srcObject = null;
|
||
officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
|
||
renderChrome();
|
||
}
|
||
|
||
function disposeOfficeScreenUi(): void {
|
||
stopLocalScreenShare();
|
||
releaseRemoteViewer("off", "dispose");
|
||
officeScreenPanel?.dispose();
|
||
officeScreenPanel = null;
|
||
if (screensOverlay) screensOverlay.hidden = true;
|
||
}
|
||
|
||
async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
|
||
if (!office || !inside || office.depth !== "full") return;
|
||
// One authored surface role at a time. A viewer texture must not survive
|
||
// behind a new presenter preview or be cleared later over the presenter.
|
||
releaseRemoteViewer("off");
|
||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||
showDetail("This browser does not provide tab or window sharing.");
|
||
return;
|
||
}
|
||
let pendingStream: MediaStream | null = null;
|
||
let pendingVideo: HTMLVideoElement | null = null;
|
||
let pendingTexture: VideoTexture | null = null;
|
||
try {
|
||
showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
|
||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||
video: {
|
||
displaySurface: "browser",
|
||
width: { ideal: 1_280, max: 1_280 },
|
||
height: { ideal: 720, max: 720 },
|
||
frameRate: { ideal: 15, max: 15 },
|
||
},
|
||
audio: false,
|
||
// Chromium honours these as chooser preferences. Other browsers ignore
|
||
// unknown dictionary members and still require the same explicit prompt.
|
||
monitorTypeSurfaces: "exclude",
|
||
selfBrowserSurface: "exclude",
|
||
surfaceSwitching: "include",
|
||
} as DisplayMediaStreamOptions);
|
||
pendingStream = stream;
|
||
const video = document.createElement("video");
|
||
pendingVideo = video;
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
video.srcObject = stream;
|
||
await video.play();
|
||
const texture = new VideoTexture(video);
|
||
pendingTexture = texture;
|
||
texture.colorSpace = SRGBColorSpace;
|
||
texture.generateMipmaps = false;
|
||
stopLocalScreenShare();
|
||
if (!office?.bindMediaSurface(surface.screenId, { canView: true, optedIn: true }, texture)) {
|
||
for (const track of stream.getTracks()) track.stop();
|
||
texture.dispose();
|
||
video.pause();
|
||
video.srcObject = null;
|
||
showDetail("That screen is no longer available.");
|
||
return;
|
||
}
|
||
const shared = { screenId: surface.screenId, stream, video, texture, remote: null };
|
||
sharedScreen = shared;
|
||
pendingStream = null;
|
||
pendingVideo = null;
|
||
pendingTexture = null;
|
||
stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true });
|
||
officeScreenPanel?.update(office.listMediaSurfaces());
|
||
renderChrome();
|
||
showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
|
||
// Anonymous/self-host-only behavior ends here exactly as before. The
|
||
// transport chunk is fetched only for a signed-in explicit share.
|
||
if (access.subject !== null) void startRemotePresenter(surface, shared);
|
||
} catch (error) {
|
||
for (const track of pendingStream?.getTracks() ?? []) track.stop();
|
||
pendingTexture?.dispose();
|
||
if (pendingVideo) {
|
||
pendingVideo.pause();
|
||
pendingVideo.srcObject = null;
|
||
}
|
||
if ((error as DOMException)?.name !== "NotAllowedError") {
|
||
showDetail("The screen preview could not start. Try again from Office screens.");
|
||
}
|
||
}
|
||
}
|
||
|
||
function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
|
||
if (!screensOverlay || !inside || !office || office.depth !== "full") return null;
|
||
if (officeScreenPanel) {
|
||
officeScreenPanel.update(office.listMediaSurfaces());
|
||
return officeScreenPanel;
|
||
}
|
||
officeScreenPanel = createOfficeScreenPanel({
|
||
container: screensOverlay,
|
||
surfaces: office.listMediaSurfaces(),
|
||
onRequestShare: (surface) => { void startLocalScreenShare(surface); },
|
||
onSelect(surface) {
|
||
if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) ||
|
||
(remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) {
|
||
releaseRemoteViewer("off");
|
||
}
|
||
},
|
||
onStopShare: (surface) => {
|
||
if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
|
||
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
|
||
releaseRemoteViewer("stopped");
|
||
}
|
||
},
|
||
onViewerOptIn(surface, optedIn) {
|
||
if (!optedIn) {
|
||
if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
|
||
releaseRemoteViewer("off");
|
||
} else {
|
||
remotePanelStatus(surface.screenId, "off");
|
||
}
|
||
} else if (access.subject !== null) {
|
||
void startRemoteViewer(surface);
|
||
}
|
||
},
|
||
});
|
||
const syncOverlay = () => queueMicrotask(() => {
|
||
if (officeScreenPanel && !officeScreenPanel.state().open) screensOverlay.hidden = true;
|
||
});
|
||
officeScreenPanel.root.addEventListener("click", syncOverlay);
|
||
officeScreenPanel.root.addEventListener("keydown", (event) => {
|
||
event.stopPropagation();
|
||
syncOverlay();
|
||
});
|
||
return officeScreenPanel;
|
||
}
|
||
|
||
function openOfficeScreens(): void {
|
||
const panel = ensureOfficeScreenPanel();
|
||
if (!panel || !screensOverlay) return;
|
||
screensOverlay.hidden = false;
|
||
panel.open();
|
||
}
|
||
|
||
// The `Office screens →` button is bound by `mount`; this is the overlay's own
|
||
// backdrop, which is not chrome and stays here with the panel it dismisses.
|
||
screensOverlay?.addEventListener("click", (event) => {
|
||
if (event.target !== screensOverlay) return;
|
||
officeScreenPanel?.close();
|
||
screensOverlay.hidden = true;
|
||
});
|
||
|
||
// ---- 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;
|
||
flyToChapter(view.id);
|
||
}
|
||
|
||
/**
|
||
* Go to a named view: a city chapter outside, a viewpoint inside.
|
||
*
|
||
* Split out of `flyToIndex` because the places list and the ladder keys address
|
||
* a rung by **identity**, and a position in a list is the one thing about a
|
||
* chapter that this round is deliberately not allowed to change — twenty-nine
|
||
* capture guards aim at `#chapters .chapter` by index and would silently shoot
|
||
* the wrong frame if it moved. Everything that used to be reached by index still
|
||
* is; nothing new is.
|
||
*/
|
||
function flyToChapter(viewId: string) {
|
||
const view = currentViews().find((candidate) => candidate.id === viewId);
|
||
if (!view) return;
|
||
if (inside && office) {
|
||
requestControlMode("office-overview");
|
||
cancelOfficeArrival();
|
||
office.flyTo(view.id);
|
||
renderChrome();
|
||
}
|
||
else {
|
||
/*
|
||
* A door is a door only while there is somewhere else to go.
|
||
*
|
||
* `los-angeles` and `san-francisco` carry real authored poses — 38 units at
|
||
* 26 of height, which is a 73 km stand-off over the city — and on the three
|
||
* -board product those poses are never used, because this line intercepts
|
||
* the click and switches boards instead. On the merged board both cities
|
||
* are *on this board*, so the interception is what stops you looking at
|
||
* them: it is the reason a visitor who clicks SF ends up somewhere the
|
||
* merged board is not.
|
||
*
|
||
* Left alone, the fall-through below flies the authored pose, and the
|
||
* offices stay reachable the way they already are on every other board —
|
||
* the amber pins, which `scene.ts` gates on the board's own bounds and the
|
||
* merged board's bounds contain both.
|
||
*/
|
||
const destination = cityId === "california" && !ONE_CALIFORNIA
|
||
? CALIFORNIA_DESTINATIONS.get(view.id)
|
||
: undefined;
|
||
if (destination) {
|
||
requestControlMode("overview");
|
||
officeId = destination.officeId;
|
||
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
|
||
switchCity(destination.cityId);
|
||
return;
|
||
}
|
||
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
|
||
city?.flyTo(view.id);
|
||
requestControlMode("drive");
|
||
closePanelForPlay();
|
||
renderChrome();
|
||
return;
|
||
}
|
||
requestControlMode("overview");
|
||
city?.flyTo(view.id);
|
||
renderChrome();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Step out of the office to the city, by whichever route keeps the URL honest.
|
||
*
|
||
* Returns true when it has started a navigation and the caller should stop — the
|
||
* page is on its way out, and anything after this runs against a document that
|
||
* is about to be replaced.
|
||
*
|
||
* The `?view=office` case is the cheap half of the same fix. There is no other
|
||
* host to go to, so the scene swap stands, but the query that put us in the
|
||
* office is dropped from the address bar on the way out. `replaceState` rather
|
||
* than `pushState`: the office and the city are one page, and a back button that
|
||
* walks a history of scene swaps is a back button that does not leave.
|
||
*/
|
||
function leaveToCity(cityWanted?: string): boolean {
|
||
const url = cityDoorUrl(cityWanted);
|
||
if (url !== null) {
|
||
location.assign(url);
|
||
return true;
|
||
}
|
||
const here = new URL(location.href);
|
||
if (here.searchParams.get("view") === "office") {
|
||
here.searchParams.delete("view");
|
||
history.replaceState(null, "", `${here.pathname}${here.search}${here.hash}`);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function switchCity(id: string) {
|
||
requestControlMode(inside ? "office-overview" : "overview");
|
||
if (inside && leaveToCity(id)) return;
|
||
if (inside) leaveOffice();
|
||
if (id === wantedCity) return;
|
||
// Arriving at a detailed board should put its local front door under the
|
||
// existing Office button. The California overview keeps whichever building
|
||
// the traveller last visited; it is a scale, not a fourth office location.
|
||
if (id === "socal") officeId = "mateo-court";
|
||
else if (id === "sf" && officeId === "mateo-court") officeId = "lumbridge-hq";
|
||
if (id === "california") {
|
||
if (journey.vehicle) dispatchJourney({ type: "exit-vehicle" });
|
||
if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
|
||
if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
|
||
} else {
|
||
journeyToCity(id === "socal" ? "socal" : "bay-area");
|
||
}
|
||
wantedCity = id;
|
||
/**
|
||
* No boot card, and no `building()` wrapper.
|
||
*
|
||
* `building()` puts an opaque full-screen panel up and holds it until a frame
|
||
* of the new scene has been drawn — correct while the outgoing board was
|
||
* disposed at the top of `mountCity` and there was genuinely nothing to look
|
||
* at, and wrong now that there is. The board you are on stays on screen and
|
||
* stays draggable for the whole build; `mountCity` raises the switch pill and
|
||
* `presentBoard` runs the fog dip. This is the line the owner feels.
|
||
*/
|
||
void 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);
|
||
}
|
||
|
||
/**
|
||
* Step one rung of the ladder, in the direction the list reads.
|
||
*
|
||
* `[` and `]` used to step the *board*, three ways round a cycle — which stopped
|
||
* being a true sentence the moment boards stopped being modes. A rung is what
|
||
* the left column now offers, and a rung on another board is reached exactly as
|
||
* a click on it would be.
|
||
*/
|
||
function stepLadder(delta: number): void {
|
||
if (inside) return;
|
||
const rows = PLACES;
|
||
const at = rows.findIndex((rung) => rung.key === activePlace()?.key);
|
||
const next = rows[Math.min(rows.length - 1, Math.max(0, (at < 0 ? 0 : at) + delta))];
|
||
if (next && next.key !== rows[at]?.key) goToPlace(next);
|
||
}
|
||
|
||
/**
|
||
* The rung the camera is standing on, or `null` inside a building.
|
||
*
|
||
* Recomputed rather than stored: it is a function of one live number
|
||
* (`cameraStandoffMetres`) and one live id (`SceneHandle.current()`), and a
|
||
* cached copy of a derived value is how a highlight and a camera start
|
||
* disagreeing.
|
||
*/
|
||
function activePlace(): LadderRung | null {
|
||
if (inside || !city) return null;
|
||
return activeRung(LADDER, cityId, city.cameraStandoffMetres(), city.current());
|
||
}
|
||
|
||
/**
|
||
* Go to a rung, on this board or another one.
|
||
*
|
||
* The cross-board case is the whole reason the ladder exists, and it has to
|
||
* dispatch the journey event: `journey/state.ts` models a board change as an
|
||
* explicit event and gates `enter-office` on `OFFICES[officeId] === location.scale`.
|
||
* A board that changed without one leaves the door into Mateo Court rejecting
|
||
* with `wrong-city` while the Southland is visibly on screen, and nothing throws.
|
||
* `switchCity` already dispatches; this routes through it rather than around it.
|
||
*/
|
||
function goToPlace(rung: LadderRung): void {
|
||
if (rung.board !== cityId) {
|
||
pendingPlace = rung;
|
||
switchCity(rung.board);
|
||
return;
|
||
}
|
||
flyToChapter(rung.id);
|
||
}
|
||
|
||
/**
|
||
* The rung to fly to once the board it is on has arrived, or `null`.
|
||
*
|
||
* A board switch is asynchronous and a rung is a pose on the board that is
|
||
* coming, so the two cannot be issued in one statement. Cleared by
|
||
* `presentBoard` whichever way the switch went, so a cancelled build cannot
|
||
* leave a flight queued against a board nobody asked for.
|
||
*/
|
||
let pendingPlace: LadderRung | null = null;
|
||
|
||
/**
|
||
* 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) {
|
||
if (leaveToCity()) return;
|
||
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;
|
||
try {
|
||
/*
|
||
* The boot card is the whole of the "something is happening" story now, and
|
||
* it is enough: `building()` puts it up before the fetch and takes it down
|
||
* only once a frame of the new scene is on the glass. The old arrangement
|
||
* also wrote "Opening the office…" onto the door itself, which meant this
|
||
* file reaching into a label `mount.ts` now owns to say something the card
|
||
* in front of it was already saying.
|
||
*/
|
||
await building("Fetching the office…", () => enterOffice());
|
||
} finally {
|
||
entering = false;
|
||
// Writes the real label whichever way it went — "← Back to the city" if we
|
||
// are in, the door again if the chunk fetch failed.
|
||
renderChrome();
|
||
}
|
||
}
|
||
|
||
/** Switch between the authored dollhouse camera and the local possessed actor. */
|
||
function toggleOfficeWalk(): boolean {
|
||
if (!inside) {
|
||
if (!city?.actorState()) return false;
|
||
const active = city.controlMode() !== "actor";
|
||
requestControlMode(active ? "actor" : "overview");
|
||
if (active) closePanelForPlay();
|
||
renderChrome();
|
||
return true;
|
||
}
|
||
const walker = office?.walker;
|
||
if (!walker) return false;
|
||
const active = !walker.active();
|
||
requestControlMode(active ? "office-walk" : "office-overview");
|
||
if (active) closePanelForPlay();
|
||
renderChrome();
|
||
return true;
|
||
}
|
||
|
||
function toggleAircraft(): boolean {
|
||
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
|
||
const active = city.controlMode() !== "aircraft";
|
||
requestControlMode(active ? "aircraft" : "overview");
|
||
if (active) closePanelForPlay();
|
||
renderChrome();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Clicking a building on the city walks into it.
|
||
*
|
||
* On the canvas rather than on anything the engine owns, because the engine
|
||
* reports picks by *hover* — `onMarkerPick` fires as the pointer crosses a pin
|
||
* and again with `null` as it leaves — so there is no click event to hang this
|
||
* on down there. `hoveredMarker` is whatever that hover last resolved, which is
|
||
* exactly what a click on the same pixel means.
|
||
*
|
||
* Guarded on not already being inside: the city's canvas is the office's canvas
|
||
* too, they share one renderer, and a stray click on the floor of a room should
|
||
* not re-enter the building you are standing in.
|
||
*/
|
||
canvas.addEventListener("click", () => {
|
||
if (inside) return;
|
||
const marker = hoveredMarker;
|
||
if (!marker) return;
|
||
const id = officeIdOf(marker);
|
||
if (id === null || entering) return;
|
||
entering = true;
|
||
officeId = id;
|
||
void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => {
|
||
entering = false;
|
||
renderChrome();
|
||
});
|
||
});
|
||
|
||
// ---- Panels, plan and overlays ----------------------------------------------
|
||
|
||
/**
|
||
* Get the left column out of the way when a body takes over.
|
||
*
|
||
* Only on a phone, where `#panel` is a bottom sheet covering a third of the
|
||
* screen: possessing an actor and then being unable to see it is not a trade
|
||
* anybody would make, and the sheet has a toggle to bring it back. Above that
|
||
* width the column is furniture beside the map rather than on top of it, and
|
||
* closing it would be taking away a thing that costs nothing.
|
||
*
|
||
* `panelChosen` is set for the same reason a keypress sets it: once anything has
|
||
* had an opinion about this panel, the viewport stops having one.
|
||
*
|
||
* The plan view is deliberately **not** closed here any more. It used to be, and
|
||
* the reason was that on a phone it was a bottom sheet in exactly the place the
|
||
* joystick now occupies; `ui/chromeState.ts` moved it to a glanceable corner and
|
||
* closing it is now taking the map away at the moment somebody is navigating.
|
||
*/
|
||
function closePanelForPlay(): void {
|
||
if (window.innerWidth > 600) return;
|
||
panelOpen = false;
|
||
panelChosen = true;
|
||
}
|
||
|
||
/**
|
||
* 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:
|
||
* a phone starts without `M` and had no button. `#plan-toggle` and the touch
|
||
* pad's Map button are that way in, and both arrive here through `mount`'s
|
||
* `onTogglePlan`, so the key and the buttons cannot disagree about the state.
|
||
*/
|
||
function togglePlan(): void {
|
||
setPlanOpen(!planOpen);
|
||
}
|
||
|
||
function setPlanOpen(open: boolean): void {
|
||
planOpen = open;
|
||
planChosen = true;
|
||
renderChrome();
|
||
}
|
||
|
||
function setPanelOpen(open: boolean): void {
|
||
panelOpen = open;
|
||
panelChosen = true;
|
||
renderChrome();
|
||
}
|
||
|
||
/**
|
||
* The viewport crossed a published breakpoint — a rotation, a window drag, a
|
||
* tablet turning over.
|
||
*
|
||
* Both seeds are re-run, and that "both" is the fix: `resize` used to re-seed
|
||
* the plan and, for no stated reason, not the panel, so rotating a tablet from
|
||
* portrait to landscape left the sheet closed over a layout wide enough that the
|
||
* toggle which would reopen it is not drawn. Either is skipped once its own
|
||
* control has been touched.
|
||
*/
|
||
function adoptLayout(_layout: ChromeLayout): void {
|
||
if (!panelChosen) panelOpen = seedPanelOpen(window.innerWidth);
|
||
if (!planChosen) planOpen = seedPlanOpen(window.innerWidth);
|
||
renderChrome();
|
||
}
|
||
|
||
function routeDriveIsActive(): boolean {
|
||
const state = !inside ? city?.vehicleState() : null;
|
||
return state !== null && state !== undefined && city?.controlMode() === "drive" &&
|
||
city.current() === state.routeId;
|
||
}
|
||
|
||
function toggleVehicleCamera(): boolean {
|
||
if (!routeDriveIsActive() || !city) return false;
|
||
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
||
return true;
|
||
}
|
||
|
||
function cameraForward(
|
||
camera: { position: { x: number; z: number } },
|
||
controls: { target: { x: number; z: number } },
|
||
) {
|
||
return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z };
|
||
}
|
||
|
||
function publishPlayActions(): boolean {
|
||
const input = playInput.snapshot();
|
||
const requests = playInput.consumeRequests();
|
||
if (routeDriveIsActive() && city) {
|
||
city.setVehicleActions({
|
||
...vehicleActionsFromPlay(input),
|
||
modeRequest: requests.has("assist") ? "assisted" : "none",
|
||
reset: requests.has("reset"),
|
||
});
|
||
if (requests.has("camera")) toggleVehicleCamera();
|
||
return true;
|
||
}
|
||
const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null;
|
||
if (walker?.active() && office) {
|
||
walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls)));
|
||
return true;
|
||
}
|
||
if (!inside && city?.controlMode() === "actor") {
|
||
const state = city.actorState();
|
||
if (!state) return false;
|
||
if (state.kind === "crow" && state.mode === "flight") {
|
||
city.setActorActions({
|
||
...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false,
|
||
});
|
||
} else {
|
||
city.setActorActions({
|
||
...groundActorActionsFromPlay(
|
||
input,
|
||
state.yaw,
|
||
cameraForward(city.stageScene.camera, city.stageScene.controls),
|
||
),
|
||
pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false,
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
if (!inside && city?.controlMode() === "aircraft") {
|
||
city.setAircraftActions({
|
||
...aircraftActionsFromPlay(input),
|
||
modeRequest: requests.has("assist") ? "assisted" : "none",
|
||
reset: requests.has("reset"),
|
||
});
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
window.addEventListener("keyup", (event) => {
|
||
const control = controlForKey(event.key);
|
||
if (!control) return;
|
||
playInput.setDigital("keyboard", control, false);
|
||
if (publishPlayActions()) event.preventDefault();
|
||
});
|
||
|
||
function releaseAllPlayInput(): void {
|
||
clearPublishedPlayInput();
|
||
publishPlayActions();
|
||
}
|
||
window.addEventListener("blur", releaseAllPlayInput);
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState !== "visible") releaseAllPlayInput();
|
||
});
|
||
|
||
let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
|
||
function pollPlayGamepad() {
|
||
try {
|
||
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
|
||
if (pad) {
|
||
const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
|
||
gamepadButtons = sample.buttons;
|
||
playInput.clearSource("gamepad");
|
||
playInput.setAxes("gamepad", sample.axes);
|
||
for (const control of sample.digital) playInput.setDigital("gamepad", control, true);
|
||
for (const request of sample.requests) playInput.request(request);
|
||
publishPlayActions();
|
||
} else {
|
||
playInput.clearSource("gamepad");
|
||
gamepadButtons = { assist: false, reset: false, camera: false };
|
||
}
|
||
} catch {
|
||
// Some privacy-hardened browsers expose the method but throw until a pad
|
||
// has produced a trusted event. Keyboard/touch remain fully functional.
|
||
}
|
||
requestAnimationFrame(pollPlayGamepad);
|
||
}
|
||
requestAnimationFrame(pollPlayGamepad);
|
||
|
||
window.addEventListener("pointerdown", (event) => {
|
||
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
|
||
}, { capture: true });
|
||
|
||
/**
|
||
* Keyboard access to everything the pointer 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.
|
||
*
|
||
* Every binding below is resolved through `ui/shortcuts.ts`, which is also what
|
||
* renders the `?` sheet — so a key that works and a key that is documented are
|
||
* now the same list. They were not: the sheet had no row for `G` while `g` was
|
||
* bound to glide *and* inserted at runtime meaning godmode, and Space was
|
||
* documented as "Handbrake while driving" while being the generic primary in
|
||
* every mode.
|
||
*/
|
||
window.addEventListener("keydown", (event) => {
|
||
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
|
||
const target = event.target;
|
||
if (
|
||
target instanceof HTMLInputElement ||
|
||
target instanceof HTMLSelectElement ||
|
||
target instanceof HTMLTextAreaElement ||
|
||
(target instanceof HTMLElement && target.isContentEditable)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Escape") {
|
||
// Through the applier rather than at `#shortcuts.hidden`, so the focus
|
||
// return and the `onShortcuts` callback stay correct whichever way the
|
||
// sheet was opened.
|
||
if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts();
|
||
else if (inside) {
|
||
if (!leaveToCity()) leaveOffice();
|
||
}
|
||
else showDetail(null);
|
||
return;
|
||
}
|
||
if (event.key === "?") {
|
||
if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts();
|
||
else chrome?.openShortcuts();
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
if (event.key >= "1" && event.key <= "9") {
|
||
/**
|
||
* A digit addresses whatever list is on screen.
|
||
*
|
||
* Inside a building that is still the viewpoint list. Outside it, the places
|
||
* list replaced the chapter list, so a digit that flew to the current
|
||
* board's nth chapter would be flying to a row nobody can see — and on the
|
||
* Bay Area board, 1-9 would silently mean nine different places from the
|
||
* nine printed in the column.
|
||
*/
|
||
if (inside) flyToIndex(Number(event.key) - 1);
|
||
else {
|
||
const rung = PLACES[Number(event.key) - 1];
|
||
if (rung) goToPlace(rung);
|
||
}
|
||
return;
|
||
}
|
||
if (event.key === "[") {
|
||
if (boardTabsVisible()) stepCity(-1);
|
||
else stepLadder(-1);
|
||
return;
|
||
}
|
||
if (event.key === "]") {
|
||
if (boardTabsVisible()) stepCity(1);
|
||
else stepLadder(1);
|
||
return;
|
||
}
|
||
const lower = event.key.toLowerCase();
|
||
const control = controlForKey(event.key);
|
||
if (control) {
|
||
playInput.setDigital("keyboard", control, true);
|
||
if (publishPlayActions()) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
/**
|
||
* The three one-shot requests — assist, reset, camera — from the same table
|
||
* the held controls come from.
|
||
*
|
||
* This was three hand-written `lower === "p"`-shaped branches, each carrying
|
||
* its own copy of "which modes is this meaningful in". The mode test is still
|
||
* here, because it is a fact about the simulation rather than about the
|
||
* keyboard: only a drive has a camera to swap and only a drive or a flight has
|
||
* an assist to resume, and a `P` that silently queued a request in overview
|
||
* would be a key that does nothing while claiming to do something.
|
||
*/
|
||
const edge = edgeForKey(event.key);
|
||
if (edge !== null) {
|
||
const driving = routeDriveIsActive();
|
||
const flying = city?.controlMode() === "aircraft";
|
||
const meaningful = edge === "camera" ? driving : driving || flying;
|
||
if (meaningful) {
|
||
playInput.request(edge);
|
||
publishPlayActions();
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
}
|
||
if (lower === "v" && toggleOfficeWalk()) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
if (lower === "m") {
|
||
togglePlan();
|
||
return;
|
||
}
|
||
if (lower === "o") void toggleOffice();
|
||
});
|
||
|
||
// ---- Time -------------------------------------------------------------------
|
||
|
||
/**
|
||
* One writer for one override.
|
||
*
|
||
* The `#hour` slider is gone: `capabilitiesFor` hands `timeControl` and `debug`
|
||
* to the same tier, so the only person who could see the scrubber was the person
|
||
* who can open the godmode panel — and it wrote an hour onto *today*, silently
|
||
* discarding whatever date the panel had set.
|
||
*
|
||
* The clock line stays visible to everyone; a map that will not say what time it
|
||
* is showing is worse than one you cannot scrub. This gate is belt and braces:
|
||
* "no control" and "no override" are two different facts, and the renderer
|
||
* depends on the second.
|
||
*/
|
||
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 anyone else the result 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.
|
||
*/
|
||
|
||
/** 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. The stage travels with the place even though
|
||
* there is only ever one, because what the panel is told is *which board these
|
||
* counters are about* — handing it 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`,
|
||
* and the panel prints "no hook wired" rather than a guess.
|
||
*/
|
||
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.
|
||
renderChrome();
|
||
},
|
||
/**
|
||
* Both dials read through the module-level handles rather than closing over
|
||
* a board, because the panel outlives the city: it is mounted once and every
|
||
* later `mountCity` swaps `city` and `trafficDial` underneath it. A closure
|
||
* over the board that was current when the panel opened would go on driving
|
||
* a disposed scene after the first city switch.
|
||
*/
|
||
/**
|
||
* Read through the module-level `office`, never closed over — the panel is
|
||
* mounted once and every `switchOffice` replaces the scene underneath it.
|
||
*/
|
||
office: {
|
||
onHouseLights(mode) {
|
||
houseLights = mode;
|
||
// `officeLighting` is the only caller of `setSolarElevation` and
|
||
// `updateSun` is the only caller of that, so this is the whole apply.
|
||
updateSun();
|
||
},
|
||
onRobotsVisible(visible) {
|
||
office?.setRobotsVisible(visible);
|
||
},
|
||
onCeilingsVisible(visible) {
|
||
office?.setCeilingsVisible(visible);
|
||
},
|
||
read() {
|
||
// Only while a room is actually on the stage. Out in the city the office
|
||
// is paused and kept, and a dimmer aimed at a scene nobody is rendering
|
||
// is exactly the dead panel this section is arranged to avoid.
|
||
if (!inside || !office) return null;
|
||
return {
|
||
id: officeId,
|
||
depth: office.depth,
|
||
houseLevel: office.houseLevel(),
|
||
robots: office.robots().length,
|
||
};
|
||
},
|
||
},
|
||
sky: {
|
||
onExtraTraffic(count) {
|
||
trafficDial?.setExtra(count);
|
||
},
|
||
onSatellitesVisible(visible) {
|
||
satellitesVisible = visible;
|
||
city?.setSatellitesVisible(visible);
|
||
},
|
||
read() {
|
||
const counts = city?.satelliteCounts();
|
||
return {
|
||
extraTraffic: trafficDial?.extra() ?? 0,
|
||
trafficIsLive: cityFlights?.live() ?? false,
|
||
// `total: 0` is a board with no catalogue, which is not the same as a
|
||
// catalogue with nothing above the horizon — the panel says so.
|
||
satellites: counts && counts.total > 0 ? counts : null,
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
// 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);
|
||
// The godmode row in the `?` sheet is no longer inserted from here: it is a
|
||
// `KEYMAP` entry scoped to the god tier and rendered by `renderShortcutSheet`,
|
||
// which is the same table the key handler reads. The hand-written row said
|
||
// `G`, and `G` is glide.
|
||
renderChrome();
|
||
}
|
||
|
||
/**
|
||
* 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. Its geometry used to be set here as
|
||
* element styles rather than as a rule, because every visitor downloads
|
||
* `index.html` and a `#pose-dock { … }` sitting in it that can never match is
|
||
* the one trace this arrangement would otherwise leave behind. That reason still
|
||
* holds and the styles have moved anyway: `tools/godmode.ts` injects its own
|
||
* stylesheet, that stylesheet is fetched only behind `access.can.debug`, and a
|
||
* rule in it can carry the `env(safe-area-inset-bottom)` and the phone
|
||
* breakpoint an inline style could not.
|
||
*/
|
||
function instrumentDock(): HTMLElement {
|
||
if (poseDockBody) return poseDockBody;
|
||
|
||
const dock = document.createElement("aside");
|
||
dock.id = "pose-dock";
|
||
dock.setAttribute("aria-label", "Pose editor");
|
||
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.className = "help";
|
||
toggle.setAttribute("aria-expanded", "true");
|
||
|
||
const body = document.createElement("div");
|
||
|
||
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;
|
||
}
|
||
|
||
// ---- The boot card ----------------------------------------------------------
|
||
|
||
const bootCard = document.querySelector<HTMLElement>("#boot");
|
||
const bootStep = document.querySelector<HTMLElement>("#boot-step");
|
||
const switchPill = document.querySelector<HTMLElement>("#switching");
|
||
const switchStep = document.querySelector<HTMLElement>("#switching-step");
|
||
|
||
/**
|
||
* What a board switch says now that it has stopped covering the screen.
|
||
*
|
||
* The boot card is the *first* board of a session and nothing else. Every switch
|
||
* after it builds behind a board that is still on screen and still draggable, so
|
||
* the only honest thing left to say is that something is happening — and a pill
|
||
* in the bottom corner says it without taking the picture away. It never takes a
|
||
* pointer event, which is the point: the board underneath stays usable.
|
||
*
|
||
* `fraction` is the heightfield's, straight off the Worker, and `null` before it
|
||
* has reported anything.
|
||
*/
|
||
function showSwitchProgress(label: string, fraction: number | null): void {
|
||
if (switchStep === null || switchPill === null) return;
|
||
// Never over the boot card. On the first mount of the session the card is the
|
||
// whole story and a second progress affordance under it is noise.
|
||
if (bootCard?.hidden === false) return;
|
||
const text =
|
||
fraction === null
|
||
? `Building ${label}…`
|
||
: `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
|
||
if (switchStep.textContent !== text) switchStep.textContent = text;
|
||
if (switchPill.hidden) switchPill.hidden = false;
|
||
}
|
||
|
||
function hideSwitchProgress(): void {
|
||
if (switchPill !== null && !switchPill.hidden) switchPill.hidden = true;
|
||
}
|
||
|
||
/**
|
||
* 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: the heightfield builds in a Worker, so
|
||
* `createScene` resolves while the main thread is idle and two animation frames
|
||
* go by in 33 ms — long before the first WebGL draw. The card faded on an empty
|
||
* canvas. `renderer.info.render.frame` is the renderer counting its own draws
|
||
* and is the one witness a fast frame cannot fool.
|
||
*
|
||
* The timeout bounds a promise that would otherwise never settle: an abandoned
|
||
* build never draws, and the boot card must not outlive 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 (onMainThread) return;
|
||
// Two destinations and exactly one of them is ever up: the card on the first
|
||
// mount of the session, the pill on every switch after it.
|
||
if (bootCard?.hidden === false) {
|
||
if (bootStep) bootStep.textContent = `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
|
||
return;
|
||
}
|
||
showSwitchProgress(label, fraction);
|
||
}
|
||
|
||
// ---- Boot -----------------------------------------------------------------
|
||
|
||
/**
|
||
* Bind the interface to the page, once.
|
||
*
|
||
* Every handler is one line and every one of them ends in a function that
|
||
* already existed — that is the measure of whether the chrome extraction
|
||
* worked. `mount` owns the listeners, the joystick, the shortcuts dialog, the
|
||
* onboarding coach and the device panel from here on; this file owns what the
|
||
* buttons mean.
|
||
*/
|
||
function mountInterface(): ChromeHandle {
|
||
return mountChrome(document, {
|
||
onEnter: () => void toggleOffice(),
|
||
onWalk: () => { toggleOfficeWalk(); },
|
||
onFly: () => { toggleAircraft(); },
|
||
onScreens: () => openOfficeScreens(),
|
||
onDevices: () => openDevicePanel(),
|
||
onDeviceCommand: (command) => sendDeviceCommand(command),
|
||
onSelectBoard: (id) => {
|
||
if (inside) void switchOffice(id);
|
||
else switchCity(id);
|
||
},
|
||
onSelectView: (_id, index) => flyToIndex(index),
|
||
onSelectPlace: (key) => {
|
||
const rung = LADDER.find((candidate) => candidate.key === key);
|
||
if (rung) goToPlace(rung);
|
||
},
|
||
onMode: (mode) => {
|
||
requestControlMode(mode);
|
||
if (mode !== "overview" && mode !== "office-overview") closePanelForPlay();
|
||
renderChrome();
|
||
},
|
||
onTogglePanel: (open) => setPanelOpen(open),
|
||
onTogglePlan: (open) => setPlanOpen(open),
|
||
onDismissDetail: () => showDetail(null),
|
||
onCharacter: () => openProfileEditor(),
|
||
// A held touch button is one source per control rather than one per pointer:
|
||
// `mount` captures the pointer and guarantees the matching release, so the
|
||
// per-pointer bookkeeping this file used to do has an owner now.
|
||
onTouchHold: (control, pressed) => {
|
||
playInput.setDigital(`touch:${control}`, control, pressed);
|
||
publishPlayActions();
|
||
},
|
||
onTouchEdge: (edge) => {
|
||
playInput.request(edge);
|
||
publishPlayActions();
|
||
},
|
||
onStick: (axes) => {
|
||
if (axes === null) playInput.clearSource("stick");
|
||
else playInput.setAxes("stick", axes);
|
||
publishPlayActions();
|
||
},
|
||
onOnboardingFinished: () => {
|
||
// The coach has recorded itself as seen; this is the app agreeing, so the
|
||
// next `renderChrome` does not put it straight back up.
|
||
firstVisit = false;
|
||
renderChrome();
|
||
},
|
||
onLayoutChange: (layout) => adoptLayout(layout),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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, the local hardware simulator, and a label at the
|
||
* bottom of the screen saying which of them you are looking at.
|
||
*/
|
||
async function boot() {
|
||
chrome = mountInterface();
|
||
renderChrome();
|
||
|
||
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
||
access = await resolveAccess();
|
||
if (access.subject !== null) {
|
||
const loadedProfile = loadLocalProfile(sessionStorage, LOCAL_PROFILE_KEY);
|
||
localProfile = loadedProfile.status === "loaded"
|
||
? loadedProfile.profile
|
||
: createDefaultLocalProfile(access.subject, access.subject);
|
||
if (loadedProfile.status !== "loaded" || loadedProfile.migrated) {
|
||
saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, localProfile);
|
||
}
|
||
} else {
|
||
localProfile = null;
|
||
}
|
||
dispatchJourney({
|
||
type: "sign-in-actor-swap",
|
||
actor: access.subject === null
|
||
? {
|
||
id: "anonymous",
|
||
kind: "crow",
|
||
signedIn: false,
|
||
profile: { displayName: "Guest" },
|
||
}
|
||
: {
|
||
id: access.subject,
|
||
kind: "humanoid",
|
||
signedIn: true,
|
||
profile: {
|
||
displayName: localProfile?.displayName ?? access.subject,
|
||
color: humanoidAppearance()?.accentColor,
|
||
},
|
||
},
|
||
});
|
||
applyTimeControl();
|
||
renderChrome();
|
||
|
||
// 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.liveMarkers && access.feeds?.markers) {
|
||
try {
|
||
const feed = await tera.markers();
|
||
markers = feed.value;
|
||
// The caller's palette, plus the door colour it cannot know about.
|
||
palette = { ...feed.palette, ...OFFICE_PALETTE };
|
||
liveData = feed.live;
|
||
} catch {
|
||
// A missing API is the self-host default, not an error.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Which metro to build, from the URL if it names one this build has.
|
||
*
|
||
* The parameter exists to make leaving the office lossless — `office.` sends
|
||
* you to `tera.?city=socal` rather than dropping you in the Bay Area — but it
|
||
* is read unconditionally, because a deep link to a city is a reasonable thing
|
||
* to want on its own and a parameter that only works when it arrives from one
|
||
* particular page is a trap. An unknown id falls back rather than failing:
|
||
* `?city=paris` on a build with two cities in it should show a city, not a
|
||
* black screen.
|
||
*/
|
||
const wanted = new URLSearchParams(location.search).get("city");
|
||
const fallback = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||
const first = CITIES.find((c) => c.id === wanted) ?? fallback;
|
||
await building(`Building ${first?.label ?? "the city"}…`, () =>
|
||
mountCity(first?.id ?? DEFAULT_CITY_ID),
|
||
);
|
||
|
||
// There must be a mounted scene before remote snapshots have anywhere to go.
|
||
// Anonymous and zero-server builds retain their existing local-only path.
|
||
// Hosted presence is an enhancement: a failed lazy chunk or unavailable API
|
||
// must not stop profile/tools initialization after the world is already live.
|
||
await initializeRealtimePresence().catch(() => undefined);
|
||
|
||
// 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();
|