diff --git a/index.html b/index.html
index af198e2..1a6f50f 100644
--- a/index.html
+++ b/index.html
@@ -472,6 +472,10 @@
@media (hover: hover) {
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
}
+ /* A toggle has to look like its state, or it is a button that appears to do
+ nothing on every second press. Same treatment the chapter and city
+ buttons take, for the same reason and from the same attribute. */
+ .help[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
/* ---- Bottom left: where the numbers came from -------------------------
Shown only when the data is live — see `renderSource` in main.ts for why
@@ -684,6 +688,9 @@
/* No keyboard, so no key hints — and with them gone the card around
them is a full-width bar of glass holding one button. On a phone the
hint *is* the `?` button. */
+ /* The key hints go because there is no keyboard. The plan button is not
+ one of them and is not a `span`, so it stays — which is the whole
+ reason it exists. */
.hint span { display: none; }
.hint {
align-self: flex-end;
@@ -707,6 +714,16 @@
overflow: hidden;
text-overflow: ellipsis;
}
+ /* Except when it is carrying the office's disclosure, which is a
+ sentence and not a label. "sample occupancy · these people …" is a
+ truncation that removes the only word that mattered, and a disclosure
+ cut off mid-clause is worse than none at all — it looks like a caption
+ somebody bothered to write, so nobody goes looking for the rest. It
+ gets the width and the second line. */
+ body.sample-occupancy .source {
+ max-width: calc(100vw - var(--s3) * 2);
+ white-space: normal;
+ }
/* The plan, as a bottom sheet. At 11.5rem in a corner it ate a fifth of
a 375px map and its own affordances were about six pixels across —
diff --git a/src/interiors/officeScene.ts b/src/interiors/officeScene.ts
index 5b54baa..6aad42c 100644
--- a/src/interiors/officeScene.ts
+++ b/src/interiors/officeScene.ts
@@ -292,16 +292,67 @@ export function createOfficeScene(office: Office, options: OfficeSceneOptions):
const floorY = plan.level(viewpoint.levelId)?.floorY ?? 0;
const focus = viewpoint.focus;
const TARGET_Y = 1.2;
+ /**
+ * The correction scales the whole offset from the target, not the ground
+ * distance alone, and the difference is the entire fix.
+ *
+ * Pushing the camera back while leaving `height` where it was does not step
+ * away from the shot, it *flattens* it: the reference pack's establishing
+ * view is 32 m out and 14 m up, a comfortable look down onto the floor, and
+ * multiplying only the 32 leaves the camera ninety-odd metres away and still
+ * fourteen up — a near-horizontal squint at the edge of a floor plate,
+ * stranded near the horizon with the bottom half of the frame empty. Tried
+ * it; it was worse than the bug. Scaling both preserves the elevation angle
+ * exactly, so the shot is the one the author framed, from further away.
+ */
+ const k = widthCorrection();
+ const above = Math.max(focus.height, TARGET_Y + 0.3) - TARGET_Y;
return {
target: new THREE.Vector3(focus.at.x, floorY + TARGET_Y, focus.at.z),
position: new THREE.Vector3(
- focus.at.x + Math.sin(focus.rotation) * focus.distance,
- floorY + Math.max(focus.height, TARGET_Y + 0.3),
- focus.at.z + Math.cos(focus.rotation) * focus.distance,
+ focus.at.x + Math.sin(focus.rotation) * focus.distance * k,
+ floorY + TARGET_Y + above * k,
+ focus.at.z + Math.cos(focus.rotation) * focus.distance * k,
),
};
}
+ /**
+ * The aspect ratio a pack's `distance` was written against.
+ *
+ * Every viewpoint in `lumbridge-hq.ts` was framed by eye in a landscape
+ * browser, which makes 16:9 the honest reading of what those numbers mean —
+ * and this is the only place that reading is written down, so a pack author
+ * who wants to know what `distance: 32` promises can find out.
+ */
+ const AUTHORED_ASPECT = 16 / 9;
+
+ /**
+ * How much further back a narrow viewport has to stand.
+ *
+ * `camera.fov` is *vertical*, so the width you can see is
+ * `distance * tan(fov / 2) * aspect` — and a phone held upright has an aspect
+ * near 0.5 against the 1.78 the pack was written for. At the same distance
+ * that is a third of the width, which is exactly what the arrival shot looked
+ * like: a thirty-four-metre floor plate shoved off the corner of the screen
+ * with the top of the frame full of empty sky. The pack was not wrong and the
+ * renderer was not wrong; the number simply meant something else on that
+ * screen.
+ *
+ * So the correction preserves the *width* the author framed, which is the
+ * thing they were actually choosing — "everything in this building is
+ * somewhere in this frame" is a statement about width, and the extra height a
+ * tall screen throws in for free costs nothing. It only ever pushes back,
+ * never pulls in: a viewport wider than 16:9 already shows more than was asked
+ * for, and creeping closer to trim it would crop an establishing shot on a
+ * desktop to make a rule tidy.
+ */
+ function widthCorrection(): number {
+ const aspect = kit.camera.aspect;
+ if (!(aspect > 0) || !Number.isFinite(aspect)) return 1;
+ return Math.max(1, AUTHORED_ASPECT / aspect);
+ }
+
/**
* Where you arrive when the pack declares no viewpoints at all.
*
diff --git a/src/main.ts b/src/main.ts
index 586e84c..32fae96 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -840,6 +840,7 @@ function watchOccupancy() {
scene.setPresence(people);
officePlan?.setPresence(people);
renderOfficeBadge();
+ renderSource();
});
}
@@ -973,6 +974,35 @@ function renderLegend() {
*/
function renderSource() {
if (!source) return;
+ /**
+ * The office's provenance line, which outranks the city's.
+ *
+ * It used to live on `#office-badge` inside `#panel`, and on a phone `#panel`
+ * is a bottom sheet that starts closed — so the one sentence standing between
+ * twenty-five invented people at real desks and a screenshot presented as a
+ * staff list was behind a hamburger, on the device most likely to take the
+ * screenshot. This slot is the right home for it anyway and not merely a
+ * visible one: `#source` is where this page already says what on screen is and
+ * is not real, it is fixed and always drawn, and the phone stylesheet calls it
+ * "the one caption that is never allowed to be dropped for space".
+ *
+ * It replaces the city's label rather than joining it, because while you are
+ * standing in the office the markers, the sky and the traffic are facts about
+ * a board behind you — and "live data" printed over invented colleagues is the
+ * exact species of lie the liveness wording was rewritten to stop telling.
+ */
+ const sample = inside && presenceIsSample;
+ // A class rather than an inline style, so the stylesheet keeps the decision
+ // about how a phone lays this out and this keeps the decision about what it
+ // says. The rule it turns on is the one that stops the sentence being
+ // ellipsised down to the half that reads as reassuring.
+ document.body.classList.toggle("sample-occupancy", sample);
+ if (sample) {
+ const caption = "sample occupancy · these people are invented";
+ if (source.textContent !== caption) source.textContent = caption;
+ source.hidden = false;
+ return;
+ }
const label = describeLiveness({
markers: liveData,
// An override is a sky somebody invented, so it retires the claim for as
@@ -1041,29 +1071,11 @@ function renderCredits() {
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
- /**
- * The other thing worth saying in this spot, and the more urgent of the two.
- *
- * The public badge above explains an absence. This one explains a *presence*,
- * which is the direction that can actually mislead somebody: twenty-five
- * invented names sitting at real desks look exactly like a staff list, and the
- * only thing standing between that and a screenshot presented as one is this
- * sentence. `sample.ts` says the same thing to a reader of the source; this
- * says it to the person looking at the room.
- *
- * It is not suppressed on a real deployment whose API happened to be down,
- * because that is precisely the case where it is needed: a `/presence` that
- * did not answer falls back to the fabricated roster, and a member who is not
- * told that will read invented people as their colleagues.
- */
- const sampleOffice = inside && office !== null && office.depth === "full" && presenceIsSample;
- officeBadge.hidden = !publicOffice && !sampleOffice;
- if (sampleOffice) {
- officeBadge.replaceChildren(
- document.createTextNode("Sample occupancy — these people are invented."),
- );
- return;
- }
+ // The fabricated-occupancy caption used to be here too and is now on
+ // `#source` — see `renderSource`. This badge keeps the message that is a call
+ // to action rather than a disclosure, because that one belongs beside the
+ // office controls and survives being missed; the other one does not.
+ officeBadge.hidden = !publicOffice;
if (!publicOffice) return;
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
@@ -1269,6 +1281,8 @@ function togglePlan() {
applyPlan();
}
+planToggle?.addEventListener("click", () => togglePlan());
+
panelToggle?.addEventListener("click", () => {
panelOpen = !panelOpen;
applyPanel();