@xhulz/nina 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/nina.mjs +10 -1
- package/package.json +1 -1
- package/releases/0.49.0/core/GAPS.md +125 -0
- package/releases/0.49.0/core/answered.json +53 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/architect.md +1 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/dba.md +3 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/devops.md +1 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/qa.md +7 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/secops.md +3 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
- package/releases/0.49.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
- package/releases/0.49.0/core/tree/.claude/agents/architect.md +129 -0
- package/releases/0.49.0/core/tree/.claude/agents/dba.md +117 -0
- package/releases/0.49.0/core/tree/.claude/agents/devops.md +118 -0
- package/releases/0.49.0/core/tree/.claude/agents/implementer.md +162 -0
- package/releases/0.49.0/core/tree/.claude/agents/integration-tester.md +168 -0
- package/releases/0.49.0/core/tree/.claude/agents/planner.md +132 -0
- package/releases/0.49.0/core/tree/.claude/agents/qa.md +150 -0
- package/releases/0.49.0/core/tree/.claude/agents/reviewer.md +184 -0
- package/releases/0.49.0/core/tree/.claude/agents/secops.md +120 -0
- package/releases/0.49.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
- package/releases/0.49.0/core/tree/.claude/agents/solidity-dev.md +123 -0
- package/releases/0.49.0/core/tree/.claude/agents-overview.md +64 -0
- package/releases/0.49.0/core/tree/.claude/frontend.md +10 -0
- package/releases/0.49.0/core/tree/.claude/graph.md +75 -0
- package/releases/0.49.0/core/tree/.claude/patterns.md +251 -0
- package/releases/0.49.0/core/tree/.claude/pills/README.md +147 -0
- package/releases/0.49.0/core/tree/.claude/pipeline.md +101 -0
- package/releases/0.49.0/core/tree/.claude/retrieval.md +103 -0
- package/releases/0.49.0/core/tree/.claude/router.md +202 -0
- package/releases/0.49.0/core/tree/.claude/templates/integration.md +45 -0
- package/releases/0.49.0/core/tree/CLAUDE.md +164 -0
- package/releases/0.49.0/core/tree/scripts/cost-watch.mjs +25 -0
- package/releases/0.49.0/core/tree/scripts/edit-guard.mjs +24 -0
- package/releases/0.49.0/core/tree/scripts/harness-check.mjs +71 -0
- package/releases/0.49.0/core/tree/scripts/loop-gate.mjs +19 -0
- package/releases/0.49.0/core/vocabulary.json +13 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
- package/releases/0.49.0/surfaces/blockchain/tree/.claude/router.md +6 -0
- package/releases/0.49.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
- package/releases/0.49.0/surfaces/d1/tree/.claude/agents/dba.md +13 -0
- package/releases/0.49.0/surfaces/d1/tree/.claude/agents/devops.md +2 -0
- package/releases/0.49.0/surfaces/d1/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.49.0/surfaces/d1/tree/.claude/patterns.md +8 -0
- package/releases/0.49.0/surfaces/d1/tree/CLAUDE.md +2 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/architect.md +5 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/dba.md +54 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/devops.md +5 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/integration-tester.md +10 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/reviewer.md +22 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/graph.md +8 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/patterns.md +22 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/pipeline.md +13 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/retrieval.md +5 -0
- package/releases/0.49.0/surfaces/db/tree/.claude/router.md +4 -0
- package/releases/0.49.0/surfaces/db/tree/CLAUDE.md +17 -0
- package/releases/0.49.0/surfaces/drizzle/tree/.claude/agents/dba.md +9 -0
- package/releases/0.49.0/surfaces/drizzle/tree/.claude/agents/devops.md +5 -0
- package/releases/0.49.0/surfaces/drizzle/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.49.0/surfaces/drizzle/tree/.claude/patterns.md +7 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/devops.md +29 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
- package/releases/0.49.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/agents/reviewer.md +56 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/patterns.md +8 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/retrieval.md +2 -0
- package/releases/0.49.0/surfaces/frontend/tree/.claude/router.md +14 -0
- package/releases/0.49.0/surfaces/frontend/tree/CLAUDE.md +5 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/graph.md +10 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
- package/releases/0.49.0/surfaces/integrations/tree/.claude/router.md +6 -0
- package/releases/0.49.0/surfaces/integrations/tree/CLAUDE.md +17 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/graph.md +2 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/patterns.md +49 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/pipeline.md +2 -0
- package/releases/0.49.0/surfaces/money/tree/.claude/retrieval.md +2 -0
- package/releases/0.49.0/surfaces/money/tree/CLAUDE.md +11 -0
- package/releases/0.49.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
- package/releases/0.49.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
- package/releases/0.49.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
- package/releases/0.49.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
- package/releases/0.49.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
- package/releases/0.49.0/surfaces/pii/tree/CLAUDE.md +5 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/architect.md +2 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/dba.md +42 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/devops.md +11 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/implementer.md +2 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/integration-tester.md +2 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/agents/reviewer.md +2 -0
- package/releases/0.49.0/surfaces/prisma/tree/.claude/patterns.md +15 -0
- package/releases/0.49.0/surfaces/prisma/tree/CLAUDE.md +5 -0
- package/src/commands/gate.mjs +5 -11
- package/src/commands/pipeline.mjs +22 -1
- package/src/commands/upgrade.mjs +8 -0
- package/src/gate.mjs +48 -3
- package/src/office.html +1069 -0
- package/src/office.mjs +434 -0
- package/src/wiring.mjs +11 -1
package/src/office.html
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>NINA office</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<style>
|
|
9
|
+
:root { color-scheme: dark; }
|
|
10
|
+
html, body { margin: 0; height: 100%; background: #1b1d26; overflow: hidden; }
|
|
11
|
+
canvas { display: block; touch-action: none; cursor: grab; }
|
|
12
|
+
canvas.dragging { cursor: grabbing; }
|
|
13
|
+
#zoom { position: fixed; top: 10px; right: 10px; display: flex; gap: 4px; }
|
|
14
|
+
#zoom button { font: 600 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-width: 34px; height: 30px; padding: 0 9px; color: #fff8e8; background: rgba(34, 32, 43, 0.88); border: 2px solid #262331; border-radius: 4px; cursor: pointer; }
|
|
15
|
+
#zoom button:hover { background: #3a3f58; }
|
|
16
|
+
#zoom button[aria-pressed="true"] { color: #ffd34d; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<canvas id="screen"></canvas>
|
|
21
|
+
<div id="zoom" role="toolbar" aria-label="zoom">
|
|
22
|
+
<button id="zoom-out" type="button" title="zoom out (−)" aria-label="zoom out">−</button>
|
|
23
|
+
<button id="zoom-fit" type="button" title="the whole office (0)">fit</button>
|
|
24
|
+
<button id="zoom-in" type="button" title="zoom in (+)" aria-label="zoom in">+</button>
|
|
25
|
+
</div>
|
|
26
|
+
<script>
|
|
27
|
+
'use strict';
|
|
28
|
+
/*
|
|
29
|
+
* The NINA office: the project's pipeline as a floor plan seen from above. `nina pipeline --view` serves this page
|
|
30
|
+
* with the room filled in below, and streams the gate ledger's beats at /events. Every stage has a desk in the room
|
|
31
|
+
* of its phase — design, build, review and the gates, the test lab, release — off one corridor; the orchestrator,
|
|
32
|
+
* NINA, keeps the reception, the owner is the phone on its counter, and new agents come in from the garden. Everything is
|
|
33
|
+
* drawn here, in pixels, on a small canvas scaled up; nothing is fetched from anywhere else. The view starts close
|
|
34
|
+
* enough to read and follows the work around the floor; dragging, the wheel and the buttons take it over.
|
|
35
|
+
*/
|
|
36
|
+
const ROOM = /*NINA_ROOM*/null || { project: 'demo', stages: ['planner', 'architect', 'implementer', 'reviewer', 'qa', 'dba', 'integration-tester', 'devops', 'secops'], many: ['architect', 'implementer', 'reviewer'], owner: 'you', replay: false };
|
|
37
|
+
|
|
38
|
+
// ─── palette and sprites ──────────────────────────────────────────────────────────────────────
|
|
39
|
+
const C = {
|
|
40
|
+
ink: '#262331', white: '#ffffff', paper: '#fff8e8', red: '#e43b44', green: '#3ec74d',
|
|
41
|
+
floor: '#dfe3e8', floorLine: '#ccd2d9', floorLight: '#e9ecf0', hall: '#d4d9df', hallLine: '#c3c9d0',
|
|
42
|
+
wallTop: '#f7f6f2', wallFace: '#efe4c3', wallFaceDark: '#e2d5ae', band: '#4e7356', bandDark: '#3b5a43', rail: '#9a7247', base: '#3a4a3f',
|
|
43
|
+
wood: '#d9a066', woodMid: '#b97f45', woodDark: '#7a4f2c', counter: '#e8b878',
|
|
44
|
+
chair: '#3c6bd0', chairDark: '#274a96', seatRed: '#c4473b', seatRedDark: '#8e2f27',
|
|
45
|
+
frame: '#2c2f3c', screen: '#59c1ff', screenOff: '#3a4d66', code: '#d8f3ff', key: '#9aa3ad',
|
|
46
|
+
leaf: '#3e8948', leafDark: '#265c42', leafLight: '#63c74d', leafShine: '#9de64e', pot: '#c76b3c', potDark: '#8e4a2a', potWhite: '#e9e9ee',
|
|
47
|
+
grass: '#69b33a', grassDark: '#4f9030', grassLight: '#8fd14f', trunk: '#6b4a2b', path: '#cfc6b2', pathDark: '#b4aa93', flower: '#f6757a', flowerY: '#ffd34d',
|
|
48
|
+
glass: '#9fdcff', glassShine: '#d8f2ff', metal: '#a3acb7', metalDark: '#6c7480', rack: '#3a3f4b', ledG: '#3ef06a', ledR: '#ff5d5d',
|
|
49
|
+
board: '#fdfdfb', boardEdge: '#9aa3ad', book1: '#e43b44', book2: '#3c6bd0', book3: '#feae34', book4: '#3ec74d', sign: '#22202b', signText: '#ffd34d',
|
|
50
|
+
rug: '#c9d6e8', rugEdge: '#9fb3cf',
|
|
51
|
+
};
|
|
52
|
+
const SHIRTS = ['#e86a92', '#3c8bd9', '#3ec74d', '#feae34', '#8b6fd6', '#f2763a', '#1fb5a5', '#b0b8c4', '#a3d63a', '#f3a6c4', '#a2445e', '#2a8f56'];
|
|
53
|
+
const SKINS = ['#ffd6b3', '#f1c27d', '#e0ac69', '#c68642', '#8d5524'];
|
|
54
|
+
const HAIRS = ['#3a2a1a', '#e6a33a', '#24212e', '#a5532c', '#c2c3c7', '#7e2553', '#5f574f', '#d96b2b'];
|
|
55
|
+
const CONFETTI = ['#e43b44', '#feae34', '#ffd34d', '#3ec74d', '#3c8bd9', '#e86a92'];
|
|
56
|
+
|
|
57
|
+
// People are ten pixels wide and fourteen tall, outlined. `o` outline, `h` hair, `s` skin, `e` eyes, `t` shirt,
|
|
58
|
+
// `p` trousers, `k` shoes.
|
|
59
|
+
const SPRITE = {
|
|
60
|
+
front: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ossessesso', '.osssssso.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '.okk..kko.'],
|
|
61
|
+
frontA: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ossessesso', '.osssssso.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '.okk...oo.'],
|
|
62
|
+
frontB: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ossessesso', '.osssssso.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '..oo..kko.'],
|
|
63
|
+
back: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', '.ohhhhhho.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '.okk..kko.'],
|
|
64
|
+
backA: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', '.ohhhhhho.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '.okk...oo.'],
|
|
65
|
+
backB: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', '.ohhhhhho.', '..otttto..', '.otttttto.', 'otttttttto', 'osttttttso', '.otttttto.', '.oppppppo.', '.opp..ppo.', '..oo..kko.'],
|
|
66
|
+
cheer: ['os......so', 'oshhhhhhso', 'ohhhhhhhho', 'ohssssssho', 'ossessesso', '.osssssso.', '..otttto..', '.otttttto.', '.otttttto.', '.otttttto.', '.otttttto.', '.oppppppo.', '.opp..ppo.', 'okko..okko'],
|
|
67
|
+
sitA: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', '.ohhhhhho.', '.sotttto..', 'sotttttto.', 'otttttttto', 'otttttttto', '.otttttto.'],
|
|
68
|
+
sitB: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', '.ohhhhhho.', '..ottttos.', '.ottttttos', 'otttttttto', 'otttttttto', '.otttttto.'],
|
|
69
|
+
};
|
|
70
|
+
// NINA, the orchestrator, is a woman with long hair and a skirt: `d` is the skirt. At rest she faces the room.
|
|
71
|
+
const NINA_SPRITE = {
|
|
72
|
+
front: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ohsessesho', 'ohssssssho', 'ohhottohho', 'ohttttttho', 'otttttttto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '.okk..kko.'],
|
|
73
|
+
frontA: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ohsessesho', 'ohssssssho', 'ohhottohho', 'ohttttttho', 'otttttttto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '.okk...oo.'],
|
|
74
|
+
frontB: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohssssssho', 'ohsessesho', 'ohssssssho', 'ohhottohho', 'ohttttttho', 'otttttttto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '..oo..kko.'],
|
|
75
|
+
back: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'otthhhhtto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '.okk..kko.'],
|
|
76
|
+
backA: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'otthhhhtto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '.okk...oo.'],
|
|
77
|
+
backB: ['..oooooo..', '.ohhhhhho.', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'ohhhhhhhho', 'otthhhhtto', 'osttttttso', '.oddddddo.', 'oddddddddo', '.oso..oso.', '..oo..kko.'],
|
|
78
|
+
cheer: ['os......so', 'oshhhhhhso', 'ohhhhhhhho', 'ohssssssho', 'ohsessesho', 'ohssssssho', 'ohhottohho', '.ohttttho.', '.otttttto.', '.oddddddo.', 'oddddddddo', '.oso..oso.', '.oso..oso.', 'okko..okko'],
|
|
79
|
+
};
|
|
80
|
+
const BUBBLE = {
|
|
81
|
+
ask: ['.ooooooo.', 'owwwwwwwo', 'owwkkkwwo', 'owwwwkwwo', 'owwwkkwwo', 'owwwkwwwo', 'owwwwwwwo', 'owwwkwwwo', '.oowwwoo.', '...owo...', '....o....'],
|
|
82
|
+
alarm: ['.ooooooo.', 'owwwwwwwo', 'owwwrwwwo', 'owwwrwwwo', 'owwwrwwwo', 'owwwrwwwo', 'owwwwwwwo', 'owwwrwwwo', '.oowwwoo.', '...owo...', '....o....'],
|
|
83
|
+
pass: ['.ooooooo.', 'owwwwwwwo', 'owwwwwgwo', 'owwwwggwo', 'owgwggwwo', 'owggwwwwo', 'owwgwwwwo', 'owwwwwwwo', '.oowwwoo.', '...owo...', '....o....'],
|
|
84
|
+
back: ['.ooooooo.', 'owwwwwwwo', 'owrwwwrwo', 'owwrwrwwo', 'owwwrwwwo', 'owwrwrwwo', 'owrwwwrwo', 'owwwwwwwo', '.oowwwoo.', '...owo...', '....o....'],
|
|
85
|
+
mail: ['ooooooooo', 'okwwwwwko', 'owkwwwkwo', 'owwkwkwwo', 'owwwkwwwo', 'owwwwwwwo', 'ooooooooo', '.........', '.........', '.........', '.........'],
|
|
86
|
+
talk: ['.ooooooo.', 'owwwwwwwo', 'owwwwwwwo', 'owkwkwkwo', 'owwwwwwwo', '.ooowwwoo', '...owoo..', '...oo....', '.........', '.........', '.........'],
|
|
87
|
+
phone: ['..ooooo..', '.orrrrro.', 'orrooorro', 'oro...oro', '..orrro..', '.orrrrro.', '.orwrwro.', '.orrrrro.', '..ooooo..', '.........', '.........'],
|
|
88
|
+
};
|
|
89
|
+
const BUBBLE_COLORS = { o: C.ink, w: C.white, k: C.ink, r: C.red, g: C.green };
|
|
90
|
+
const GLYPH = {
|
|
91
|
+
N: ['x..x', 'xx.x', 'x.xx', 'x..x', 'x..x'],
|
|
92
|
+
I: ['xxx', '.x.', '.x.', '.x.', 'xxx'],
|
|
93
|
+
A: ['.xx.', 'x..x', 'xxxx', 'x..x', 'x..x'],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** A small stable number from a string, for an agent's looks. */
|
|
97
|
+
function hash(text) {
|
|
98
|
+
let h = 2166136261;
|
|
99
|
+
for (const ch of String(text)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
|
|
100
|
+
return h >>> 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function paint(ctx, rows, x, y, colors, flip = false) {
|
|
104
|
+
rows.forEach((row, dy) => {
|
|
105
|
+
for (let dx = 0; dx < row.length; dx += 1) {
|
|
106
|
+
const ch = row[flip ? row.length - 1 - dx : dx];
|
|
107
|
+
const color = colors[ch];
|
|
108
|
+
if (ch !== '.' && color) {
|
|
109
|
+
ctx.fillStyle = color;
|
|
110
|
+
ctx.fillRect(Math.round(x) + dx, Math.round(y) + dy, 1, 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const rect = (ctx, x, y, w, h, color) => {
|
|
116
|
+
ctx.fillStyle = color;
|
|
117
|
+
ctx.fillRect(Math.round(x), Math.round(y), w, h);
|
|
118
|
+
};
|
|
119
|
+
/** A filled box with a one-pixel outline, the way every piece of furniture here is drawn. */
|
|
120
|
+
function box(ctx, x, y, w, h, fill, line = C.ink) {
|
|
121
|
+
rect(ctx, x, y, w, h, line);
|
|
122
|
+
rect(ctx, x + 1, y + 1, w - 2, h - 2, fill);
|
|
123
|
+
}
|
|
124
|
+
function disc(ctx, cx, cy, r, color) {
|
|
125
|
+
for (let y = -r; y <= r; y += 1) for (let x = -r; x <= r; x += 1) if (x * x + y * y <= r * r + r * 0.6) rect(ctx, cx + x, cy + y, 1, 1, color);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── the floor plan ───────────────────────────────────────────────────────────────────────────
|
|
129
|
+
const LW = 480;
|
|
130
|
+
const CORRIDOR_X = 224;
|
|
131
|
+
const CORRIDOR_W = 32;
|
|
132
|
+
const HALL_X = CORRIDOR_X + CORRIDOR_W / 2;
|
|
133
|
+
const FACE = 24;
|
|
134
|
+
const DESK_STEP = 52;
|
|
135
|
+
const DESK_ROW = 46;
|
|
136
|
+
const GARDEN = 52;
|
|
137
|
+
|
|
138
|
+
/** The rooms, by phase, in the order the work moves through them. A stage not named here works in build. */
|
|
139
|
+
const PHASES = [
|
|
140
|
+
{ key: 'design', name: 'design', side: 'left', roles: ['planner', 'architect'] },
|
|
141
|
+
{ key: 'build', name: 'build', side: 'left', roles: ['implementer', 'solidity-dev'] },
|
|
142
|
+
{ key: 'review', name: 'review & gates', side: 'right', roles: ['reviewer', 'dba', 'integration-tester', 'solidity-auditor'] },
|
|
143
|
+
{ key: 'lab', name: 'test lab', side: 'right', roles: ['qa'] },
|
|
144
|
+
{ key: 'release', name: 'release', side: 'right', roles: ['devops', 'secops'] },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const rooms = new Map(PHASES.map((ph) => [ph.key, { ...ph, stages: [] }]));
|
|
148
|
+
ROOM.stages.forEach((role, i) => {
|
|
149
|
+
const phase = PHASES.find((ph) => ph.roles.includes(role))?.key ?? 'build';
|
|
150
|
+
rooms.get(phase).stages.push({ role, index: i, seats: ROOM.many.includes(role) ? 2 : 1 });
|
|
151
|
+
});
|
|
152
|
+
const lobby = { key: 'lobby', name: 'reception', side: 'left', stages: [] };
|
|
153
|
+
const columns = {
|
|
154
|
+
left: [rooms.get('design'), rooms.get('build'), lobby],
|
|
155
|
+
right: [rooms.get('review'), rooms.get('lab'), rooms.get('release')],
|
|
156
|
+
};
|
|
157
|
+
for (const room of [...rooms.values(), lobby]) {
|
|
158
|
+
const desks = room.stages.reduce((n, st) => n + st.seats, 0);
|
|
159
|
+
room.deskCount = desks;
|
|
160
|
+
room.rows = Math.max(1, Math.ceil(desks / 4));
|
|
161
|
+
room.h = room === lobby ? 116 : FACE + 8 + room.rows * DESK_ROW + 26;
|
|
162
|
+
}
|
|
163
|
+
for (const [side, list] of Object.entries(columns)) {
|
|
164
|
+
let y = 0;
|
|
165
|
+
for (const room of list) {
|
|
166
|
+
room.x = side === 'left' ? 0 : CORRIDOR_X + CORRIDOR_W;
|
|
167
|
+
room.w = side === 'left' ? CORRIDOR_X : LW - room.x;
|
|
168
|
+
room.y = y;
|
|
169
|
+
y += room.h;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// The shorter column's last room takes up the difference, so both reach the garden wall together.
|
|
173
|
+
const FLOOR_H = Math.max(...Object.values(columns).map((list) => list.at(-1).y + list.at(-1).h));
|
|
174
|
+
for (const list of Object.values(columns)) list.at(-1).h = FLOOR_H - list.at(-1).y;
|
|
175
|
+
const LH = FLOOR_H + GARDEN;
|
|
176
|
+
for (const room of [...rooms.values(), lobby]) {
|
|
177
|
+
room.laneY = room.y + room.h - 14;
|
|
178
|
+
room.doorX = room.side === 'left' ? room.x + room.w : room.x;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Each stage's desks and the places to stand by them; and the stage of a role. */
|
|
182
|
+
const stages = new Map();
|
|
183
|
+
for (const room of rooms.values()) {
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const st of room.stages) {
|
|
186
|
+
const stage = { role: st.role, room, shirt: SHIRTS[st.index % SHIRTS.length], desks: [], slots: [], folder: null };
|
|
187
|
+
for (let k = 0; k < st.seats; k += 1, n += 1) {
|
|
188
|
+
const dx = room.x + 12 + (n % 4) * DESK_STEP;
|
|
189
|
+
const dy = room.y + FACE + 6 + Math.floor(n / 4) * DESK_ROW;
|
|
190
|
+
const aisle = dx + 44;
|
|
191
|
+
const seatY = dy + 34;
|
|
192
|
+
const exit = [{ x: aisle, y: seatY }, { x: aisle, y: room.laneY }];
|
|
193
|
+
stage.desks.push({ dx, dy });
|
|
194
|
+
stage.slots.push({ x: dx + 18, y: seatY, room, exit, kind: 'seat', who: null, desk: stage.desks.at(-1) });
|
|
195
|
+
}
|
|
196
|
+
const first = stage.desks[0];
|
|
197
|
+
stage.drop = { x: first.dx + 44, y: first.dy + 30, room, exit: [{ x: first.dx + 44, y: room.laneY }] };
|
|
198
|
+
stage.spare = { x: first.dx + 44, y: first.dy + 40, room, exit: [{ x: first.dx + 44, y: room.laneY }], kind: 'stand' };
|
|
199
|
+
stages.set(st.role, stage);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const COUNTER = { x: lobby.x + 14, y: lobby.y + FACE + 22, w: 104 };
|
|
203
|
+
// NINA stands at the end of the counter, facing the room: behind it, only her head showed.
|
|
204
|
+
const BOSS_HOME = { x: COUNTER.x + COUNTER.w + 10, y: COUNTER.y + 8, room: lobby, exit: [{ x: COUNTER.x + COUNTER.w + 10, y: lobby.laneY }], kind: 'stand' };
|
|
205
|
+
// The owner is not drawn: they are the phone on the counter, where the gate's calls ring and their answers come in.
|
|
206
|
+
const PHONE = { x: COUNTER.x + 72, y: COUNTER.y - 11 };
|
|
207
|
+
const OWNER_HOME = { x: PHONE.x + 5, y: PHONE.y + 14, room: lobby, kind: 'line' };
|
|
208
|
+
const ENTRANCE = { x: lobby.x + 150, y: FLOOR_H - 2, room: lobby, exit: [{ x: lobby.x + 150, y: lobby.laneY }], kind: 'stand' };
|
|
209
|
+
|
|
210
|
+
/** The way from where someone stands to a spot: out to their room's lane, along the corridor when it is another room. */
|
|
211
|
+
function route(from, to) {
|
|
212
|
+
const out = from.exit ?? [{ x: from.x, y: from.room.laneY }];
|
|
213
|
+
const into = [...(to.exit ?? [{ x: to.x, y: to.room.laneY }])].reverse();
|
|
214
|
+
const pts = [...out];
|
|
215
|
+
if (from.room !== to.room) {
|
|
216
|
+
pts.push({ x: from.room.doorX, y: from.room.laneY }, { x: HALL_X, y: from.room.laneY }, { x: HALL_X, y: to.room.laneY }, { x: to.room.doorX, y: to.room.laneY });
|
|
217
|
+
}
|
|
218
|
+
pts.push(...into, { x: to.x, y: to.y });
|
|
219
|
+
return pts.filter((p, i) => i === 0 || p.x !== pts[i - 1].x || p.y !== pts[i - 1].y);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── the building, drawn once ─────────────────────────────────────────────────────────────────
|
|
223
|
+
function tiles(g, x, y, w, h, fill, line) {
|
|
224
|
+
rect(g, x, y, w, h, fill);
|
|
225
|
+
for (let gx = Math.ceil(x / 16) * 16; gx < x + w; gx += 16) rect(g, gx, y, 1, h, line);
|
|
226
|
+
for (let gy = Math.ceil(y / 16) * 16; gy < y + h; gy += 16) rect(g, x, gy, w, 1, line);
|
|
227
|
+
}
|
|
228
|
+
function plant(g, x, y, big) {
|
|
229
|
+
if (big) {
|
|
230
|
+
box(g, x + 2, y + 12, 10, 8, C.pot);
|
|
231
|
+
rect(g, x + 3, y + 13, 8, 1, '#e08a58');
|
|
232
|
+
disc(g, x + 7, y + 7, 6, C.leafDark);
|
|
233
|
+
disc(g, x + 6, y + 6, 5, C.leaf);
|
|
234
|
+
disc(g, x + 5, y + 4, 2, C.leafLight);
|
|
235
|
+
rect(g, x + 4, y + 3, 1, 1, C.leafShine);
|
|
236
|
+
rect(g, x + 9, y + 7, 1, 1, C.leafShine);
|
|
237
|
+
} else {
|
|
238
|
+
box(g, x + 1, y + 6, 7, 6, C.potWhite);
|
|
239
|
+
disc(g, x + 4, y + 4, 3, C.leaf);
|
|
240
|
+
rect(g, x + 3, y + 2, 1, 1, C.leafShine);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function tree(g, x, y) {
|
|
244
|
+
rect(g, x + 7, y + 16, 4, 8, C.trunk);
|
|
245
|
+
rect(g, x + 2, y + 23, 14, 2, 'rgba(0,0,0,0.18)');
|
|
246
|
+
disc(g, x + 9, y + 10, 9, C.leafDark);
|
|
247
|
+
disc(g, x + 8, y + 9, 8, C.leaf);
|
|
248
|
+
disc(g, x + 6, y + 6, 4, C.leafLight);
|
|
249
|
+
for (const [a, b] of [[4, 5], [10, 4], [7, 11], [12, 9]]) rect(g, x + a, y + b, 1, 1, C.leafShine);
|
|
250
|
+
}
|
|
251
|
+
function deskAt(g, dx, dy) {
|
|
252
|
+
box(g, dx + 11, dy, 14, 10, C.frame);
|
|
253
|
+
rect(g, dx + 16, dy + 10, 4, 1, C.frame);
|
|
254
|
+
box(g, dx, dy + 9, 36, 10, C.wood);
|
|
255
|
+
rect(g, dx + 1, dy + 10, 34, 1, '#ebc08e');
|
|
256
|
+
box(g, dx, dy + 18, 36, 7, C.woodMid);
|
|
257
|
+
rect(g, dx + 1, dy + 24, 34, 1, C.woodDark);
|
|
258
|
+
rect(g, dx + 13, dy + 13, 10, 2, C.key);
|
|
259
|
+
rect(g, dx + 29, dy + 12, 3, 3, C.white);
|
|
260
|
+
box(g, dx + 13, dy + 27, 10, 8, C.chair);
|
|
261
|
+
rect(g, dx + 14, dy + 28, 8, 2, '#5b8be3');
|
|
262
|
+
}
|
|
263
|
+
function windowAt(g, x, y, w) {
|
|
264
|
+
box(g, x, y, w, 14, C.wallTop);
|
|
265
|
+
rect(g, x + 2, y + 2, w - 4, 10, C.glass);
|
|
266
|
+
rect(g, x + Math.floor(w / 2), y + 2, 1, 10, C.wallTop);
|
|
267
|
+
rect(g, x + 3, y + 3, 3, 1, C.glassShine);
|
|
268
|
+
rect(g, x + Math.floor(w / 2) + 2, y + 3, 3, 1, C.glassShine);
|
|
269
|
+
}
|
|
270
|
+
function shelf(g, x, y) {
|
|
271
|
+
box(g, x, y, 22, 26, C.woodMid);
|
|
272
|
+
for (let r = 0; r < 3; r += 1) {
|
|
273
|
+
rect(g, x + 1, y + 8 + r * 8, 20, 1, C.woodDark);
|
|
274
|
+
[C.book1, C.book2, C.book3, C.book4, C.book2].forEach((c, i) => rect(g, x + 2 + i * 4, y + 2 + r * 8, 3, 6, c));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function wallItem(g, room, kind, x) {
|
|
278
|
+
const y = room.y + 3;
|
|
279
|
+
if (kind === 'window') windowAt(g, x, y, 30);
|
|
280
|
+
else if (kind === 'board') {
|
|
281
|
+
box(g, x, y, 34, 15, C.board, C.boardEdge);
|
|
282
|
+
rect(g, x + 4, y + 4, 12, 1, C.book2);
|
|
283
|
+
rect(g, x + 4, y + 7, 18, 1, C.book1);
|
|
284
|
+
rect(g, x + 20, y + 4, 8, 6, C.book3);
|
|
285
|
+
} else if (kind === 'clock') {
|
|
286
|
+
disc(g, x + 5, y + 7, 5, C.ink);
|
|
287
|
+
disc(g, x + 5, y + 7, 4, C.board);
|
|
288
|
+
} else if (kind === 'poster') {
|
|
289
|
+
box(g, x, y + 1, 12, 15, '#7fc8f8');
|
|
290
|
+
rect(g, x + 3, y + 5, 6, 6, C.white);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function sign(g, x, y, w) {
|
|
294
|
+
box(g, x, y, w, 9, C.sign, C.ink);
|
|
295
|
+
}
|
|
296
|
+
/** How wide a room's name plate is, so what hangs beside it starts after it. */
|
|
297
|
+
const signWidth = (room) => Math.max(40, room.name.length * 4 + 8);
|
|
298
|
+
/** Where a room's clock, board or poster hangs: towards the far end of its wall. */
|
|
299
|
+
const wallSpot = (room) => room.x + room.w - 70;
|
|
300
|
+
|
|
301
|
+
const background = document.createElement('canvas');
|
|
302
|
+
background.width = LW;
|
|
303
|
+
background.height = LH;
|
|
304
|
+
{
|
|
305
|
+
const g = background.getContext('2d');
|
|
306
|
+
// The garden, and the path to the door.
|
|
307
|
+
rect(g, 0, FLOOR_H, LW, GARDEN, C.grass);
|
|
308
|
+
for (let i = 0; i < 260; i += 1) {
|
|
309
|
+
const hx = hash(`tuft${i}`);
|
|
310
|
+
rect(g, hx % LW, FLOOR_H + 4 + ((hx >> 9) % (GARDEN - 6)), 1, 2, i % 3 ? C.grassDark : C.grassLight);
|
|
311
|
+
}
|
|
312
|
+
rect(g, ENTRANCE.x - 10, FLOOR_H, 20, GARDEN, C.path);
|
|
313
|
+
for (let y = FLOOR_H + 4; y < LH; y += 8) rect(g, ENTRANCE.x - 10, y, 20, 1, C.pathDark);
|
|
314
|
+
for (const tx of [6, 60, 112, 200, 268, 330, 392, 446]) if (Math.abs(tx + 9 - ENTRANCE.x) > 24) tree(g, tx, FLOOR_H + 14);
|
|
315
|
+
for (const fx of [34, 92, 236, 300, 362, 424]) if (Math.abs(fx - ENTRANCE.x) > 20) {
|
|
316
|
+
rect(g, fx, FLOOR_H + 6, 2, 2, C.flower);
|
|
317
|
+
rect(g, fx + 5, FLOOR_H + 9, 2, 2, C.flowerY);
|
|
318
|
+
}
|
|
319
|
+
// The corridor, its floor running the building's height.
|
|
320
|
+
tiles(g, CORRIDOR_X, 0, CORRIDOR_W, FLOOR_H, C.hall, C.hallLine);
|
|
321
|
+
rect(g, CORRIDOR_X, 0, CORRIDOR_W, FACE, C.wallFace);
|
|
322
|
+
rect(g, CORRIDOR_X, FACE - 10, CORRIDOR_W, 8, C.band);
|
|
323
|
+
rect(g, CORRIDOR_X, FACE - 2, CORRIDOR_W, 2, C.base);
|
|
324
|
+
box(g, CORRIDOR_X + 9, 4, 14, 9, '#3ec74d');
|
|
325
|
+
rect(g, CORRIDOR_X + 11, 7, 10, 3, C.white);
|
|
326
|
+
plant(g, CORRIDOR_X + 9, FACE + 2, true);
|
|
327
|
+
plant(g, CORRIDOR_X + 9, FLOOR_H - 26, true);
|
|
328
|
+
// Each room: its floor, the wall it faces, its furniture.
|
|
329
|
+
for (const room of [...rooms.values(), lobby]) {
|
|
330
|
+
tiles(g, room.x, room.y, room.w, room.h, C.floor, C.floorLine);
|
|
331
|
+
rect(g, room.x, room.y, room.w, FACE, C.wallFace);
|
|
332
|
+
rect(g, room.x, room.y + FACE - 11, room.w, 1, C.rail);
|
|
333
|
+
rect(g, room.x, room.y + FACE - 10, room.w, 8, C.band);
|
|
334
|
+
rect(g, room.x, room.y + FACE - 10, room.w, 1, C.bandDark);
|
|
335
|
+
rect(g, room.x, room.y + FACE - 2, room.w, 2, C.base);
|
|
336
|
+
if (room !== lobby) sign(g, room.x + 8, room.y + 4, signWidth(room));
|
|
337
|
+
}
|
|
338
|
+
for (const room of rooms.values()) {
|
|
339
|
+
const far = room.side === 'left' ? room.x + 6 : room.x + room.w - 22;
|
|
340
|
+
wallItem(g, room, 'window', room.x + 8 + signWidth(room) + 16);
|
|
341
|
+
wallItem(g, room, room.key === 'design' ? 'board' : room.key === 'lab' ? 'poster' : 'clock', wallSpot(room));
|
|
342
|
+
for (const st of room.stages) for (const d of stages.get(st.role).desks) deskAt(g, d.dx, d.dy);
|
|
343
|
+
if (room.stages.length === 0) {
|
|
344
|
+
// A room with no stage of its own is somewhere to sit.
|
|
345
|
+
rect(g, room.x + 40, room.y + FACE + 14, room.w - 80, 28, C.rug);
|
|
346
|
+
box(g, room.x + 70, room.y + FACE + 20, room.w - 140, 14, C.wood);
|
|
347
|
+
for (let i = 0; i < 4; i += 1) box(g, room.x + 58 + i * 30, room.y + FACE + 8, 10, 9, C.seatRed);
|
|
348
|
+
}
|
|
349
|
+
plant(g, far, room.laneY - 18, true);
|
|
350
|
+
if (room.key === 'design') shelf(g, room.x + room.w - 30, room.y + FACE + 2);
|
|
351
|
+
if (room.key === 'lab') {
|
|
352
|
+
box(g, room.x + room.w - 24, room.y + FACE + 2, 16, 30, C.rack);
|
|
353
|
+
for (let i = 0; i < 6; i += 1) rect(g, room.x + room.w - 21, room.y + FACE + 5 + i * 4, 2, 1, i % 3 ? C.ledG : C.ledR);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// The reception: the counter's back, the waiting chairs, a vending machine, plants; the sign on its wall.
|
|
357
|
+
{
|
|
358
|
+
const signW = [...'NINA'].reduce((w, ch) => w + GLYPH[ch][0].length * 2 + 2, -2);
|
|
359
|
+
const sx = COUNTER.x + COUNTER.w / 2 - signW / 2;
|
|
360
|
+
box(g, sx - 5, lobby.y + 2, signW + 10, 16, C.sign);
|
|
361
|
+
let x = sx;
|
|
362
|
+
for (const ch of 'NINA') {
|
|
363
|
+
GLYPH[ch].forEach((row, dy) => [...row].forEach((px, dx) => px === 'x' && rect(g, x + dx * 2, lobby.y + 5 + dy * 2, 2, 2, C.signText)));
|
|
364
|
+
x += GLYPH[ch][0].length * 2 + 2;
|
|
365
|
+
}
|
|
366
|
+
windowAt(g, lobby.x + 132, lobby.y + 3, 30);
|
|
367
|
+
for (let i = 0; i < 3; i += 1) {
|
|
368
|
+
box(g, lobby.x + 140 + i * 14, lobby.y + FACE + 2, 11, 10, i === 1 ? C.chair : C.seatRed);
|
|
369
|
+
rect(g, lobby.x + 141 + i * 14, lobby.y + FACE + 3, 9, 2, i === 1 ? '#5b8be3' : '#df6a5e');
|
|
370
|
+
}
|
|
371
|
+
box(g, lobby.x + 186, lobby.y + FACE - 6, 18, 28, C.metal);
|
|
372
|
+
rect(g, lobby.x + 189, lobby.y + FACE - 3, 12, 14, C.glass);
|
|
373
|
+
for (let i = 0; i < 3; i += 1) rect(g, lobby.x + 190 + i * 4, lobby.y + FACE - 1, 2, 2, [C.red, C.flowerY, C.green][i]);
|
|
374
|
+
rect(g, lobby.x + 140, lobby.y + FACE + 22, 40, 16, C.rug);
|
|
375
|
+
box(g, lobby.x + 150, lobby.y + FACE + 26, 20, 8, C.wood);
|
|
376
|
+
plant(g, COUNTER.x - 12, COUNTER.y - 4, false);
|
|
377
|
+
plant(g, lobby.x + 208, lobby.laneY - 20, true);
|
|
378
|
+
rect(g, ENTRANCE.x - 12, FLOOR_H - 8, 24, 6, C.rugEdge);
|
|
379
|
+
}
|
|
380
|
+
// The walls themselves: their white tops, outlined, with doors onto the corridor and out to the garden.
|
|
381
|
+
const wallV = (x, y0, y1) => {
|
|
382
|
+
rect(g, x - 2, y0, 4, y1 - y0, C.ink);
|
|
383
|
+
rect(g, x - 1, y0, 2, y1 - y0, C.wallTop);
|
|
384
|
+
};
|
|
385
|
+
const wallH = (x0, x1, y) => {
|
|
386
|
+
rect(g, x0, y - 2, x1 - x0, 4, C.ink);
|
|
387
|
+
rect(g, x0, y - 1, x1 - x0, 2, C.wallTop);
|
|
388
|
+
};
|
|
389
|
+
for (const room of [...rooms.values(), lobby]) {
|
|
390
|
+
wallH(room.x, room.x + room.w, room.y + 1);
|
|
391
|
+
const outer = room.side === 'left' ? room.x + 1 : room.x + room.w - 1;
|
|
392
|
+
wallV(outer, room.y, room.y + room.h);
|
|
393
|
+
const doorTop = room.laneY - 12;
|
|
394
|
+
const doorBottom = room.laneY + 8;
|
|
395
|
+
wallV(room.doorX, room.y, doorTop);
|
|
396
|
+
wallV(room.doorX, doorBottom, room.y + room.h);
|
|
397
|
+
rect(g, room.doorX - 2, doorTop - 2, 4, 2, C.rail);
|
|
398
|
+
}
|
|
399
|
+
wallH(0, ENTRANCE.x - 12, FLOOR_H - 1);
|
|
400
|
+
wallH(ENTRANCE.x + 12, LW, FLOOR_H - 1);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── the people ───────────────────────────────────────────────────────────────────────────────
|
|
404
|
+
const SPEED = 75;
|
|
405
|
+
const people = new Map();
|
|
406
|
+
let placeholders = 0;
|
|
407
|
+
|
|
408
|
+
function person(key, role, look) {
|
|
409
|
+
const h = hash(look ?? key);
|
|
410
|
+
return {
|
|
411
|
+
key, agent: null, role, x: ENTRANCE.x, y: ENTRANCE.y, at: ENTRANCE, slot: null, queue: [], current: null, path: [],
|
|
412
|
+
pose: 'stand', facing: 'down', typing: false, bubble: null, carrying: null, cheerUntil: 0, leaving: false, born: performance.now(),
|
|
413
|
+
colors: { o: C.ink, h: HAIRS[h % HAIRS.length], s: SKINS[(h >> 3) % SKINS.length], e: C.ink, t: stages.get(role)?.shirt ?? SHIRTS[h % SHIRTS.length], p: '#3a3f58', k: '#22202b' },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
// The orchestrator is NINA: the session that follows the router and hands the work on.
|
|
417
|
+
const boss = person('orchestrator', null, 'orchestrator-boss');
|
|
418
|
+
Object.assign(boss, { x: BOSS_HOME.x, y: BOSS_HOME.y, at: BOSS_HOME, home: BOSS_HOME, sprites: NINA_SPRITE });
|
|
419
|
+
Object.assign(boss.colors, { h: '#8b3a1a', s: '#f1c27d', t: '#ffd34d', d: '#2f4a8a' });
|
|
420
|
+
const owner = person('owner', null, ROOM.owner);
|
|
421
|
+
Object.assign(owner, { x: OWNER_HOME.x, y: OWNER_HOME.y, at: OWNER_HOME, home: OWNER_HOME, hidden: true });
|
|
422
|
+
|
|
423
|
+
const walk = (p, to, then) => p.queue.push({ kind: 'walk', to, then });
|
|
424
|
+
const wait = (p, ms) => p.queue.push({ kind: 'wait', ms });
|
|
425
|
+
const act = (p, fn) => p.queue.push({ kind: 'do', fn });
|
|
426
|
+
const homeOf = (p) => (p === boss ? BOSS_HOME : p === owner ? OWNER_HOME : p.slot);
|
|
427
|
+
|
|
428
|
+
function say(p, kind, ms) {
|
|
429
|
+
p.bubble = { kind, until: ms ? performance.now() + ms : Infinity };
|
|
430
|
+
}
|
|
431
|
+
function hush(p, kinds) {
|
|
432
|
+
if (p.bubble && (!kinds || kinds.includes(p.bubble.kind))) p.bubble = null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** A free desk of a stage for someone, sending its longest-idle occupant away when every desk is taken. */
|
|
436
|
+
function seatAt(stage, p) {
|
|
437
|
+
const mine = stage.slots.find((sl) => sl.who === p);
|
|
438
|
+
if (mine) return mine;
|
|
439
|
+
let slot = stage.slots.find((sl) => !sl.who);
|
|
440
|
+
if (!slot) {
|
|
441
|
+
const idle = stage.slots.filter((sl) => sl.who && !sl.who.typing && !sl.who.current && sl.who.queue.length === 0).sort((a, b) => a.who.born - b.who.born)[0];
|
|
442
|
+
if (idle) {
|
|
443
|
+
leave(idle.who);
|
|
444
|
+
slot = idle;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!slot) {
|
|
448
|
+
const extra = { ...stage.spare, who: p, extra: true };
|
|
449
|
+
if (p.slot && p.slot.who === p) p.slot.who = null;
|
|
450
|
+
p.slot = extra;
|
|
451
|
+
return extra;
|
|
452
|
+
}
|
|
453
|
+
if (p.slot && p.slot.who === p) p.slot.who = null;
|
|
454
|
+
slot.who = p;
|
|
455
|
+
p.slot = slot;
|
|
456
|
+
return slot;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function leave(p) {
|
|
460
|
+
if (p.slot && p.slot.who === p) p.slot.who = null;
|
|
461
|
+
p.slot = null;
|
|
462
|
+
p.typing = false;
|
|
463
|
+
p.leaving = true;
|
|
464
|
+
walk(p, ENTRANCE, () => p.leaving && people.delete(p.key));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function place(p, spot) {
|
|
468
|
+
p.x = spot.x;
|
|
469
|
+
p.y = spot.y;
|
|
470
|
+
p.at = spot;
|
|
471
|
+
p.pose = spot.kind === 'seat' ? 'sit' : 'stand';
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Who reported or works as this agent: known by id, or the person at its desk still waiting for theirs. */
|
|
475
|
+
function find(agent, role) {
|
|
476
|
+
if (agent && people.has(agent)) return people.get(agent);
|
|
477
|
+
const waiting = [...people.values()].filter((p) => p.role === role && !p.agent && !p.leaving).sort((a, b) => a.born - b.born)[0];
|
|
478
|
+
if (waiting && agent) {
|
|
479
|
+
people.delete(waiting.key);
|
|
480
|
+
waiting.key = agent;
|
|
481
|
+
waiting.agent = agent;
|
|
482
|
+
people.set(agent, waiting);
|
|
483
|
+
}
|
|
484
|
+
return waiting ?? null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── what each beat does ──────────────────────────────────────────────────────────────────────
|
|
488
|
+
const lines = [];
|
|
489
|
+
function note(at, text) {
|
|
490
|
+
const t = new Date(at);
|
|
491
|
+
lines.push(`${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')} ${text}`);
|
|
492
|
+
while (lines.length > 4) lines.shift();
|
|
493
|
+
}
|
|
494
|
+
let confetti = [];
|
|
495
|
+
let banner = null;
|
|
496
|
+
/** What the view follows when it is closer than the whole office: a person as they walk, or a spot. */
|
|
497
|
+
let focus = null;
|
|
498
|
+
const follow = (target) => (focus = target);
|
|
499
|
+
const RECEPTION = { x: COUNTER.x + COUNTER.w / 2, y: COUNTER.y + 14 };
|
|
500
|
+
/** The stage a dispatch past its cap was for, while the gate's call waits on the owner. */
|
|
501
|
+
let capRole = null;
|
|
502
|
+
function settleCap() {
|
|
503
|
+
capRole = null;
|
|
504
|
+
hush(boss, ['alarm']);
|
|
505
|
+
hush(owner, ['phone']);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function onBeat(b) {
|
|
509
|
+
const stage = b.role ? stages.get(b.role) : null;
|
|
510
|
+
if (b.type === 'work') {
|
|
511
|
+
if (!stage) return;
|
|
512
|
+
let p = b.agent ? people.get(b.agent) ?? null : null;
|
|
513
|
+
if (b.message && p) {
|
|
514
|
+
if (!b.instant) say(p, 'mail', 1800);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (!p) {
|
|
518
|
+
const key = b.agent ?? `waiting-${(placeholders += 1)}`;
|
|
519
|
+
p = person(key, b.role, b.agent ?? key);
|
|
520
|
+
p.agent = b.agent;
|
|
521
|
+
people.set(key, p);
|
|
522
|
+
}
|
|
523
|
+
p.work = b.id;
|
|
524
|
+
// The dispatch the gate asked about went out: the phone stops.
|
|
525
|
+
if (capRole === b.role) settleCap();
|
|
526
|
+
p.leaving = false;
|
|
527
|
+
const slot = seatAt(stage, p);
|
|
528
|
+
if (b.instant) {
|
|
529
|
+
place(p, slot);
|
|
530
|
+
p.typing = true;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
note(b.at, `${b.from ? `${b.from.role} → ` : ''}${b.role}${b.resumed ? ' (resumed)' : ''}`);
|
|
534
|
+
// Whoever handed the work on carries the folder over — red when it comes back — or the orchestrator does.
|
|
535
|
+
const from = b.from?.agent ? people.get(b.from.agent) : null;
|
|
536
|
+
const carrier = from && from !== p && !from.leaving ? from : boss;
|
|
537
|
+
const folder = b.from?.back ? C.red : C.paper;
|
|
538
|
+
act(carrier, () => {
|
|
539
|
+
carrier.carrying = folder;
|
|
540
|
+
follow(carrier);
|
|
541
|
+
});
|
|
542
|
+
walk(carrier, stage.drop, () => {
|
|
543
|
+
carrier.carrying = null;
|
|
544
|
+
stage.folder = { color: folder, until: performance.now() + 8000 };
|
|
545
|
+
if (focus === carrier) follow(stage.drop);
|
|
546
|
+
});
|
|
547
|
+
wait(carrier, 300);
|
|
548
|
+
const home = homeOf(carrier);
|
|
549
|
+
if (home) walk(carrier, home, () => place(carrier, home));
|
|
550
|
+
else leave(carrier);
|
|
551
|
+
if (p.x !== slot.x || p.y !== slot.y) walk(p, slot);
|
|
552
|
+
act(p, () => {
|
|
553
|
+
place(p, slot);
|
|
554
|
+
p.typing = true;
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (b.type === 'verdict') {
|
|
559
|
+
let p = find(b.agent, b.role);
|
|
560
|
+
if (!p && stage) {
|
|
561
|
+
p = person(b.agent, b.role, b.agent);
|
|
562
|
+
p.agent = b.agent;
|
|
563
|
+
people.set(b.agent, p);
|
|
564
|
+
place(p, seatAt(stage, p));
|
|
565
|
+
}
|
|
566
|
+
if (!p) return;
|
|
567
|
+
const finish = () => {
|
|
568
|
+
p.typing = false;
|
|
569
|
+
if (b.asks) say(p, 'ask');
|
|
570
|
+
else if (!b.instant) say(p, b.back ? 'back' : 'pass', 3000);
|
|
571
|
+
// Someone standing beside a full desk goes once their report is in.
|
|
572
|
+
if (p.slot?.extra) {
|
|
573
|
+
wait(p, b.instant ? 0 : 2500);
|
|
574
|
+
act(p, () => leave(p));
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
if (b.instant) finish();
|
|
578
|
+
else {
|
|
579
|
+
act(p, finish);
|
|
580
|
+
follow(p);
|
|
581
|
+
}
|
|
582
|
+
if (!b.instant) note(b.at, `${b.role} ${b.verdict}${b.issues.length ? ` · ${b.issues.slice(0, 3).join(', ')}${b.issues.length > 3 ? '…' : ''}` : ''}${b.asks ? ' — asks you' : ''}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (b.type === 'party') {
|
|
586
|
+
if (b.instant) return;
|
|
587
|
+
note(b.at, `${b.role} ${b.verdict} — done`);
|
|
588
|
+
banner = { text: `${b.role} ${b.verdict}`, until: performance.now() + 3500 };
|
|
589
|
+
const now = performance.now();
|
|
590
|
+
confetti = Array.from({ length: 140 }, (_, i) => ({ x: Math.random() * LW, y: -Math.random() * 60, vx: (Math.random() - 0.5) * 30, vy: 25 + Math.random() * 45, color: CONFETTI[i % CONFETTI.length], until: now + 4500 }));
|
|
591
|
+
for (const p of [...people.values(), boss, owner]) p.cheerUntil = now + 3000;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (b.type === 'question') {
|
|
595
|
+
say(boss, 'ask');
|
|
596
|
+
if (!b.instant) {
|
|
597
|
+
note(b.at, `NINA asks ${ROOM.owner}`);
|
|
598
|
+
follow(RECEPTION);
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (b.type === 'answered') {
|
|
603
|
+
// The owner's answer answers every question waiting on them, a stage's sent to them included.
|
|
604
|
+
for (const p of [boss, ...people.values()]) hush(p, ['ask']);
|
|
605
|
+
if (!b.instant) {
|
|
606
|
+
say(owner, 'talk', 1500);
|
|
607
|
+
note(b.at, `${ROOM.owner} answered`);
|
|
608
|
+
follow(RECEPTION);
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (b.type === 'cap') {
|
|
613
|
+
capRole = b.role;
|
|
614
|
+
say(boss, 'alarm');
|
|
615
|
+
say(owner, 'phone');
|
|
616
|
+
if (!b.instant) {
|
|
617
|
+
note(b.at, `gate: ${b.source} → ${b.role} would go past its cap — asking ${ROOM.owner}`);
|
|
618
|
+
follow(RECEPTION);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (b.type === 'refused' || b.type === 'stale') {
|
|
623
|
+
// A dispatch the owner refused never ran; a run that never reported has stopped. Either way, whoever sat down
|
|
624
|
+
// for it gets up — and a refusal answers the gate's call.
|
|
625
|
+
const p = [...people.values()].find((x) => x.work === b.work);
|
|
626
|
+
if (p && (!p.agent || b.type === 'stale')) leave(p);
|
|
627
|
+
if (b.type === 'refused' && capRole === b.role) settleCap();
|
|
628
|
+
if (!b.instant) note(b.at, b.type === 'refused' ? `${b.role}: not sent — ${ROOM.owner} said no` : `${b.role} stopped without a report`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (b.type === 'owner') {
|
|
632
|
+
for (const p of people.values()) hush(p, ['ask']);
|
|
633
|
+
capRole = null;
|
|
634
|
+
hush(boss);
|
|
635
|
+
hush(owner);
|
|
636
|
+
if (!b.instant) {
|
|
637
|
+
say(owner, 'talk', 2000);
|
|
638
|
+
note(b.at, `${ROOM.owner} spoke`);
|
|
639
|
+
follow(RECEPTION);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function reset() {
|
|
645
|
+
people.clear();
|
|
646
|
+
for (const stage of stages.values()) {
|
|
647
|
+
stage.folder = null;
|
|
648
|
+
for (const sl of stage.slots) sl.who = null;
|
|
649
|
+
}
|
|
650
|
+
for (const p of [boss, owner]) {
|
|
651
|
+
p.queue = [];
|
|
652
|
+
p.current = null;
|
|
653
|
+
p.bubble = null;
|
|
654
|
+
p.carrying = null;
|
|
655
|
+
place(p, p.home);
|
|
656
|
+
}
|
|
657
|
+
lines.length = 0;
|
|
658
|
+
confetti = [];
|
|
659
|
+
banner = null;
|
|
660
|
+
capRole = null;
|
|
661
|
+
focus = null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ─── the stream ───────────────────────────────────────────────────────────────────────────────
|
|
665
|
+
let status = 'connecting…';
|
|
666
|
+
let session = null;
|
|
667
|
+
const events = new EventSource('events');
|
|
668
|
+
events.addEventListener('hello', (m) => {
|
|
669
|
+
const hello = JSON.parse(m.data);
|
|
670
|
+
reset();
|
|
671
|
+
session = hello.session;
|
|
672
|
+
status = hello.session ? (hello.replay ? 'replay' : 'live') : 'waiting for the first dispatch';
|
|
673
|
+
});
|
|
674
|
+
events.addEventListener('beat', (m) => onBeat(JSON.parse(m.data)));
|
|
675
|
+
events.addEventListener('end', () => (status = 'replay finished — reload to watch it again'));
|
|
676
|
+
events.onerror = () => {
|
|
677
|
+
if (!status.startsWith('replay finished')) status = 'reconnecting…';
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
// ─── each frame ───────────────────────────────────────────────────────────────────────────────
|
|
681
|
+
function step(p, dt) {
|
|
682
|
+
if (!p.current && p.queue.length > 0) {
|
|
683
|
+
p.current = p.queue.shift();
|
|
684
|
+
if (p.current.kind === 'walk') {
|
|
685
|
+
p.path = route(p.at ?? ENTRANCE, p.current.to);
|
|
686
|
+
p.pose = 'walk';
|
|
687
|
+
p.typing = false;
|
|
688
|
+
} else if (p.current.kind === 'wait') p.current.left = p.current.ms;
|
|
689
|
+
else {
|
|
690
|
+
p.current.fn();
|
|
691
|
+
p.current = null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const c = p.current;
|
|
695
|
+
if (c?.kind === 'walk') {
|
|
696
|
+
let budget = SPEED * dt;
|
|
697
|
+
while (budget > 0 && p.path.length > 0) {
|
|
698
|
+
const t = p.path[0];
|
|
699
|
+
const dx = t.x - p.x;
|
|
700
|
+
const dy = t.y - p.y;
|
|
701
|
+
const d = Math.hypot(dx, dy);
|
|
702
|
+
if (Math.abs(dy) > Math.abs(dx)) p.facing = dy < 0 ? 'up' : 'down';
|
|
703
|
+
else if (dx !== 0) p.facing = dx < 0 ? 'left' : 'right';
|
|
704
|
+
if (d <= budget) {
|
|
705
|
+
p.x = t.x;
|
|
706
|
+
p.y = t.y;
|
|
707
|
+
budget -= d;
|
|
708
|
+
p.path.shift();
|
|
709
|
+
} else {
|
|
710
|
+
p.x += (dx / d) * budget;
|
|
711
|
+
p.y += (dy / d) * budget;
|
|
712
|
+
budget = 0;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (p.path.length === 0) {
|
|
716
|
+
p.at = c.to;
|
|
717
|
+
p.pose = 'stand';
|
|
718
|
+
p.facing = 'down';
|
|
719
|
+
p.current = null;
|
|
720
|
+
c.then?.();
|
|
721
|
+
}
|
|
722
|
+
} else if (c?.kind === 'wait') {
|
|
723
|
+
c.left -= dt * 1000;
|
|
724
|
+
if (c.left <= 0) p.current = null;
|
|
725
|
+
}
|
|
726
|
+
if (p.bubble && p.bubble.until < performance.now()) p.bubble = null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const world = document.createElement('canvas');
|
|
730
|
+
world.width = LW;
|
|
731
|
+
world.height = LH;
|
|
732
|
+
const w = world.getContext('2d');
|
|
733
|
+
const screen = document.getElementById('screen');
|
|
734
|
+
const s = screen.getContext('2d');
|
|
735
|
+
// Drawn at a scale that is not a whole number, the office is first blown up by the next whole number and then shrunk
|
|
736
|
+
// smoothly, so every pixel comes out the same size instead of alternating between two.
|
|
737
|
+
const crisp = document.createElement('canvas');
|
|
738
|
+
const cg = crisp.getContext('2d');
|
|
739
|
+
|
|
740
|
+
// ─── the view ─────────────────────────────────────────────────────────────────────────────────
|
|
741
|
+
// Sizes are in office pixels; `scale` is screen pixels for each. Fitted whole into a window beside the conversation
|
|
742
|
+
// the office drew a person under twenty screen pixels tall, so the view starts at READABLE screen points an office
|
|
743
|
+
// pixel — the whole office when that is nearly what fits — and follows the work; the viewer's own zoom is kept. The
|
|
744
|
+
// title and the zoom keep a band of their own above the office, so neither covers a room's name.
|
|
745
|
+
const READABLE = 2.5;
|
|
746
|
+
const CLOSEST = 8;
|
|
747
|
+
const BAND = 48;
|
|
748
|
+
const HANDS_OFF_MS = 12000;
|
|
749
|
+
const ZOOM_KEY = 'nina-office-zoom';
|
|
750
|
+
let dpr = 1;
|
|
751
|
+
let fit = 1;
|
|
752
|
+
let scale = 1;
|
|
753
|
+
let band = 0;
|
|
754
|
+
let ox = 0;
|
|
755
|
+
let oy = 0;
|
|
756
|
+
const cam = { x: LW / 2, y: LH / 2 };
|
|
757
|
+
let handsOffUntil = 0;
|
|
758
|
+
/** 'fit', a zoom the viewer chose in points an office pixel, or null when they never chose. */
|
|
759
|
+
let chosen = null;
|
|
760
|
+
try {
|
|
761
|
+
const kept = localStorage.getItem(ZOOM_KEY);
|
|
762
|
+
chosen = kept === 'fit' ? 'fit' : Number(kept) > 0 ? Number(kept) : null;
|
|
763
|
+
} catch {
|
|
764
|
+
// A page without storage starts at the readable zoom every time.
|
|
765
|
+
}
|
|
766
|
+
function level() {
|
|
767
|
+
const whole = fit / dpr;
|
|
768
|
+
if (chosen === 'fit' || (chosen === null && whole >= READABLE * 0.85)) return whole;
|
|
769
|
+
return Math.max(whole, Math.min(CLOSEST, chosen ?? READABLE));
|
|
770
|
+
}
|
|
771
|
+
/** The middle of the part of the screen the office is drawn in. */
|
|
772
|
+
const middle = () => [screen.width / 2, band + (screen.height - band) / 2];
|
|
773
|
+
const ui = (points) => Math.round(points * dpr);
|
|
774
|
+
|
|
775
|
+
function syncButtons() {
|
|
776
|
+
document.getElementById('zoom-fit').setAttribute('aria-pressed', String(level() <= (fit / dpr) * 1.001));
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function resize() {
|
|
780
|
+
dpr = window.devicePixelRatio || 1;
|
|
781
|
+
screen.width = Math.floor(window.innerWidth * dpr);
|
|
782
|
+
screen.height = Math.floor(window.innerHeight * dpr);
|
|
783
|
+
screen.style.width = `${window.innerWidth}px`;
|
|
784
|
+
screen.style.height = `${window.innerHeight}px`;
|
|
785
|
+
band = Math.round(BAND * dpr);
|
|
786
|
+
fit = Math.min(screen.width / LW, (screen.height - band) / LH);
|
|
787
|
+
scale = level() * dpr;
|
|
788
|
+
syncButtons();
|
|
789
|
+
}
|
|
790
|
+
window.addEventListener('resize', resize);
|
|
791
|
+
resize();
|
|
792
|
+
|
|
793
|
+
/** Zooms by a factor, keeping the office pixel under a screen point where it is. */
|
|
794
|
+
function zoomBy(factor, sx = middle()[0], sy = middle()[1]) {
|
|
795
|
+
const [mx, my] = middle();
|
|
796
|
+
const wx = cam.x + (sx - mx) / scale;
|
|
797
|
+
const wy = cam.y + (sy - my) / scale;
|
|
798
|
+
const next = Math.min(CLOSEST, level() * factor);
|
|
799
|
+
chosen = next <= (fit / dpr) * 1.001 ? 'fit' : next;
|
|
800
|
+
try {
|
|
801
|
+
localStorage.setItem(ZOOM_KEY, String(chosen));
|
|
802
|
+
} catch {
|
|
803
|
+
// Kept for this page only.
|
|
804
|
+
}
|
|
805
|
+
scale = level() * dpr;
|
|
806
|
+
cam.x = wx - (sx - mx) / scale;
|
|
807
|
+
cam.y = wy - (sy - my) / scale;
|
|
808
|
+
syncButtons();
|
|
809
|
+
}
|
|
810
|
+
const handsOn = () => (handsOffUntil = performance.now() + HANDS_OFF_MS);
|
|
811
|
+
|
|
812
|
+
document.getElementById('zoom-in').addEventListener('click', () => zoomBy(1.25));
|
|
813
|
+
document.getElementById('zoom-out').addEventListener('click', () => zoomBy(0.8));
|
|
814
|
+
document.getElementById('zoom-fit').addEventListener('click', () => zoomBy(0));
|
|
815
|
+
window.addEventListener('keydown', (e) => {
|
|
816
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
817
|
+
if (e.key === '+' || e.key === '=') zoomBy(1.25);
|
|
818
|
+
else if (e.key === '-' || e.key === '_') zoomBy(0.8);
|
|
819
|
+
else if (e.key === '0') zoomBy(0);
|
|
820
|
+
});
|
|
821
|
+
let drag = null;
|
|
822
|
+
screen.addEventListener('pointerdown', (e) => {
|
|
823
|
+
if (e.button !== 0) return;
|
|
824
|
+
drag = { x: e.clientX, y: e.clientY, cx: cam.x, cy: cam.y };
|
|
825
|
+
screen.setPointerCapture(e.pointerId);
|
|
826
|
+
screen.classList.add('dragging');
|
|
827
|
+
});
|
|
828
|
+
screen.addEventListener('pointermove', (e) => {
|
|
829
|
+
if (!drag) return;
|
|
830
|
+
// A release the page never heard of — over a menu, outside the window — ends the drag at the next move.
|
|
831
|
+
if (e.buttons === 0) {
|
|
832
|
+
dropDrag();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
cam.x = drag.cx - ((e.clientX - drag.x) * dpr) / scale;
|
|
836
|
+
cam.y = drag.cy - ((e.clientY - drag.y) * dpr) / scale;
|
|
837
|
+
handsOn();
|
|
838
|
+
});
|
|
839
|
+
const dropDrag = () => {
|
|
840
|
+
drag = null;
|
|
841
|
+
screen.classList.remove('dragging');
|
|
842
|
+
};
|
|
843
|
+
screen.addEventListener('pointerup', dropDrag);
|
|
844
|
+
screen.addEventListener('pointercancel', dropDrag);
|
|
845
|
+
// A double click hands the view back to the work.
|
|
846
|
+
screen.addEventListener('dblclick', () => (handsOffUntil = 0));
|
|
847
|
+
screen.addEventListener(
|
|
848
|
+
'wheel',
|
|
849
|
+
(e) => {
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
// A pinch on a trackpad arrives as the wheel with Ctrl held; two fingers alone move the view.
|
|
852
|
+
if (e.ctrlKey || e.metaKey) zoomBy(Math.exp(-e.deltaY * 0.01), e.clientX * dpr, e.clientY * dpr);
|
|
853
|
+
else {
|
|
854
|
+
cam.x += (e.deltaX * dpr) / scale;
|
|
855
|
+
cam.y += (e.deltaY * dpr) / scale;
|
|
856
|
+
}
|
|
857
|
+
handsOn();
|
|
858
|
+
},
|
|
859
|
+
{ passive: false },
|
|
860
|
+
);
|
|
861
|
+
|
|
862
|
+
/** Eases the view toward what it follows, unless the viewer moved it lately, and keeps the office on screen. */
|
|
863
|
+
function aim(dt, now) {
|
|
864
|
+
scale = level() * dpr;
|
|
865
|
+
const viewW = screen.width / scale;
|
|
866
|
+
const viewH = (screen.height - band) / scale;
|
|
867
|
+
if (focus && now > handsOffUntil) {
|
|
868
|
+
const ease = Math.min(1, dt * 2.5);
|
|
869
|
+
cam.x += (focus.x - cam.x) * ease;
|
|
870
|
+
cam.y += (focus.y - 8 - cam.y) * ease;
|
|
871
|
+
}
|
|
872
|
+
cam.x = viewW >= LW ? LW / 2 : Math.min(LW - viewW / 2, Math.max(viewW / 2, cam.x));
|
|
873
|
+
cam.y = viewH >= LH ? LH / 2 : Math.min(LH - viewH / 2, Math.max(viewH / 2, cam.y));
|
|
874
|
+
const [mx, my] = middle();
|
|
875
|
+
ox = Math.round(mx - cam.x * scale);
|
|
876
|
+
oy = Math.round(my - cam.y * scale);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function drawPerson(p, now) {
|
|
880
|
+
if (p.hidden) return;
|
|
881
|
+
const set = p.sprites ?? SPRITE;
|
|
882
|
+
const hop = p.cheerUntil > now ? Math.round(Math.abs(Math.sin(now / 110)) * 3) : 0;
|
|
883
|
+
if (p.pose === 'sit') {
|
|
884
|
+
const rows = p.typing && Math.floor(now / 170) % 2 ? SPRITE.sitB : SPRITE.sitA;
|
|
885
|
+
paint(w, rows, p.x - 5, p.y - 14 - hop, p.colors);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const stepping = p.pose === 'walk' && Math.floor(now / 150) % 2;
|
|
889
|
+
const up = p.facing === 'up';
|
|
890
|
+
let rows = up ? set.back : set.front;
|
|
891
|
+
if (p.pose === 'walk') rows = up ? (stepping ? set.backA : set.backB) : stepping ? set.frontA : set.frontB;
|
|
892
|
+
if (p.cheerUntil > now) rows = set.cheer;
|
|
893
|
+
rect(w, p.x - 4, p.y, 8, 1, 'rgba(0,0,0,0.22)');
|
|
894
|
+
paint(w, rows, p.x - 5, p.y - 14 - hop, p.colors, p.facing === 'left');
|
|
895
|
+
if (p.carrying) box(w, p.x + 3, p.y - 8 - hop, 5, 4, p.carrying);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function drawBubble(p) {
|
|
899
|
+
if (!p.bubble) return;
|
|
900
|
+
// Seated, beside the head: above it, the bubble would cover the desk's nameplate.
|
|
901
|
+
if (p.pose === 'sit') paint(w, BUBBLE[p.bubble.kind], p.x + 5, p.y - 24, BUBBLE_COLORS);
|
|
902
|
+
else paint(w, BUBBLE[p.bubble.kind], p.x - 4, p.y - 27, BUBBLE_COLORS);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function drawWorld(now) {
|
|
906
|
+
w.drawImage(background, 0, 0);
|
|
907
|
+
for (const stage of stages.values()) {
|
|
908
|
+
stage.slots.forEach((sl, i) => {
|
|
909
|
+
const d = stage.desks[i];
|
|
910
|
+
const busy = sl.who?.typing;
|
|
911
|
+
rect(w, d.dx + 12, d.dy + 1, 12, 7, busy ? C.screen : C.screenOff);
|
|
912
|
+
if (busy) for (let k = 0; k < 3; k += 1) rect(w, d.dx + 13, d.dy + 2 + k * 2, 3 + ((Math.floor(now / 200) + k * 3) % 8), 1, C.code);
|
|
913
|
+
});
|
|
914
|
+
if (stage.folder && stage.folder.until > now) box(w, stage.desks[0].dx + 2, stage.desks[0].dy + 11, 7, 5, stage.folder.color);
|
|
915
|
+
}
|
|
916
|
+
// The clock in each room says the time it is here.
|
|
917
|
+
const t = new Date();
|
|
918
|
+
for (const room of rooms.values()) {
|
|
919
|
+
if (room.key === 'design' || room.key === 'lab') continue;
|
|
920
|
+
const cx = wallSpot(room) + 5;
|
|
921
|
+
const cy = room.y + 10;
|
|
922
|
+
for (const [len, turn] of [[2, (t.getHours() % 12) / 12 + t.getMinutes() / 720], [3, t.getMinutes() / 60]]) {
|
|
923
|
+
for (let i = 0; i <= len; i += 1) rect(w, cx + Math.round(Math.sin(turn * Math.PI * 2) * i), cy - Math.round(Math.cos(turn * Math.PI * 2) * i), 1, 1, C.ink);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// The counter, then everyone by how far down they stand.
|
|
927
|
+
box(w, COUNTER.x, COUNTER.y - 6, COUNTER.w, 8, C.counter);
|
|
928
|
+
box(w, COUNTER.x, COUNTER.y + 1, COUNTER.w, 10, C.woodMid);
|
|
929
|
+
rect(w, COUNTER.x + 1, COUNTER.y + 10, COUNTER.w - 2, 1, C.woodDark);
|
|
930
|
+
box(w, COUNTER.x + 20, COUNTER.y - 14, 14, 9, C.frame);
|
|
931
|
+
rect(w, COUNTER.x + 21, COUNTER.y - 13, 12, 6, boss.at === BOSS_HOME && !boss.current ? C.screen : C.screenOff);
|
|
932
|
+
// The owner's phone, shaking while the gate's call waits on them.
|
|
933
|
+
const shake = owner.bubble?.kind === 'phone' && Math.floor(now / 70) % 2 ? 1 : 0;
|
|
934
|
+
box(w, PHONE.x + shake, PHONE.y, 11, 6, C.rack);
|
|
935
|
+
rect(w, PHONE.x + 2 + shake, PHONE.y + 2, 7, 1, C.key);
|
|
936
|
+
rect(w, PHONE.x + 2 + shake, PHONE.y + 4, 7, 1, C.key);
|
|
937
|
+
box(w, PHONE.x - 1 + shake, PHONE.y - 3, 13, 3, C.red);
|
|
938
|
+
const crowd = [...people.values(), owner, boss].sort((a, b) => a.y - b.y);
|
|
939
|
+
for (const p of crowd) drawPerson(p, now);
|
|
940
|
+
for (const p of crowd) drawBubble(p);
|
|
941
|
+
confetti = confetti.filter((c) => c.until > now && c.y < LH);
|
|
942
|
+
for (const c of confetti) rect(w, c.x, c.y, 2, 1, c.color);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
|
|
946
|
+
const SANS = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
947
|
+
|
|
948
|
+
/** Writes a line, shrinking it until it fits `max`, and returns how wide it came out. */
|
|
949
|
+
function text(str, x, y, size, color, align = 'left', max = Infinity, weight = '', family = MONO) {
|
|
950
|
+
let px = size;
|
|
951
|
+
const font = () => `${weight}${weight ? ' ' : ''}${px}px ${family}`;
|
|
952
|
+
s.font = font();
|
|
953
|
+
while (px > ui(7) && s.measureText(str).width > max) {
|
|
954
|
+
px -= 1;
|
|
955
|
+
s.font = font();
|
|
956
|
+
}
|
|
957
|
+
s.textAlign = align;
|
|
958
|
+
s.textBaseline = 'middle';
|
|
959
|
+
s.fillStyle = color;
|
|
960
|
+
s.fillText(str, x, y);
|
|
961
|
+
return s.measureText(str).width;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function panel(x, y, width, height, fill = 'rgba(27,29,38,0.84)') {
|
|
965
|
+
s.fillStyle = fill;
|
|
966
|
+
s.beginPath();
|
|
967
|
+
if (s.roundRect) s.roundRect(x, y, width, height, ui(3));
|
|
968
|
+
else s.rect(x, y, width, height);
|
|
969
|
+
s.fill();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** A name on a dark plate, centred on a screen point, never wider than `max`. */
|
|
973
|
+
function plate(str, cx, cy, size, color, max) {
|
|
974
|
+
let px = size;
|
|
975
|
+
s.font = `600 ${px}px ${SANS}`;
|
|
976
|
+
while (px > ui(7) && s.measureText(str).width > max - ui(8)) {
|
|
977
|
+
px -= 1;
|
|
978
|
+
s.font = `600 ${px}px ${SANS}`;
|
|
979
|
+
}
|
|
980
|
+
const width = s.measureText(str).width + ui(8);
|
|
981
|
+
const height = px + ui(6);
|
|
982
|
+
panel(cx - width / 2, cy - height / 2, width, height, 'rgba(34,32,43,0.9)');
|
|
983
|
+
text(str, cx, cy + ui(0.5), px, color, 'center', Infinity, '600', SANS);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function drawScreen(now) {
|
|
987
|
+
s.imageSmoothingEnabled = false;
|
|
988
|
+
s.fillStyle = '#1b1d26';
|
|
989
|
+
s.fillRect(0, 0, screen.width, screen.height);
|
|
990
|
+
if (Number.isInteger(scale) || scale > 4) s.drawImage(world, ox, oy, LW * scale, LH * scale);
|
|
991
|
+
else {
|
|
992
|
+
const k = Math.max(1, Math.ceil(scale));
|
|
993
|
+
if (crisp.width !== LW * k) {
|
|
994
|
+
crisp.width = LW * k;
|
|
995
|
+
crisp.height = LH * k;
|
|
996
|
+
}
|
|
997
|
+
cg.imageSmoothingEnabled = false;
|
|
998
|
+
cg.drawImage(world, 0, 0, LW * k, LH * k);
|
|
999
|
+
s.imageSmoothingEnabled = true;
|
|
1000
|
+
s.imageSmoothingQuality = 'high';
|
|
1001
|
+
s.drawImage(crisp, ox, oy, LW * scale, LH * scale);
|
|
1002
|
+
s.imageSmoothingEnabled = false;
|
|
1003
|
+
}
|
|
1004
|
+
// Names in the office, at the screen's resolution and never smaller than can be read.
|
|
1005
|
+
const at = (x, y) => [ox + x * scale, oy + y * scale];
|
|
1006
|
+
const label = Math.max(ui(11), Math.round(scale * 3.4));
|
|
1007
|
+
for (const room of rooms.values()) {
|
|
1008
|
+
const [x, y] = at(room.x + 12, room.y + 8.5);
|
|
1009
|
+
text(room.name, x, y, label, C.signText, 'left', (signWidth(room) - 7) * scale, '600', SANS);
|
|
1010
|
+
}
|
|
1011
|
+
for (const stage of stages.values()) {
|
|
1012
|
+
for (const d of stage.desks) {
|
|
1013
|
+
const [x, y] = at(d.dx + 18, d.dy + 21.5);
|
|
1014
|
+
plate(stage.role, x, y, label, '#fff8e8', (DESK_STEP - 4) * scale);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
{
|
|
1018
|
+
const [x, y] = at(COUNTER.x + COUNTER.w / 2, COUNTER.y + 6);
|
|
1019
|
+
plate('reception', x, y, label, '#fff8e8', (COUNTER.w - 8) * scale);
|
|
1020
|
+
// NINA's name goes with her, beside her head, wherever she carries the work.
|
|
1021
|
+
const [ninaX, ninaY] = at(boss.x + 7, boss.y - 9);
|
|
1022
|
+
s.font = `600 ${label}px ${SANS}`;
|
|
1023
|
+
plate('NINA', ninaX + s.measureText('NINA').width / 2 + ui(8), ninaY, label, C.signText, 40 * scale);
|
|
1024
|
+
}
|
|
1025
|
+
// What stays put on the screen: the title, the zoom (in the page), the log, a pass worth a party.
|
|
1026
|
+
const size = ui(12);
|
|
1027
|
+
{
|
|
1028
|
+
const title = `NINA office · ${ROOM.project}${session ? ` · ${session.slice(0, 8)}` : ''} · ${status}`;
|
|
1029
|
+
s.font = `${size}px ${MONO}`;
|
|
1030
|
+
s.fillStyle = '#1b1d26';
|
|
1031
|
+
s.fillRect(0, 0, screen.width, band);
|
|
1032
|
+
const width = Math.min(s.measureText(title).width + ui(16), screen.width - ui(150));
|
|
1033
|
+
panel(ui(10), ui(9), width, ui(30));
|
|
1034
|
+
text(title, ui(18), ui(24), size, '#e6e8ee', 'left', width - ui(16));
|
|
1035
|
+
}
|
|
1036
|
+
if (lines.length > 0) {
|
|
1037
|
+
const lineH = ui(19);
|
|
1038
|
+
const top = screen.height - lines.length * lineH - ui(12);
|
|
1039
|
+
s.fillStyle = 'rgba(27,29,38,0.84)';
|
|
1040
|
+
s.fillRect(0, top, screen.width, screen.height - top);
|
|
1041
|
+
lines.forEach((l, i) => text(l, ui(14), top + ui(6) + lineH * (i + 0.5), size, '#fff8e8', 'left', screen.width - ui(28)));
|
|
1042
|
+
}
|
|
1043
|
+
if (banner && banner.until > now) {
|
|
1044
|
+
const big = ui(Math.max(20, Math.min(40, window.innerWidth / 22)));
|
|
1045
|
+
const width = Math.min(screen.width * 0.86, big * 16);
|
|
1046
|
+
panel(screen.width / 2 - width / 2, screen.height / 2 - big, width, big * 2, 'rgba(27,29,38,0.78)');
|
|
1047
|
+
text(`✓ ${banner.text}`, screen.width / 2, screen.height / 2, big, '#3ec74d', 'center', width - big, 'bold');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
let last = performance.now();
|
|
1052
|
+
function frame(now) {
|
|
1053
|
+
const dt = Math.min(0.1, (now - last) / 1000);
|
|
1054
|
+
last = now;
|
|
1055
|
+
for (const p of [...people.values(), boss, owner]) step(p, dt);
|
|
1056
|
+
for (const c of confetti) {
|
|
1057
|
+
c.x += c.vx * dt;
|
|
1058
|
+
c.y += c.vy * dt;
|
|
1059
|
+
c.vy += 30 * dt;
|
|
1060
|
+
}
|
|
1061
|
+
aim(dt, now);
|
|
1062
|
+
drawWorld(now);
|
|
1063
|
+
drawScreen(now);
|
|
1064
|
+
requestAnimationFrame(frame);
|
|
1065
|
+
}
|
|
1066
|
+
requestAnimationFrame(frame);
|
|
1067
|
+
</script>
|
|
1068
|
+
</body>
|
|
1069
|
+
</html>
|