A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.
Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.
Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".
Three things this run found and fixed rather than shipped:
- entryUrl came off the wire and went straight into an href with no scheme
check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
javascript: URL from navigating. One rejection point in access.ts now.
- A 5xx from /health was the same null as "no API at all" and therefore the
opposite conclusion. Eight seconds of tera-api restarting would have told
every anonymous visitor they were a member. A 5xx is an answer; it fails
closed.
- decodeURIComponent in cookieToken was the one path in auth/index.ts that
threw rather than returning ANONYMOUS, so one malformed cookie header from
an unauthenticated caller turned /api/v1/session into a 500.
Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+301
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* What this visitor may do — resolved once at boot, read everywhere after.
|
||||
*
|
||||
* There are three kinds of person in front of this map. Someone who has not
|
||||
* signed in (**anon**) gets the public city and a public office: the shell, the
|
||||
* furniture, the named viewpoints, nobody home. Someone signed in (**member**)
|
||||
* gets the live feeds and the people in the room. An administrator (**god**)
|
||||
* gets those plus the instruments — the time scrubber and the debug readouts —
|
||||
* which are development tools that happen to be shipped.
|
||||
*
|
||||
* This module exists so `main.ts` never has to think about auth again. Before
|
||||
* it, the app carried two loose booleans (`canEnterOffice`, `signInUrl`) and the
|
||||
* rule that produced them was inline in the boot path; every new capability
|
||||
* meant another boolean and another chance to get the rule subtly wrong. One
|
||||
* function, one value, one place to read the reasoning.
|
||||
*
|
||||
* ## These capabilities are UI, not security
|
||||
*
|
||||
* Say it plainly, because the shape of this file invites the opposite reading:
|
||||
* **nothing here is a boundary.** It is a set of decisions about what to draw.
|
||||
* Anyone can open the console and set `can.liveData` to true.
|
||||
*
|
||||
* The two halves of that are genuinely different and it matters which is which:
|
||||
*
|
||||
* - `timeControl` and `debug` are *purely* client-side. Scrubbing the clock
|
||||
* changes a `Date` that is fed to `observe()` in this browser and moves a sun
|
||||
* this browser is drawing. There is no server to enforce anything against, so
|
||||
* hiding the control here **is** the whole enforcement, and that is fine and
|
||||
* honest: the worst a determined visitor achieves is a sunset at 2 p.m. on
|
||||
* their own screen. Nothing leaks.
|
||||
*
|
||||
* - `liveData` and `officeDepth` are **not** enforced here even slightly. The
|
||||
* API returns nothing — no markers, and a 404 rather than a 403 for a private
|
||||
* office pack (CONTRACT.md §6) — to a caller it does not recognise. That
|
||||
* refusal is the security. What this module does is stop the app from asking
|
||||
* for something it will not get and from rendering an empty room as though it
|
||||
* were an empty office. It is a convenience laid on top of a server-side rule,
|
||||
* never a substitute for one. If you are ever tempted to move an access check
|
||||
* *out* of the API and into here because it is easier, that is the moment this
|
||||
* file has been misread.
|
||||
*
|
||||
* ## Why an unreachable API means `member` and not `god`
|
||||
*
|
||||
* A clean clone with no server is the repo's flagship case (CONTRACT.md §0) and
|
||||
* it has to be a good experience, so it gets `member`: live-shaped UI over the
|
||||
* bundled sample data, the whole office, no sign-in prompt for a door that does
|
||||
* not exist. It deliberately does **not** get `god`. The self-host promise is
|
||||
* "clone it and it works", not "clone it and you are an administrator of a
|
||||
* deployment you did not configure" — and the difference stops mattering only
|
||||
* until someone puts a static build in front of an API they do not control, at
|
||||
* which point a client that awards itself godmode whenever it cannot reach the
|
||||
* server has turned a network failure into a privilege escalation.
|
||||
*
|
||||
* Godmode comes from an explicit server-side grant. Always. Absence of an answer
|
||||
* is not an answer.
|
||||
*/
|
||||
|
||||
import { authFetch } from "./session.ts";
|
||||
|
||||
/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
|
||||
const BASE = "/api/v1";
|
||||
|
||||
/**
|
||||
* How long either probe may take before it counts as no answer.
|
||||
*
|
||||
* Boot awaits this, so an unbounded wait is not "eventually correct", it is a
|
||||
* map that never appears. A black-holed port — a firewall dropping packets
|
||||
* rather than refusing the connection — hangs `fetch` indefinitely, and that is
|
||||
* exactly the deployment mistake most likely to be made by the person this
|
||||
* timeout protects.
|
||||
*/
|
||||
const TIMEOUT_MS = 4000;
|
||||
|
||||
export type Tier = "anon" | "member" | "god";
|
||||
|
||||
export interface Capabilities {
|
||||
/** Step into the office at all. True for everyone; `officeDepth` is what differs. */
|
||||
enterOffice: boolean;
|
||||
/** "public" = shell, furniture and named views, nobody home. "full" = presence and occupants. */
|
||||
officeDepth: "public" | "full";
|
||||
/** Scrub the clock and the date. God only — see the note about why this is honest. */
|
||||
timeControl: boolean;
|
||||
/** Live markers and live flights rather than the fabricated sample set. */
|
||||
liveData: boolean;
|
||||
/** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
tier: Tier;
|
||||
subject: string | null;
|
||||
/** Where to send someone who is not signed in. `null` means this deployment has no door. */
|
||||
signInUrl: string | null;
|
||||
can: Capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table. One place, so "what does a member actually get?" is answered by
|
||||
* reading five lines rather than by grepping for `tier ===` across the app.
|
||||
*
|
||||
* `enterOffice` is true for all three on purpose. An earlier cut of this made
|
||||
* the office a members-only destination and the anonymous view of the site was
|
||||
* a map with a greyed-out button on it — the single most interesting thing this
|
||||
* project does, visible only as something you cannot have. The public office is
|
||||
* the same room with the occupancy layer off, and it costs nothing to show,
|
||||
* because the floorplan is a data file in this bundle and not a secret.
|
||||
*/
|
||||
export function capabilitiesFor(tier: Tier): Capabilities {
|
||||
return {
|
||||
enterOffice: true,
|
||||
officeDepth: tier === "anon" ? "public" : "full",
|
||||
timeControl: tier === "god",
|
||||
liveData: tier !== "anon",
|
||||
debug: tier === "god",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the deployment what it is, then ask it who you are.
|
||||
*
|
||||
* **The rule is the auth mode, not the presence of a login form.** This is a
|
||||
* bug that has already been fixed once in this repo and the way it was written
|
||||
* is worth keeping in front of anyone editing this function. The old line was:
|
||||
*
|
||||
* canEnterOffice = s.authenticated || !s.passwordLogin;
|
||||
*
|
||||
* which reads as "if this box cannot sign anyone in, it must be open". True for
|
||||
* `auth: none`. Dangerously false for `sso` and `jwt`, where `POST
|
||||
* /api/v1/session` is 404 precisely *because* credentials are issued somewhere
|
||||
* else — so on an SSO deployment that line handed every anonymous visitor the
|
||||
* private view while the config still said the deployment was private.
|
||||
*
|
||||
* So the mode comes from `/api/v1/health`, which already reports it, and only
|
||||
* `none` means open. Everything else is a private deployment and has to be told
|
||||
* affirmatively who you are.
|
||||
*
|
||||
* The two failure paths land in deliberately different places, and the asymmetry
|
||||
* is the entire point:
|
||||
*
|
||||
* - **No answer from `/health`** — no API, no deployment-level auth to honour,
|
||||
* the self-host default. `member`, no sign-in link.
|
||||
* - **`/health` answered and named a mode, then `/session` failed** — this is a
|
||||
* configured private deployment having a bad minute. Fail *closed*: `anon`.
|
||||
* An API that has already told you it has auth is not an API you may assume is
|
||||
* open.
|
||||
*/
|
||||
export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> {
|
||||
const health = await getJson<{
|
||||
auth?: { mode?: unknown; entryUrl?: unknown };
|
||||
}>(fetcher, "/health");
|
||||
|
||||
// Something is mounted at `/api/v1` and it is unwell. That is not the same
|
||||
// fact as "there is no API", and collapsing the two is how a deployment that
|
||||
// says it is private comes up open: `tera-api` restarts, Caddy answers 502 for
|
||||
// the eight seconds it takes, and every anonymous visitor in that window would
|
||||
// otherwise be told they are a member — badge, full-depth office, and a
|
||||
// markers request the server is about to refuse anyway. A 5xx is an answer,
|
||||
// so it is treated like a failed `/session`: closed, and no sign-in link,
|
||||
// because we do not yet know which door this deployment uses.
|
||||
if (health.kind === "broken") return access("anon", null, null);
|
||||
|
||||
// Nothing answered. Clone-and-run: full experience, no door, no godmode.
|
||||
if (health.kind === "gone") return access("member", null, null);
|
||||
|
||||
const body = health.body;
|
||||
const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
|
||||
const entryUrl = entryHref(body.auth?.entryUrl);
|
||||
|
||||
// A box with auth switched off is a self-host that chose to stay open. Same
|
||||
// deal as no API at all, and for the same reason it is `member` and not `god`.
|
||||
if (mode === "none") return access("member", null, null);
|
||||
|
||||
const fetched = await getJson<{
|
||||
authenticated?: unknown;
|
||||
subject?: unknown;
|
||||
passwordLogin?: unknown;
|
||||
admin?: unknown;
|
||||
}>(fetcher, "/session");
|
||||
// Every way `/session` can fail is the same way here — this deployment has
|
||||
// already said it has auth, so anything short of an affirmative answer is
|
||||
// `anon`. The three-way split above exists for `/health`, where the question
|
||||
// is whether there is an API at all; by this line that question is settled.
|
||||
const session = fetched.kind === "ok" ? fetched.body : null;
|
||||
|
||||
const authenticated = session !== null && session.authenticated === true;
|
||||
const passwordLogin = session !== null && session.passwordLogin === true;
|
||||
/**
|
||||
* Read defensively, because `admin` is newer than some servers this client
|
||||
* will meet. A deployment that has not been updated omits the field, `typeof`
|
||||
* says `undefined`, and its signed-in users are members — which is the only
|
||||
* safe direction for a missing field to fall. Never infer godmode from
|
||||
* silence; see the module header.
|
||||
*/
|
||||
const admin = session !== null && typeof session.admin === "boolean" ? session.admin : false;
|
||||
const subject = session !== null && typeof session.subject === "string" ? session.subject : null;
|
||||
|
||||
/**
|
||||
* The door, in order of how likely it is to actually work.
|
||||
*
|
||||
* `entryUrl` is the identity provider naming itself, so it wins. Otherwise the
|
||||
* local form, but *only* on a server that said it can process one: `login.html`
|
||||
* ships in this bundle and so is never a 404, which makes the failure mode
|
||||
* worse rather than better — a page that renders, takes an email and a
|
||||
* password, and posts them to an endpoint that answers 404 because this
|
||||
* deployment issues credentials elsewhere. An inert state that says "sign in
|
||||
* required" is more honest than a form that cannot succeed.
|
||||
*
|
||||
* A `/session` that did not answer counts as no local form for the same
|
||||
* reason. `GET /session` is public and always answers on a healthy box; if it
|
||||
* did not, the login POST is not going to fare better.
|
||||
*/
|
||||
const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);
|
||||
|
||||
if (!authenticated) return access("anon", null, signInUrl);
|
||||
return access(admin ? "god" : "member", subject, signInUrl);
|
||||
}
|
||||
|
||||
function access(tier: Tier, subject: string | null, signInUrl: string | null): Access {
|
||||
return { tier, subject, signInUrl, can: capabilitiesFor(tier) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The three answers a request to `/api/v1` can carry, which is one more than
|
||||
* this used to have.
|
||||
*
|
||||
* `null` for everything was the right shape while the only question was "is
|
||||
* there an API". It stopped being the right shape once the answer decided
|
||||
* whether an anonymous visitor is a member: a 502 while `tera-api` restarts and
|
||||
* a bare static host with no API behind it are the same `null` and the opposite
|
||||
* conclusion. So there are three, and no more than three — a 404 and a DNS
|
||||
* failure still land together, because no caller branches on the difference.
|
||||
*/
|
||||
type Fetched<T> =
|
||||
/** 2xx, JSON, parsed. */
|
||||
| { kind: "ok"; body: T }
|
||||
/** Nothing is mounted here: transport failure, 404, or a static host's HTML shell. */
|
||||
| { kind: "gone" }
|
||||
/** Something is mounted here and it is failing: 5xx. */
|
||||
| { kind: "broken" };
|
||||
|
||||
/**
|
||||
* One GET, sorted into one of the three.
|
||||
*
|
||||
* Still deliberately coarse, in the spirit of `adapters/http.ts`: a timeout, a
|
||||
* CORS refusal and a DNS failure are all `gone`, and a taxonomy of failures
|
||||
* nobody reads is a taxonomy nobody maintains. The one distinction that earns
|
||||
* its keep is 5xx, because it is the only status that means "the thing exists".
|
||||
*
|
||||
* The content-type check is not pedantry. A static host serving this bundle
|
||||
* answers an unknown path with `index.html` and a 200, so without it `/health`
|
||||
* "succeeds", `res.json()` throws on a `<!doctype html>`, and the throw happens
|
||||
* to land in the right place — which is a correct outcome arrived at by
|
||||
* accident. Checking makes it a decision.
|
||||
*/
|
||||
async function getJson<T>(fetcher: typeof fetch, path: string): Promise<Fetched<T>> {
|
||||
try {
|
||||
const res = await fetcher(`${BASE}${path}`, {
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (res.status >= 500) return { kind: "broken" };
|
||||
if (!res.ok) return { kind: "gone" };
|
||||
if (!(res.headers.get("content-type") ?? "").includes("json")) return { kind: "gone" };
|
||||
return { kind: "ok", body: (await res.json()) as T };
|
||||
} catch {
|
||||
// Includes a body that claimed JSON and was not. A malformed answer from a
|
||||
// live server is closer to a broken server than to an absent one, but it is
|
||||
// indistinguishable here from a socket that died mid-read, and `gone` is
|
||||
// what the zero-config case needs. The status check above is the line that
|
||||
// actually catches a sick API.
|
||||
return { kind: "gone" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `entryUrl` as something safe to put in an `href`.
|
||||
*
|
||||
* It arrives from `/api/v1/health`, which is to say from whatever this browser
|
||||
* is pointed at, and it lands in `a.href` in two places in `main.ts`. A CSP of
|
||||
* `script-src 'self' 'unsafe-inline'` — which is what `deploy/STATIC.md`
|
||||
* recommends and what the Lumbridge vhost serves — does **not** block a
|
||||
* `javascript:` URL from navigating, so an operator who pastes an untrusted
|
||||
* `TERA_AUTH_ENTRY_URL`, or an API that has been taken over, gets script
|
||||
* execution in the origin where the sso bearer token lives.
|
||||
*
|
||||
* Rejecting it once here beats validating at each sink, and the accepted set is
|
||||
* deliberately narrow: an absolute `http`/`https` URL, or a path on this origin.
|
||||
* Anything else — `javascript:`, `data:`, `blob:`, a protocol-relative `//host`
|
||||
* that silently leaves the origin — is not a sign-in page, and the honest
|
||||
* outcome for a deployment whose door is unusable is no door at all.
|
||||
*/
|
||||
function entryHref(raw: unknown): string | null {
|
||||
if (typeof raw !== "string" || raw === "") return null;
|
||||
try {
|
||||
const url = new URL(raw, window.location.origin);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+209
-60
@@ -400,6 +400,16 @@ export const DEFAULT_MOONLIGHT: MoonlightOptions = {
|
||||
// night on a screen someone is looking at in a lit room, not the photon
|
||||
// count. At the honest value the map is a black rectangle, which is the bug
|
||||
// this exists to fix.
|
||||
//
|
||||
// Left where it was when the moonless floor came up under it, and that is a
|
||||
// decision rather than an oversight. The key is already three quarters of the
|
||||
// light in a full-moon frame, so raising it to keep the gap would have been
|
||||
// raising the one term that is nearest to overshooting into a blue-graded day;
|
||||
// the gap is defended in `applyNight` instead, on the fill, where there was
|
||||
// room. What did have to be checked is that moonrise is still an *event* —
|
||||
// see the sanity checks at the foot of this file, where a full moon 43° up is
|
||||
// seven times the key of a moonless night and better than twice its ground
|
||||
// luminance on screen.
|
||||
intensity: 1.15,
|
||||
color: 0x9db4e8,
|
||||
skyLift: 0.85,
|
||||
@@ -484,11 +494,71 @@ const DEFAULT_SHADOW_FLOOR_DEG = 7;
|
||||
const NIGHT_FLOOR_TOP = 0x090e1c;
|
||||
const NIGHT_FLOOR_HORIZON = 0x16203a;
|
||||
|
||||
/** The same sky with a full moon in it. */
|
||||
/**
|
||||
* The same floor, for the light that lands on the ground.
|
||||
*
|
||||
* `NIGHT_FLOOR_TOP` above makes the argument for the sky and then only fixes
|
||||
* the sky, which is exactly half the job — and the half that hides the other
|
||||
* half, because a lifted sky behind a black landmass reads as a working render
|
||||
* of an empty ocean. What it was actually producing at -18° was terrain at
|
||||
* #000004 under a sky at #16203a: the coastline gone, the hills gone, water and
|
||||
* land the same colour, and nothing left in the frame but the lit windows and
|
||||
* the freeway threads floating in it.
|
||||
*
|
||||
* A photometrically correct answer here really is close to zero. A moonless
|
||||
* night is a few thousandths of a lux of airglow and starlight, against a hundred
|
||||
* thousand at noon, and any honest ratio lands under one code value. But three
|
||||
* things make zero the wrong number to render:
|
||||
*
|
||||
* - **The display has no room underneath.** Everything from 1/1000 of white
|
||||
* down to nothing shares the bottom two or three code values of an 8-bit
|
||||
* sRGB ramp. A correctly exposed night does not come out dim, it comes out
|
||||
* quantised to black, and no amount of squinting recovers a coastline that
|
||||
* was rounded to #000.
|
||||
* - **Nobody is dark-adapted.** The eye that can read a moonless landscape has
|
||||
* spent forty minutes getting there. The eye looking at this has a lit room
|
||||
* behind it and a white browser chrome around it, and its black point is
|
||||
* several stops above the screen's.
|
||||
* - **This is a map.** It is looked at from eighty kilometres up, from outside
|
||||
* the atmosphere it is depicting, by someone who wants to know where the bay
|
||||
* is. A view that goes correctly blank at 3 a.m. is not a night mode, it is
|
||||
* an outage — and it is reported as one.
|
||||
*
|
||||
* So these are the same kind of lie as `DEFAULT_MOONLIGHT.intensity`: not the
|
||||
* light there is, but the light a moonless night *looks like* it has once you
|
||||
* are standing in it. Held deliberately low enough that the city's own lit
|
||||
* windows stay the brightest thing in the frame by a factor of four or five,
|
||||
* which is the one relationship that makes it read as night rather than as a
|
||||
* blue-graded day.
|
||||
*
|
||||
* Split five ways rather than folded into one brightness because the *ratio*
|
||||
* between them is what stops the result looking like fog. Ambient is
|
||||
* unshaped — every surface gets the same number whichever way it faces — so a
|
||||
* night lit by ambient alone is flat, and flat and dark is fog, not darkness.
|
||||
* The hemisphere carries most of it instead, sky term well above ground term, so
|
||||
* a roof is lighter than a wall; and the keyframe table's token sidelight
|
||||
* survives at full strength on a moonless night (see `applyNight`) so the hills
|
||||
* still have a lit side and a dark one.
|
||||
*/
|
||||
const NIGHT_FLOOR_HEMI_SKY = 0x354c88;
|
||||
const NIGHT_FLOOR_HEMI_GROUND = 0x1f2740;
|
||||
const NIGHT_FLOOR_HEMI_INTENSITY = 0.78;
|
||||
const NIGHT_FLOOR_AMBIENT = 0x47557f;
|
||||
const NIGHT_FLOOR_AMBIENT_INTENSITY = 0.22;
|
||||
|
||||
/**
|
||||
* The same sky, and the same fill, with a full moon in it.
|
||||
*
|
||||
* These moved up when the floor did, and they had to: a floor raised to meet the
|
||||
* moon has deleted the moon, and `moonPosition` is four hundred lines of Meeus
|
||||
* that would then be decorative. The gap is the point — a moonlit night has to
|
||||
* arrive as an event, four to five times the moonless floor in linear light, and
|
||||
* with a *direction* in it that the floor by construction does not have.
|
||||
*/
|
||||
const MOONLIT_SKY_TOP = 0x111d3e;
|
||||
const MOONLIT_SKY_HORIZON = 0x2d3c62;
|
||||
const MOONLIT_HEMI_SKY = 0x2b3b60;
|
||||
const MOONLIT_AMBIENT = 0x3f4c76;
|
||||
const MOONLIT_HEMI_SKY = 0x40597f;
|
||||
const MOONLIT_AMBIENT = 0x546490;
|
||||
|
||||
/**
|
||||
* Where fog starts, as a fraction of where it ends. `cityDaylight`'s 210/460 is
|
||||
@@ -557,40 +627,52 @@ function keyframes(dayTop: number, dayHorizon: number): readonly Keyframe[] {
|
||||
// either; it is a token sidelight standing in for moonlight and city
|
||||
// glow, because a scene lit by hemisphere alone has no silhouettes in it
|
||||
// and reads as a bug rather than as darkness.
|
||||
//
|
||||
// The three night stops used to be an order of magnitude below this, and
|
||||
// the reason they were wrong is instructive: they were read off a
|
||||
// photograph of a night sky, which is a picture of the *sky* and says
|
||||
// nothing about the ground under it. Multiplied out, `hemiSky` at 0x121a30
|
||||
// times 0.25 came to about four thousandths of the fill at noon, which is
|
||||
// roughly honest and rendered San Francisco as #000004. What is here now
|
||||
// is the ground reading the eye reports — the coastline findable, the
|
||||
// hills with a lit side, the bay darker than the land around it — with the
|
||||
// sky stops left where they were, because those were never the problem.
|
||||
// See `NIGHT_FLOOR_HEMI_SKY`, which is what actually holds this up: these
|
||||
// rows sit just under the floor and the floor is what binds.
|
||||
elevation: -18,
|
||||
skyTop: 0x05070f,
|
||||
skyHorizon: 0x0b1120,
|
||||
sunColor: 0x2e3c66,
|
||||
sunIntensity: 0.05,
|
||||
hemiSky: 0x121a30,
|
||||
hemiGround: 0x080a10,
|
||||
hemiIntensity: 0.25,
|
||||
ambientColor: 0x28304a,
|
||||
ambientIntensity: 0.1,
|
||||
sunColor: 0x44558a,
|
||||
sunIntensity: 0.16,
|
||||
hemiSky: 0x2f447e,
|
||||
hemiGround: 0x1b2234,
|
||||
hemiIntensity: 0.55,
|
||||
ambientColor: 0x414e78,
|
||||
ambientIntensity: 0.16,
|
||||
},
|
||||
{
|
||||
elevation: -12,
|
||||
skyTop: 0x080d1e,
|
||||
skyHorizon: 0x141d38,
|
||||
sunColor: 0x3d4a76,
|
||||
sunIntensity: 0.07,
|
||||
hemiSky: 0x18223c,
|
||||
hemiGround: 0x0a0d16,
|
||||
hemiIntensity: 0.28,
|
||||
ambientColor: 0x2c3552,
|
||||
ambientIntensity: 0.11,
|
||||
sunColor: 0x51629b,
|
||||
sunIntensity: 0.19,
|
||||
hemiSky: 0x32477d,
|
||||
hemiGround: 0x1d2437,
|
||||
hemiIntensity: 0.57,
|
||||
ambientColor: 0x424f7a,
|
||||
ambientIntensity: 0.17,
|
||||
},
|
||||
{
|
||||
elevation: -6,
|
||||
skyTop: 0x101a3a,
|
||||
skyHorizon: 0x2b3560,
|
||||
sunColor: 0x5b5d8e,
|
||||
sunIntensity: 0.12,
|
||||
hemiSky: 0x22304f,
|
||||
hemiGround: 0x121520,
|
||||
hemiIntensity: 0.35,
|
||||
ambientColor: 0x38406a,
|
||||
ambientIntensity: 0.14,
|
||||
sunColor: 0x66699a,
|
||||
sunIntensity: 0.26,
|
||||
hemiSky: 0x3c558c,
|
||||
hemiGround: 0x23293c,
|
||||
hemiIntensity: 0.6,
|
||||
ambientColor: 0x485389,
|
||||
ambientIntensity: 0.19,
|
||||
},
|
||||
{
|
||||
// The sun on the horizon. Warm at the bottom, cold at the top, and the
|
||||
@@ -766,7 +848,7 @@ export function createAtmosphere(options: AtmosphereOptions): Atmosphere {
|
||||
}
|
||||
fogFar = Math.max(fogFar, floorFar);
|
||||
|
||||
const fogColor = applyObscuration(rig, obscuration, day, condition);
|
||||
const fogColor = applyObscuration(rig, obscuration, day, night, condition);
|
||||
|
||||
// A clear day keeps the near plane it was given; anything shorter holds the
|
||||
// ratio instead, because fog that starts where the clear day's did and ends
|
||||
@@ -943,15 +1025,25 @@ function applyNight(
|
||||
// silhouettes in it and reads as a bug. There is a real moon now, so the
|
||||
// stand-in gets out of its way — not entirely, because something still has
|
||||
// to hold the shape of the city up on an overcast night at new moon.
|
||||
rig.sunIntensity *= 1 - 0.85 * night;
|
||||
//
|
||||
// Weighted by how much moon there actually is, and not, as it was, by how
|
||||
// much night there is. Those are the same number only on the nights the
|
||||
// moon happens to be up, and the difference is the whole bug: `moonRig`
|
||||
// correctly returns nothing for a moon below the horizon, so on the common
|
||||
// case — which is most of every month, and *every* night before moonrise —
|
||||
// this line was removing 85% of the only directional light in the scene in
|
||||
// favour of a moon that was not there. The hills lost their lit side and
|
||||
// the frame went flat, on exactly the nights that needed the stand-in most.
|
||||
const present = clamp(moon.glow, 0, 1);
|
||||
rig.sunIntensity *= 1 - 0.85 * night * present;
|
||||
|
||||
const glow = clamp(moon.glow * options.skyLift, 0, 1);
|
||||
rig.skyTop = mixHex(rig.skyTop, MOONLIT_SKY_TOP, glow);
|
||||
rig.skyHorizon = mixHex(rig.skyHorizon, MOONLIT_SKY_HORIZON, glow);
|
||||
rig.hemiSky = mixHex(rig.hemiSky, MOONLIT_HEMI_SKY, 0.7 * glow);
|
||||
rig.ambientColor = mixHex(rig.ambientColor, MOONLIT_AMBIENT, 0.7 * glow);
|
||||
rig.hemiIntensity *= 1 + 0.5 * glow;
|
||||
rig.ambientIntensity *= 1 + 0.45 * glow;
|
||||
rig.hemiIntensity *= 1 + 0.7 * glow;
|
||||
rig.ambientIntensity *= 1 + 0.6 * glow;
|
||||
}
|
||||
|
||||
// Starlight, airglow, and the sodium of everywhere else bouncing off whatever
|
||||
@@ -965,6 +1057,32 @@ function applyNight(
|
||||
// the frame without having to know this ran.
|
||||
rig.skyTop = mixHex(rig.skyTop, atLeast(rig.skyTop, NIGHT_FLOOR_TOP), night);
|
||||
rig.skyHorizon = mixHex(rig.skyHorizon, atLeast(rig.skyHorizon, NIGHT_FLOOR_HORIZON), night);
|
||||
|
||||
// And the same again for the light that lands on the ground, which is the
|
||||
// half the sky floor above was always missing. Colours per channel through
|
||||
// `atLeast` for the reason that function documents — the floor is a blue, not
|
||||
// a brightness — and the two intensities by plain maximum, because both terms
|
||||
// are colour *times* intensity and flooring only one of them can be undone by
|
||||
// the other. Both ramp in on `night`, so this is a floor that arrives through
|
||||
// civil twilight rather than a step that switches on at some elevation.
|
||||
//
|
||||
// A moonlit night is already well above all five of these and passes through
|
||||
// untouched, which is the whole reason the moon's own lift went up when this
|
||||
// went in. See `MOONLIT_HEMI_SKY`.
|
||||
rig.hemiSky = mixHex(rig.hemiSky, atLeast(rig.hemiSky, NIGHT_FLOOR_HEMI_SKY), night);
|
||||
rig.hemiGround = mixHex(rig.hemiGround, atLeast(rig.hemiGround, NIGHT_FLOOR_HEMI_GROUND), night);
|
||||
const ambientFloor = atLeast(rig.ambientColor, NIGHT_FLOOR_AMBIENT);
|
||||
rig.ambientColor = mixHex(rig.ambientColor, ambientFloor, night);
|
||||
rig.hemiIntensity = lerp(
|
||||
rig.hemiIntensity,
|
||||
Math.max(rig.hemiIntensity, NIGHT_FLOOR_HEMI_INTENSITY),
|
||||
night,
|
||||
);
|
||||
rig.ambientIntensity = lerp(
|
||||
rig.ambientIntensity,
|
||||
Math.max(rig.ambientIntensity, NIGHT_FLOOR_AMBIENT_INTENSITY),
|
||||
night,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1215,11 +1333,27 @@ function windGate(layer: MarineLayerOptions, weather: WeatherObservation | null)
|
||||
* `cityDaylight()` commits to and the physically honest answer — distant haze
|
||||
* is lit by the sky it sits in front of, so it goes warm at sunset along with
|
||||
* everything else rather than staying a neutral grey.
|
||||
*
|
||||
* **The convergence is a daytime effect and only a daytime effect**, which is
|
||||
* what `night` is for. Fog is bright because the sun is in it, so pulling the
|
||||
* fill toward the fog colour is a *brightening* and a flattening — the two
|
||||
* things that make an overcast noon look like an overcast noon. Run the same
|
||||
* mixes at 3 a.m., where the fog colour is a near-black derived from a night
|
||||
* sky, and they are pure subtraction: San Francisco's August marine layer is at
|
||||
* its thickest at three in the morning, and it was quietly taking 63% of the
|
||||
* ground fill and all of its colour straight back out again — after
|
||||
* `applyNight` had finished, so the night floor could not see it happen and had
|
||||
* no chance to defend the frame. Which is a fair description of what a fog does
|
||||
* to a photograph and a terrible description of what it does to a city, where
|
||||
* the deck is lit from *underneath* by everything that is still switched on.
|
||||
* The sky still converges at night; it should, since a foggy night has no stars
|
||||
* in it. The ground no longer does.
|
||||
*/
|
||||
function applyObscuration(
|
||||
rig: Rig,
|
||||
obscuration: number,
|
||||
day: number,
|
||||
night: number,
|
||||
condition: SkyCondition,
|
||||
): number {
|
||||
let thick = mixHex(desaturate(rig.skyHorizon, 0.9), 0xbfc8cc, 0.5 * day);
|
||||
@@ -1233,10 +1367,11 @@ function applyObscuration(
|
||||
rig.sunIntensity *= 1 - 0.88 * obscuration;
|
||||
rig.sunColor = mixHex(rig.sunColor, 0xdfe6ea, 0.7 * obscuration);
|
||||
|
||||
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration);
|
||||
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration);
|
||||
const lit = 1 - night;
|
||||
rig.hemiSky = mixHex(rig.hemiSky, fogColor, 0.7 * obscuration * lit);
|
||||
rig.hemiGround = desaturate(rig.hemiGround, 0.6 * obscuration * lit);
|
||||
rig.hemiIntensity *= 1 + 0.12 * obscuration * day;
|
||||
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration);
|
||||
rig.ambientColor = mixHex(rig.ambientColor, fogColor, 0.6 * obscuration * lit);
|
||||
rig.ambientIntensity *= 1 + 0.45 * obscuration * day;
|
||||
|
||||
return fogColor;
|
||||
@@ -1387,12 +1522,14 @@ function wrapSigned(x: number, period: number): number {
|
||||
* back a clear golden morning or it is not a model of anything — October is
|
||||
* the month San Francisco is warm and cloudless and every visitor is
|
||||
* surprised by it.
|
||||
* - **03:00 PDT** (-23.7°, with the moon down): key 0.002, hemisphere 0.32,
|
||||
* ambient 0.12, and the light direction's `y` pinned at 0.122, which is
|
||||
* sin 7° — the shadow floor, keeping what is left of the token night
|
||||
* sidelight from shining up through the ground. The key is a thousandth
|
||||
* - **03:00 PDT** (-23.7°, with the moon down): key 0.045, hemisphere 0.78,
|
||||
* ambient 0.22, and the light direction's `y` pinned at 0.122, which is
|
||||
* sin 7° — the shadow floor, keeping the token night sidelight from shining
|
||||
* up through the ground. The hemisphere and the ambient are the night
|
||||
* floor's own two numbers to the digit, which is the floor doing exactly the
|
||||
* job it is there for; the key is down to 0.045 from the table's 0.16
|
||||
* because the marine layer is at its thickest at 3 a.m. in June and takes
|
||||
* 88% of it; the sky is a fog grey rather than a night blue for the same
|
||||
* 88% of it, and the sky is a fog grey rather than a night blue for the same
|
||||
* reason, which is right — a foggy night has no stars in it either.
|
||||
* - **Solar noon, 21 December** (28.8°): sun 2.07, fog 204/453, sky exactly
|
||||
* the palette's own. Out of season, the layer is not there.
|
||||
@@ -1416,42 +1553,54 @@ function wrapSigned(x: number, period: number): number {
|
||||
* *comes from* when nobody was asked; it is not what makes obscuration look
|
||||
* like anything.
|
||||
*
|
||||
* Tromsø on 5 January, at -2.98°, returns sun 0.30 against a twilight-blue sky
|
||||
* Tromsø on 5 January, at -2.98°, returns sun 0.37 against a twilight-blue sky
|
||||
* and nothing non-finite anywhere, which is the polar-night path through
|
||||
* `solar.ts` arriving here intact.
|
||||
*
|
||||
* **At night, San Francisco with no weather and no marine layer**, so that the
|
||||
* moon can be read on its own:
|
||||
*
|
||||
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 0.307, colour
|
||||
* #9bb2e6, hemisphere 0.46, ambient 0.17, sky #101b39 over #2a385b. A
|
||||
* seventh of the sun's noon intensity against nearly half of its fill,
|
||||
* which is a soft directional key with shadows you can find and not trip
|
||||
* over — on a sky that is unmistakably night and unmistakably blue.
|
||||
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.008
|
||||
* of the table's own sidelight colour, and the sky lands on #090e1c over
|
||||
* #16203a — the floor, exactly. That is the darkest frame this file can
|
||||
* produce, and it is the point: a genuinely correct night is `#000` and
|
||||
* `#000` is a bug report.
|
||||
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.078. A quarter of
|
||||
* the full moon's key from half its disc and a tenth of its altitude,
|
||||
* which is the phase curve and the rise ramp both doing visible work.
|
||||
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.16 and the
|
||||
* - **Full moon 43° up** (28 August 2026, 08:00 UTC): key 1.174, colour
|
||||
* #9cb3e6, hemisphere 1.12 of #3a5188, ambient 0.29 of #4d5c87, sky #101b39
|
||||
* over #2a385b. Half the sun's noon intensity, which is a preposterous
|
||||
* number and the one that puts a soft directional key on the city with
|
||||
* shadows you can find and not trip over — on a sky that is unmistakably
|
||||
* night and unmistakably blue.
|
||||
* - **New moon, below the horizon** (12 August 2026, 08:00 UTC): key 0.160 of
|
||||
* the table's own sidelight colour #44558a, hemisphere 0.78 of #354c88 over
|
||||
* #1f2740, ambient 0.22 of #47557f, and the sky at #090e1c over #16203a.
|
||||
* Every one of those five is the floor to the digit. That is the darkest
|
||||
* frame this file can produce, and it is the point: a genuinely correct
|
||||
* night is `#000` and `#000` is a bug report. Rendered, the Bay Area board
|
||||
* comes out at about y23 on the land against y15 on the bay and y29 on the
|
||||
* sky, with downtown's windows peaking past y130 — dark, but a dark you can
|
||||
* find a coastline in.
|
||||
* - **Half moon 4° up** (20 August 2026, 06:00 UTC): key 0.397. A third of the
|
||||
* full moon's key from half its disc and a tenth of its altitude, which is
|
||||
* the phase curve and the rise ramp both doing visible work.
|
||||
* - **The same full moon with `PACIFIC_MARINE_LAYER` on**: key 0.610 and the
|
||||
* sky greyed to #232b43 over #2d3855. The fog takes half the moonlight and
|
||||
* all of the colour, and August is when it would.
|
||||
* - **The same night reported overcast**: key 0.032 — no shadows at all —
|
||||
* all of the colour, and August is when it would. The *fill* it no longer
|
||||
* takes; see `applyObscuration`.
|
||||
* - **The same night reported overcast**: key 0.131 — no shadows at all —
|
||||
* with the fill barely down, because a cloud deck over a full moon is a
|
||||
* softbox rather than a lid.
|
||||
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.050,
|
||||
* colour #2e3c66, the table's token sidelight left in charge. The night
|
||||
* floor under the sky still applies, because that one is not about the
|
||||
* moon.
|
||||
* - **`moonlight: null`** returns the pre-moon rig unchanged: key 0.160,
|
||||
* colour #44558a, the table's token sidelight left in charge — which is now
|
||||
* the same frame the moon-below-the-horizon case produces, and should be.
|
||||
* Both floors still apply, because neither of them is about the moon.
|
||||
*
|
||||
* The gap between the second of those and the first is the one relationship
|
||||
* this file is tuned around: seven times the key, and on screen a Bay Area board
|
||||
* that goes from about y23 on the land to about y53. Moonrise is an event you
|
||||
* can watch happen, which is the whole justification for `moonPosition` being
|
||||
* four hundred lines of Meeus rather than a constant.
|
||||
*
|
||||
* The 28 August 2026 dusk is worth watching as a sequence, because it is the
|
||||
* configuration `combineKey` exists for — a full moon rising as the sun sets,
|
||||
* the two of them opposite each other in the sky. At sun +0.8° the key is 0.663
|
||||
* and #d4835a from the west; at -3.4° it is 0.226 and #9482a0 from between
|
||||
* them; by -7.3° it is 0.303 and #9ab0e3 from the east. The shadows swing
|
||||
* the two of them opposite each other in the sky. At sun +0.5° the key is 0.592
|
||||
* and #ce805b from the west; at -3.7° it is 0.328 and #8e6f87 from between
|
||||
* them; by -7.6° it is 0.572 and #8ea0d3 from the east. The shadows swing
|
||||
* across the city over about half an hour, which is not an artefact — it is
|
||||
* what actually happens, and on the one night a month it happens on.
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,8 +113,23 @@ const OFFICE_LIT = 0.24;
|
||||
const WINDOW_WARM = 0xffc178;
|
||||
const WINDOW_COOL = 0xd8e4ff;
|
||||
|
||||
/** Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown. */
|
||||
const WINDOW_GAIN = 0.8;
|
||||
/**
|
||||
* Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown.
|
||||
*
|
||||
* 0.8 when the ground under the city was effectively black, 0.95 now that
|
||||
* `atmosphere.ts` holds a real floor under a moonless night. That floor moved
|
||||
* the terrain from about #000004 to something you can find a coastline in, and
|
||||
* a window has to stay the brightest thing in the frame by a comfortable factor
|
||||
* or the whole picture stops being a city at night and becomes a city at dusk.
|
||||
* It is the *ratio* that is being defended here, not the absolute value.
|
||||
*
|
||||
* Still under 1, and that is not an accident: at 1.0 the emissive term alone
|
||||
* saturates the channel and a lit pane clips to white, taking `WINDOW_WARM` with
|
||||
* it. A skyline whose windows have lost the difference between tungsten and a
|
||||
* ceiling fluorescent is a skyline with the character taken out of it, and there
|
||||
* is no HDR buffer here to get it back from.
|
||||
*/
|
||||
const WINDOW_GAIN = 0.95;
|
||||
|
||||
/** Sodium, because a street lamp is the one light in a city that still is. */
|
||||
const LAMP_COLOR = 0xffb264;
|
||||
@@ -372,14 +387,22 @@ if (uNight > 0.002) {
|
||||
float footprint = max(fwidth(grid.x), fwidth(grid.y));
|
||||
float detail = 1.0 - smoothstep(0.5, 1.4, footprint);
|
||||
// 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's
|
||||
// own mean — and 1.8 times that is what is actually used, which is a lie
|
||||
// own mean — and 2.6 times that is what is actually used, which is a lie
|
||||
// worth being explicit about. The mean is the right answer for a display
|
||||
// whose response is linear, and no display's is: a pixel that in reality
|
||||
// contains one small blazing window and three dark ones does not read to
|
||||
// the eye as the average of the four, it reads as lit. With no HDR buffer
|
||||
// and no bloom to arrive at that honestly, the multiplier is the cheap way
|
||||
// to keep the far city as bright as the near city says it ought to be.
|
||||
float glow = mix(1.8 * 0.2128 * chance, coverage * on, detail);
|
||||
//
|
||||
// 1.8 for as long as the ground was black, because against black anything
|
||||
// reads. This is the branch the whole-board framing takes — every pixel of
|
||||
// the city is past the fwidth cutoff from up there — so it is also the
|
||||
// branch that had to answer when the atmosphere's night floor brought the
|
||||
// terrain up to meet it. At 1.8 against the new floor the lit grid and the
|
||||
// bare ground came out at the same luminance and downtown stopped being
|
||||
// findable, which is a worse bug than the one being fixed.
|
||||
float glow = mix(2.6 * 0.2128 * chance, coverage * on, detail);
|
||||
|
||||
// Roughly seven windows in ten warm. A skyline is mostly people's lamps and
|
||||
// only partly the floors the cleaners are still on.
|
||||
@@ -553,17 +576,22 @@ function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
* - **-8° and below**: 1.0. The lights stopped changing some minutes ago;
|
||||
* what changed after that was the sky behind them.
|
||||
*
|
||||
* Downtown's mean emission at distance is 1.8 x 0.2128 x 0.24 x 0.8 = 0.074,
|
||||
* against the avenues' 0.025 — a ratio of just under 3:1, which is the whole
|
||||
* Downtown's mean emission at distance is 2.6 x 0.2128 x 0.24 x 0.95 = 0.126,
|
||||
* against the avenues' 0.043 — a ratio of just under 3:1, which is the whole
|
||||
* picture, since the thing that makes a night skyline is not that the towers
|
||||
* are taller but that they are the part of the city with all its lights still
|
||||
* on. Around each of those figures the per-building variation spans 0.15x to
|
||||
* 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the
|
||||
* financial district does not smear into a single rectangle when you pull back.
|
||||
*
|
||||
* On a moonless night the buildings come out at about #4a403d against water at
|
||||
* #191b21 and a sky at #2d3855: the city is the brightest thing in the frame,
|
||||
* as it should be, and the sky is still visibly a sky.
|
||||
* On a moonless night, whole-board framing, measured off the render: the Bay
|
||||
* Area board puts downtown at about y34 mean and its brightest windows past
|
||||
* y130, against land at y23, bay water at y15 and sky at y29; the SoCal board,
|
||||
* which has no marine layer over it, comes out at y36 / y137 against land y34,
|
||||
* ocean y13 and sky y15. The city is comfortably the brightest thing in the
|
||||
* frame in both, which is the relationship that has to hold — and it stopped
|
||||
* holding, briefly, when `atmosphere.ts` first raised the ground under it.
|
||||
* That is what the 2.6 and the 0.95 are for.
|
||||
*
|
||||
* SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call,
|
||||
* comfortably under the 24,000 ceiling. The ceiling exists for the city pack
|
||||
|
||||
+198
-18
@@ -36,12 +36,40 @@
|
||||
* ceilings come off, the walls between you and what you are looking at go
|
||||
* translucent, and the existing camera, flight and picking machinery is reused
|
||||
* verbatim.
|
||||
*
|
||||
* ### Two depths, and the public one is the architecture without the people
|
||||
*
|
||||
* `depth: "public"` is the office an anonymous visitor gets, and the office is
|
||||
* becoming a front door in its own right, so this is the majority case rather
|
||||
* than a degraded one. It keeps the shell, the floor plan, the furniture, the
|
||||
* lighting and every named `View`. It builds **no presence layer at all** — no
|
||||
* occupants, no avatars, no seat states, nothing to hover that could name a
|
||||
* person — and `Plan` has already dropped whatever the pack marked
|
||||
* `audience: "private"` before this file sees it.
|
||||
*
|
||||
* The rule the two depths are written to is *build-time exclusion, never
|
||||
* visibility toggling*. There is no `presence.group.visible = false` path here
|
||||
* and there must not be one: a scene that constructs the private objects and
|
||||
* then hides them still hands every one of them to `scene.traverse`, to the
|
||||
* devtools scene graph and to anyone who types `scene.children` into a console.
|
||||
* That is a data leak dressed as a privacy feature, and it is worse than not
|
||||
* having the feature, because it looks like it works.
|
||||
*
|
||||
* **None of that is a security boundary.** The office pack is bundled into the
|
||||
* static build, so its contents are public by construction whatever they are
|
||||
* marked, and `lumbridge-hq.ts` is fabricated sample data besides. The only
|
||||
* thing genuinely being withheld from an anonymous visitor is occupancy, and it
|
||||
* is withheld because live `Presence` comes from the API and **the API is what
|
||||
* refuses an anonymous caller** — not because this file declined to draw it. If
|
||||
* a future deployment ever ships real occupant data, that server-side refusal is
|
||||
* the fix; a `depth` argument in the browser is not, and never will be. See the
|
||||
* note on `Audience` in `types.ts`.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import { createSceneKit, type Pose } from "../engine/scenekit.ts";
|
||||
import type { StageScene } from "../engine/stage.ts";
|
||||
import type { LightingState, View } from "../engine/types.ts";
|
||||
import type { LightingState, Pin, View } from "../engine/types.ts";
|
||||
import type { AssetRegistry } from "../assets/kit.ts";
|
||||
import { MaterialRegistry, type MaterialQuality } from "../assets/materials.ts";
|
||||
import type { InteriorPalette } from "../assets/palette.ts";
|
||||
@@ -51,11 +79,15 @@ import type { InteriorPalette } from "../assets/palette.ts";
|
||||
// replacement of a built-in id.
|
||||
import "../assets/office/index.ts";
|
||||
import { createFurnishings, type Furnishings } from "./furnish.ts";
|
||||
import { Plan, type PlanOptions } from "./plan.ts";
|
||||
import { Plan, type Depth, type PlanOptions } from "./plan.ts";
|
||||
import { createPresenceLayer, type PresenceLayer, type PresencePalette } from "./presence.ts";
|
||||
import { createShell, type Shell, type WallInfo } from "./shell.ts";
|
||||
import type { Office, Point2, Presence, Viewpoint } from "./types.ts";
|
||||
|
||||
// Re-exported so a caller can name the tier it is asking for without importing
|
||||
// the resolver. `Plan` is where depth is *applied*; this is where it is chosen.
|
||||
export type { Depth } from "./plan.ts";
|
||||
|
||||
export interface OfficeSceneOptions {
|
||||
/**
|
||||
* The renderer's canvas. Orbit input and pointer coordinates are read against
|
||||
@@ -63,9 +95,25 @@ export interface OfficeSceneOptions {
|
||||
* renderer and has its own everything else.
|
||||
*/
|
||||
dom: HTMLElement;
|
||||
/**
|
||||
* How much of the office to build. Defaults to `"full"`, which is every
|
||||
* caller that existed before this option did.
|
||||
*
|
||||
* `"public"` is the not-signed-in building: same shell, same plan, same
|
||||
* furniture, same lighting, same views, and no people. See the header for what
|
||||
* that means and, more importantly, for what it does not mean.
|
||||
*
|
||||
* There is no way to change this after construction, on purpose. Signing in
|
||||
* while standing in the public office is a `dispose()` and a second
|
||||
* `createOfficeScene` at `"full"`, which is cheap if you hand both of them the
|
||||
* same `materials` — the textures are the expensive part and they are drawn
|
||||
* once per registry, not once per office.
|
||||
*/
|
||||
depth?: Depth;
|
||||
/**
|
||||
* Bring your own, to share one set of materials and textures across two
|
||||
* offices. Made here otherwise, and disposed here only if it was made here.
|
||||
* offices — or across the same office reopened at another depth. Made here
|
||||
* otherwise, and disposed here only if it was made here.
|
||||
*/
|
||||
materials?: MaterialRegistry;
|
||||
quality?: MaterialQuality;
|
||||
@@ -76,7 +124,19 @@ export interface OfficeSceneOptions {
|
||||
colorFor?: (key: string) => number | undefined;
|
||||
/** Resolves a `Presence.colorKey` to a colour. Also opaque. */
|
||||
presencePalette?: PresencePalette;
|
||||
/** Full depth only. At `"public"` there is no presence to pick. */
|
||||
onPresencePick?: (presence: Presence | null) => void;
|
||||
/**
|
||||
* Public depth only: the pointer is over a desk, and here is what a stranger
|
||||
* is allowed to be told about it.
|
||||
*
|
||||
* The public office is not a diorama — you can still hover the furniture — but
|
||||
* what comes back is a `Pin` and never a `Presence`, and its label is
|
||||
* `"Desk 14"`. It is a separate callback rather than a widened
|
||||
* `onPresencePick` because the two carry different things: one says who is
|
||||
* there, and this one says only that there is a there.
|
||||
*/
|
||||
onPlacePick?: (place: Pin | null) => void;
|
||||
/** Overrides the fixed interior rig. Must carry `sky: null` and `fog: null`. */
|
||||
lighting?: LightingState;
|
||||
/** Defaults to false — the lid comes off, because that is the whole view. */
|
||||
@@ -93,23 +153,45 @@ export interface OfficeSceneOptions {
|
||||
|
||||
export interface OfficeScene extends StageScene {
|
||||
plan: Plan;
|
||||
/**
|
||||
* What this office actually is, so the caller can tell what it got rather than
|
||||
* assuming it got what it asked for. The UI reads this to decide whether to
|
||||
* print the "no presence" badge and whether to offer a sign-in.
|
||||
*/
|
||||
depth: Depth;
|
||||
/** The pack's viewpoints, as the thing a legend prints and `flyTo` is keyed on. */
|
||||
views: View[];
|
||||
flyTo(viewId: string): void;
|
||||
current(): string | null;
|
||||
onViewChange(fn: (id: string) => void): void;
|
||||
/** Occupancy, bound by seat id. Safe to call before the scene is shown. */
|
||||
/**
|
||||
* Occupancy, bound by seat id. Safe to call before the scene is shown.
|
||||
*
|
||||
* A no-op at public depth — there is no layer to put anybody in — and it warns
|
||||
* once rather than silently accepting people it will not draw. A caller that
|
||||
* finds itself needing that warning is asking an anonymous session for
|
||||
* occupancy, which is a question the API should already have refused.
|
||||
*/
|
||||
setPresence(people: Presence[]): void;
|
||||
/** Scene-space label anchors per presence id, for an HTML overlay. */
|
||||
/** Scene-space label anchors per presence id, for an HTML overlay. Empty at public depth. */
|
||||
anchors: Map<string, THREE.Vector3>;
|
||||
setCeilingsVisible(visible: boolean): void;
|
||||
setLighting(state: LightingState): void;
|
||||
}
|
||||
|
||||
export function createOfficeScene(office: Office, options: OfficeSceneOptions): OfficeScene {
|
||||
const plan = new Plan(office, options.plan ?? {});
|
||||
const depth: Depth = options.depth ?? "full";
|
||||
// The scene's `depth` wins over anything `plan` carried. There is one tier per
|
||||
// office and it is chosen here; a `PlanOptions.depth` that disagreed with the
|
||||
// handle's would produce a scene whose `depth` field was a lie, which is the
|
||||
// one field a caller has to be able to trust.
|
||||
const plan = new Plan(office, { ...(options.plan ?? {}), depth });
|
||||
const scene = new THREE.Scene();
|
||||
scene.name = `office:${office.id}`;
|
||||
// The public build says so in the scene graph, and the full one keeps the name
|
||||
// it has always had. Whoever is reading `scene.name` in the devtools is the
|
||||
// exact person who needs to know which of the two buildings they are looking
|
||||
// at before they conclude anything from what is missing.
|
||||
scene.name = depth === "full" ? `office:${office.id}` : `office:${office.id}:public`;
|
||||
|
||||
const ownsMaterials = options.materials === undefined;
|
||||
const materials =
|
||||
@@ -169,8 +251,18 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
...(options.registry ? { registry: options.registry } : {}),
|
||||
...(options.colorFor ? { colorFor: options.colorFor } : {}),
|
||||
});
|
||||
const presence: PresenceLayer = createPresenceLayer(plan, options.presencePalette ?? {});
|
||||
scene.add(shell.group, furnishings.group, presence.group);
|
||||
// A public office has no presence layer, rather than an empty one. The
|
||||
// difference is not cosmetic: an empty `PresenceLayer` is a `THREE.Group`
|
||||
// named "presence" hanging in the scene graph, a `setPresence` that works, and
|
||||
// a pair of figure geometries one call away from being populated by any code
|
||||
// that gets a handle on it. None of that should exist in the building a
|
||||
// stranger is looking at. The layer is `null`, the group is never added, and
|
||||
// every path that would have used it is written to cope with its absence
|
||||
// rather than to hide it. See the header.
|
||||
const presence: PresenceLayer | null =
|
||||
depth === "full" ? createPresenceLayer(plan, options.presencePalette ?? {}) : null;
|
||||
scene.add(shell.group, furnishings.group);
|
||||
if (presence) scene.add(presence.group);
|
||||
shell.ceilings.visible = options.showCeilings ?? false;
|
||||
|
||||
// ---- Viewpoints ---------------------------------------------------------
|
||||
@@ -241,13 +333,56 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
|
||||
// ---- Picking ------------------------------------------------------------
|
||||
|
||||
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
|
||||
// rather than the array: the office outlives any one set of people in it.
|
||||
kit.setPicking<Presence>({
|
||||
targets: () => presence.pickables,
|
||||
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
|
||||
onChange: (person) => options.onPresencePick?.(person),
|
||||
});
|
||||
/**
|
||||
* At public depth, the desks are the pick surface and a desk is a number.
|
||||
*
|
||||
* Built once, up front, and handed out by reference — `SceneKit` decides
|
||||
* whether the hover changed by comparing what `resolve` returned against what
|
||||
* it returned last frame, so a fresh object literal per hit would fire
|
||||
* `onChange` every frame the pointer sat still.
|
||||
*
|
||||
* The numbering is the point of the map. A desk's real address is its seat id,
|
||||
* `eng-14`, and that string says which team sits there — it is the id a
|
||||
* private occupancy API is keyed on precisely because it means something. A
|
||||
* stranger gets `Desk 14`, numbered from one in plan order across the whole
|
||||
* building, which says only that this office has at least fourteen desks. The
|
||||
* bank ids, the seat ids and the station numbers stay on this side of the
|
||||
* callback.
|
||||
*/
|
||||
const places: Map<string, Pin> | null = depth === "public" ? new Map() : null;
|
||||
if (places) {
|
||||
let n = 0;
|
||||
for (const level of plan.levels) {
|
||||
for (const prop of level.props) {
|
||||
if (prop.source?.part !== "desk") continue;
|
||||
n += 1;
|
||||
places.set(prop.id, { id: `desk-${n}`, label: `Desk ${n}`, colorKey: "desk" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (presence) {
|
||||
// `pickables` is rebuilt in place whenever occupancy changes, so the getter
|
||||
// rather than the array: the office outlives any one set of people in it.
|
||||
kit.setPicking<Presence>({
|
||||
targets: () => presence.pickables,
|
||||
resolve: (hit) => (hit.object.userData.presence as Presence | undefined) ?? null,
|
||||
onChange: (person) => options.onPresencePick?.(person),
|
||||
});
|
||||
} else if (places) {
|
||||
// The furnishings are instanced, so the hit resolves in two steps: the
|
||||
// instanced mesh plus the instance index gives a prop id, and only the prop
|
||||
// ids that are in the map — the desks — resolve to anything at all. A chair,
|
||||
// a plant or a light is not a place and comes back `null`.
|
||||
kit.setPicking<Pin>({
|
||||
targets: () => furnishings.pickables,
|
||||
resolve: (hit) => {
|
||||
const id = furnishings.propAt(hit.object, hit.instanceId);
|
||||
return id === null ? null : (places.get(id) ?? null);
|
||||
},
|
||||
onChange: (place) => options.onPlacePick?.(place),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Occlusion fade -----------------------------------------------------
|
||||
|
||||
@@ -296,19 +431,51 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
|
||||
// ---- The scene, as the stage sees it ------------------------------------
|
||||
|
||||
/**
|
||||
* Disposal is one-way and it is checked, because the reason this handle gets
|
||||
* thrown away is usually that another one is being built to replace it.
|
||||
*
|
||||
* Signing in while standing in the public office disposes this scene and
|
||||
* constructs a `"full"` one; the stage is mid-frame when that happens, and a
|
||||
* `tick` arriving after `dispose` would drive an `OrbitControls` that has
|
||||
* already released its listeners. Guarding here rather than asking every
|
||||
* caller to sequence it correctly is the difference between a dispose you can
|
||||
* rely on and one that mostly works.
|
||||
*/
|
||||
let disposed = false;
|
||||
let warnedNoPresence = false;
|
||||
|
||||
return {
|
||||
scene,
|
||||
camera: kit.camera,
|
||||
controls: kit.controls,
|
||||
plan,
|
||||
depth,
|
||||
views,
|
||||
anchors: presence.anchors,
|
||||
// A public office anchors nothing, because it has nobody to anchor. The
|
||||
// empty map is this scene's own rather than a shared module-level one: an
|
||||
// HTML overlay that writes into what it was handed should not be able to
|
||||
// reach across into another office.
|
||||
anchors: presence?.anchors ?? new Map<string, THREE.Vector3>(),
|
||||
flyTo,
|
||||
current: () => currentView,
|
||||
onViewChange(fn) {
|
||||
viewListeners.push(fn);
|
||||
},
|
||||
setPresence(people) {
|
||||
if (!presence) {
|
||||
// Once, not once per poll: an occupancy feed pointed at the public
|
||||
// office will call this every few seconds, and the console is where the
|
||||
// author of the caller finds out that nothing is happening.
|
||||
if (!warnedNoPresence) {
|
||||
warnedNoPresence = true;
|
||||
console.warn(
|
||||
`[tera/interiors] office "${office.id}" was built at depth "public"; ` +
|
||||
`${people.length} presence record(s) ignored. Rebuild at "full" to show people.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
presence.setPresence(people);
|
||||
},
|
||||
setCeilingsVisible(visible) {
|
||||
@@ -321,11 +488,14 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// detail card for whoever the pointer was over survives the journey.
|
||||
onExit: () => kit.resetPick(),
|
||||
tick(dt) {
|
||||
if (disposed) return;
|
||||
kit.tick(dt);
|
||||
updateOcclusion();
|
||||
},
|
||||
dispose() {
|
||||
presence.dispose();
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
presence?.dispose();
|
||||
furnishings.dispose();
|
||||
shell.dispose();
|
||||
kit.dispose();
|
||||
@@ -333,6 +503,16 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
|
||||
// other asset in the page is still using it.
|
||||
if (ownsMaterials) materials.dispose();
|
||||
scene.clear();
|
||||
// Three things the old version left behind, and all three matter when the
|
||||
// reason for disposing is that a second office is about to be built: the
|
||||
// background `Color`, the view listeners — whose closures reach back into
|
||||
// whatever UI created this scene — and the desk table. None of them is
|
||||
// large; all of them are held for as long as anything holds this handle,
|
||||
// and a handle is exactly the sort of thing a `let office` keeps a stale
|
||||
// copy of.
|
||||
scene.background = null;
|
||||
viewListeners.length = 0;
|
||||
places?.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,10 +42,24 @@
|
||||
* `console.warn` — and then the offending item is dropped or repaired. An
|
||||
* exception with no context in the middle of a 180-prop pack tells the author
|
||||
* nothing and loses the other 179.
|
||||
*
|
||||
* ### Depth: a public build does not build the private half
|
||||
*
|
||||
* `PlanOptions.depth` is the other reason something can be absent from the build
|
||||
* product, and it is the one that is not an error. At `"public"` every item the
|
||||
* pack marked `audience: "private"` is skipped here, in the resolution pass,
|
||||
* before it is a placement and long before it is a mesh. That ordering is the
|
||||
* whole point: a private prop that is built and then hidden is still in
|
||||
* `scene.traverse`, in the devtools graph and in a `JSON.stringify` of this
|
||||
* object, which is a data leak with a checkbox in front of it. Read the note on
|
||||
* `Audience` in `types.ts` — including the paragraph saying this is a UI tier
|
||||
* and not a security boundary, because a pack is bundled into the static build
|
||||
* and is public whatever it is marked.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AssetId,
|
||||
Audience,
|
||||
DeskBank,
|
||||
Level,
|
||||
Office,
|
||||
@@ -279,7 +293,38 @@ export interface PlanProblem {
|
||||
* polygon and opening helpers can stay free functions. */
|
||||
type Report = (where: string, message: string, action: PlanProblem["action"]) => void;
|
||||
|
||||
/**
|
||||
* How much of a pack to resolve.
|
||||
*
|
||||
* The counterpart to `Audience` and deliberately not the same union: an item
|
||||
* says who it is *for* (`"public"` or `"private"`), a build says how far in it
|
||||
* *goes* (`"public"` or `"full"`). Spelling both with a shared two-member union
|
||||
* would make `depth === audience` compile and mean nothing.
|
||||
*
|
||||
* It lives here rather than in `types.ts` because it is not something a pack can
|
||||
* say. `types.ts` is the contract for authored data; this is an argument to the
|
||||
* thing that reads it.
|
||||
*/
|
||||
export type Depth = "public" | "full";
|
||||
|
||||
/** Whether a build at `depth` includes an item the pack marked `audience`. */
|
||||
function included(depth: Depth, audience: Audience | undefined): boolean {
|
||||
return depth === "full" || audience !== "private";
|
||||
}
|
||||
|
||||
export interface PlanOptions {
|
||||
/**
|
||||
* `"full"` — the default — resolves the whole pack. `"public"` skips every
|
||||
* item marked `audience: "private"`, which is how the office gets a
|
||||
* not-signed-in version without a second pack to keep in step. See the header.
|
||||
*
|
||||
* One consequence worth knowing about: id collisions are detected against what
|
||||
* was actually resolved, so a pack whose private half collides with its public
|
||||
* half reports that problem at `"full"` and not at `"public"`. Validate a pack
|
||||
* at full depth — that is the build the author is responsible for, and the
|
||||
* public one is a subset of it.
|
||||
*/
|
||||
depth?: Depth;
|
||||
/**
|
||||
* How high a hole must clear for a walker to pass through it, in metres.
|
||||
* Defaults to 1.1.
|
||||
@@ -311,6 +356,12 @@ function devBuild(): boolean {
|
||||
|
||||
export class Plan {
|
||||
readonly office: Office;
|
||||
/**
|
||||
* How much of the pack this is. Read it rather than inferring it from what is
|
||||
* missing — an office with nothing marked private resolves identically at both
|
||||
* depths, and that is the normal case rather than a suspicious one.
|
||||
*/
|
||||
readonly depth: Depth;
|
||||
readonly levels: readonly LevelPlan[];
|
||||
/** Only those whose `levelId` resolves. `viewpoints[0]` is still the arrival pose. */
|
||||
readonly viewpoints: readonly Viewpoint[];
|
||||
@@ -327,6 +378,7 @@ export class Plan {
|
||||
|
||||
constructor(office: Office, options: PlanOptions = {}) {
|
||||
this.office = office;
|
||||
this.depth = options.depth ?? "full";
|
||||
this.walkHeight = options.walkHeight ?? DEFAULT_WALK_HEIGHT;
|
||||
|
||||
const problems: PlanProblem[] = [];
|
||||
@@ -395,6 +447,11 @@ export class Plan {
|
||||
report(where, `on unknown level "${viewpoint.levelId}"`, "dropped");
|
||||
return;
|
||||
}
|
||||
// Not a problem, so not reported: a viewpoint the pack reserved for the
|
||||
// signed-in building is absent from `viewpoints` at public depth, which
|
||||
// means it is absent from the legend too rather than leaving a button that
|
||||
// flies nowhere.
|
||||
if (!included(this.depth, viewpoint.audience)) return;
|
||||
seen.viewpoint.add(viewpoint.id);
|
||||
viewpoints.push(viewpoint);
|
||||
this.viewpointsById.set(viewpoint.id, viewpoint);
|
||||
@@ -493,9 +550,16 @@ export class Plan {
|
||||
const floorplan = level.floorplan;
|
||||
const extent = new Extent();
|
||||
|
||||
// Every one of the five passes below opens the same way: a private item at
|
||||
// public depth is skipped before anything is resolved about it, so it never
|
||||
// becomes a `ResolvedRoom`, a `PropPlacement` or a `ResolvedSeat` and there
|
||||
// is nothing downstream for a mesh layer to build or a traversal to find.
|
||||
// It is not reported — the pack is not wrong, it is being read at a depth
|
||||
// that does not include it.
|
||||
const rooms: ResolvedRoom[] = [];
|
||||
(floorplan.rooms ?? []).forEach((room, ri) => {
|
||||
const at = `${where}.rooms[${ri}]`;
|
||||
if (!included(this.depth, room.audience)) return;
|
||||
if (seen.room.has(room.id)) {
|
||||
report(at, `duplicate room id "${room.id}"`, "dropped");
|
||||
return;
|
||||
@@ -545,11 +609,13 @@ export class Plan {
|
||||
const seats: ResolvedSeat[] = [];
|
||||
(floorplan.deskBanks ?? []).forEach((bank, bi) => {
|
||||
const at = `${where}.deskBanks[${bi}]`;
|
||||
if (!included(this.depth, bank.audience)) return;
|
||||
this.expandBank(bank, level, floorY, at, seen, report, props, seats);
|
||||
});
|
||||
|
||||
(floorplan.props ?? []).forEach((prop, pi) => {
|
||||
const at = `${where}.props[${pi}]`;
|
||||
if (!included(this.depth, prop.audience)) return;
|
||||
if (seen.prop.has(prop.id)) {
|
||||
report(at, `duplicate prop id "${prop.id}"`, "dropped");
|
||||
return;
|
||||
@@ -560,6 +626,7 @@ export class Plan {
|
||||
|
||||
(floorplan.seats ?? []).forEach((seat, si) => {
|
||||
const at = `${where}.seats[${si}]`;
|
||||
if (!included(this.depth, seat.audience)) return;
|
||||
if (seen.seat.has(seat.id)) {
|
||||
report(at, `duplicate seat id "${seat.id}"`, "dropped");
|
||||
return;
|
||||
@@ -571,6 +638,7 @@ export class Plan {
|
||||
const zones: ResolvedZone[] = [];
|
||||
(floorplan.zones ?? []).forEach((zone, zi) => {
|
||||
const at = `${where}.zones[${zi}]`;
|
||||
if (!included(this.depth, zone.audience)) return;
|
||||
if (seen.zone.has(zone.id)) {
|
||||
report(at, `duplicate zone id "${zone.id}"`, "dropped");
|
||||
return;
|
||||
|
||||
@@ -18,6 +18,21 @@
|
||||
* turn a private id into a public coordinate, which is the exact thing the split
|
||||
* exists to prevent.
|
||||
*
|
||||
* ### The public office does not call this file
|
||||
*
|
||||
* `createOfficeScene(office, { depth: "public" })` never constructs a
|
||||
* `PresenceLayer`. Not an empty one, not a hidden one — none. That is worth
|
||||
* stating here rather than only at the call site, because the tempting change to
|
||||
* this file, the first time somebody wants an anonymous view, is a `visible`
|
||||
* flag or an `if (anonymous) return` inside `setPresence`. Both of those leave a
|
||||
* layer in the scene graph that is one call away from being populated, and
|
||||
* `officeScene.ts` is where the decision belongs precisely so that the layer
|
||||
* that must not exist is not built at all.
|
||||
*
|
||||
* The split above is what makes that cheap: an office pack has no people in it,
|
||||
* so a building with no presence layer is not a building with something taken
|
||||
* out of it. It is the same building, before anyone arrived.
|
||||
*
|
||||
* ### Figures
|
||||
*
|
||||
* Two poses, one merged geometry each, one material per colour, one mesh per
|
||||
|
||||
@@ -106,6 +106,58 @@ export type AssetId = string;
|
||||
*/
|
||||
export type SurfaceId = string;
|
||||
|
||||
// ---- Audience -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Who a piece of a pack is built for.
|
||||
*
|
||||
* An office has two audiences now. `office.lumbridgecorp.com` is a front door
|
||||
* anyone can walk up to, and the same building signed in is the one with the
|
||||
* people in it. Marking a room, a prop, a bank, a seat, a zone or a viewpoint
|
||||
* `"private"` says: this exists for the second audience and not the first, and
|
||||
* a public build must never construct it.
|
||||
*
|
||||
* Absent means `"public"`. Every pack written before this field existed keeps
|
||||
* working, and a pack that never thinks about it never has to.
|
||||
*
|
||||
* ### It is not built, rather than built and hidden
|
||||
*
|
||||
* `Plan` drops private items during resolution, so a public build has no
|
||||
* `PropPlacement`, no `ResolvedSeat` and no mesh for them at all. Building them
|
||||
* and setting `visible = false` would leave every one of them in
|
||||
* `scene.traverse`, in the devtools scene graph and in a `JSON.stringify` of the
|
||||
* plan — a data leak dressed as a privacy feature. See `PlanOptions.depth` in
|
||||
* `plan.ts`, which is where the drop happens.
|
||||
*
|
||||
* ### Walls have no audience, and cannot get one
|
||||
*
|
||||
* A wall is the difference between a floor plan and a floor, and it is what the
|
||||
* collision pass is made of. A building whose partitions come and go with who is
|
||||
* looking at it is two different buildings, and the walk-mode collider would be
|
||||
* describing whichever one you were not in. Mark what stands in the room. A
|
||||
* `Room` *can* be marked, but a private room takes its floor slab and its
|
||||
* ceiling with it and leaves a hole in the plan, so that is nearly always the
|
||||
* wrong field to reach for — mark the contents.
|
||||
*
|
||||
* ### This is a UI tier and it is not a security boundary
|
||||
*
|
||||
* **A pack is bundled into the static build, so everything in it is public by
|
||||
* construction**, whatever this field says. The file is in the JavaScript;
|
||||
* anyone who wants the private half can read it out of the bundle in ten
|
||||
* seconds. What the field buys is that an anonymous visitor is not *shown* the
|
||||
* parts of a building that are nobody's business. That is a product decision
|
||||
* worth making, and it is not the same act as withholding them.
|
||||
*
|
||||
* The thing that is genuinely private is `Presence` — who is in today and where
|
||||
* they sit — and it is private because it never appears in a pack at all. It
|
||||
* arrives from an API over authentication, and **the API is what refuses an
|
||||
* anonymous caller**. Nothing on this side of the wire can enforce that. A pack
|
||||
* that puts something actually secret behind `audience: "private"` has published
|
||||
* it, and the reason this paragraph is here is so that nobody discovers that
|
||||
* later.
|
||||
*/
|
||||
export type Audience = "public" | "private";
|
||||
|
||||
// ---- The office -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -220,6 +272,12 @@ export interface Room {
|
||||
* an atrium, a double-height void, or a cutaway you want to look down into.
|
||||
*/
|
||||
ceiling?: RoomCeiling | null;
|
||||
/**
|
||||
* See `Audience`. Absent means public. A private room takes its floor slab and
|
||||
* its ceiling with it and leaves a hole in the plan, which is almost never
|
||||
* what is wanted — mark the props in the room instead.
|
||||
*/
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
/** A ceiling override for one room. Both fields fall back to the level. */
|
||||
@@ -331,6 +389,8 @@ export interface Prop {
|
||||
* without knowing which mesh is which.
|
||||
*/
|
||||
seat?: string;
|
||||
/** See `Audience`. Absent means public. */
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,6 +453,12 @@ export interface DeskBank {
|
||||
pose?: SeatPose;
|
||||
/** Overrides the bank `id` as the seat-id prefix. */
|
||||
seatPrefix?: string;
|
||||
/**
|
||||
* See `Audience`. Absent means public, and it covers the whole expansion: a
|
||||
* private bank generates no desks, no chairs and no seats, so there is nothing
|
||||
* left for a presence to bind to.
|
||||
*/
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
// ---- Seats and zones ------------------------------------------------------
|
||||
@@ -413,6 +479,13 @@ export interface Seat {
|
||||
/** Which way an occupant looks. See `Yaw`. */
|
||||
facing: Yaw;
|
||||
pose: SeatPose;
|
||||
/**
|
||||
* See `Audience`. Absent means public. A private seat is not resolved at
|
||||
* public depth, so a `Presence` naming it is dropped the same way one naming a
|
||||
* seat that does not exist is — which is the answer you want, since at public
|
||||
* depth there is no presence layer to drop it into either.
|
||||
*/
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -430,6 +503,13 @@ export interface Zone {
|
||||
outline: Outline;
|
||||
/** Opaque palette key, resolved by the caller. */
|
||||
colorKey?: string;
|
||||
/**
|
||||
* See `Audience`. Absent means public. A zone is a label on an area and a
|
||||
* label is exactly the sort of thing that turns out to be organisational —
|
||||
* "Engineering" says who sits there — so this is the field a pack reaches for
|
||||
* most.
|
||||
*/
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
// ---- Viewpoints -----------------------------------------------------------
|
||||
@@ -454,6 +534,17 @@ export interface Viewpoint extends View {
|
||||
/** Camera azimuth about the target. See `Yaw`. */
|
||||
rotation: Yaw;
|
||||
};
|
||||
/**
|
||||
* See `Audience`. Absent means public.
|
||||
*
|
||||
* Use it sparingly and think first. A viewpoint is a promise printed in a
|
||||
* legend, and a visitor told there are five and shown three has been lied to;
|
||||
* a private viewpoint disappears from `views` entirely rather than leaving a
|
||||
* dead button, but the honest fix is usually to reframe the shot rather than
|
||||
* to withhold it. Mark one private only when the *pose itself* is the
|
||||
* disclosure — a camera two metres from the whiteboard in the board room.
|
||||
*/
|
||||
audience?: Audience;
|
||||
}
|
||||
|
||||
// ---- Presence -------------------------------------------------------------
|
||||
|
||||
+504
-46
@@ -22,7 +22,11 @@ import SOCAL from "./cities/socal.ts";
|
||||
import { createTeraClient } from "./adapters/http.ts";
|
||||
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts";
|
||||
import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
|
||||
import { authFetch } from "./session.ts";
|
||||
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
|
||||
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
|
||||
import { createMinimap, type Minimap } from "./engine/minimap.ts";
|
||||
import { MaterialRegistry } from "./assets/materials.ts";
|
||||
|
||||
const CITIES: { id: string; label: string; city: City }[] = [
|
||||
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
|
||||
@@ -32,7 +36,10 @@ const CITIES: { id: string; label: string; city: City }[] = [
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
|
||||
if (!canvas) throw new Error("#scene canvas missing");
|
||||
|
||||
const tera = createTeraClient();
|
||||
// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
|
||||
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
|
||||
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
|
||||
const tera = createTeraClient({ fetch: authFetch });
|
||||
|
||||
let city: SceneHandle | null = null;
|
||||
let cityId = "sf";
|
||||
@@ -41,8 +48,47 @@ let inside = false;
|
||||
let markers: Marker[] = SAMPLE_MARKERS;
|
||||
let palette: MarkerPalette = SAMPLE_PALETTE;
|
||||
let liveData = false;
|
||||
let canEnterOffice = 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"),
|
||||
};
|
||||
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
|
||||
let minimap: Minimap | null = null;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const officeMaterials = new MaterialRegistry({ quality: "high" });
|
||||
|
||||
/**
|
||||
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
|
||||
* two names, and which name you arrived at is the whole difference: one is a
|
||||
* map with an office in it, the other is an office with a map behind it. The
|
||||
* city is still built underneath either way — that is what makes "← Back to the
|
||||
* city" work from the office front door — so this is a policy about where boot
|
||||
* *stops*, not about what boot builds.
|
||||
*/
|
||||
const OPENS_IN_OFFICE =
|
||||
location.hostname.split(".")[0] === "office" ||
|
||||
new URLSearchParams(location.search).get("view") === "office";
|
||||
|
||||
// ---- Time -----------------------------------------------------------------
|
||||
|
||||
@@ -67,6 +113,10 @@ function updateSun() {
|
||||
const env = observe(active.center.lat, active.center.lng, currentInstant());
|
||||
city.setLighting(atmosphere.apply(env));
|
||||
city.setSolarElevation(env.sun.elevation);
|
||||
// The plan view follows the same day the map does. It computes its own
|
||||
// palette from this one number rather than reading the rig, because a rig is
|
||||
// a set of three.js lights and the minimap has none.
|
||||
minimap?.setSolarElevation(env.sun.elevation);
|
||||
|
||||
const clock = document.querySelector<HTMLElement>("#clock");
|
||||
if (!clock) return;
|
||||
@@ -92,21 +142,38 @@ function mountCity(id: string) {
|
||||
office?.dispose();
|
||||
office = null;
|
||||
inside = false;
|
||||
minimap?.dispose();
|
||||
minimap = null;
|
||||
city?.dispose();
|
||||
|
||||
cityId = id;
|
||||
city = createScene(canvas, {
|
||||
city: entry.city,
|
||||
markerPalette: palette,
|
||||
flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
|
||||
// `liveData` alone is not enough: it only records that a feed answered
|
||||
// once, at boot, before the tier was known. An anonymous visitor asking for
|
||||
// live traffic gets an empty sky rather than the simulation, which looks
|
||||
// like a broken layer instead of an honest one.
|
||||
flights:
|
||||
access.can.liveData && liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
|
||||
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null),
|
||||
});
|
||||
// Fog distances are scene units, so they have to follow the board — 210/460
|
||||
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
|
||||
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
|
||||
// out on the whole-board view: a fog starting nearer than that is behind the
|
||||
// viewer's own shoulder, and at night, when the fog colour is nearly black
|
||||
// rather than bright haze, it turns the entire map off.
|
||||
// viewer's own shoulder, and every pixel in frame is then at full fog.
|
||||
//
|
||||
// That last failure used to be catastrophic and is now only bad, and the
|
||||
// difference is worth recording because the comment used to claim the worse
|
||||
// version. The night fog colour is derived from the sky, the night sky was
|
||||
// nearly black, and so a fog plane behind the camera turned the entire map
|
||||
// off. `atmosphere.ts` now floors the night ground rig and stops the
|
||||
// obscuration convergence subtracting it again, and the night fog here lands
|
||||
// around #16203a — aerial perspective that lifts distance rather than a
|
||||
// blackout. The clearance is still required: a board flattened to one uniform
|
||||
// value is unreadable at any brightness. It is no longer the difference
|
||||
// between a map and a black rectangle.
|
||||
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
|
||||
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
|
||||
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
|
||||
@@ -128,18 +195,100 @@ function mountCity(id: string) {
|
||||
});
|
||||
city.setMarkers(id === "sf" ? markers : []);
|
||||
city.onChapterChange(() => renderLegend());
|
||||
|
||||
/**
|
||||
* The plan view, built last, because it reads the finished `World` — the
|
||||
* heightfield the terrain has already paid for — and the live camera and
|
||||
* controls the scene has just made. It is torn down and rebuilt with the
|
||||
* city for the same reason the city is: nothing in it survives a change of
|
||||
* board, and it holds a `World` that would otherwise leak.
|
||||
*/
|
||||
minimap = createMinimap({
|
||||
world: city.world,
|
||||
city: entry.city,
|
||||
camera: city.stageScene.camera,
|
||||
controls: city.stageScene.controls,
|
||||
markerPalette: palette,
|
||||
onSeek(lat, lng) {
|
||||
if (!city) return;
|
||||
/**
|
||||
* Slide the orbit target and carry the camera with it, keeping the offset
|
||||
* between them. A seek is "look over there", not "go to chapter three":
|
||||
* snapping to a chapter pose throws away the angle and the distance the
|
||||
* user spent the last minute choosing, and doing it from a click on a map
|
||||
* is the kind of surprise that stops people clicking on the map.
|
||||
*
|
||||
* No easing, deliberately. `flyTo` would need a pose, which is the thing
|
||||
* being avoided, and an instant move is also the correct answer under
|
||||
* `prefers-reduced-motion`.
|
||||
*/
|
||||
const { camera, controls } = city.stageScene;
|
||||
const [x, z] = city.world.project(lat, lng);
|
||||
const y = city.world.groundAt(lat, lng);
|
||||
const dx = camera.position.x - controls.target.x;
|
||||
const dy = camera.position.y - controls.target.y;
|
||||
const dz = camera.position.z - controls.target.z;
|
||||
controls.target.set(x, y, z);
|
||||
camera.position.set(x + dx, y + dy, z + dz);
|
||||
},
|
||||
onHover(info) {
|
||||
if (!minimapReadout) return;
|
||||
minimapReadout.textContent = info
|
||||
? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
|
||||
: "";
|
||||
},
|
||||
});
|
||||
minimapFrame?.replaceChildren(minimap.canvas);
|
||||
minimap.setMarkers(id === "sf" ? markers : []);
|
||||
|
||||
updateSun();
|
||||
renderLegend();
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimap's own frame pump.
|
||||
*
|
||||
* `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
|
||||
* the alternative is adding an `onTick` to the scene handle for exactly one
|
||||
* call site. This is the smaller change and it costs nothing measurable: an
|
||||
* idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the
|
||||
* loop keeps running unchanged across a city swap, across the office swap, and
|
||||
* during the window where there is no minimap at all.
|
||||
*/
|
||||
requestAnimationFrame(function pumpMinimap() {
|
||||
requestAnimationFrame(pumpMinimap);
|
||||
minimap?.tick();
|
||||
});
|
||||
|
||||
// ---- 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.
|
||||
*/
|
||||
function enterOffice() {
|
||||
if (!city || !canEnterOffice) return;
|
||||
if (!city) return;
|
||||
if (!office) {
|
||||
const depth = access.can.officeDepth;
|
||||
office = createOfficeScene(LUMBRIDGE_HQ, {
|
||||
dom: city.stage.renderer.domElement,
|
||||
background: 0x11161c,
|
||||
depth,
|
||||
materials: officeMaterials,
|
||||
// Two different questions, so two different callbacks. `onPresencePick`
|
||||
// answers "who is at this desk"; `onPlacePick` answers only "this is a
|
||||
// desk, and it is the fourteenth one" — which is all a stranger is told.
|
||||
...(depth === "full"
|
||||
? { onPresencePick: (p) => showDetail(p ? p.label : null) }
|
||||
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
|
||||
});
|
||||
office.onViewChange(() => renderLegend());
|
||||
}
|
||||
@@ -166,6 +315,14 @@ const subtitle = document.querySelector<HTMLElement>("#subtitle");
|
||||
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
|
||||
const cityNav = document.querySelector<HTMLElement>("#cities");
|
||||
const source = document.querySelector<HTMLElement>("#source");
|
||||
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
|
||||
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
|
||||
const tierBadge = document.querySelector<HTMLElement>("#tier");
|
||||
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
|
||||
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
|
||||
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
|
||||
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
|
||||
const helpButton = document.querySelector<HTMLButtonElement>("#help");
|
||||
|
||||
function showDetail(text: string | null) {
|
||||
const card = document.querySelector<HTMLElement>("#detail");
|
||||
@@ -179,12 +336,14 @@ function renderCityPicker() {
|
||||
cityNav.replaceChildren();
|
||||
for (const c of CITIES) {
|
||||
const b = document.createElement("button");
|
||||
b.className = c.id === cityId && !inside ? "city active" : "city";
|
||||
// `aria-pressed` rather than a class, because that is what these are: two
|
||||
// buttons of which exactly one is on. The stylesheet keys off the attribute
|
||||
// so the visual state and the announced state cannot drift apart.
|
||||
b.className = "city";
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-pressed", String(c.id === cityId && !inside));
|
||||
b.textContent = c.label;
|
||||
b.addEventListener("click", () => {
|
||||
if (inside) leaveOffice();
|
||||
if (c.id !== cityId) mountCity(c.id);
|
||||
});
|
||||
b.addEventListener("click", () => switchCity(c.id));
|
||||
cityNav.append(b);
|
||||
}
|
||||
}
|
||||
@@ -199,13 +358,12 @@ function renderLegend() {
|
||||
nav.replaceChildren();
|
||||
views.forEach((view, i) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = view.id === activeId ? "chapter active" : "chapter";
|
||||
button.className = "chapter";
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-pressed", String(view.id === activeId));
|
||||
const number = view.number ?? String(i + 1).padStart(2, "0");
|
||||
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
|
||||
button.addEventListener("click", () => {
|
||||
if (inside && office) office.flyTo(view.id);
|
||||
else city?.flyTo(view.id);
|
||||
});
|
||||
button.addEventListener("click", () => flyToIndex(i));
|
||||
nav.append(button);
|
||||
});
|
||||
|
||||
@@ -220,64 +378,364 @@ function renderLegend() {
|
||||
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
|
||||
}
|
||||
if (enterButton) {
|
||||
if (inside) enterButton.textContent = "← Back to the city";
|
||||
else if (canEnterOffice) enterButton.textContent = "Enter the office →";
|
||||
else enterButton.textContent = "Sign in to enter the office →";
|
||||
// One label for everyone. The door is open at both tiers; what differs is
|
||||
// what is behind it, and that is the badge's job to say, not the button's.
|
||||
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
|
||||
}
|
||||
if (source) {
|
||||
source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies";
|
||||
source.className = liveData ? "source live" : "source";
|
||||
}
|
||||
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
|
||||
if (canvas) {
|
||||
canvas.setAttribute(
|
||||
"aria-label",
|
||||
inside
|
||||
? `${LUMBRIDGE_HQ.name}, seen from above. Drag to orbit, scroll to zoom.`
|
||||
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
|
||||
);
|
||||
}
|
||||
minimap?.setChapters(city.chapters, city.current());
|
||||
renderOfficeBadge();
|
||||
}
|
||||
|
||||
enterButton?.addEventListener("click", () => {
|
||||
/**
|
||||
* The one thing a public visitor is actually missing, said in the place where
|
||||
* they would notice it missing.
|
||||
*
|
||||
* An empty office with no explanation reads as a bug — a floor that failed to
|
||||
* load — and the fix for that is a sentence, not a disabled button. The
|
||||
* sign-in link is offered *beside* the office rather than in front of it, so it
|
||||
* is an upgrade and never a toll gate.
|
||||
*/
|
||||
function renderOfficeBadge() {
|
||||
if (!officeBadge) return;
|
||||
const publicOffice = inside && office !== null && office.depth === "public";
|
||||
officeBadge.hidden = !publicOffice;
|
||||
if (!publicOffice) return;
|
||||
officeBadge.replaceChildren(
|
||||
document.createTextNode("Public view — the building, not the people. "),
|
||||
);
|
||||
if (access.signInUrl !== null) {
|
||||
const link = document.createElement("a");
|
||||
link.href = access.signInUrl;
|
||||
link.textContent = "Sign in for the live floor";
|
||||
officeBadge.append(link, document.createTextNode("."));
|
||||
} else {
|
||||
officeBadge.append(document.createTextNode("Sign in to see who's in."));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the site thinks you are, in the corner, always. Three words and a name.
|
||||
*
|
||||
* It is here rather than buried in a menu because every other difference on
|
||||
* this page — an empty office, sample markers, a missing scrubber — is a
|
||||
* *silence*, and a silence you cannot attribute is indistinguishable from a
|
||||
* fault. This is the line that tells you which of the two you are looking at.
|
||||
*/
|
||||
function renderTierBadge() {
|
||||
if (!tierBadge) return;
|
||||
tierBadge.className = `card tier ${access.tier}`;
|
||||
const label = document.createElement("span");
|
||||
/**
|
||||
* The label names what you *get*, not who you are, and that is deliberate.
|
||||
* "Signed in" was the first draft and it is a lie in the commonest case:
|
||||
* a clean clone with no API at all resolves to `member`, and telling someone
|
||||
* they are signed in to a server that does not exist is the sort of small
|
||||
* dishonesty that makes the rest of the interface untrustworthy. "Full view"
|
||||
* is true whether the tier came from a session or from there being nothing to
|
||||
* have a session with; the subject, when there is one, says the rest.
|
||||
*/
|
||||
label.textContent =
|
||||
access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
|
||||
tierBadge.replaceChildren(label);
|
||||
if (access.subject !== null) {
|
||||
const who = document.createElement("span");
|
||||
who.className = "who";
|
||||
who.textContent = access.subject;
|
||||
tierBadge.append(who);
|
||||
} else if (access.signInUrl !== null) {
|
||||
const link = document.createElement("a");
|
||||
link.href = access.signInUrl;
|
||||
link.textContent = "Sign in";
|
||||
tierBadge.append(link);
|
||||
}
|
||||
tierBadge.hidden = false;
|
||||
}
|
||||
|
||||
// ---- Navigation -------------------------------------------------------------
|
||||
|
||||
/** The views on offer right now — city chapters, or office viewpoints inside. */
|
||||
function currentViews(): View[] {
|
||||
if (inside && office) return office.views;
|
||||
return city?.chapters ?? [];
|
||||
}
|
||||
|
||||
function flyToIndex(index: number) {
|
||||
const view = currentViews()[index];
|
||||
if (!view) return;
|
||||
if (inside && office) office.flyTo(view.id);
|
||||
else city?.flyTo(view.id);
|
||||
}
|
||||
|
||||
function switchCity(id: string) {
|
||||
if (inside) leaveOffice();
|
||||
else if (canEnterOffice) enterOffice();
|
||||
else window.location.href = "/login.html";
|
||||
if (id === cityId) return;
|
||||
const label = CITIES.find((c) => c.id === id)?.label ?? id;
|
||||
void building(`Building ${label}…`, () => mountCity(id));
|
||||
}
|
||||
|
||||
function stepCity(delta: number) {
|
||||
const at = CITIES.findIndex((c) => c.id === cityId);
|
||||
const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
|
||||
if (next) switchCity(next.id);
|
||||
}
|
||||
|
||||
function toggleOffice() {
|
||||
if (inside) {
|
||||
leaveOffice();
|
||||
return;
|
||||
}
|
||||
// Only the first entry builds anything; after that the office is parked in
|
||||
// memory next to the paused city and the swap is a pointer.
|
||||
if (office) enterOffice();
|
||||
else void building("Building the office…", () => enterOffice());
|
||||
}
|
||||
|
||||
enterButton?.addEventListener("click", () => toggleOffice());
|
||||
|
||||
// ---- Panels, plan and overlays ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Two pieces of chrome are a *user* decision rather than a media query, and the
|
||||
* distinction matters: a media query that hides the plan below 600px also makes
|
||||
* `M` do nothing there, which is the width where a plan view is most useful and
|
||||
* least affordable. So the width only seeds the initial state, and the moment
|
||||
* someone presses the key the viewport stops having an opinion.
|
||||
*/
|
||||
let panelOpen = window.innerWidth > 900;
|
||||
let planOpen = window.innerWidth > 600;
|
||||
let planChosen = false;
|
||||
|
||||
function applyPanel() {
|
||||
document.body.classList.toggle("panel-closed", !panelOpen);
|
||||
panelToggle?.setAttribute("aria-expanded", String(panelOpen));
|
||||
}
|
||||
|
||||
function applyPlan() {
|
||||
document.body.classList.toggle("minimap-off", !planOpen);
|
||||
}
|
||||
|
||||
panelToggle?.addEventListener("click", () => {
|
||||
panelOpen = !panelOpen;
|
||||
applyPanel();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!planChosen) {
|
||||
planOpen = window.innerWidth > 600;
|
||||
applyPlan();
|
||||
}
|
||||
});
|
||||
|
||||
function openShortcuts() {
|
||||
if (!shortcutsCard || !shortcutsCard.hidden) return;
|
||||
shortcutsCard.hidden = false;
|
||||
document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
|
||||
}
|
||||
|
||||
function closeShortcuts() {
|
||||
if (!shortcutsCard || shortcutsCard.hidden) return;
|
||||
shortcutsCard.hidden = true;
|
||||
helpButton?.focus();
|
||||
}
|
||||
|
||||
helpButton?.addEventListener("click", () => openShortcuts());
|
||||
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
|
||||
shortcutsCard?.addEventListener("click", (event) => {
|
||||
// The backdrop, not the sheet. Clicking the card itself must not close it.
|
||||
if (event.target === shortcutsCard) closeShortcuts();
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyboard access to everything the mouse can reach.
|
||||
*
|
||||
* Bound to `window` rather than to the canvas, because the canvas is only
|
||||
* focusable by accident and a shortcut that stops working when you tab to the
|
||||
* legend is worse than no shortcut. The guard is the usual one: a keystroke
|
||||
* that lands in a text field or on the plan view's own arrow-key handler
|
||||
* belongs to that control, not to this.
|
||||
*/
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
|
||||
else if (inside) leaveOffice();
|
||||
else showDetail(null);
|
||||
return;
|
||||
}
|
||||
if (event.key === "?") {
|
||||
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
|
||||
else openShortcuts();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key >= "1" && event.key <= "9") {
|
||||
flyToIndex(Number(event.key) - 1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "[") {
|
||||
stepCity(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "]") {
|
||||
stepCity(1);
|
||||
return;
|
||||
}
|
||||
const lower = event.key.toLowerCase();
|
||||
if (lower === "m") {
|
||||
planOpen = !planOpen;
|
||||
planChosen = true;
|
||||
applyPlan();
|
||||
return;
|
||||
}
|
||||
if (lower === "o") toggleOffice();
|
||||
});
|
||||
|
||||
// ---- Time -------------------------------------------------------------------
|
||||
|
||||
const scrubber = document.querySelector<HTMLInputElement>("#hour");
|
||||
scrubber?.addEventListener("input", () => {
|
||||
if (!access.can.timeControl) return;
|
||||
hourOverride = Number(scrubber.value);
|
||||
updateSun();
|
||||
});
|
||||
document.querySelector<HTMLElement>("#now")?.addEventListener("click", () => {
|
||||
if (!access.can.timeControl) return;
|
||||
hourOverride = null;
|
||||
if (scrubber) scrubber.value = String(new Date().getHours());
|
||||
updateSun();
|
||||
});
|
||||
|
||||
/**
|
||||
* The scrubber is an instrument, and instruments are god-only. The *clock* is
|
||||
* not: a map that will not tell you what time it is showing is worse than one
|
||||
* you cannot scrub, so `#clock` stays outside `#scrub` and stays visible to
|
||||
* everyone.
|
||||
*
|
||||
* `hidden` rather than `disabled`, because a disabled slider is still a tab
|
||||
* stop and still announces itself — an affordance offered and withdrawn in the
|
||||
* same breath. Without the control there is no override, so the clock follows
|
||||
* the wall clock, which is the honest default anyway.
|
||||
*/
|
||||
function applyTimeControl() {
|
||||
const scrub = document.querySelector<HTMLElement>("#scrub");
|
||||
if (scrub) scrub.hidden = !access.can.timeControl;
|
||||
if (!access.can.timeControl) hourOverride = null;
|
||||
if (scrubber) scrubber.value = String(new Date().getHours());
|
||||
}
|
||||
|
||||
// ---- The boot card ----------------------------------------------------------
|
||||
|
||||
const bootCard = document.querySelector<HTMLElement>("#boot");
|
||||
const bootStep = document.querySelector<HTMLElement>("#boot-step");
|
||||
|
||||
/**
|
||||
* Wait until the browser has actually put pixels on the glass.
|
||||
*
|
||||
* Writing to `textContent` and then immediately building a heightfield paints
|
||||
* nothing: the style change and the two seconds of synchronous work are in the
|
||||
* same task, so the frame the user sees is the one *after* the work. Two
|
||||
* `requestAnimationFrame`s straddle a paint, which is the whole trick — a
|
||||
* single one still runs before it.
|
||||
*/
|
||||
function painted(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run something slow and synchronous with the boot card up and a sentence
|
||||
* saying what it is.
|
||||
*
|
||||
* The Bay Area heightfield takes about 2.3 s and the office about half that.
|
||||
* Neither can be made asynchronous without splitting the builders across
|
||||
* frames, which is a large change to earn a progress bar. 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.
|
||||
*/
|
||||
async function building<T>(label: string, work: () => T): Promise<T> {
|
||||
if (bootStep) bootStep.textContent = label;
|
||||
if (bootCard) {
|
||||
bootCard.hidden = false;
|
||||
bootCard.classList.remove("done");
|
||||
}
|
||||
await painted();
|
||||
const result = work();
|
||||
// A second paint before the fade, so the first frame of the finished scene is
|
||||
// behind the card rather than appearing with it.
|
||||
await painted();
|
||||
bootCard?.classList.add("done");
|
||||
window.setTimeout(() => {
|
||||
if (bootCard?.classList.contains("done")) bootCard.hidden = true;
|
||||
}, 300);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Boot -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Markers are awaited before the scene is built, because `markerPalette` is
|
||||
* fixed at construction and the sample palette's keys are not the API's.
|
||||
* Everything else about the API is optional: no server means sample data and a
|
||||
* label saying so.
|
||||
* Access first, then data, then the board.
|
||||
*
|
||||
* The order is load-bearing in both directions and it used to be wrong. Markers
|
||||
* were fetched before the tier was known, which is a request an anonymous
|
||||
* visitor should not be making; and both decisions have to be settled before
|
||||
* the *first* `mountCity`, because `markerPalette` is fixed at scene
|
||||
* construction — the sample palette's keys are not the API's — and the flight
|
||||
* source is chosen in the same call.
|
||||
*
|
||||
* Everything about the API remains optional. No server means the bundled sample
|
||||
* set, the simulated traffic, and a label at the bottom of the screen saying
|
||||
* which of the two you are looking at.
|
||||
*/
|
||||
async function boot() {
|
||||
try {
|
||||
const feed = await tera.markers();
|
||||
markers = feed.value;
|
||||
palette = feed.palette;
|
||||
liveData = feed.live;
|
||||
} catch {
|
||||
// A missing API is the self-host default, not an error.
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
|
||||
if (res.ok) {
|
||||
const s = (await res.json()) as { authenticated: boolean; passwordLogin: boolean };
|
||||
canEnterOffice = s.authenticated || !s.passwordLogin;
|
||||
} else {
|
||||
canEnterOffice = true;
|
||||
applyPanel();
|
||||
applyPlan();
|
||||
|
||||
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
|
||||
access = await resolveAccess();
|
||||
applyTimeControl();
|
||||
renderTierBadge();
|
||||
|
||||
if (access.can.liveData) {
|
||||
try {
|
||||
const feed = await tera.markers();
|
||||
markers = feed.value;
|
||||
palette = feed.palette;
|
||||
liveData = feed.live;
|
||||
} catch {
|
||||
// A missing API is the self-host default, not an error.
|
||||
}
|
||||
} catch {
|
||||
// No server means nothing to sign in to, so the office is open. That is the
|
||||
// self-host posture: auth is something a deployment adds, not removes.
|
||||
canEnterOffice = true;
|
||||
}
|
||||
mountCity("sf");
|
||||
|
||||
const first = CITIES[0];
|
||||
await building(`Building ${first?.label ?? "the city"}…`, () => mountCity(first?.id ?? "sf"));
|
||||
|
||||
// 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("Building the office…", () => enterOffice());
|
||||
|
||||
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,26 @@
|
||||
* this building — see ARCHITECTURE.md §3.3, and the note on `Presence` in
|
||||
* `interiors/types.ts`, which is the same rule one level in.
|
||||
*
|
||||
* ### Two audiences, and why this pack barely uses the second one
|
||||
*
|
||||
* A handful of items below carry `audience: "private"`, which means a public
|
||||
* build — `createOfficeScene(office, { depth: "public" })`, the office an
|
||||
* anonymous visitor gets — does not construct them. There are seven of them: two
|
||||
* zones and five objects in the server room. Everything else in this file is
|
||||
* built at both depths, and that is the honest answer for this pack rather than
|
||||
* a gap in it. The difference between the public building and the signed-in one
|
||||
* is **the people**, and the people are not in this file at all.
|
||||
*
|
||||
* That is the whole reason a pack can be published. `Presence` binds by seat id
|
||||
* and arrives from somewhere else, so the geometry and the occupancy are two
|
||||
* separate acts and only one of them is happening here. Read the note on
|
||||
* `Audience` in `interiors/types.ts` before reaching for the field, especially
|
||||
* the part that says it is a UI tier and not a security boundary: **this file is
|
||||
* bundled into the static build**, so a self-hoster who marks their real floor
|
||||
* plan private has published their real floor plan with an extra step. The
|
||||
* building here is fabricated. If yours is not, the thing that keeps a stranger
|
||||
* out is your API, not this field.
|
||||
*
|
||||
* ### The coordinate frame
|
||||
*
|
||||
* Metres, `1 unit = 1 m`, the floor on the XZ plane with +Y up. The origin is
|
||||
@@ -51,6 +71,7 @@
|
||||
|
||||
import type {
|
||||
AssetId,
|
||||
Audience,
|
||||
DeskBank,
|
||||
Level,
|
||||
Office,
|
||||
@@ -281,7 +302,7 @@ function scatter(
|
||||
prefix: string,
|
||||
kind: AssetId,
|
||||
points: Point2[],
|
||||
opts: { rotation?: Yaw; elevation?: number; colorKey?: string } = {},
|
||||
opts: { rotation?: Yaw; elevation?: number; colorKey?: string; audience?: Audience } = {},
|
||||
): Prop[] {
|
||||
return points.map((position, i) => ({
|
||||
id: `${prefix}-${String(i + 1).padStart(2, "0")}`,
|
||||
@@ -290,6 +311,10 @@ function scatter(
|
||||
rotation: opts.rotation ?? NORTH,
|
||||
elevation: opts.elevation,
|
||||
colorKey: opts.colorKey,
|
||||
// A run of identical props is a run of identical props at both depths.
|
||||
// Nothing in the format stops a pack marking one rack of four private, but
|
||||
// half a row is a stranger thing to look at than no row.
|
||||
audience: opts.audience,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1143,9 +1168,35 @@ const PROPS: Prop[] = [
|
||||
// hot-aisle arrangement and also the only way two rows of anything read as
|
||||
// deliberate. A storage locker is not a rack, but it is a 1.2 x 0.5 x 1.8 m
|
||||
// box with a front and a back, and at this scale that is a rack.
|
||||
...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), { rotation: NORTH }),
|
||||
...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), { rotation: SOUTH }),
|
||||
{ id: "mdf-shelf", kind: SHELF, position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 }, rotation: SOUTH },
|
||||
//
|
||||
// The five objects in this room are the pack's one worked example of
|
||||
// `audience: "private"`. The room, its raised floor, its walls and its lid are
|
||||
// all still built at public depth — the *architecture* is not the secret — but
|
||||
// what is standing in it is not shown to a stranger. How many cabinets an
|
||||
// organisation runs and which way the aisle faces is the kind of detail that
|
||||
// is worth nothing to a visitor and something to somebody else, and the room
|
||||
// already keeps its ceiling for the same reason, which is the pack saying the
|
||||
// same thing twice in two vocabularies.
|
||||
//
|
||||
// At public depth this leaves a lit, empty, raised-floor room, which is an
|
||||
// honest picture of a room you are not being shown the inside of. The lights
|
||||
// stay: a dark hole in a floor plan reads as a rendering fault, not as
|
||||
// discretion.
|
||||
...scatter("mdf-rack-n", LOCKER, grid(27.4, 13.4, 2, 1, 1.3, 0), {
|
||||
rotation: NORTH,
|
||||
audience: "private",
|
||||
}),
|
||||
...scatter("mdf-rack-s", LOCKER, grid(27.4, 15.8, 2, 1, 1.3, 0), {
|
||||
rotation: SOUTH,
|
||||
audience: "private",
|
||||
}),
|
||||
{
|
||||
id: "mdf-shelf",
|
||||
kind: SHELF,
|
||||
position: { x: 29.4, z: DEPTH - EXT_FACE - 0.18 },
|
||||
rotation: SOUTH,
|
||||
audience: "private",
|
||||
},
|
||||
...scatter("mdf-light", TROFFER, grid(27.6, 14.0, 2, 2, 2.0, 2.4), { elevation: 2.6 }),
|
||||
|
||||
// -- Facilities -----------------------------------------------------------
|
||||
@@ -1169,10 +1220,41 @@ const PROPS: Prop[] = [
|
||||
* "eng" is a team, a cost centre or a colour scheme is not the engine's
|
||||
* business — the same rule as `Marker.colorKey`, which is why there is no
|
||||
* `kind` field to be tempted by.
|
||||
*
|
||||
* ### Two of them are private, and it is the names that make them so
|
||||
*
|
||||
* The engine cannot tell these four apart, but a reader can. "Social" and
|
||||
* "Focus" describe what the floor is *for*: anyone standing in the lounge can
|
||||
* see that it is the lounge, and a public visitor learning that the three
|
||||
* glass-lidded boxes are the focus booths has learned nothing they could not
|
||||
* have guessed from the plan.
|
||||
*
|
||||
* "Engineering" and "Studio" describe who *sits* there, and that is a different
|
||||
* kind of fact. It is org chart drawn on a floor: how many desks each function
|
||||
* has, where they are relative to each other, which corner grew last quarter.
|
||||
* Nobody signed in is surprised by it and nobody anonymous is owed it, which is
|
||||
* exactly the line `audience` exists to draw — so the two team zones are not
|
||||
* built at public depth and the two spatial ones are.
|
||||
*
|
||||
* This is the granularity the field is for. The alternative anybody reaches for
|
||||
* first is a single `private: true` on the whole floorplan, and it is useless:
|
||||
* a building is not private or public, the labels on it are.
|
||||
*/
|
||||
const ZONES: Zone[] = [
|
||||
{ id: "zone-eng", name: "Engineering", outline: rect(8.0, 0.6, 18.9, 7.6), colorKey: "team-a" },
|
||||
{ id: "zone-studio", name: "Studio", outline: rect(19.4, 0.6, 25.2, 7.6), colorKey: "team-b" },
|
||||
{
|
||||
id: "zone-eng",
|
||||
name: "Engineering",
|
||||
outline: rect(8.0, 0.6, 18.9, 7.6),
|
||||
colorKey: "team-a",
|
||||
audience: "private",
|
||||
},
|
||||
{
|
||||
id: "zone-studio",
|
||||
name: "Studio",
|
||||
outline: rect(19.4, 0.6, 25.2, 7.6),
|
||||
colorKey: "team-b",
|
||||
audience: "private",
|
||||
},
|
||||
{ id: "zone-social", name: "Social", outline: rect(SOCIAL_W, 0, WIDTH, SPINE_N), colorKey: "social" },
|
||||
{ id: "zone-focus", name: "Focus", outline: rect(X_BOOTH_1, BOOTH_N, X_BOOTH_E, SPINE_N), colorKey: "focus" },
|
||||
];
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* The browser's half of `TERA_AUTH_MODE=sso`.
|
||||
*
|
||||
* In `password` mode the server issues an HttpOnly cookie and this module has
|
||||
* nothing to do — `credentials: "same-origin"` carries the session and no script
|
||||
* ever sees it, which is the better arrangement and the reason it is still the
|
||||
* default for a self-hoster.
|
||||
*
|
||||
* `sso` mode cannot work that way. The identity provider is a **different
|
||||
* origin** from this one, so the session it hands out is not a cookie this site
|
||||
* can read or be sent. What the browser gets instead is a bearer token, which
|
||||
* means it has to be stored somewhere a script can reach and attached by hand.
|
||||
* That is a real downgrade — an XSS bug on this page can now walk off with a
|
||||
* session — and it is accepted here for the same reason the rest of the fleet
|
||||
* accepts it: the alternative is a cross-origin cookie with `SameSite=None`,
|
||||
* which is worse, and the token is short-lived and revocable at the issuer.
|
||||
*
|
||||
* `sessionStorage`, not `localStorage`: the token dies with the tab. A shared
|
||||
* office kiosk is a plausible way to use this, and "signed in forever on a
|
||||
* machine somebody walked away from" is the failure this avoids.
|
||||
*/
|
||||
|
||||
/** Namespaced so a self-hoster running something else on this origin is unaffected. */
|
||||
const KEY = "tera.session.token";
|
||||
|
||||
/** Whether this build was given an identity provider to sign in against. */
|
||||
export const IDENTITY_URL: string = import.meta.env.VITE_IDENTITY_URL ?? "";
|
||||
/** The provider's PUBLIC key. Publishable by design — it gates nothing on its own. */
|
||||
export const IDENTITY_KEY: string = import.meta.env.VITE_IDENTITY_ANON_KEY ?? "";
|
||||
export const identityConfigured = IDENTITY_URL !== "" && IDENTITY_KEY !== "";
|
||||
|
||||
export function readToken(): string | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(KEY);
|
||||
return raw === null || raw === "" ? null : raw;
|
||||
} catch {
|
||||
// Storage can throw outright in a partitioned or cookie-blocked context.
|
||||
// No token is a correct answer there; it just means signing in again.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToken(token: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, token);
|
||||
} catch {
|
||||
// Nothing to do: the sign-in still succeeded at the issuer, and the caller
|
||||
// finds out on the next request that it did not stick.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* see writeToken */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` with the bearer token attached when there is one.
|
||||
*
|
||||
* `credentials: "same-origin"` is kept alongside it so a `password`-mode
|
||||
* deployment — where the cookie is the session and this module holds nothing —
|
||||
* goes on working through exactly the same call.
|
||||
*/
|
||||
export function authFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const token = readToken();
|
||||
const headers = new Headers(init.headers);
|
||||
if (token !== null) headers.set("authorization", `Bearer ${token}`);
|
||||
return fetch(input, { ...init, credentials: "same-origin", headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an email and password for an access token at the identity provider.
|
||||
*
|
||||
* A direct `fetch` rather than `@supabase/supabase-js`, which is a large
|
||||
* dependency for one documented REST call and would be this repo's only reason
|
||||
* to carry it. The same call is what the rest of the fleet's sites make.
|
||||
*
|
||||
* Returns the token, or `null` for every way it can fail — the caller shows one
|
||||
* message either way, because distinguishing "no such account" from "wrong
|
||||
* password" is an enumeration oracle and not a kindness.
|
||||
*/
|
||||
export async function signIn(email: string, password: string): Promise<string | null> {
|
||||
if (!identityConfigured) return null;
|
||||
try {
|
||||
const res = await fetch(`${IDENTITY_URL.replace(/\/+$/, "")}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: { apikey: IDENTITY_KEY, "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { access_token?: unknown };
|
||||
return typeof body.access_token === "string" && body.access_token !== ""
|
||||
? body.access_token
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* The two build-time values that turn on `sso` sign-in in the browser.
|
||||
*
|
||||
* Both are PUBLIC — an issuer URL and a publishable key — and neither grants
|
||||
* anything on its own; the server still revalidates every token against
|
||||
* `TERA_AUTH_REVALIDATE_URL`. They are optional, and a build without them keeps
|
||||
* the local password form, which is the self-host default.
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_IDENTITY_URL?: string;
|
||||
readonly VITE_IDENTITY_ANON_KEY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user