Signing in puts people in the building
`member` and `anon` were told apart inside the office by the client and by nothing else. `access.ts` picked an `officeDepth`, `createOfficeScene` built a presence layer at full depth — and then nobody ever called `setPresence`, so both tiers rendered the identical empty room. A tier that changes nothing you can see is not a tier, and `routes/markers.ts` had already written down why one drawn only in the browser is worse than none: it is a UI hiding a control over a body the API hands to whoever asks. So the refusal happens on the server now. `GET /api/v1/offices/:id/presence` is the one route that always takes a session, whatever else the deployment is configured for. `markers.ts` serves its feed to anonymous callers when no feed is configured, on the grounds that there is nothing there to protect; that reasoning does not transfer, and the difference is the whole point — a marker is a company at an address and a presence is a person at a desk. The ordering inside the handler is the security property, not a detail. It resolves the viewer *before* it looks at the id, so an anonymous caller gets an identical 401 for a real office, a private one and one that was never created. Check the office first and 404-for-unknown against 401-for-known tells them apart perfectly, which is the enumeration oracle CONTRACT.md §6 forbids, wearing a different status code. Three requests and one `deepEqual` hold that down. `TERA_PRESENCE_DIR` is a second directory rather than a `people` field on the pack, and that is the design. `types.ts` says a `Presence` binds to a `seatId` and never to a coordinate so the geometry can be published while the people cannot — which buys nothing if both live in one file, because an operator who wants a public floorplan then has to strip the roster out by hand, and the first time they forget the leak is permanent. Two directories makes the safe thing the default thing. An office with no roster is 200 and empty, never 404: "no such office" and "nobody has told me who is in this one" are different problems with different fixes, and one 404 sends an operator after the wrong one. On the client, occupancy arrives after the room is on screen rather than before — the building is worth looking at while a second request is in flight. An API that answers is believed, including when it answers with nobody; an office where everyone has gone home is a real fact and overwriting it with invented people to liven up the demo is the one thing this must never do. An API that does not answer falls back to a fabricated roster, exactly as the markers do, because a clone with no server is the flagship case and a member shown the same empty room as a stranger has been told the tier means something when it does not. Those twenty-five people are invented and the page says so. `sample.ts` says it to a reader of the source; `#office-badge` now says "Sample occupancy — these people are invented" to the person looking at the room, and it is not suppressed when a real deployment's API merely happened to be down — that is exactly the case where a member would otherwise read invented names as their colleagues. Fabricated names at real desks look like a staff list, and a screenshot of one must not be possible to take without the caption. The floor plan marks the occupied desks, one colour for everybody where the scene has four: at three device pixels a hue is a guess. The plan answers "is anyone there" and the room answers "who, and what are they doing". Hovering a desk names them, and the readout reads as an address getting more specific — metres, then room, then person. server: 127 tests pass, 11 of them new. Client typechecks and builds; the office chunk absorbed the plan renderer and the entry chunk moved 2.3 kB for the sample roster. Checked in the browser at office.lumbridgecorp.com: FULL VIEW, the badge, figures at the benches, dots on the plan, and "3.7, 16.7 m · Alcatraz · Clementine Roux" under the pointer.
This commit is contained in:
@@ -36,6 +36,7 @@ import * as THREE from "three";
|
||||
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { kit, type AssetRegistry } from "../assets/kit.ts";
|
||||
import type { LevelPlan, Plan, ResolvedRoom } from "../interiors/plan.ts";
|
||||
import type { Presence } from "../interiors/types.ts";
|
||||
|
||||
/** What the pointer is over, for a readout line the caller owns. */
|
||||
export interface OfficePlanHoverInfo {
|
||||
@@ -44,6 +45,15 @@ export interface OfficePlanHoverInfo {
|
||||
z: number;
|
||||
/** The room under the pointer, by name, or `null` out in the circulation. */
|
||||
room: string | null;
|
||||
/**
|
||||
* Who is at the desk under the pointer, by label, or `null`.
|
||||
*
|
||||
* A label and never an id. The plan is drawn from the pack, which knows seat
|
||||
* `eng-04` and nothing else; the name arrives separately over an authenticated
|
||||
* request, and handing back the id when there is nobody there would leak the
|
||||
* seating chart into a widget that is otherwise pure geometry.
|
||||
*/
|
||||
person: string | null;
|
||||
/** The level being drawn, by name. Printed only when there is more than one. */
|
||||
level: string;
|
||||
}
|
||||
@@ -78,6 +88,16 @@ export interface OfficeMinimap {
|
||||
* asked for and arriving.
|
||||
*/
|
||||
setActiveView(id: string | null): void;
|
||||
/**
|
||||
* Who is in, so the plan can mark their desks.
|
||||
*
|
||||
* Takes `Presence[]` rather than a set of seat ids so the hover readout can
|
||||
* name somebody without a second lookup by the caller. A presence whose seat
|
||||
* is not on this plan is dropped, exactly as `presence.ts` drops it in the
|
||||
* scene and for the same reason: there is nowhere to put it, and inventing a
|
||||
* spot would turn a private id into a public coordinate.
|
||||
*/
|
||||
setPresence(people: readonly Presence[]): void;
|
||||
/** Call from the stage tick. Cheap by construction — see the file header. */
|
||||
tick(): void;
|
||||
/** Re-do the backing store at the current size and re-rasterise the plan. */
|
||||
@@ -195,6 +215,10 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
*/
|
||||
let level: LevelPlan | null = plan.levels[0] ?? null;
|
||||
let activeViewId: string | null = null;
|
||||
/** Occupied seats on this storey: x, y device pixels per person, laid out once. */
|
||||
let occupiedPx = new Float64Array(0);
|
||||
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
|
||||
let peopleBySeat = new Map<string, string>();
|
||||
|
||||
// Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize
|
||||
// and on a change of storey, so the draw loop reads numbers and never projects.
|
||||
@@ -438,6 +462,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
|
||||
layoutLabels();
|
||||
layoutViewpoints();
|
||||
layoutOccupied();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -469,6 +494,25 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the occupied desks are, in device pixels.
|
||||
*
|
||||
* Resolved through `plan.seat()` rather than through the caller, because the
|
||||
* seat's position is the plan's fact and a second copy of it would be a second
|
||||
* thing to get wrong. Seats on another storey resolve fine and are skipped
|
||||
* here — they are drawn when that storey is.
|
||||
*/
|
||||
function layoutOccupied() {
|
||||
if (!level || scale <= 0) return;
|
||||
const points: number[] = [];
|
||||
for (const seatId of peopleBySeat.keys()) {
|
||||
const seat = plan.seat(seatId);
|
||||
if (!seat || seat.levelId !== level.id) continue;
|
||||
points.push(toPxX(seat.position.x), toPxY(seat.position.z));
|
||||
}
|
||||
occupiedPx = new Float64Array(points);
|
||||
}
|
||||
|
||||
function layoutViewpoints() {
|
||||
if (!level) return;
|
||||
const here = plan.viewpoints.filter((v) => v.levelId === level?.id);
|
||||
@@ -672,6 +716,34 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An occupied desk, as a filled dot with a dark rim.
|
||||
*
|
||||
* Drawn in the overlay rather than into the static raster, because occupancy
|
||||
* is the one thing on this plan that changes without the building changing —
|
||||
* re-rasterising fifteen rooms and two hundred props to move one dot would be
|
||||
* the wrong trade by three orders of magnitude.
|
||||
*
|
||||
* One colour for everybody, deliberately, where the scene has four. The scene
|
||||
* has the room to distinguish heads-down from in-a-meeting and this does not:
|
||||
* at three device pixels a hue is a guess, and four guesses on one plan is a
|
||||
* legend nobody asked for. The plan answers "is anyone there", the room
|
||||
* answers "who, and what are they doing".
|
||||
*/
|
||||
function drawOccupied(ctx: Ctx) {
|
||||
if (occupiedPx.length === 0) return;
|
||||
const r = 2.4 * dpr;
|
||||
ctx.lineWidth = dpr;
|
||||
ctx.fillStyle = theme.occupied;
|
||||
ctx.strokeStyle = theme.occupiedEdge;
|
||||
for (let i = 0; i < occupiedPx.length; i += 2) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(occupiedPx[i] ?? 0, occupiedPx[i + 1] ?? 0, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawPing(ctx: Ctx, now: number) {
|
||||
if (pinging === 0) return;
|
||||
const t = (now - pinging) / PING_MS;
|
||||
@@ -698,6 +770,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
ctx.clip();
|
||||
|
||||
drawFootprint(ctx);
|
||||
drawOccupied(ctx);
|
||||
drawViewpoints(ctx);
|
||||
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
|
||||
drawCamera(ctx);
|
||||
@@ -756,6 +829,38 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
return plan.roomAt(level.id, { x, z });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoever is sitting within a desk's width of the pointer, or nobody.
|
||||
*
|
||||
* A radius rather than a hit test on the seat itself, because a seat is a
|
||||
* point and a pointer on a 14 rem widget is worth about fifteen centimetres of
|
||||
* office. Three quarters of a metre is close enough to be unambiguous — desks
|
||||
* in a bench are 1.7 m apart — and forgiving enough to be usable.
|
||||
*
|
||||
* Linear over the occupied seats, which is the right algorithm at this size: a
|
||||
* full floor is a few dozen people, this runs on pointer moves already
|
||||
* throttled by the browser, and a spatial index would be more code than the
|
||||
* thing it indexes.
|
||||
*/
|
||||
function personNear(x: number, z: number): string | null {
|
||||
if (!level) return null;
|
||||
const reach = 0.75;
|
||||
let best: string | null = null;
|
||||
let bestGap = reach * reach;
|
||||
for (const [seatId, label] of peopleBySeat) {
|
||||
const seat = plan.seat(seatId);
|
||||
if (!seat || seat.levelId !== level.id) continue;
|
||||
const dx = seat.position.x - x;
|
||||
const dz = seat.position.z - z;
|
||||
const gap = dx * dx + dz * dz;
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
best = label;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function seekTo(px: number, py: number) {
|
||||
if (!ready) return;
|
||||
const x = clampX(px);
|
||||
@@ -797,6 +902,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
x: wx,
|
||||
z: wz,
|
||||
room: hoverRoom,
|
||||
person: personNear(wx, wz),
|
||||
level: level?.name ?? "",
|
||||
});
|
||||
}
|
||||
@@ -964,6 +1070,18 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
setPresence(people) {
|
||||
peopleBySeat = new Map();
|
||||
for (const person of people) {
|
||||
// Last writer wins on a duplicated seat, which matches what the scene
|
||||
// does with two meshes at one position: you see one person. A roster
|
||||
// that seats two people at one desk is wrong in the roster.
|
||||
peopleBySeat.set(person.seatId, person.label);
|
||||
}
|
||||
layoutOccupied();
|
||||
dirty = true;
|
||||
},
|
||||
|
||||
tick() {
|
||||
if (!ready || !viewCtx) return;
|
||||
const now = performance.now();
|
||||
@@ -1032,6 +1150,8 @@ interface Theme {
|
||||
propEdge: string;
|
||||
label: string;
|
||||
labelHalo: string;
|
||||
occupied: string;
|
||||
occupiedEdge: string;
|
||||
frame: string;
|
||||
footprintFill: string;
|
||||
footprintStroke: string;
|
||||
@@ -1086,6 +1206,11 @@ function buildTheme(): Theme {
|
||||
// The ground colour, near-opaque, so a name over a desk bank sits in its own
|
||||
// small clearing rather than in the middle of the desks.
|
||||
labelHalo: rgba(rgbOf(0x0a0d11), 0.82),
|
||||
// Brighter than the furniture it sits on and cooler than the amber the
|
||||
// camera owns, so a busy floor never competes with "where am I looking",
|
||||
// which is still this widget's first job.
|
||||
occupied: rgba(rgbOf(0x8ec3e8), 0.95),
|
||||
occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7),
|
||||
frame: rgba(rgbOf(0x9fb4c6), 0.3),
|
||||
// Faint, for the reason the city widget's is faint: on the whole-floor view
|
||||
// the footprint covers most of the widget, and a fill that is a hint over
|
||||
|
||||
Reference in New Issue
Block a user