1392 lines
60 KiB
TypeScript
1392 lines
60 KiB
TypeScript
/**
|
||
* Fabricated demo data, so that a clone of this repo has something on it.
|
||
*
|
||
* **Everything in this file is invented.** The companies do not exist, have
|
||
* never existed, and are named the way they are — Wobbegong, Nonsuch, Pennyfarthing
|
||
* — specifically so that nobody can mistake one for a real business. The
|
||
* coordinates were typed by hand from a general sense of where San Francisco's
|
||
* neighbourhoods are; none of them is anybody's address, and none of them came
|
||
* out of a geocoder.
|
||
*
|
||
* That last point is the licence rule and not a stylistic preference. No real
|
||
* company data ships in this repo, for two reasons that happen to want the same
|
||
* thing. The privacy one is obvious: pipeline status — who is talking to whom,
|
||
* who said no — is private, and a public repo is the wrong place for it. The
|
||
* licence one is sharper and is the subject of ARCHITECTURE.md §3.2 and
|
||
* CONTRACT.md §8: real positions are *geocoded*, and a geocoder built on
|
||
* OpenStreetMap returns ODbL data. Serving a snapshot of those coordinates is
|
||
* Publicly Using a Derivative Database, which drags share-alike onto everything
|
||
* served alongside it, whether or not the rows live in the repo. So real markers
|
||
* arrive at runtime over the API — see `http.ts` — carrying a provenance field
|
||
* the server checks, and the repo itself ships this: fiction, which owes nobody
|
||
* anything.
|
||
*
|
||
* The demo is worth having anyway. A map with no pins on it teaches nobody what
|
||
* the thing is for, and half the interesting behaviour in `markers.ts` — the
|
||
* ghost treatment for a marker that has no real position yet — is invisible
|
||
* without data that exercises it.
|
||
*/
|
||
|
||
import { regionOf, syntheticRoutes, type SimRoute } from "../engine/flights.ts";
|
||
import type { Presence } from "../interiors/types.ts";
|
||
import type { City, Marker, MarkerPalette } from "../engine/types.ts";
|
||
|
||
/**
|
||
* A small pipeline, as colours.
|
||
*
|
||
* Five states is about the fewest that still shows why `colorKey` is opaque to
|
||
* the engine: none of these words means anything to `markers.ts`, which looks
|
||
* the key up here and draws whatever it finds. A public sector map would supply
|
||
* an entirely different set against the same renderer.
|
||
*/
|
||
export const SAMPLE_PALETTE: MarkerPalette = {
|
||
watching: 0x7f8b99,
|
||
applied: 0x4f9cf2,
|
||
talking: 0x3fbf9a,
|
||
offer: 0xf2b134,
|
||
closed: 0xd2544f,
|
||
};
|
||
|
||
/**
|
||
* Where the unplaced markers float: out over the bay, east of the Ferry
|
||
* Building, in a short arc.
|
||
*
|
||
* A marker with `located: false` has no address yet, and the honest thing to do
|
||
* with a position you do not have is to not pretend you have one. Open water is
|
||
* the clearest way to say that on a map — nothing is there, the pin visibly
|
||
* stands on nothing, and it cannot be misread as a building. `markers.ts` gives
|
||
* these a different silhouette and a lower opacity as well, so the tell does not
|
||
* rest on placement alone.
|
||
*/
|
||
const UNPLACED_LNG = -122.3665;
|
||
|
||
/**
|
||
* Twenty-two invented companies across San Francisco.
|
||
*
|
||
* Placed by neighbourhood rather than by street: Hayes Valley and SoMa are
|
||
* crowded because that is the fact the map is usually drawing attention to, the
|
||
* Bayview and the Richmond have one each, and three have no position at all.
|
||
* The clustering is the point — a marker layer that only ever gets evenly
|
||
* scattered test data hides every overlap problem it has.
|
||
*/
|
||
export const SAMPLE_MARKERS: Marker[] = [
|
||
// Hayes Valley and the bowl below Buena Vista.
|
||
{
|
||
id: "sample-thimbleway",
|
||
label: "Thimbleway Systems",
|
||
colorKey: "talking",
|
||
lat: 37.7768,
|
||
lng: -122.4243,
|
||
located: true,
|
||
blurb: "Invented. Model-serving, allegedly, in a Victorian with bad wiring.",
|
||
},
|
||
{
|
||
id: "sample-nonsuch",
|
||
label: "Nonsuch Cartography Co.",
|
||
colorKey: "applied",
|
||
lat: 37.7752,
|
||
lng: -122.4262,
|
||
located: true,
|
||
blurb: "Invented. Maps of places that are not there.",
|
||
},
|
||
{
|
||
id: "sample-marmalade",
|
||
label: "Marmalade Interchange",
|
||
colorKey: "watching",
|
||
lat: 37.7781,
|
||
lng: -122.4218,
|
||
located: true,
|
||
blurb: "Invented. Moves data between two formats nobody uses.",
|
||
},
|
||
|
||
// SoMa, where the grid turns forty-six degrees.
|
||
{
|
||
id: "sample-kettle-anvil",
|
||
label: "Kettle & Anvil Compute",
|
||
colorKey: "offer",
|
||
lat: 37.7805,
|
||
lng: -122.4051,
|
||
located: true,
|
||
blurb: "Invented. Sells the shovels, or claims to.",
|
||
},
|
||
{
|
||
id: "sample-ninth-pelican",
|
||
label: "Ninth Pelican Labs",
|
||
colorKey: "applied",
|
||
lat: 37.7784,
|
||
lng: -122.4009,
|
||
located: true,
|
||
blurb: "Invented. There were never eight others.",
|
||
},
|
||
{
|
||
id: "sample-brassbound",
|
||
label: "Brassbound Telemetry",
|
||
colorKey: "closed",
|
||
lat: 37.7822,
|
||
lng: -122.4074,
|
||
located: true,
|
||
blurb: "Invented. Went quiet after the second call.",
|
||
},
|
||
|
||
// The Financial District and Jackson Square.
|
||
{
|
||
id: "sample-grimsby-doone",
|
||
label: "Grimsby & Doone Photonics",
|
||
colorKey: "watching",
|
||
lat: 37.7941,
|
||
lng: -122.4008,
|
||
located: true,
|
||
blurb: "Invented. Two surnames and a laser.",
|
||
},
|
||
{
|
||
id: "sample-tugboat",
|
||
label: "Tugboat Actuarial",
|
||
colorKey: "applied",
|
||
lat: 37.7958,
|
||
lng: -122.4032,
|
||
located: true,
|
||
blurb: "Invented. Insurance for things that have already happened.",
|
||
},
|
||
|
||
// Mission Bay: landfill, then biotech.
|
||
{
|
||
id: "sample-fogbank",
|
||
label: "Fogbank Freight",
|
||
colorKey: "talking",
|
||
lat: 37.7709,
|
||
lng: -122.3918,
|
||
located: true,
|
||
blurb: "Invented. Logistics, in a building younger than most of the staff.",
|
||
},
|
||
{
|
||
id: "sample-bittern",
|
||
label: "Bittern & Sons Biologics",
|
||
colorKey: "watching",
|
||
lat: 37.7688,
|
||
lng: -122.3894,
|
||
located: true,
|
||
blurb: "Invented. No sons.",
|
||
},
|
||
|
||
// The Mission, flat and sunny.
|
||
{
|
||
id: "sample-unlikely-weather",
|
||
label: "Bureau of Unlikely Weather",
|
||
colorKey: "offer",
|
||
lat: 37.7602,
|
||
lng: -122.4151,
|
||
located: true,
|
||
blurb: "Invented. Forecasts nobody asked for.",
|
||
},
|
||
{
|
||
id: "sample-perpetual-bagel",
|
||
label: "Perpetual Bagel Works",
|
||
colorKey: "closed",
|
||
lat: 37.7574,
|
||
lng: -122.4192,
|
||
located: true,
|
||
blurb: "Invented. The name was the whole pitch.",
|
||
},
|
||
|
||
// Potrero Hill and Dogpatch, the old industrial edge.
|
||
{
|
||
id: "sample-wobbegong",
|
||
label: "Wobbegong Robotics",
|
||
colorKey: "talking",
|
||
lat: 37.7589,
|
||
lng: -122.4002,
|
||
located: true,
|
||
blurb: "Invented. Named after a carpet shark, for reasons never explained.",
|
||
},
|
||
{
|
||
id: "sample-sourdough-semi",
|
||
label: "Sourdough Semiconductor",
|
||
colorKey: "applied",
|
||
lat: 37.7597,
|
||
lng: -122.3881,
|
||
located: true,
|
||
blurb: "Invented. A fab in a city with no fabs.",
|
||
},
|
||
{
|
||
id: "sample-pennyfarthing",
|
||
label: "Pennyfarthing Power",
|
||
colorKey: "watching",
|
||
lat: 37.7564,
|
||
lng: -122.3973,
|
||
located: true,
|
||
blurb: "Invented. Batteries, uphill.",
|
||
},
|
||
|
||
// The north side and the hills.
|
||
{
|
||
id: "sample-lamplighter",
|
||
label: "Lamplighter Aerostatics",
|
||
colorKey: "watching",
|
||
lat: 37.8004,
|
||
lng: -122.4086,
|
||
located: true,
|
||
blurb: "Invented. Airships, which are always about to come back.",
|
||
},
|
||
{
|
||
id: "sample-halfpenny",
|
||
label: "Halfpenny Optics",
|
||
colorKey: "applied",
|
||
lat: 37.7929,
|
||
lng: -122.4147,
|
||
located: true,
|
||
blurb: "Invented. Lenses, four hundred feet above the water they look at.",
|
||
},
|
||
|
||
// One each in the parts of the city the map usually forgets.
|
||
{
|
||
id: "sample-cormorant",
|
||
label: "Cormorant Freight Systems",
|
||
colorKey: "talking",
|
||
lat: 37.7357,
|
||
lng: -122.3908,
|
||
located: true,
|
||
blurb: "Invented. The only pin south of Islais Creek, which is the point.",
|
||
},
|
||
{
|
||
id: "sample-tidewrack",
|
||
label: "Tidewrack Instruments",
|
||
colorKey: "watching",
|
||
lat: 37.7802,
|
||
lng: -122.4638,
|
||
located: true,
|
||
blurb: "Invented. Sensors, in the fog, on purpose.",
|
||
},
|
||
|
||
// Three with no position yet. See `UNPLACED_LNG`.
|
||
{
|
||
id: "sample-quibble",
|
||
label: "Quibble Quantum",
|
||
colorKey: "applied",
|
||
lat: 37.7965,
|
||
lng: UNPLACED_LNG,
|
||
located: false,
|
||
blurb: "Invented, and unplaced: no address on file, so the map does not invent one.",
|
||
},
|
||
{
|
||
id: "sample-antelope-foundry",
|
||
label: "Antelope Foundry",
|
||
colorKey: "watching",
|
||
lat: 37.7905,
|
||
lng: UNPLACED_LNG,
|
||
located: false,
|
||
blurb: "Invented, and unplaced.",
|
||
},
|
||
{
|
||
id: "sample-mudlark",
|
||
label: "Mudlark Instruments",
|
||
colorKey: "closed",
|
||
lat: 37.7845,
|
||
lng: UNPLACED_LNG,
|
||
located: false,
|
||
blurb: "Invented, and unplaced.",
|
||
},
|
||
];
|
||
|
||
/**
|
||
* Sample traffic over the Bay Area, for when the API is not there to send a
|
||
* flight plan.
|
||
*
|
||
* The corridors are roughly the real ones — arrivals down the peninsula from
|
||
* the north, departures turning out over the Pacific, a slow light aircraft
|
||
* crossing the bay — because that is what makes the sky read as this city's sky
|
||
* rather than as random motion. The callsigns are not: no real operator uses
|
||
* these prefixes, which keeps a demo from looking like a feed of actual
|
||
* traffic. Nothing here is observed, and `flights.ts` explains at length why
|
||
* this project ships a simulator instead of a client for somebody's live data.
|
||
*
|
||
* The unqualified name is a leftover and is kept because the app imports it.
|
||
* Every leg in it is over San Francisco, which is only the right answer for one
|
||
* of the two cities in this build; `sampleRoutesFor` is the entry point that
|
||
* knows the difference.
|
||
*/
|
||
export const SAMPLE_ROUTES: SimRoute[] = [
|
||
{ callsign: "NIMBUS 4", from: [37.95, -122.36], to: [37.66, -122.4], fromAlt: 2400, toAlt: 500, duration: 190 },
|
||
{ callsign: "NIMBUS 17", from: [37.93, -122.31], to: [37.65, -122.38], fromAlt: 2100, toAlt: 450, duration: 210 },
|
||
{ callsign: "PELICAN 2", from: [37.64, -122.39], to: [37.9, -122.62], fromAlt: 700, toAlt: 5200, duration: 165 },
|
||
{ callsign: "PELICAN 31", from: [37.7, -122.21], to: [37.88, -122.55], fromAlt: 1800, toAlt: 6100, duration: 230 },
|
||
{ callsign: "CORMORANT 8", from: [37.62, -122.6], to: [37.95, -122.28], fromAlt: 6800, toAlt: 8200, duration: 260 },
|
||
{ callsign: "KESTREL 5", from: [37.83, -122.56], to: [37.7, -122.22], fromAlt: 1100, toAlt: 1300, duration: 300 },
|
||
{ callsign: "NIMBUS 40", from: [37.96, -122.48], to: [37.63, -122.36], fromAlt: 3100, toAlt: 600, duration: 205 },
|
||
];
|
||
|
||
/**
|
||
* Sample traffic over the Los Angeles basin.
|
||
*
|
||
* The same idea as `SAMPLE_ROUTES` and it exists because that one was being
|
||
* flown over both cities: the SoCal board would come up with a sky whose every
|
||
* aircraft was nearly six hundred kilometres north of it, off the edge of the world
|
||
* and therefore invisible. A city that renders an empty sky looks like a city
|
||
* whose flight layer failed.
|
||
*
|
||
* Again the geography is roughly right and the callsigns are invented. LAX runs
|
||
* west almost always, so the arrivals here come in from the east over the
|
||
* basin and the departures go out over the water before turning; Burbank sits
|
||
* up the valley behind the hills, and the light aircraft is following the coast
|
||
* because in this basin that is what they do.
|
||
*/
|
||
export const SAMPLE_SOCAL_ROUTES: SimRoute[] = [
|
||
{ callsign: "CONDOR 6", from: [34.02, -117.45], to: [33.945, -118.36], fromAlt: 3500, toAlt: 400, duration: 330 },
|
||
{ callsign: "CONDOR 21", from: [33.99, -117.29], to: [33.94, -118.33], fromAlt: 4100, toAlt: 450, duration: 360 },
|
||
{ callsign: "AVOCET 12", from: [33.945, -118.42], to: [34.15, -118.84], fromAlt: 500, toAlt: 5200, duration: 190 },
|
||
{ callsign: "AVOCET 30", from: [33.68, -117.87], to: [33.34, -118.4], fromAlt: 600, toAlt: 4800, duration: 210 },
|
||
{ callsign: "CURLEW 3", from: [33.36, -117.3], to: [33.69, -117.86], fromAlt: 4200, toAlt: 500, duration: 235 },
|
||
{ callsign: "TOWHEE 9", from: [34.33, -118.82], to: [34.2, -118.36], fromAlt: 3000, toAlt: 450, duration: 175 },
|
||
{ callsign: "CONDOR 44", from: [34.36, -118.66], to: [33.32, -117.28], fromAlt: 9200, toAlt: 9800, duration: 300 },
|
||
{ callsign: "SANDPIPER 4", from: [33.6, -118.02], to: [34.03, -118.62], fromAlt: 900, toAlt: 1100, duration: 330 },
|
||
];
|
||
|
||
/**
|
||
* The right sample sky for a city, and something defensible for a city nobody
|
||
* has drawn one for.
|
||
*
|
||
* Keyed on `city.id` rather than on position because the hand-placed corridors
|
||
* are the whole value here: knowing that arrivals come down the peninsula and
|
||
* that LAX departs to the west is knowledge about two named places, and there
|
||
* is no way to derive it from a bounding box. What *can* be derived is legs
|
||
* that are at least in the right region, which is what `syntheticRoutes` does
|
||
* and what any third city gets until somebody sits down with a chart.
|
||
*/
|
||
export function sampleRoutesFor(city: Pick<City, "id" | "center" | "bounds">): SimRoute[] {
|
||
if (city.id === "sf") return SAMPLE_ROUTES;
|
||
if (city.id === "socal") return SAMPLE_SOCAL_ROUTES;
|
||
return syntheticRoutes(regionOf(city));
|
||
}
|
||
|
||
// ---- The office ------------------------------------------------------------
|
||
|
||
/**
|
||
* A fabricated roster for the reference office, so that "sign in" has something
|
||
* to show in a clone with no server behind it.
|
||
*
|
||
* **Every person here is invented**, by the same rule and for the same reason as
|
||
* the companies above: the names are deliberately unmistakable for anyone's
|
||
* — Quillon, Marchetti, Oyelaran-Pike — and none of them works anywhere. Real
|
||
* occupancy is the most private thing this project touches. It says who was at
|
||
* their desk and, by omission, who was not, which is an attendance record; it
|
||
* arrives at runtime over an authenticated endpoint (`routes/presence.ts`) and
|
||
* it never, ever lands in this repo.
|
||
*
|
||
* The demo is worth having for the same reason the markers are. An empty
|
||
* building teaches nobody what the office is *for*, and the entire presence
|
||
* layer — the figures, the seat binding, the hover card that names somebody —
|
||
* is invisible without data that exercises it. It also makes the tier honest
|
||
* locally: `access.ts` hands a clone `member`, and a member who is shown the
|
||
* same empty room as a stranger has been told the tier means something when it
|
||
* does not.
|
||
*
|
||
* The seat ids are real ids from `offices/lumbridge-hq.ts` and have to be: a
|
||
* presence whose seat is not in the plan is dropped by `presence.ts`, silently
|
||
* and correctly, so a typo here is an invisible absence rather than an error.
|
||
* They are also spread deliberately — two meeting rooms mid-session, a full
|
||
* bench, a half-empty one, three focus booths of which one is taken — because a
|
||
* floor where every desk is occupied reads as a texture rather than as people.
|
||
*/
|
||
export const SAMPLE_PRESENCE: Presence[] = [
|
||
// Reception, and someone waiting.
|
||
{ id: "p-01", seatId: "reception-01", label: "Wren Abaddon", colorKey: "in" },
|
||
{ id: "p-02", seatId: "lobby-01", label: "Visitor", colorKey: "guest" },
|
||
|
||
// The eng bench, full along the window row and thinning behind it.
|
||
{ id: "p-03", seatId: "eng-01", label: "Tobias Quillon", colorKey: "in" },
|
||
{ id: "p-04", seatId: "eng-02", label: "Ines Marchetti", colorKey: "in" },
|
||
{ id: "p-05", seatId: "eng-03", label: "Dara Oyelaran-Pike", colorKey: "in" },
|
||
{ id: "p-06", seatId: "eng-04", label: "Sunniva Holt", colorKey: "in" },
|
||
{ id: "p-07", seatId: "eng-05", label: "Casimir Vane", colorKey: "focus" },
|
||
{ id: "p-08", seatId: "eng-06", label: "Perpetua Nkemelu", colorKey: "in" },
|
||
{ id: "p-09", seatId: "eng-08", label: "Rafferty Osgood", colorKey: "in" },
|
||
{ id: "p-10", seatId: "eng-11", label: "Marisol Thibault", colorKey: "focus" },
|
||
|
||
// Ops, about half in.
|
||
{ id: "p-11", seatId: "ops-01", label: "Anouk Sterling", colorKey: "in" },
|
||
{ id: "p-12", seatId: "ops-02", label: "Emeka Farrow", colorKey: "in" },
|
||
{ id: "p-13", seatId: "ops-05", label: "Bettina Kovač", colorKey: "in" },
|
||
{ id: "p-14", seatId: "ops-07", label: "Xiomara Belfry", colorKey: "focus" },
|
||
{ id: "p-15", seatId: "ops-10", label: "Aurelio Pinsent", colorKey: "in" },
|
||
|
||
// Design.
|
||
{ id: "p-16", seatId: "design-01", label: "Halcyon Reeve", colorKey: "in" },
|
||
{ id: "p-17", seatId: "design-03", label: "Zephyrine Mbeki", colorKey: "in" },
|
||
{ id: "p-18", seatId: "design-04", label: "Ptolemy Sandoval", colorKey: "in" },
|
||
|
||
// One focus booth of three, which is the honest ratio.
|
||
{ id: "p-19", seatId: "booth-02", label: "Ottoline Grieve", colorKey: "focus" },
|
||
|
||
// Alcatraz, mid-session — six of ten, which is what a real meeting looks like.
|
||
{ id: "p-20", seatId: "alcatraz-01", label: "Cormac Dellwood", colorKey: "meeting" },
|
||
{ id: "p-21", seatId: "alcatraz-02", label: "Solveig Amankwah", colorKey: "meeting" },
|
||
{ id: "p-22", seatId: "alcatraz-03", label: "Ignatius Pell", colorKey: "meeting" },
|
||
{ id: "p-23", seatId: "alcatraz-06", label: "Rosalind Achebe", colorKey: "meeting" },
|
||
{ id: "p-24", seatId: "alcatraz-07", label: "Vasco Underhill", colorKey: "meeting" },
|
||
{ id: "p-25", seatId: "alcatraz-08", label: "Clementine Roux", colorKey: "meeting" },
|
||
];
|
||
|
||
/**
|
||
* `colorKey` -> colour, for the fabricated roster.
|
||
*
|
||
* Four states and no more, because the palette is the legend whether or not one
|
||
* is drawn: a viewer can tell "at their desk" from "heads-down" from "in a
|
||
* meeting" at a glance, and a fifth shade would be a distinction nobody could
|
||
* name. Amber for focus is the interface's own accent doing what it does
|
||
* everywhere else in this app — marking the thing you are meant to notice —
|
||
* and the guest green is the only hue that is not in the office's own materials,
|
||
* because a visitor is the one person in the building who is not part of it.
|
||
*/
|
||
export const SAMPLE_PRESENCE_PALETTE: Record<string, number> = {
|
||
in: 0x6f9ec4,
|
||
focus: 0xf2b134,
|
||
meeting: 0xc4796f,
|
||
guest: 0x7fb886,
|
||
};
|
||
|
||
|
||
// ---- The office, over a day ------------------------------------------------
|
||
|
||
/**
|
||
* The fabricated roster as it stands at one particular instant: who is in the
|
||
* building, and **which seat each of them is in right now**.
|
||
*
|
||
* `SAMPLE_PRESENCE` is a photograph of one good morning, and a photograph was
|
||
* the whole truth for as long as the office had no clock. It is not any more.
|
||
* The room is lit by the same sun the city is, the house lights come up as that
|
||
* sun goes down, and the robots go on walking the floor after dark — so a
|
||
* building with all twenty-five of these people still sitting in it at one in
|
||
* the morning was the least believable thing in it. Everything else in the scene
|
||
* had learned what time it was; the people had not.
|
||
*
|
||
* The first version of this function fixed half of that: it thinned the roster
|
||
* by hour, so the floor filled and emptied. The other half is that everybody it
|
||
* kept was pinned to one seat from dawn to dusk, and a floor where the same
|
||
* twenty-five silhouettes sit in the same twenty-five chairs for eleven hours is
|
||
* a texture rather than a place. A real floor has people in the meeting rooms
|
||
* when there is a meeting, at the kitchen island at lunch, and in the lounge in
|
||
* the afternoon. So this now answers a harder question than "are you in": it
|
||
* answers "where are you", and a `Presence` binds to a `seatId`, so the whole
|
||
* feature is **choosing seat ids**. Nothing here invents a position; it could
|
||
* not if it wanted to, which is the point of the seat-id split and is argued at
|
||
* length on `Presence` in `interiors/types.ts`.
|
||
*
|
||
* This is deliberately a **pure function of an instant the caller supplies**,
|
||
* not a ticking source. The app already has exactly one clock — the wall clock,
|
||
* or whatever the godmode scrubber has overridden it with — and a module that
|
||
* read `new Date()` for itself would be a second one, correct until the moment
|
||
* somebody drags the scrubber to midnight and finds the desks still full. That
|
||
* is the bug this exists to fix, so it is not one to reintroduce one layer down.
|
||
*
|
||
* ### What replaced "always a subset of SAMPLE_PRESENCE"
|
||
*
|
||
* It used to be one. Returning a filtered `SAMPLE_PRESENCE` bought two hard
|
||
* properties for nothing — never more people than seats, and never two people in
|
||
* one seat — because the input already had one person per seat and filtering
|
||
* cannot create a collision. **That is gone**, and it has to be: a person who is
|
||
* at their desk at eleven and in Alcatraz at ten is by definition not always at
|
||
* the seat the photograph gives them. The result is still `Presence[]` and the
|
||
* exported signature has not moved, but the objects in it now carry seat ids
|
||
* that are nowhere in `SAMPLE_PRESENCE`, and a `colorKey` that can differ from
|
||
* the one there.
|
||
*
|
||
* The two properties are bought back a different way, and it is worth being
|
||
* precise because the second one is the easy bug here:
|
||
*
|
||
* - **One person, one seat.** The loop below emits at most one `Presence` per
|
||
* roster entry, ever. A person is either inside exactly one booking or at
|
||
* their own desk, never both, and the `bookings` list for one person is
|
||
* audited at import for self-overlap. Twenty-five people therefore produce at
|
||
* most twenty-five entries, at twenty-five distinct seats — so the roster can
|
||
* never name more people than the floor can hold.
|
||
* - **One seat, one person.** This is not free and is not derivable from the
|
||
* per-person view, because it is a statement about *pairs* of people. It comes
|
||
* from `BOOKINGS` being a hand-written table rather than a hash: within one
|
||
* booking the room seats are the object's keys, so a repeat is a **compile
|
||
* error** (TS1117), and across bookings that are live at the same moment the
|
||
* import-time audit at the bottom of this file checks every pair. A booking
|
||
* may also never name a seat that somebody sits at all day; that is checked
|
||
* too, since sending a visitor to `eng-03` would put two people in a chair
|
||
* without either booking overlapping the other.
|
||
*
|
||
* `SAMPLE_PRESENCE` itself is untouched and still exported — it is what a caller
|
||
* with no clock, and every test written before this existed, is entitled to keep
|
||
* getting.
|
||
*
|
||
* ### What it costs
|
||
*
|
||
* Called on every clock tick — `updateSun` in `main.ts` runs on a once-a-minute wall-clock interval, and on every godmode scrub
|
||
* — and on every frame of a godmode scrub, so it is worth stating rather than
|
||
* hoping.
|
||
*
|
||
* Per call: one pass over the twenty-five-entry roster; for each person a walk
|
||
* of their own bookings which stops at the first one starting after `t`; and for
|
||
* whoever is not in a booking, four number comparisons against a day that was
|
||
* computed once. Summed over the roster the booking walk is bounded by the whole
|
||
* table — fifty seat assignments across fifteen bookings, nobody holding more
|
||
* than four — so the worst case is a couple of hundred number comparisons.
|
||
* **Nothing is allocated except the result array**: every `Presence` this can
|
||
* return, at a desk or in a room, is built once at import and handed out by
|
||
* reference, exactly as the old filter handed out `SAMPLE_PRESENCE`'s own
|
||
* objects. There is no hashing on this path at all; the hashes run once per seat
|
||
* while `ROSTER` is built.
|
||
*
|
||
* Import-time work is the roster build and the audit, together a couple of
|
||
* thousand comparisons over those fifty entries, once, and neither of them on
|
||
* any frame.
|
||
*
|
||
* ### No weekend, on purpose
|
||
*
|
||
* A Sunday is not modelled and the day of the week is never read. This is a
|
||
* judgement call and it is the one worth arguing with: a real building is nearly
|
||
* empty all weekend, and modelling that would be more honest than not.
|
||
*
|
||
* It is also, roughly two days in seven, a first impression of an empty floor
|
||
* for somebody who has just walked into the office for the first time — and this
|
||
* file's own header is already clear that an empty building teaches nobody what
|
||
* the office is for. An empty room at one in the morning is read as "it is one
|
||
* in the morning". An empty room at eleven on a Sunday, by a visitor who has not
|
||
* thought about what day it is, is read as a floor that failed to load. The
|
||
* overnight curve alone buys the thing that was actually missing — a building
|
||
* that visibly lives on a clock — and it does it at every hour of every day
|
||
* instead of costing us two of them. A deployment that wants weekends can filter
|
||
* this result by `when.getDay()` in four lines; a demo that has already been
|
||
* dismissed cannot be got back.
|
||
*
|
||
* ### Which clock
|
||
*
|
||
* `when.getHours()` — the viewer's local time, matching `#clock`, the godmode
|
||
* scrubber and every other reading of a `Date` in this app. That is right for
|
||
* the flagship case and imperfect for one that exists: the sun in the windows is
|
||
* computed from the site's latitude and longitude while the roster here follows
|
||
* the viewer, so a visitor in another time zone gets a floor that fills up at
|
||
* their nine o'clock under a sky that belongs to the building's. Fixing that
|
||
* properly needs a real time zone on `OfficeSite`, which is a change to the
|
||
* office contract and not to a demo adapter. Guessing one from `site.lng` is
|
||
* *not* the fix and was rejected: a longitude gives you mean solar time, and it
|
||
* knows nothing about daylight saving. Until there is a real zone, a caller that
|
||
* genuinely knows the building's can shift the `Date` it passes.
|
||
*/
|
||
export function samplePresenceAt(when: Date): Presence[] {
|
||
const t = when.getHours() * 60 + when.getMinutes() + when.getSeconds() / 60;
|
||
const out: Presence[] = [];
|
||
for (const person of ROSTER) {
|
||
// A booking wins outright. Somebody in a meeting is not also at their desk,
|
||
// and the order of these two branches is the entire "one person, one seat"
|
||
// guarantee for a single roster entry.
|
||
const booked = bookedAt(person, t);
|
||
if (booked) {
|
||
out.push(booked);
|
||
continue;
|
||
}
|
||
if (person.desk && isIn(person.desk.day, t)) out.push(person.desk.at);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Office-aware fallback for shipped environments.
|
||
*
|
||
* The legacy roster above is valuable as a renderer fixture, but its seat ids
|
||
* describe the original reference tower and therefore cannot truthfully appear
|
||
* in either active HQ. A missing presence service now means what it says: no
|
||
* fabricated people. Keeping this resolver keyed by the stable office id gives
|
||
* self-hosters and future sample packs an explicit seam without ever treating a
|
||
* shiny room as evidence of employees.
|
||
*/
|
||
export function samplePresenceForOfficeAt(_officeId: string, _when: Date): Presence[] {
|
||
return [];
|
||
}
|
||
|
||
/**
|
||
* Minutes since local midnight, as a literal. Every window in this section is
|
||
* written with it so that the source reads as a timetable.
|
||
*/
|
||
function at(hour: number, minute: number): number {
|
||
return hour * 60 + minute;
|
||
}
|
||
|
||
/**
|
||
* Where the six people the photograph catches mid-meeting actually sit.
|
||
*
|
||
* `SAMPLE_PRESENCE` is a snapshot at one moment, so for most of the roster the
|
||
* seat it gives somebody *is* their desk and this table has nothing to say. For
|
||
* the six in Alcatraz it is not: the snapshot caught them in a room they were
|
||
* booked into, and a model of a whole day needs to know where they go when the
|
||
* meeting ends. "Somebody in Alcatraz at 10:00 is at their desk at 11:00" is the
|
||
* requirement, and this is the line that says which desk.
|
||
*
|
||
* The alternative was to move them to desks in `SAMPLE_PRESENCE` itself and let
|
||
* the schedule put them back in the room. That was rejected because the snapshot
|
||
* is a deliberately varied fixture — its own comment lists the spread as the
|
||
* reason it exists — and a `SAMPLE_PRESENCE` in which everybody sits at a desk
|
||
* exercises exactly one kind of seat. Six lines here cost less than that.
|
||
*
|
||
* Keyed on the snapshot's seat rather than on the person id for the same reason
|
||
* `deskDay` is seeded from the seat: the seat is the address, and renaming
|
||
* Cormac Dellwood should not move anybody's desk. The desks chosen are free ones
|
||
* — nobody else in the roster sits at a `sales-*` station, at `design-02`,
|
||
* `ops-04` or `eng-07` — which the audit below rechecks, because two people
|
||
* sharing a home desk would be a permanent double occupancy rather than an
|
||
* occasional one.
|
||
*/
|
||
const DESKS: Record<string, string> = {
|
||
"alcatraz-01": "sales-01",
|
||
"alcatraz-02": "sales-02",
|
||
"alcatraz-03": "sales-03",
|
||
"alcatraz-06": "design-02",
|
||
"alcatraz-07": "ops-04",
|
||
"alcatraz-08": "eng-07",
|
||
};
|
||
|
||
/**
|
||
* The three shifts that are *not* hashed, and the reason the building is never
|
||
* empty during working hours.
|
||
*
|
||
* Everyone else's day is drawn from a hash, and a hash makes no promises: there
|
||
* is a seed for which every single person arrives at ten and the floor is bare
|
||
* at half past nine. Rather than clamp the answer afterwards — a clamp is a
|
||
* decision that changes as the clock moves, which is exactly the popping this
|
||
* function is supposed to be free of — three people are given fixed hours and
|
||
* the guarantee is read off them.
|
||
*
|
||
* They overlap in a deliberate chain: the early ops shift is in before anyone
|
||
* and out mid-afternoon, reception opens the front desk before it hands over,
|
||
* and one engineer works late and locks up. Each one starts before the previous
|
||
* one leaves, so their union is **continuous from 06:15 to 22:40**, and the
|
||
* floor cannot be empty inside it. Outside it the building genuinely does empty,
|
||
* which is the whole point of the change: at two in the morning there is nobody
|
||
* here, and the house lights and the robots are what the night shift looks like.
|
||
*
|
||
* Anchors take **no lunch break**, unlike everybody else. A break in an anchor
|
||
* is a break in the guarantee, and three staggered shifts that all happen to
|
||
* step out at half past twelve is precisely the accident that would put an empty
|
||
* floor on screen at the one hour nobody would think to check.
|
||
*
|
||
* A booking does **not** break the guarantee, and this is the thing to check
|
||
* before adding one. All three anchors are in `BOOKINGS` — the ops shift at the
|
||
* morning stand-up, reception in Bernal at twenty to four, the late engineer in
|
||
* Alcatraz and then alone in the kitchen at quarter past seven — but a booking
|
||
* *relocates* somebody, it never removes them. The claim above is that the floor
|
||
* is not empty, not that any particular chair is filled, so an anchor who is in
|
||
* a meeting still holds it up.
|
||
*
|
||
* The keys are desk seat ids and they must belong to somebody in the roster; see
|
||
* the audit below, which is there because renaming a seat would otherwise
|
||
* dissolve the guarantee without a word.
|
||
*/
|
||
const PRESENCE_ANCHORS: Record<string, { from: number; to: number }> = {
|
||
"ops-01": { from: at(6, 15), to: at(15, 5) },
|
||
"reception-01": { from: at(7, 10), to: at(18, 50) },
|
||
"eng-11": { from: at(11, 20), to: at(22, 40) },
|
||
};
|
||
|
||
/**
|
||
* One room, booked, with a named person in each chair.
|
||
*
|
||
* `seats` is keyed on the **room seat** and valued with the occupant's **desk
|
||
* seat**, which is the opposite of how it reads out loud and is that way round
|
||
* on purpose: object keys are unique, so a table that puts two people in
|
||
* `alcatraz-03` does not compile (TS1117). The single easiest mistake in this
|
||
* whole feature is caught by `tsc` rather than by anybody noticing two figures
|
||
* z-fighting in one chair.
|
||
*/
|
||
interface Booking {
|
||
/** For the audit's messages. Nothing reads it at runtime. */
|
||
room: string;
|
||
from: number;
|
||
to: number;
|
||
/** The `colorKey` an occupant wears while they are here. See `isGuest`. */
|
||
colorKey: string;
|
||
/** Room seat -> the desk seat of whoever is in it. */
|
||
seats: Record<string, string>;
|
||
}
|
||
|
||
/**
|
||
* The day's diary: fifteen bookings, fifty chairs filled, hand-written.
|
||
*
|
||
* ### Why this is a table and not code
|
||
*
|
||
* The obvious shape is generative — hash a person against a session the way
|
||
* `deskDay` hashes their arrival, and let the room fill up. That is what the
|
||
* previous version did for its two Alcatraz sessions, and it needed a designated
|
||
* chair-of-the-meeting to stop the room drawing empty on an unlucky seed. Scaled
|
||
* up to the nine places this table books, it fails for reasons that are not
|
||
* about taste:
|
||
*
|
||
* - **Collisions become a search problem.** Two independently-hashed sessions
|
||
* that overlap in time can pick the same room, and two independently-hashed
|
||
* people can pick the same chair. Fixing that at read time means resolving
|
||
* conflicts against whoever else happens to be present — and a resolution that
|
||
* depends on the rest of the roster is a decision that changes as the clock
|
||
* moves. That is the popping this whole section is built to avoid: person B
|
||
* would slide from `kitchen-02` to `kitchen-03` at the instant person A walked
|
||
* in, for no reason a viewer could see.
|
||
* - **A hash cannot make a meeting look like a meeting.** Six named people round
|
||
* one table for fifty minutes is the thing worth drawing. A per-person
|
||
* probability will happily put one person alone in a ten-seat board room —
|
||
* that is precisely the failure the old chair-of-the-meeting rule was there to
|
||
* paper over — and every fix for it, a threshold, a floor on attendance, a
|
||
* quorum, is another coefficient that adds up to writing the guest list badly.
|
||
* - **The invariant becomes visible.** Read down `seats` and you can see that
|
||
* nobody is in two rooms; that is not a property you can read off a hash at
|
||
* all, only test for.
|
||
*
|
||
* So the diary is data. It is also the honest shape for this file, whose header
|
||
* has said from the first line that everything in it is invented: a day somebody
|
||
* wrote down is more obviously fiction than a day a hash produced, not less.
|
||
*
|
||
* ### The shape of the day
|
||
*
|
||
* Ops stand up at twenty past nine, five of six round Bernal. The big review
|
||
* fills eight of Alcatraz's ten from five to ten until quarter to eleven. A
|
||
* visitor waits in reception, is taken into Bolinas, and leaves. Somebody sits
|
||
* in Lands End with the door shut for an hour — one person in a two-seat quiet
|
||
* room is what a quiet room looks like, and it is the one place on this floor
|
||
* where a single occupant is the correct picture rather than a failed meeting.
|
||
* Lunch is two sittings, because thirteen of these seat ids draw a hashed lunch
|
||
* window and the kitchen has five seats. Ten of the thirteen eat here; the other
|
||
* three go out and are simply gone, which is the truth about a lunch hour and is
|
||
* why the building thins at half past twelve without emptying. The afternoon is
|
||
* a lounge, a
|
||
* second Alcatraz session, a Bernal, two focus booths and a second visitor. The
|
||
* late engineer eats alone in the kitchen at quarter past seven.
|
||
*
|
||
* ### Constraints an edit has to keep
|
||
*
|
||
* All of these are checked at import, so breaking one is a line in the console
|
||
* and not a mystery on screen:
|
||
*
|
||
* 1. Every value is the desk seat of somebody in `ROSTER`.
|
||
* 2. No room seat is anybody's desk. Bookings move people to rooms, never into
|
||
* another person's chair.
|
||
* 3. Bookings that are live at the same instant share neither a room seat nor a
|
||
* person.
|
||
*
|
||
* There is a fourth that is not checked and does not need to be: every booking
|
||
* here lies inside its attendee's working day. `dayFor` widens a day to cover
|
||
* its bookings anyway, so a booking that ran past somebody's departure would
|
||
* keep them in the building rather than making them flicker — but as authored,
|
||
* with the hashed days these seat ids produce, that widening is a no-op today.
|
||
* It is there so that the *next* edit to this table cannot open a hole.
|
||
*/
|
||
const BOOKINGS: Booking[] = [
|
||
{
|
||
// Ops, standing up. The early shift is an anchor and is in from a quarter
|
||
// past six; the other four draw arrivals between half seven and a quarter
|
||
// to nine, so nobody here is being dragged in for their own stand-up.
|
||
room: "Bernal",
|
||
from: at(9, 20),
|
||
to: at(9, 50),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"bernal-01": "ops-01", // Anouk Sterling
|
||
"bernal-02": "ops-02", // Emeka Farrow
|
||
"bernal-03": "ops-05", // Bettina Kovač
|
||
"bernal-04": "ops-10", // Aurelio Pinsent
|
||
"bernal-05": "ops-04", // Vasco Underhill
|
||
},
|
||
},
|
||
{
|
||
// The morning review — eight of ten, which is what a full room looks like
|
||
// without being a roll call. The six the photograph caught are all here.
|
||
room: "Alcatraz",
|
||
from: at(9, 55),
|
||
to: at(10, 45),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"alcatraz-01": "sales-01", // Cormac Dellwood
|
||
"alcatraz-02": "sales-02", // Solveig Amankwah
|
||
"alcatraz-03": "sales-03", // Ignatius Pell
|
||
"alcatraz-04": "design-02", // Rosalind Achebe
|
||
"alcatraz-05": "design-01", // Halcyon Reeve
|
||
"alcatraz-06": "ops-04", // Vasco Underhill
|
||
"alcatraz-07": "eng-07", // Clementine Roux
|
||
"alcatraz-08": "eng-01", // Tobias Quillon
|
||
},
|
||
},
|
||
{
|
||
// The visitor waiting, which is a booking of the lobby sofa by the person
|
||
// who has no desk to be at instead. `lobby-01 -> lobby-01` looks like a
|
||
// no-op and is the point: a guest is in the building only when the diary
|
||
// says so, so their whole day is bookings and there is no fall-through.
|
||
room: "Reception",
|
||
from: at(10, 15),
|
||
to: at(10, 35),
|
||
colorKey: "guest",
|
||
seats: { "lobby-01": "lobby-01" },
|
||
},
|
||
{
|
||
// ...and then taken in. Three in the four-seat room.
|
||
room: "Bolinas",
|
||
from: at(10, 35),
|
||
to: at(11, 20),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"bolinas-01": "design-03", // Zephyrine Mbeki
|
||
"bolinas-02": "eng-02", // Ines Marchetti
|
||
"bolinas-03": "lobby-01", // the visitor, who stays green — see `isGuest`
|
||
},
|
||
},
|
||
{
|
||
// Lands End, door shut, one person. See the note on quiet rooms above.
|
||
room: "Lands End",
|
||
from: at(11, 0),
|
||
to: at(12, 0),
|
||
colorKey: "focus",
|
||
seats: { "quiet-01": "eng-05" }, // Casimir Vane
|
||
},
|
||
{
|
||
// First sitting. Every one of these five has a hashed lunch window that
|
||
// overlaps this, so `dayFor` drops the window and they eat here instead of
|
||
// vanishing — which is the point of putting the island in the diary at all.
|
||
room: "Kitchen",
|
||
from: at(12, 5),
|
||
to: at(12, 40),
|
||
colorKey: "in",
|
||
seats: {
|
||
"kitchen-01": "design-03", // Zephyrine Mbeki
|
||
"kitchen-02": "eng-06", // Perpetua Nkemelu
|
||
"kitchen-03": "design-01", // Halcyon Reeve
|
||
"kitchen-04": "sales-01", // Cormac Dellwood
|
||
"kitchen-05": "ops-05", // Bettina Kovač
|
||
},
|
||
},
|
||
{
|
||
// Second sitting, five minutes after the first clears.
|
||
room: "Kitchen",
|
||
from: at(12, 45),
|
||
to: at(13, 20),
|
||
colorKey: "in",
|
||
seats: {
|
||
"kitchen-01": "design-04", // Ptolemy Sandoval
|
||
"kitchen-02": "ops-07", // Xiomara Belfry
|
||
"kitchen-03": "eng-04", // Sunniva Holt
|
||
"kitchen-04": "eng-01", // Tobias Quillon
|
||
"kitchen-05": "booth-02", // Ottoline Grieve
|
||
},
|
||
},
|
||
{
|
||
// Two in the afternoon, all four lounge chairs.
|
||
room: "Lounge",
|
||
from: at(14, 5),
|
||
to: at(14, 45),
|
||
colorKey: "in",
|
||
seats: {
|
||
"lounge-01": "eng-03", // Dara Oyelaran-Pike
|
||
"lounge-02": "ops-02", // Emeka Farrow
|
||
"lounge-03": "design-02", // Rosalind Achebe
|
||
"lounge-04": "eng-08", // Rafferty Osgood
|
||
},
|
||
},
|
||
{
|
||
// The long one. Overlaps the lounge above by twenty-five minutes and shares
|
||
// nobody with it, which is the pair the audit is really watching.
|
||
room: "Alcatraz",
|
||
from: at(14, 20),
|
||
to: at(15, 25),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"alcatraz-01": "sales-02", // Solveig Amankwah
|
||
"alcatraz-02": "sales-03", // Ignatius Pell
|
||
"alcatraz-03": "eng-11", // Marisol Thibault
|
||
"alcatraz-04": "design-01", // Halcyon Reeve
|
||
"alcatraz-05": "ops-05", // Bettina Kovač
|
||
"alcatraz-06": "eng-06", // Perpetua Nkemelu
|
||
},
|
||
},
|
||
{
|
||
// Heads-down until five. A booth is one seat, which is the point of it.
|
||
room: "Focus booth 1",
|
||
from: at(15, 30),
|
||
to: at(17, 0),
|
||
colorKey: "focus",
|
||
seats: { "booth-01": "eng-07" }, // Clementine Roux
|
||
},
|
||
{
|
||
// The second visitor, waiting while reception is still at the front desk.
|
||
room: "Reception",
|
||
from: at(15, 20),
|
||
to: at(15, 40),
|
||
colorKey: "guest",
|
||
seats: { "lobby-01": "lobby-01" },
|
||
},
|
||
{
|
||
// Handed over at twenty to four, which is also when reception leaves the
|
||
// desk for Bernal below. That is a story and not a coincidence.
|
||
room: "Bolinas",
|
||
from: at(15, 40),
|
||
to: at(16, 15),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"bolinas-01": "sales-01", // Cormac Dellwood
|
||
"bolinas-02": "design-03", // Zephyrine Mbeki
|
||
"bolinas-03": "lobby-01", // the visitor
|
||
},
|
||
},
|
||
{
|
||
room: "Bernal",
|
||
from: at(15, 40),
|
||
to: at(16, 20),
|
||
colorKey: "meeting",
|
||
seats: {
|
||
"bernal-01": "eng-02", // Ines Marchetti
|
||
"bernal-02": "eng-04", // Sunniva Holt
|
||
"bernal-03": "ops-07", // Xiomara Belfry
|
||
"bernal-04": "design-04", // Ptolemy Sandoval
|
||
"bernal-05": "reception-01", // Wren Abaddon, off the front desk
|
||
},
|
||
},
|
||
{
|
||
room: "Focus booth 3",
|
||
from: at(16, 40),
|
||
to: at(17, 45),
|
||
colorKey: "focus",
|
||
seats: { "booth-03": "sales-03" }, // Ignatius Pell
|
||
},
|
||
{
|
||
// The late shift eats alone at quarter past seven, in a building that by
|
||
// then holds one or two people. This is the booking that makes the evening
|
||
// read as an evening rather than as one figure that never moves.
|
||
room: "Kitchen",
|
||
from: at(19, 15),
|
||
to: at(19, 40),
|
||
colorKey: "in",
|
||
seats: { "kitchen-04": "eng-11" }, // Marisol Thibault
|
||
},
|
||
];
|
||
|
||
/** One person's whole day, resolved once. */
|
||
interface Rostered {
|
||
/** The desk seat, which is the key everything else about this person is on. */
|
||
home: string;
|
||
/**
|
||
* Where they are when nothing is booked, and when they are there at all.
|
||
*
|
||
* The two travel together in one object so that the check in
|
||
* `samplePresenceAt` narrows both at once. `null` for the visitor, who has no
|
||
* desk and exists only inside a booking.
|
||
*/
|
||
desk: { at: Presence; day: DeskDay } | null;
|
||
/** Their own bookings, earliest first, audited not to overlap each other. */
|
||
bookings: Episode[];
|
||
}
|
||
|
||
/** One person, in one room, for one window. */
|
||
interface Episode {
|
||
from: number;
|
||
to: number;
|
||
/**
|
||
* The finished `Presence`, built at import.
|
||
*
|
||
* A person's diary never changes, so neither does the object describing them
|
||
* in it, and building one per call would be twenty-five allocations a second
|
||
* to produce values that are already known. This is what lets
|
||
* `samplePresenceAt` allocate nothing but its result array.
|
||
*/
|
||
who: Presence;
|
||
}
|
||
|
||
/**
|
||
* A guest stays a guest wherever they are sitting.
|
||
*
|
||
* The `colorKey` on a booking describes the *room* — amber for a booth, the
|
||
* meeting red for a table — and it is right for the twenty-four people who work
|
||
* here. It is wrong for the one who does not: the green is the only hue that is
|
||
* not in the office's own materials precisely because a visitor is the one
|
||
* person in the building who is not part of it, and a visitor who turns red on
|
||
* walking into Bolinas has lost the only thing that marked them out.
|
||
*
|
||
* `colorKey` is opaque to the engine and to `presence.ts` — that is the contract
|
||
* and it is not being bent here — but this module *defines* these keys, in
|
||
* `SAMPLE_PRESENCE_PALETTE` above, so it is the one place in the repo entitled
|
||
* to know that `"guest"` means somebody is a guest.
|
||
*/
|
||
function isGuest(person: Presence): boolean {
|
||
return person.colorKey === "guest";
|
||
}
|
||
|
||
/**
|
||
* The roster, resolved: twenty-five people, each with a desk, a day and a diary.
|
||
*
|
||
* Built at import rather than lazily, because the audit at the bottom of this
|
||
* file has to walk the same tables anyway and two passes over fifty entries at
|
||
* module load is not a cost worth deferring. `SAMPLE_PRESENCE` is a constant and
|
||
* this file is the only thing that writes it, so there is nothing to invalidate.
|
||
*/
|
||
const ROSTER: Rostered[] = SAMPLE_PRESENCE.map((person) => {
|
||
const home = DESKS[person.seatId] ?? person.seatId;
|
||
const bookings = bookingsFor(person, home);
|
||
if (isGuest(person)) return { home, desk: null, bookings };
|
||
|
||
const anchor = PRESENCE_ANCHORS[home];
|
||
// An anchor is a fixed day with no lunch hole, which is exactly a `DeskDay`
|
||
// with an empty interval in it — so the two kinds of person go down one path
|
||
// from here rather than two. `t >= 0 && t < 0` is false at every instant,
|
||
// which is what "takes no lunch break" means arithmetically.
|
||
const base: DeskDay = anchor
|
||
? { arrive: anchor.from, leave: anchor.to, lunchFrom: 0, lunchTo: 0 }
|
||
: deskDay(home);
|
||
|
||
return {
|
||
home,
|
||
desk: {
|
||
// The snapshot's colour describes the snapshot. `"meeting"` is a statement
|
||
// about a room, and a person sitting at their own desk wearing it would be
|
||
// telling the viewer they were somewhere they are not; the other three
|
||
// keys — in, focus, guest — are statements about the person and survive.
|
||
at: { ...person, seatId: home, colorKey: person.colorKey === "meeting" ? "in" : person.colorKey },
|
||
day: dayFor(base, bookings),
|
||
},
|
||
bookings,
|
||
};
|
||
});
|
||
|
||
/**
|
||
* Everything in `BOOKINGS` that names this person, earliest first.
|
||
*
|
||
* O(bookings × seats) per person and therefore O(people × table) overall, which
|
||
* is twenty-five passes over fifty entries at import and nothing at all
|
||
* afterwards. The obvious index — invert the table once into a map keyed on desk
|
||
* seat — would save about twelve hundred string comparisons that happen once,
|
||
* and cost a reader a level of indirection between the diary they are editing
|
||
* and the thing that reads it.
|
||
*/
|
||
function bookingsFor(person: Presence, home: string): Episode[] {
|
||
const out: Episode[] = [];
|
||
for (const booking of BOOKINGS) {
|
||
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
|
||
if (deskSeat !== home) continue;
|
||
out.push({
|
||
from: booking.from,
|
||
to: booking.to,
|
||
who: {
|
||
...person,
|
||
seatId: roomSeat,
|
||
colorKey: isGuest(person) ? person.colorKey : booking.colorKey,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
out.sort((a, b) => a.from - b.from);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* One person's day, widened to make room for their diary.
|
||
*
|
||
* Two adjustments, both computed once and both therefore free of the popping a
|
||
* clock-dependent adjustment would cause:
|
||
*
|
||
* **The day covers its bookings.** A meeting that starts before somebody's
|
||
* hashed arrival, or runs past their hashed departure, would otherwise show them
|
||
* appearing for the meeting, vanishing when it ended, and reappearing at their
|
||
* own arrival time — present, absent, present, which is worse than either. As
|
||
* authored no booking in the table needs this; it is here so that the next one
|
||
* cannot open that hole. Note that widening can only ever add minutes, so it
|
||
* cannot shorten an anchor's shift or break the coverage guarantee.
|
||
*
|
||
* **A booking over lunch replaces lunch.** Somebody who spent half past twelve
|
||
* at the kitchen island did not also go out to eat. Without this, the ten people
|
||
* the diary feeds would still be carrying their own hashed lunch window
|
||
* underneath it, and the leftovers of that window would show on both sides of
|
||
* the meal: they would vanish from their desks a few minutes before the sitting
|
||
* they are about to be seen at, and four of the five in the first sitting would
|
||
* stand up from the island straight into what was left of it — for `ops-05`,
|
||
* whose window runs to 13:06 against a sitting that clears at 12:40,
|
||
* twenty-six minutes of absence beginning the moment the room emptied.
|
||
*
|
||
* The whole hole is dropped rather than trimmed to what the booking does not
|
||
* cover. A trimmed hole leaves exactly those leftovers, of whatever length the
|
||
* two hashes happen to disagree by, and an absence whose length nobody chose is
|
||
* a glitch rather than a lunch.
|
||
*/
|
||
function dayFor(day: DeskDay, bookings: Episode[]): DeskDay {
|
||
if (bookings.length === 0) return day;
|
||
|
||
let first = Infinity;
|
||
let last = -Infinity;
|
||
let overLunch = false;
|
||
for (const booking of bookings) {
|
||
first = Math.min(first, booking.from);
|
||
last = Math.max(last, booking.to);
|
||
// Half-open intervals, so touching at an endpoint is not an overlap.
|
||
if (booking.from < day.lunchTo && booking.to > day.lunchFrom) overLunch = true;
|
||
}
|
||
|
||
return {
|
||
arrive: Math.min(day.arrive, first),
|
||
leave: Math.max(day.leave, last),
|
||
lunchFrom: overLunch ? 0 : day.lunchFrom,
|
||
lunchTo: overLunch ? 0 : day.lunchTo,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The booking this person is inside at `t`, or `null`.
|
||
*
|
||
* The list is sorted and a person's bookings never overlap each other — audited
|
||
* below — so the first one whose window contains `t` is the only one that can,
|
||
* and the scan can stop as soon as it reaches one that has not started yet.
|
||
* Nobody in the current table has more than four.
|
||
*/
|
||
function bookedAt(person: Rostered, t: number): Presence | null {
|
||
for (const booking of person.bookings) {
|
||
if (t < booking.from) break;
|
||
if (t < booking.to) return booking.who;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** One desk worker's day, in minutes since local midnight. */
|
||
interface DeskDay {
|
||
arrive: number;
|
||
leave: number;
|
||
lunchFrom: number;
|
||
lunchTo: number;
|
||
}
|
||
|
||
/**
|
||
* Is somebody with this day at their own desk at `t`?
|
||
*
|
||
* The lunch hole is deliberately checked after the arrive/leave pair rather than
|
||
* folded into it, because these are two different facts about a person and a
|
||
* single boolean expression covering both is the one that grows an off-by-one
|
||
* the first time somebody edits it.
|
||
*/
|
||
function isIn(day: DeskDay, t: number): boolean {
|
||
if (t < day.arrive || t >= day.leave) return false;
|
||
return !(t >= day.lunchFrom && t < day.lunchTo);
|
||
}
|
||
|
||
/**
|
||
* A believable working day for one seat, from the seat id and nothing else.
|
||
*
|
||
* ### Why a timetable per person and not an occupancy curve
|
||
*
|
||
* The obvious shape for "the office fills up and empties out" is a curve — a
|
||
* fraction of the floor that is in at time `t` — with each person holding a
|
||
* fixed threshold and being in whenever the curve is above it. It was tried and
|
||
* it is wrong here, for one reason: **the curve is not monotone**. It has to dip
|
||
* at lunch, and everybody whose threshold sits near the bottom of that dip
|
||
* blinks out and back in as the curve goes down and up again. Every wobble in
|
||
* the curve costs a pop for whoever is parked at that level, and the pop lands
|
||
* on the people nearest the middle of the distribution — the ones most likely to
|
||
* be on screen. A curve with any noise in it at all strobes.
|
||
*
|
||
* A per-person timetable has no such failure mode. Each person's day is four
|
||
* fixed instants, so the in/out predicate crosses at most four times in
|
||
* twenty-four hours and each crossing goes one way. Advancing the clock by a
|
||
* second can only change the answer for somebody whose boundary falls inside
|
||
* that second, and it changes it once. Scrubbing backwards is symmetric for the
|
||
* same reason. Bookings extend that property rather than spending it: a booking
|
||
* is two more fixed instants, so a person with four of them crosses at most a
|
||
* dozen times all day and every crossing is still a single, one-way step.
|
||
*
|
||
* Note what this is *instead of* hysteresis. Hysteresis needs memory of the last
|
||
* answer, and this function must give the same answer on a fresh reload as it
|
||
* gave the frame before — a page that has just booted has no last answer to
|
||
* remember. So rather than damping a boundary that moves, the boundary is made
|
||
* not to move at all, which buys the same freedom from chatter without any
|
||
* state to get out of sync between two callers. Both the scene and the plan view
|
||
* are handed this list; if it depended on history they could disagree.
|
||
*
|
||
* ### The numbers
|
||
*
|
||
* Arrivals are `08:45` ± 75 min and departures `17:50` ± 95 min, each drawn
|
||
* triangular rather than uniform so the floor fills through a busy middle with
|
||
* thin tails instead of a flat trickle — which is what a morning actually looks
|
||
* like from a desk. Departure is then pulled by 0.6 of how far the arrival was
|
||
* from the mean, because the person who is in at half seven is the person who
|
||
* leaves at four, and independent draws produce a floor full of people working
|
||
* seven-to-four and ten-to-eight at the same time. Those coefficients put
|
||
* arrivals in `07:30`–`10:00`, departures in `15:30`–`20:10` at the extremes,
|
||
* and the day itself between 7 h and 11 h 10 — the extremes of both draws at
|
||
* once, which is rare by construction.
|
||
*
|
||
* Lunch is `12:20` ± 40 min, taken away from the desk by the roughly two thirds
|
||
* who draw under the threshold, and it lasts 24 to 52 minutes. The shortest of
|
||
* those is still twenty-four minutes of world time, which is long enough to read
|
||
* as somebody having gone to eat rather than as a figure that glitched. Ten of
|
||
* the people who draw a lunch are in the diary at the kitchen island instead and
|
||
* never leave the building; see `dayFor`.
|
||
*
|
||
* Every window lies inside one calendar day and none of them wraps midnight.
|
||
* That is a constraint worth keeping, and it applies to `BOOKINGS` too: an
|
||
* interval that wraps needs `from > to` handling in `isIn` and `bookedAt`, and
|
||
* the first person to write one without it gets a night owl who is never in
|
||
* rather than always.
|
||
*
|
||
* Seeded from the **seat** id and not the person id, because the seat is the
|
||
* address — it is what a `Presence` binds to and what survives an edit to this
|
||
* file. Renaming Tobias Quillon should not change when the person at `eng-01`
|
||
* comes in.
|
||
*
|
||
* ### It is no longer memoised
|
||
*
|
||
* It used to be, into a `Map`, and the reason was good: the old
|
||
* `samplePresenceAt` called this per person on every clock tick, so without the
|
||
* cache each tick re-hashed a day that was by construction the same day. That is
|
||
* gone. `ROSTER` now calls this exactly once per seat at import and nothing
|
||
* calls it again, and a cache in front of a function called once per key can
|
||
* only ever be wrong, never fast. Worth a paragraph rather than a silent
|
||
* deletion, because "it is memoised, so call it freely" was a documented
|
||
* property of this function and whoever adds the next caller should know it has
|
||
* lapsed.
|
||
*/
|
||
function deskDay(seatId: string): DeskDay {
|
||
const arrive = at(8, 45) + 75 * triangular(seatId, "arrive");
|
||
const leave = at(17, 50) + 95 * triangular(seatId, "leave") + 0.6 * (arrive - at(8, 45));
|
||
const lunchFrom = at(12, 20) + 40 * triangular(seatId, "lunch");
|
||
const lunchLength = unit(seatId, "lunch-length") < 0.65 ? 38 + 14 * triangular(seatId, "bite") : 0;
|
||
|
||
return { arrive, leave, lunchFrom, lunchTo: lunchFrom + lunchLength };
|
||
}
|
||
|
||
/**
|
||
* A stable number in `[0, 1)` from a seat id and a named stream.
|
||
*
|
||
* FNV-1a, which is here because it is eight lines and has no dependencies, not
|
||
* because its statistical properties matter — nothing downstream of this is a
|
||
* simulation, and the requirement is only that the same seat gets the same
|
||
* answer on every reload, in every browser, forever. `Math.imul` keeps the
|
||
* multiply in 32 bits; a plain `*` would go through a double and lose the low
|
||
* bits that are the whole output.
|
||
*
|
||
* The stream name is what makes the draws independent. Hashing the seat id once
|
||
* and slicing the bits would be cheaper and would tie a seat's arrival time to
|
||
* its lunch hour in a way that eventually shows up as everybody who comes in
|
||
* early eating at the same moment.
|
||
*/
|
||
function unit(seatId: string, stream: string): number {
|
||
const text = `${seatId}/${stream}`;
|
||
let h = 0x811c9dc5;
|
||
for (let i = 0; i < text.length; i += 1) {
|
||
h ^= text.charCodeAt(i);
|
||
h = Math.imul(h, 0x01000193);
|
||
}
|
||
return (h >>> 0) / 0x1_0000_0000;
|
||
}
|
||
|
||
/**
|
||
* A stable number in `(-1, 1)`, peaked at zero: two uniforms added, which is the
|
||
* cheapest thing that is not flat.
|
||
*
|
||
* Uniform arrival times give a floor that fills at a constant rate from half
|
||
* seven to ten, and it reads as wrong without being able to say why — there is
|
||
* no rush hour in it. One extra hash buys a peak.
|
||
*/
|
||
function triangular(seatId: string, stream: string): number {
|
||
return unit(seatId, `${stream}-a`) + unit(seatId, `${stream}-b`) - 1;
|
||
}
|
||
|
||
// ---- The audit -------------------------------------------------------------
|
||
|
||
/**
|
||
* Everything the tables above promise each other, checked once at import.
|
||
*
|
||
* These are all invariants of *source data* rather than runtime conditions, so
|
||
* this is the right place for them and there is deliberately no defensive check
|
||
* on the hot path. A runtime de-duplicator in `samplePresenceAt` would be code
|
||
* that cannot fire until somebody breaks the diary, and on the day they did it
|
||
* would turn a visible bug — two figures in one chair — into an invisible one,
|
||
* a person silently missing from the floor. Better to fail loudly here, where
|
||
* the thing that is wrong is twenty lines from the message.
|
||
*
|
||
* `console.warn` and not `throw`, matching the rest of this file's posture: a
|
||
* demo adapter with one bad row in it should still open the building.
|
||
*/
|
||
function auditRoster(): void {
|
||
const say = (message: string) => console.warn(`[tera/sample] ${message}`);
|
||
|
||
// 1. Desks are one to a person. Two roster entries resolving to the same home
|
||
// seat is a permanent double occupancy — every hour of every day, not just
|
||
// during a booking — which is the worst thing `DESKS` can get wrong.
|
||
const owner = new Map<string, string>();
|
||
for (const person of ROSTER) {
|
||
const name = person.desk?.at.label ?? "the visitor";
|
||
const held = owner.get(person.home);
|
||
if (held) say(`seat "${person.home}" is the home seat of both ${held} and ${name}`);
|
||
owner.set(person.home, name);
|
||
}
|
||
|
||
// 2. The anchors have to be anchored to something. A seat id in
|
||
// `PRESENCE_ANCHORS` that is nobody's desk matches nobody, and the continuous
|
||
// 06:15–22:40 guarantee quietly becomes a hope — the failure being an empty
|
||
// office at eleven in the morning on some seeds and not others, which is about
|
||
// the worst shape a bug can have.
|
||
for (const seatId of Object.keys(PRESENCE_ANCHORS)) {
|
||
if (!owner.has(seatId)) {
|
||
say(`anchor seat "${seatId}" is nobody's desk; the sample office can now be empty during working hours`);
|
||
}
|
||
}
|
||
|
||
// 3. Within one booking: everybody named exists, nobody is named twice, and
|
||
// nowhere it seats them is a desk.
|
||
//
|
||
// The last of those is the one that is easy to miss when reading the sweep
|
||
// below: a booking that puts somebody at `eng-03` collides with whoever lives
|
||
// at `eng-03` without overlapping any other *booking*, so no amount of
|
||
// booking-against-booking checking would ever see it. Repeating a room seat
|
||
// inside one booking is the fourth case and is not checked here at all,
|
||
// because `seats` is keyed on it and `tsc` rejects the file (TS1117).
|
||
const desks = new Set<string>();
|
||
for (const person of ROSTER) if (person.desk) desks.add(person.home);
|
||
for (const booking of BOOKINGS) {
|
||
const named = new Set<string>();
|
||
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
|
||
if (!owner.has(deskSeat)) say(`${label(booking)} seats "${deskSeat}", who is nobody`);
|
||
if (desks.has(roomSeat)) say(`${label(booking)} uses "${roomSeat}", which is somebody's desk`);
|
||
if (named.has(deskSeat)) say(`${label(booking)} seats "${deskSeat}" twice`);
|
||
named.add(deskSeat);
|
||
}
|
||
}
|
||
|
||
// 4. No two bookings that are live at the same instant share a chair or a
|
||
// person.
|
||
//
|
||
// Sweeping the `from` instants is exact rather than a sample, and it is the
|
||
// only subtle thing in this function: the set of live bookings only ever
|
||
// *gains* a member at some booking's `from`. So if two bookings ever overlap,
|
||
// they overlap at the later of their two starts, and visiting every `from`
|
||
// cannot miss a pair. Fifteen starts against fifty entries, once.
|
||
//
|
||
// The person half of this is also what `bookedAt` leans on when it stops its
|
||
// scan at the first booking that has not started: if nobody is ever in two
|
||
// bookings at once, the first match is the only match.
|
||
for (const instant of BOOKINGS.map((booking) => booking.from)) {
|
||
const chair = new Map<string, Booking>();
|
||
const busy = new Map<string, Booking>();
|
||
for (const booking of BOOKINGS) {
|
||
if (instant < booking.from || instant >= booking.to) continue;
|
||
for (const [roomSeat, deskSeat] of Object.entries(booking.seats)) {
|
||
// Same-booking repeats belong to check 3, which can name them better.
|
||
const other = chair.get(roomSeat);
|
||
if (other && other !== booking) {
|
||
say(`at ${clock(instant)}, "${roomSeat}" is booked by both ${label(other)} and ${label(booking)}`);
|
||
}
|
||
chair.set(roomSeat, booking);
|
||
|
||
const elsewhere = busy.get(deskSeat);
|
||
if (elsewhere && elsewhere !== booking) {
|
||
say(`at ${clock(instant)}, "${deskSeat}" is in both ${label(elsewhere)} and ${label(booking)}`);
|
||
}
|
||
busy.set(deskSeat, booking);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** A booking, named the way somebody scanning the diary would look for it. */
|
||
function label(booking: Booking): string {
|
||
return `${booking.room} at ${clock(booking.from)}`;
|
||
}
|
||
|
||
/** `hh:mm`, for the audit's messages only. */
|
||
function clock(t: number): string {
|
||
const hh = String(Math.floor(t / 60)).padStart(2, "0");
|
||
const mm = String(Math.floor(t % 60)).padStart(2, "0");
|
||
return `${hh}:${mm}`;
|
||
}
|
||
|
||
auditRoster();
|