@jossuealcala/madre 0.7.2 → 1.0.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +773 -1
  2. package/README.md +92 -28
  3. package/bin/madre.mjs +30 -10
  4. package/docs/REFERENCE.md +157 -81
  5. package/docs/SDK.md +23 -19
  6. package/package.json +1 -1
  7. package/public/app.js +11207 -4196
  8. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  9. package/public/assets/brand/Geist-latin.woff2 +0 -0
  10. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  12. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  14. package/public/assets/brand/OFL-Geist.txt +93 -0
  15. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  16. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  17. package/public/assets/brand/OFL-Sora.txt +93 -0
  18. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  19. package/public/assets/brand/Sora-latin.woff2 +0 -0
  20. package/public/assets/brand/madre-app-180.png +0 -0
  21. package/public/assets/brand/madre-app-192.png +0 -0
  22. package/public/assets/brand/madre-app-512.png +0 -0
  23. package/public/assets/brand/madre-app.svg +1 -0
  24. package/public/assets/brand/madre-favicon.svg +1 -0
  25. package/public/assets/news/1.0/alerts.webp +0 -0
  26. package/public/assets/news/1.0/bar.webp +0 -0
  27. package/public/assets/news/1.0/finder.webp +0 -0
  28. package/public/assets/news/1.0/github.webp +0 -0
  29. package/public/assets/news/1.0/intents.webp +0 -0
  30. package/public/assets/news/1.0/looks.webp +0 -0
  31. package/public/assets/news/1.0/modules.webp +0 -0
  32. package/public/assets/news/1.0/mother.webp +0 -0
  33. package/public/assets/news/1.0/nostromo.webp +0 -0
  34. package/public/assets/news/1.0/permissions.webp +0 -0
  35. package/public/assets/news/1.0/room.webp +0 -0
  36. package/public/assets/news/1.0/settings.webp +0 -0
  37. package/public/assets/wallpapers/biznaga.webp +0 -0
  38. package/public/assets/wallpapers/canela.webp +0 -0
  39. package/public/assets/wallpapers/jarrito.webp +0 -0
  40. package/public/assets/wallpapers/magna.webp +0 -0
  41. package/public/assets/wallpapers/oceano.webp +0 -0
  42. package/public/assets/wallpapers/paraiso.webp +0 -0
  43. package/public/assets/wallpapers/quila.webp +0 -0
  44. package/public/assets/wallpapers/tradicion.webp +0 -0
  45. package/public/assets/wallpapers/tropical.webp +0 -0
  46. package/public/assets/wallpapers/urbania.webp +0 -0
  47. package/public/assets/wallpapers/ventura.webp +0 -0
  48. package/public/assets/wallpapers/xico.webp +0 -0
  49. package/public/brands.js +28 -2
  50. package/public/editor.js +744 -0
  51. package/public/es.js +2784 -1026
  52. package/public/i18n.js +1 -1
  53. package/public/index.html +201 -175
  54. package/public/inquiry.js +11 -11
  55. package/public/loader.js +79 -0
  56. package/public/macroverso.js +1497 -0
  57. package/public/manifest.webmanifest +14 -0
  58. package/public/nostromo-engine.js +2222 -0
  59. package/public/resay.js +24 -0
  60. package/public/review-ask.js +85 -0
  61. package/public/salas-mac.css +787 -0
  62. package/public/salas-mac.html +154 -0
  63. package/public/salas-mac.js +3264 -0
  64. package/public/salas.css +85 -0
  65. package/public/salas.html +47 -0
  66. package/public/salas.js +200 -0
  67. package/public/styles.css +6207 -425
  68. package/public/touch.js +83 -0
  69. package/public/tour-chapters.js +342 -0
  70. package/public/tour.js +510 -0
  71. package/public/troubleshooting.js +159 -140
  72. package/src/adapters/codex.mjs +46 -6
  73. package/src/adapters/gemini.mjs +30 -10
  74. package/src/adapters/madre.mjs +110 -18
  75. package/src/adapters/process.mjs +42 -2
  76. package/src/auth-probe.mjs +2 -2
  77. package/src/bridge/devices.mjs +202 -0
  78. package/src/bridge/handoff.mjs +45 -0
  79. package/src/bridge/host.mjs +82 -0
  80. package/src/bridge/network.mjs +133 -0
  81. package/src/bridge/policy.mjs +89 -0
  82. package/src/bridge/qr.mjs +287 -0
  83. package/src/bridge/server.mjs +269 -0
  84. package/src/capabilities.mjs +5 -9
  85. package/src/chats.mjs +59 -22
  86. package/src/checkpoint.mjs +137 -35
  87. package/src/choices.mjs +18 -3
  88. package/src/cli-args.mjs +12 -1
  89. package/src/commands.mjs +15 -15
  90. package/src/config.mjs +73 -9
  91. package/src/conversation-context.mjs +5 -2
  92. package/src/dataset.mjs +3 -1
  93. package/src/directives.mjs +0 -5
  94. package/src/enlaces.mjs +451 -0
  95. package/src/event-store.mjs +55 -10
  96. package/src/exam.mjs +5 -5
  97. package/src/extensions.mjs +0 -2
  98. package/src/file-ops.mjs +23 -0
  99. package/src/files.mjs +52 -2
  100. package/src/github.mjs +407 -0
  101. package/src/json-file.mjs +63 -0
  102. package/src/lease.mjs +2 -2
  103. package/src/lobby-work.mjs +103 -0
  104. package/src/looks.mjs +55 -0
  105. package/src/maturity.mjs +3 -3
  106. package/src/mcp/github-server.mjs +164 -0
  107. package/src/mcp/image-server.mjs +1 -1
  108. package/src/mcp/memory-server.mjs +11 -3
  109. package/src/memory.mjs +38 -12
  110. package/src/models.mjs +89 -5
  111. package/src/modules/ahp.mjs +4 -4
  112. package/src/modules/ash.mjs +1 -1
  113. package/src/modules/bishop-plan.mjs +207 -0
  114. package/src/modules/bishop.mjs +645 -0
  115. package/src/modules/choices.mjs +2 -2
  116. package/src/modules/correo.mjs +1 -1
  117. package/src/modules/enlaces.mjs +255 -0
  118. package/src/modules/git-pulse.mjs +2 -2
  119. package/src/modules/github.mjs +322 -0
  120. package/src/modules/image-studio.mjs +3 -3
  121. package/src/modules/index.mjs +18 -4
  122. package/src/modules/intents-calibration.mjs +338 -0
  123. package/src/modules/intents-desk.mjs +481 -0
  124. package/src/modules/intents-observe.mjs +266 -0
  125. package/src/modules/intents-policy.mjs +414 -0
  126. package/src/modules/intents-try.mjs +91 -0
  127. package/src/modules/intents.mjs +145 -0
  128. package/src/modules/ollama.mjs +4 -4
  129. package/src/modules/playwright.mjs +121 -11
  130. package/src/modules/ripley.mjs +1 -1
  131. package/src/modules/runs.mjs +2 -2
  132. package/src/modules/sdk.mjs +10 -4
  133. package/src/ollama.mjs +6 -1
  134. package/src/outbound.mjs +19 -17
  135. package/src/override.mjs +100 -0
  136. package/src/ports.mjs +23 -1
  137. package/src/prices.mjs +72 -0
  138. package/src/privacy.mjs +30 -11
  139. package/src/quota-sources.mjs +0 -8
  140. package/src/request-guard.mjs +1 -1
  141. package/src/review-ask.mjs +111 -0
  142. package/src/room/archivist.mjs +55 -37
  143. package/src/room/budget.mjs +17 -5
  144. package/src/room/control.mjs +70 -7
  145. package/src/room/economy.mjs +35 -7
  146. package/src/room/escalation.mjs +2 -0
  147. package/src/room/guard.mjs +26 -2
  148. package/src/room/prompt.mjs +22 -15
  149. package/src/room/runs.mjs +84 -21
  150. package/src/room/vectors.mjs +19 -4
  151. package/src/room.mjs +351 -65
  152. package/src/router.mjs +3 -1
  153. package/src/runs.mjs +23 -5
  154. package/src/salas.mjs +313 -0
  155. package/src/sentinel-errors.mjs +1 -1
  156. package/src/server-sentinel.mjs +124 -0
  157. package/src/server.mjs +1526 -163
  158. package/src/setup.mjs +1 -0
  159. package/src/tour.mjs +159 -0
  160. package/src/updates.mjs +48 -8
  161. package/src/usage-sentinel.mjs +1 -1
  162. package/src/verdict.mjs +7 -7
  163. package/src/whats-new.mjs +160 -0
@@ -0,0 +1,1497 @@
1
+ // THE MACROVERSE: every room on this Mac as a NOSTROMO in open space (docs/MACROVERSO.md, phase 3a).
2
+ // It is entered from SALAS and only on a large screen; on a phone SALAS is the list and nothing more.
3
+ //
4
+ // Each room is drawn the way its own NOSTROMO is drawn, by the same engine (nostromo-engine.js):
5
+ // MOTHER of molten rock at the centre, every memory a dwarf in its kind's colour, the veins between
6
+ // those that share a theme. Far away, a universe is one scene placed and settled once, and from
7
+ // then on it lives without paying for its physics (`frozen`): it turns, MOTHER beats, and her
8
+ // pulses run out to its memories and between them. It is drawn at the size it shows on screen, so
9
+ // coming closer brings its detail and its light up; far ones share a few milliseconds a frame,
10
+ // turn by turn. Chosen, it is the real thing: one live scene, its physics and its magma running,
11
+ // composed over the space, and the whole space falls to black round it. Never more than one.
12
+ //
13
+ // The space is open: dragged, it moves; the wheel brings it closer; it has no edge. BISHOP stands
14
+ // at the origin (D-S12: the centre of the macroverse is the super agent), the open rooms orbit near
15
+ // it and the sleeping ones far and cold, and RECENTER brings it all home. A vein runs from BISHOP
16
+ // to every room it may read, because a contract says so, and nowhere else; a universe carried
17
+ // onto BISHOP asks for one. Dragging a chosen universe turns it in the hand, as in its room.
18
+ //
19
+ // What it costs: canvas 2D, no shaders. A sprite is never wider than SPRITE_MAX pixels and is
20
+ // painted again only when what it shows has changed; the live scene has one canvas of its own;
21
+ // frames run only while the macroverse is on screen, never in a hidden tab, and someone who asked
22
+ // for less motion gets one still frame redrawn when something changes. No line joins two universes
23
+ // until a link exists (phase 3c).
24
+
25
+ import { COLLAPSED, FOCUS_VEIL, MEMORY_COLORS, PITCH_LIMIT, TURN_PER_PIXEL, alongCurve, collapsedChip, createNostromo, currentAlong, dwarfChip, hexAlpha, hexMix, magmaMap, texturesReady } from './nostromo-engine.js';
26
+
27
+ const KINDS = Object.keys(MEMORY_COLORS);
28
+ const TILT = 0.42; // how flat the orbits read: the plane seen a little from above
29
+ const INNER = 0.27, OUTER = 0.43; // the orbits of open and sleeping rooms, as a share of the view
30
+ const TURN = (Math.PI * 2) / 900; // a whole orbit in fifteen minutes: alive, never busy
31
+ const FOCUS_ZOOM = 2.1; // how close the camera comes to a chosen universe
32
+ const ZOOM_MIN = 0.25, ZOOM_MAX = FOCUS_ZOOM * 1.5;
33
+ const SPRITE_MAX = 1024; // the widest a sprite is ever painted, in pixels
34
+ const FAR_MEMORIES = 220; // the most a far universe draws, the most alive first; its card says the rest
35
+ const FAR_TURN = 0.0016; // radians a frame a far universe turns on its own
36
+ const FAR_FPS = 30; // the most a far universe is drawn again in a second
37
+ const FAR_BUDGET = 7; // ms a frame all the far universes may take between them
38
+ const SIDE_STEP = 1.3; // a far universe's own canvas is resized only past this ratio
39
+ const SIDE_MIN = 96; // nor ever smaller than this, in css px
40
+ const FOCUS_DARK = 0.62; // how far the space falls to black round a chosen universe
41
+ const VEIN_AMBIENT = 0.45; // pulses a second along a vein even when no crossing was counted
42
+ const MEMORY_WINDOWS = 4; // memory windows open at once; the oldest gives way
43
+ const RELAX_STEPS = 60; // steps of physics before a sprite is painted
44
+ const BODY_REACH = 520; // world units from MOTHER to the edge of the brain, with room for its glow
45
+ const LIVE_SIDE = 760; // css px of the live scene's own canvas
46
+ const LIVE_TURN = 0.0012; // radians a frame a chosen universe turns on its own: a full turn in a minute and a half
47
+ const VEIN_PULSES = 48; // a ceiling on pulses in flight along the veins, as NOSTROMO's
48
+ const VEIN_LIFE = 1.4; // seconds a pulse takes from one universe to the other
49
+ const LINK_DRAG = 12; // px a universe must be carried before it is being linked, not clicked
50
+
51
+ const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
52
+ const hash = (text) => { let h = 2166136261; for (const c of String(text)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 0; };
53
+ const random = (seed) => () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; };
54
+ const ease = (k) => (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2);
55
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
56
+
57
+ function canvasOf(width, height) {
58
+ const canvas = document.createElement('canvas');
59
+ canvas.width = Math.max(1, Math.round(width));
60
+ canvas.height = Math.max(1, Math.round(height));
61
+ return canvas;
62
+ }
63
+
64
+ // ---- BISHOP, as the macroverse sees it: a nebula gathered into a sphere, floating at the centre,
65
+ // with one eye that sees everything, in violets, purples and blues. Drawn here, on canvas 2D, from
66
+ // nothing but noise generated here:
67
+ // the sphere — gas warped by gas (a nebula, not a marble), magenta and cyan drifting through the
68
+ // violet, dark lanes of dust, knots of light; lit from the top left, so it has a day side, a
69
+ // terminator and a night side, a soft sheen and a rim lit from behind: round, not a disc;
70
+ // the eye — on the sphere itself, so it foreshortens as it looks aside: an iris of fibres and
71
+ // crypts with a darker limbal ring and a corona, a pupil that opens a little when a universe is
72
+ // carried to it, the light's reflection in it, lids of gas that blink now and then; it follows
73
+ // the person (the pointer), with the small jumps of a living eye;
74
+ // round it — an atmosphere that breathes, wisps of nebula turning two ways, motes of light.
75
+ // The sphere is painted small and scaled up (gas is soft), at most ORB_FPS times a second; the
76
+ // wisps are painted once.
77
+ const ORB_SIDE = 176; // px of the sphere's own canvas
78
+ const ORB_FPS = 30; // the most the sphere is painted again in a second
79
+ const ORB_MOTES = 18; // motes of light in orbit round it
80
+ // The gas, from the deep to the lit: indigo, violet, purple, blue, a pale lilac at the brightest.
81
+ const ORB_GAS = [[0, 7, 3, 24], [0.18, 28, 10, 74], [0.36, 62, 24, 152], [0.52, 108, 50, 222], [0.66, 146, 80, 250], [0.78, 92, 118, 255], [0.9, 158, 188, 255], [1, 236, 228, 255]];
82
+ const ORB_LIGHT = (() => { const l = [-0.46, 0.56, 0.69]; const n = Math.hypot(...l); return l.map((v) => v / n); })();
83
+
84
+ // Fractal value noise on a band that wraps round in x (a sphere's longitude), 0..1, stretched.
85
+ function orbNoise(width, height, seed, octaves, stretch = 0.5) {
86
+ const field = new Float32Array(width * height);
87
+ const cell = (x, y, o) => {
88
+ let h = Math.imul(x + 1, 374761393) ^ Math.imul(y + 7, 668265263) ^ Math.imul(seed * 31 + o, 2246822519);
89
+ h = Math.imul(h ^ (h >>> 13), 1274126177);
90
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
91
+ };
92
+ let amp = 1;
93
+ for (let o = 0; o < octaves; o += 1) {
94
+ const cols = Math.min(6 << o, Math.floor(width / 3));
95
+ const rows = Math.max(2, Math.min(Math.round(cols * stretch), Math.floor(height / 4)));
96
+ for (let y = 0; y < height; y += 1) {
97
+ const fy = (y / height) * rows, y0 = Math.floor(fy), ty = fy - y0, sy = ty * ty * (3 - 2 * ty);
98
+ for (let x = 0; x < width; x += 1) {
99
+ const fx = (x / width) * cols, x0 = Math.floor(fx) % cols, tx = fx - Math.floor(fx), sx = tx * tx * (3 - 2 * tx);
100
+ const x1 = (x0 + 1) % cols;
101
+ const a = cell(x0, y0, o), b = cell(x1, y0, o), c = cell(x0, y0 + 1, o), d = cell(x1, y0 + 1, o);
102
+ field[y * width + x] += amp * (a + (b - a) * sx + (c - a) * sy + (a - b - c + d) * sx * sy);
103
+ }
104
+ }
105
+ amp *= 0.52;
106
+ }
107
+ let low = Infinity, high = -Infinity;
108
+ for (const value of field) { low = Math.min(low, value); high = Math.max(high, value); }
109
+ for (let i = 0; i < field.length; i += 1) field[i] = (field[i] - low) / (high - low || 1);
110
+ return field;
111
+ }
112
+ const smooth = (edge0, edge1, x) => { const k = clamp((x - edge0) / (edge1 - edge0), 0, 1); return k * k * (3 - 2 * k); };
113
+
114
+ function bishopOrb() {
115
+ const side = ORB_SIDE;
116
+ const W = 256, H = 128;
117
+ const gas = orbNoise(W, H, 7, 5, 1.1);
118
+ const fine = orbNoise(W, H, 13, 4, 0.9);
119
+ const warp = orbNoise(W, H, 29, 3, 0.8);
120
+ const tint = orbNoise(W, H, 41, 3, 0.7);
121
+ const air = orbNoise(W, H, 19, 4);
122
+ // Knots of light in the gas: a few bright points, soft.
123
+ const knots = new Float32Array(W * H);
124
+ {
125
+ const rnd = random(77);
126
+ for (let k = 0; k < 80; k += 1) {
127
+ const kx = Math.floor(rnd() * W), ky = 6 + Math.floor(rnd() * (H - 12)), bright = 0.35 + rnd() * 0.65;
128
+ for (let dy = -2; dy <= 2; dy += 1) for (let dx = -2; dx <= 2; dx += 1) {
129
+ const at = (ky + dy) * W + ((kx + dx + W) % W);
130
+ knots[at] = Math.max(knots[at], bright * Math.exp(-(dx * dx + dy * dy) / 1.3));
131
+ }
132
+ }
133
+ }
134
+ const sample = (field, u, v) => {
135
+ const x = (((u % 1) + 1) % 1) * W, y = clamp(v, 0, 0.999) * (H - 1);
136
+ const x0 = Math.floor(x), y0 = Math.floor(y), tx = x - x0, ty = y - y0;
137
+ const x1 = (x0 + 1) % W, y1 = Math.min(H - 1, y0 + 1);
138
+ const a = field[y0 * W + x0], b = field[y0 * W + x1], c = field[y1 * W + x0], d = field[y1 * W + x1];
139
+ return a + (b - a) * tx + (c - a) * ty + (a - b - c + d) * tx * ty;
140
+ };
141
+ const lut = new Float32Array(256 * 3);
142
+ for (let i = 0; i < 256; i += 1) {
143
+ const k = i / 255;
144
+ const at = ORB_GAS.findIndex((stop) => stop[0] >= k);
145
+ const hi = ORB_GAS[Math.max(1, at)], lo = ORB_GAS[Math.max(0, at - 1)];
146
+ const f = hi[0] === lo[0] ? 0 : (k - lo[0]) / (hi[0] - lo[0]);
147
+ for (let c = 0; c < 3; c += 1) lut[i * 3 + c] = lo[c + 1] + (hi[c + 1] - lo[c + 1]) * f;
148
+ }
149
+ // The disc, once: for every pixel inside it, its normal, where on the sphere it looks, and how
150
+ // the light falls on it (diffuse, sheen, rim).
151
+ const N = side * side;
152
+ const nxs = new Float32Array(N), nys = new Float32Array(N), nzs = new Float32Array(N);
153
+ const lon = new Float32Array(N), lat = new Float32Array(N), inside = new Uint8Array(N);
154
+ const lit = new Float32Array(N), sheen = new Float32Array(N), rimOf = new Float32Array(N);
155
+ const [Lx, Ly, Lz] = ORB_LIGHT;
156
+ for (let py = 0; py < side; py += 1) {
157
+ for (let px = 0; px < side; px += 1) {
158
+ const i = py * side + px;
159
+ const sx = ((px + 0.5) / side) * 2 - 1, sy = ((py + 0.5) / side) * 2 - 1;
160
+ const d2 = sx * sx + sy * sy;
161
+ if (d2 > 1) continue;
162
+ const z = Math.sqrt(1 - d2);
163
+ inside[i] = 1;
164
+ nxs[i] = sx; nys[i] = -sy; nzs[i] = z;
165
+ lon[i] = Math.atan2(sx, z); lat[i] = Math.asin(clamp(-sy, -1, 1));
166
+ const dot = sx * Lx - sy * Ly + z * Lz;
167
+ lit[i] = Math.max(0, dot);
168
+ sheen[i] = Math.max(0, 2 * dot * z - Lz) ** 18;
169
+ rimOf[i] = (1 - z) ** 2.4;
170
+ }
171
+ }
172
+ const canvas = canvasOf(side, side);
173
+ const ctx = canvas.getContext('2d');
174
+ const image = ctx.createImageData(side, side);
175
+ const data = image.data;
176
+
177
+ // The wisps round it, painted once: a nebula in a ring from the rim outward, thinning away.
178
+ const WS = 340, REACH = 2.6;
179
+ const wisps = canvasOf(WS, WS);
180
+ {
181
+ const wctx = wisps.getContext('2d');
182
+ const out = wctx.createImageData(WS, WS);
183
+ for (let py = 0; py < WS; py += 1) {
184
+ for (let px = 0; px < WS; px += 1) {
185
+ const dx = (px - WS / 2) / (WS / 2), dy = (py - WS / 2) / (WS / 2);
186
+ const rr = Math.hypot(dx, dy) * REACH;
187
+ if (rr < 0.82 || rr > REACH) continue;
188
+ const angle = Math.atan2(dy, dx) / (Math.PI * 2) + 0.5;
189
+ const radial = rr / REACH;
190
+ const bend = sample(warp, angle * 2, radial) - 0.5;
191
+ const f = sample(air, angle * 2 + bend * 0.3, radial + bend * 0.2) * 0.6 + sample(fine, angle * 4 + 0.21, radial * 0.7 + 0.2) * 0.4;
192
+ const fall = smooth(0.86, 1.06, rr) * (1 - smooth(1.15, REACH, rr));
193
+ const a = fall * f ** 2.3;
194
+ const h = sample(tint, angle * 2, radial);
195
+ const mag = smooth(0.6, 0.9, h), cyan = smooth(0.6, 0.9, 1 - h);
196
+ const o = (py * WS + px) * 4;
197
+ out.data[o] = 112 + 90 * mag - 40 * cyan; out.data[o + 1] = 58 + 40 * cyan; out.data[o + 2] = 240 + 15 * cyan; out.data[o + 3] = 255 * a;
198
+ }
199
+ }
200
+ wctx.putImageData(out, 0, 0);
201
+ }
202
+
203
+ // One frame of the sphere. `eye`: where it looks (x, y: -1..1 across the screen), how open its
204
+ // pupil is (`open`, 0..1), how closed its lids are (`lid`, 0..1), and whether BISHOP sleeps.
205
+ function paint(t, eye) {
206
+ // The eye's direction on the sphere and the frame round it.
207
+ let gx = eye.x * 0.62, gy = -eye.y * 0.62;
208
+ const gz = Math.sqrt(Math.max(0.05, 1 - gx * gx - gy * gy));
209
+ const gl = Math.hypot(gx, gy, gz); gx /= gl; gy /= gl; const gzn = gz / gl;
210
+ const rl = Math.hypot(gzn, gx) || 1;
211
+ const Rx = gzn / rl, Rz = -gx / rl; // right: up × gaze
212
+ const Ux = gy * Rz, Uy = gzn * Rx - gx * Rz, Uz = -gy * Rx; // up: gaze × right
213
+ const ex = gx, ey = -gy; // the eye's centre on the disc
214
+ const turn = t * 0.02;
215
+ const life = eye.asleep ? 0.45 : 1;
216
+ const pupilR = 0.082 + 0.05 * eye.open + 0.004 * Math.sin(t * 1.3);
217
+ const irisR = 0.3;
218
+ const lidHalf = (1 - eye.lid) * 0.36;
219
+ for (let i = 0; i < N; i += 1) {
220
+ const o = i * 4;
221
+ if (!inside[i]) { data[o + 3] = 0; continue; }
222
+ const nx = nxs[i], ny = nys[i], nz = nzs[i];
223
+ const c = nx * gx + ny * gy + nz * gzn;
224
+ const d2 = Math.max(0, 2 * (1 - c)); // chord² from the eye's centre
225
+ // The vortex: the gas winds into the eye, a little, faster near it.
226
+ const wind = 0.9 / (1 + d2 * 26) + t * 0.22 / (1 + d2 * 60);
227
+ const sxp = nx, syp = -ny;
228
+ const dx = sxp - ex, dy = syp - ey;
229
+ const cw = Math.cos(wind), sw = Math.sin(wind);
230
+ const qx = ex + dx * cw - dy * sw, qy = ey + dx * sw + dy * cw;
231
+ let j = Math.round(((qy + 1) / 2) * side - 0.5) * side + Math.round(((qx + 1) / 2) * side - 0.5);
232
+ if (j < 0 || j >= N || !inside[j]) j = i;
233
+ const u = lon[j] / (Math.PI * 2), v = lat[j] / Math.PI + 0.5;
234
+ // A nebula: the gas warped by gas.
235
+ const wu = sample(warp, u * 2, v * 2) - 0.5, wv = sample(warp, u * 2 + 0.31, v * 2 + 0.17) - 0.5;
236
+ const gu = u + turn + wu * 0.18, gv = v + wv * 0.12;
237
+ let g = sample(gas, gu, gv) * 0.6 + sample(fine, gu * 3 + 0.37, gv * 2.6) * 0.4;
238
+ g = smooth(0.1, 0.9, g) + (sample(fine, gu * 7 + 0.11, gv * 5.2) - 0.5) * 0.18;
239
+ const k = clamp(Math.round(g * 255), 0, 255) * 3;
240
+ let r = lut[k], gr = lut[k + 1], b = lut[k + 2];
241
+ // Magenta and cyan drifting through the violet; lanes of dust.
242
+ const h = sample(tint, gu * 1.4, gv * 1.4);
243
+ const mag = smooth(0.62, 0.92, h) * 0.38, cyan = smooth(0.62, 0.92, 1 - h) * 0.3;
244
+ r += (198 * g - r) * mag + (80 * g - r) * cyan; gr += (70 * g - gr) * mag + (196 * g - gr) * cyan; b += (226 * g - b) * mag + (255 * g - b) * cyan;
245
+ const dust = 1 - smooth(0.6, 0.82, sample(warp, gu * 3.3 + 0.6, gv * 2.9)) * 0.55;
246
+ // The light: a day side and a night side, a sheen, a rim lit from behind; the gas glows a little of its own.
247
+ const shade = (0.14 + 0.95 * lit[i]) * dust;
248
+ const glow = 0.1 + 0.2 * g;
249
+ r = r * (shade + glow) * life; gr = gr * (shade + glow) * life; b = b * (shade + glow) * life;
250
+ const knot = sample(knots, gu, gv) * life;
251
+ r += 255 * knot; gr += 214 * knot; b += 255 * knot;
252
+ const s = sheen[i] * 0.4 * life;
253
+ r += 170 * s; gr += 180 * s; b += 255 * s;
254
+ const rim = rimOf[i] * life;
255
+ r += 60 * rim; gr += 96 * rim; b += 255 * rim;
256
+ // The eye, on the sphere.
257
+ if (c > 0.6 && life > 0.5) {
258
+ const lx = nx * Rx + nz * Rz, ly = nx * Ux + ny * Uy + nz * Uz;
259
+ const e = Math.sqrt(d2);
260
+ const ay = Math.abs(ly);
261
+ const open = 1 - smooth(lidHalf - 0.035, lidHalf + 0.008, ay);
262
+ // The lids' crease, where gas meets the eye.
263
+ const crease = eye.lid > 0.02 || lidHalf < 0.34 ? Math.exp(-(((ay - lidHalf) / 0.025) ** 2)) * 0.35 * (e < irisR + 0.12 ? 1 : 0) : 0;
264
+ if (open > 0) {
265
+ const angle = Math.atan2(ly, lx) / (Math.PI * 2) + 0.5;
266
+ const fibre = 0.55 + 0.45 * sample(fine, angle * 9, e * 1.6 + 0.3);
267
+ // Crypts: soft hollows in the middle of the iris, never ticks at its rim.
268
+ const crypt = smooth(0.62, 0.86, sample(warp, angle * 3 + 0.2, e * 1.8 + 0.4)) * Math.exp(-(((e - irisR * 0.55) / (irisR * 0.28)) ** 2));
269
+ const irisIn = 1 - smooth(irisR - 0.018, irisR + 0.01, e);
270
+ const limbal = smooth(irisR - 0.075, irisR - 0.005, e);
271
+ const collar = Math.exp(-(((e - pupilR * 1.75) / 0.022) ** 2));
272
+ const depthIn = e / irisR;
273
+ let ir = 150 + 70 * (1 - depthIn), ig = 150 + 60 * (1 - depthIn), ib = 255;
274
+ const fibreLight = (0.55 + 0.6 * fibre) * (1 - 0.3 * crypt) * (1 - 0.45 * limbal) + 0.35 * collar;
275
+ ir *= fibreLight; ig *= fibreLight; ib *= Math.min(1.2, fibreLight + 0.15);
276
+ // The iris is lit by the same light, a little: a curved thing, not a sticker.
277
+ const irisLit = 0.62 + 0.45 * lit[i];
278
+ ir *= irisLit; ig *= irisLit; ib *= irisLit;
279
+ const mix = irisIn * open;
280
+ r += (ir - r) * mix; gr += (ig - gr) * mix; b += (ib - b) * mix;
281
+ const pupil = (1 - smooth(pupilR - 0.012, pupilR + 0.004, e)) * open;
282
+ r += (5 - r) * pupil; gr += (2 - gr) * pupil; b += (16 - b) * pupil;
283
+ const corona = Math.exp(-(((e - irisR) / 0.075) ** 2)) * 0.42 * open;
284
+ r += 70 * corona; gr += 116 * corona; b += 255 * corona;
285
+ // The light's reflection: where the light would glint, not where the eye looks.
286
+ const glint = Math.max(0, 1 - Math.hypot(sxp - (ex - 0.075), syp - (ey - 0.085)) / 0.028) * irisIn * open;
287
+ const glint2 = Math.max(0, 1 - Math.hypot(sxp - (ex + 0.045), syp - (ey + 0.06)) / 0.012) * irisIn * open * 0.5;
288
+ r += 255 * (glint + glint2); gr += 255 * (glint + glint2); b += 255 * (glint + glint2);
289
+ }
290
+ r *= 1 - crease; gr *= 1 - crease; b *= 1 - crease;
291
+ }
292
+ data[o] = r; data[o + 1] = gr; data[o + 2] = b;
293
+ // The edge is gas, not a line: it thins out, ragged with the gas itself.
294
+ data[o + 3] = 255 * (1 - smooth(0.92 + (g - 0.5) * 0.06, 1.0, Math.sqrt(1 - nz * nz)));
295
+ }
296
+ ctx.putImageData(image, 0, 0);
297
+ }
298
+ return { canvas, wisps, reach: REACH, paint, paintedAt: -1 };
299
+ }
300
+
301
+ // How big a universe reads at zoom 1, in css px of radius: it grows slowly with what it holds.
302
+ const reachOf = (room) => 60 + 14 * Math.log2(1 + (room.glance?.census?.memories ?? 0) / 10);
303
+
304
+ // A scene for the engine: everything it reads, and nothing of a room's page. `bare`: no sky, no
305
+ // stars, no vignette — the space around it is this file's to draw.
306
+ function sceneFor(canvas, w, h, dpr, reduced) {
307
+ canvas.width = Math.round(w * dpr);
308
+ canvas.height = Math.round(h * dpr);
309
+ const scene = {
310
+ canvas, size: { w, h, dpr }, nodes: [], links: [], pulses: [], rings: [], lastPhase: 0, blasts: [],
311
+ cam: { x: 0, y: 0, scale: 1, manual: false }, yaw: 0, pitch: 0, spin: 0, coreLit: 0,
312
+ selected: null, hover: null, lock: null, witness: null, cage: null, alarm: null, altered: false,
313
+ reduced, bare: true, coldOnly: false, maturity: 0, stage: null, words: null,
314
+ };
315
+ return { scene, engine: createNostromo(scene) };
316
+ }
317
+
318
+ // The memories a far universe draws: the most alive first, so a large archive is read by what the
319
+ // room leans on, and the veins between the ones kept.
320
+ function farMemories(data) {
321
+ if ((data.memories?.length ?? 0) <= FAR_MEMORIES) return data;
322
+ const alive = (memory) => (memory.recalled ?? 0) * 1e6 + (Date.parse(memory.lastRecalled ?? 0) || 0) / 1e9;
323
+ const kept = [...data.memories].sort((a, b) => alive(b) - alive(a) || b.id - a.id).slice(0, FAR_MEMORIES);
324
+ const ids = new Set(kept.map((memory) => memory.id));
325
+ return { ...data, memories: kept, links: (data.links ?? []).filter((link) => ids.has(link.a) && ids.has(link.b)) };
326
+ }
327
+
328
+ // One universe painted once: its scene placed, settled and drawn into a sprite by the engine. A
329
+ // room that is asleep, sealed, outdated or unreachable is MOTHER alone: cold, or caged.
330
+ function paintUniverse(one, dpr, shown = null) {
331
+ const side = Math.min(SPRITE_MAX, Math.round(Math.max(SIDE_MIN, shown ?? one.reach * 2) * dpr));
332
+ const css = side / dpr;
333
+ const canvas = canvasOf(side, side);
334
+ const { scene, engine } = sceneFor(canvas, css, css, dpr, true);
335
+ const data = one.data;
336
+ if (data?.memories?.length) {
337
+ engine.buildNodes(farMemories(data));
338
+ // Where the live scene left the bodies, if it had this universe: nothing jumps on the way back.
339
+ if (one.settled) for (const node of scene.nodes) { const was = one.settled.get(node.memory.id); if (was) Object.assign(node, was, { placed: true }); }
340
+ engine.placeNostromo();
341
+ let t = 0;
342
+ for (let step = 0; step < (one.settled ? 8 : RELAX_STEPS); step += 1) { t += 1 / 60; engine.stepNostromo(1, t); }
343
+ }
344
+ scene.cam = { x: 0, y: 0, scale: (css / 2 - 4) / BODY_REACH, manual: true };
345
+ if (data?.sealed) scene.cage = { at: 0, closed: true };
346
+ engine.projectNostromo();
347
+ engine.drawNostromo(0.7);
348
+ if (!one.room.open) {
349
+ // A cold ember: the same body, the fire gone out of it.
350
+ const ctx = canvas.getContext('2d');
351
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
352
+ ctx.globalCompositeOperation = 'source-atop';
353
+ ctx.fillStyle = 'rgba(52, 64, 88, .78)';
354
+ ctx.fillRect(0, 0, side, side);
355
+ ctx.globalCompositeOperation = 'source-over';
356
+ }
357
+ // From now on it lives: its bodies stay where they settled (no forces a frame), and it beats
358
+ // and pulses unless someone asked for less motion. A sleeping room stays the ember it was.
359
+ scene.frozen = true;
360
+ scene.reduced = reducedMotion() || !one.room.open;
361
+ return { canvas, css, ready: texturesReady(), scene, engine };
362
+ }
363
+
364
+ // A far universe drawn again at the size it now shows, when that has changed by more than a step:
365
+ // its own canvas and its camera follow, so close up it is sharp and far away it costs little.
366
+ function resizeUniverse(sprite, shown, dpr) {
367
+ const side = Math.min(SPRITE_MAX, Math.round(Math.max(SIDE_MIN, shown) * dpr));
368
+ const css = side / dpr;
369
+ sprite.canvas.width = side;
370
+ sprite.canvas.height = side;
371
+ sprite.css = css;
372
+ sprite.scene.size = { w: css, h: css, dpr };
373
+ sprite.scene.cam.scale = (css / 2 - 4) / BODY_REACH;
374
+ }
375
+
376
+ // The ground: deep space and three slow glows, painted once per window size.
377
+ function paintNebula(width, height, dpr, accent) {
378
+ const canvas = canvasOf(width * dpr, height * dpr);
379
+ const ctx = canvas.getContext('2d');
380
+ ctx.scale(dpr, dpr);
381
+ ctx.fillStyle = '#030305';
382
+ ctx.fillRect(0, 0, width, height);
383
+ for (const [x, y, radius, colour] of [[0.18, 0.2, 0.7, accent], [0.85, 0.15, 0.55, '#3dc6ff'], [0.55, 0.95, 0.8, '#ff2a1f']]) {
384
+ const glow = ctx.createRadialGradient(width * x, height * y, 0, width * x, height * y, Math.max(width, height) * radius);
385
+ glow.addColorStop(0, `${colour}26`);
386
+ glow.addColorStop(1, `${colour}00`);
387
+ ctx.fillStyle = glow;
388
+ ctx.fillRect(0, 0, width, height);
389
+ }
390
+ return canvas;
391
+ }
392
+
393
+ // A tile of stars: three of them, at three depths, slide under the camera at three speeds.
394
+ function paintStars(seed, count, biggest, dpr) {
395
+ const side = 512;
396
+ const canvas = canvasOf(side * dpr, side * dpr);
397
+ const ctx = canvas.getContext('2d');
398
+ ctx.scale(dpr, dpr);
399
+ const pick = random(seed);
400
+ for (let i = 0; i < count; i += 1) {
401
+ const warm = pick() < 0.12;
402
+ ctx.globalAlpha = 0.25 + pick() * 0.75;
403
+ ctx.fillStyle = warm ? '#ffe2a8' : pick() < 0.3 ? '#b0ccff' : '#ffffff';
404
+ ctx.beginPath(); ctx.arc(pick() * side, pick() * side, 0.3 + pick() * biggest, 0, Math.PI * 2); ctx.fill();
405
+ }
406
+ return { canvas, side };
407
+ }
408
+
409
+ export function openMacroverso({ host, rooms, here, enlaces = [], accent = '#9bff66', accentInk = '#0a1405', bishop = null, words, actions }) {
410
+ const reduced = reducedMotion();
411
+ const layer = document.createElement('section');
412
+ layer.className = 'mv';
413
+ layer.setAttribute('aria-label', words.title);
414
+ layer.style.setProperty('--mv-acc', accent);
415
+ layer.style.setProperty('--mv-acc-ink', accentInk);
416
+ const canvas = document.createElement('canvas');
417
+ canvas.className = 'mv-canvas';
418
+ canvas.tabIndex = 0;
419
+ // Its head is the Memory map's (styles.css, .nostromo-head; 2026-10-10): the name in small mono,
420
+ // what it holds in a line beside it, its keys at the end, and under them the kinds of memory as
421
+ // the same stars (nostromo-engine.js, dwarfChip). The two maps read as one family.
422
+ const head = document.createElement('header');
423
+ head.className = 'mv-head nostromo-head';
424
+ const title = document.createElement('h2');
425
+ title.className = 'mother-title';
426
+ title.textContent = words.title;
427
+ const sub = document.createElement('span');
428
+ sub.className = 'mother-sub';
429
+ const legend = document.createElement('span');
430
+ legend.className = 'nostromo-legend';
431
+ legend.setAttribute('aria-label', words.legend ?? 'Memory kinds');
432
+ for (const [kind, hex] of Object.entries(MEMORY_COLORS)) {
433
+ const chip = document.createElement('span');
434
+ chip.className = `k ${kind}`;
435
+ const said = document.createElement('i');
436
+ said.textContent = words.kinds?.[kind] ?? kind;
437
+ chip.append(kind === COLLAPSED ? collapsedChip(hex, 14) : dwarfChip(hex, 14), said);
438
+ legend.append(chip);
439
+ }
440
+ {
441
+ const chip = document.createElement('span');
442
+ chip.className = 'k dim';
443
+ const said = document.createElement('i');
444
+ said.textContent = words.kinds?.dim ?? 'Out of circulation';
445
+ chip.append(dwarfChip(hexMix(MEMORY_COLORS.fact, '#0b0f16', 0.52), 14), said);
446
+ legend.append(chip);
447
+ }
448
+ const keys = document.createElement('span');
449
+ keys.className = 'nostromo-actions';
450
+ const recenter = document.createElement('button');
451
+ recenter.type = 'button';
452
+ recenter.className = 'mother-close mv-recenter';
453
+ recenter.textContent = words.recenter ?? 'RECENTER';
454
+ recenter.hidden = true;
455
+ const back = document.createElement('button');
456
+ back.type = 'button';
457
+ back.className = 'mother-close mv-back';
458
+ back.append(words.back);
459
+ back.insertAdjacentHTML('beforeend', '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>'); // trusted: our own SVG
460
+ keys.append(recenter, back);
461
+ head.append(title, sub, legend, keys);
462
+ const card = document.createElement('aside');
463
+ card.className = 'mv-card';
464
+ card.hidden = true;
465
+ const hint = document.createElement('p');
466
+ hint.className = 'mv-hint';
467
+ hint.textContent = words.hint;
468
+ layer.append(canvas, head, card, hint);
469
+ host.append(layer);
470
+
471
+ const view = { w: 0, h: 0, dpr: 1 };
472
+ const camera = { x: 0, y: 0, zoom: 1, to: null, manual: false };
473
+ const textures = { ready: texturesReady() };
474
+ let nebula = null;
475
+ let stars = [];
476
+ let universes = [];
477
+ let hover = null;
478
+ let focus = null;
479
+ let follow = false; // the camera keeps a chosen universe in the middle while it travels
480
+ let aside = 0; // how far right of a chosen universe the camera sits, for its card
481
+ let live = null; // the one universe drawn as the real thing: its scene, its canvas
482
+ let veins = []; // one per contract between two universes here, with its pulses in flight
483
+ let linking = null; // a universe being carried onto another, to link them
484
+ let dark = 0; // how far the space has fallen to black round a chosen universe
485
+ let windows = []; // the memory windows open over the macroverse, the front one last
486
+ let raf = 0;
487
+ let last = performance.now();
488
+ let clock = 0;
489
+ let queued = false;
490
+ let resizeTimer = 0;
491
+ const drag = { active: false, moved: false, turning: false, link: null, x: 0, y: 0, x0: 0, y0: 0 };
492
+ // BISHOP at the centre: its room when the lobby knows it, and its body (bishopOrb), made on first
493
+ // sight. Where its eye looks, how open it is, and where the pointer is over the space.
494
+ const core = { centre: true, room: null, screen: null };
495
+ let orb = null;
496
+ const gaze = { x: 0, y: 0, open: 0, at: 0, jumpAt: 0, jx: 0, jy: 0, blinkAt: -1, nextBlink: 3 + Math.random() * 4, twice: false };
497
+ let pointer = null;
498
+ const motes = Array.from({ length: ORB_MOTES }, (_, i) => { const rnd = random(hash(`mote${i}`)); return { phase: rnd() * Math.PI * 2, reach: 1.3 + rnd() * 0.65, speed: 0.12 + rnd() * 0.2, tilt: (rnd() - 0.5) * 0.5, size: 0.35 + rnd() * 0.6 }; });
499
+
500
+ function layout(all) {
501
+ // BISHOP's own room is the centre, not a universe on an orbit.
502
+ core.room = bishop ? all.find((room) => room.id === bishop.id) ?? null : null;
503
+ const list = bishop ? all.filter((room) => room.id !== bishop.id) : all;
504
+ const kept = new Map(universes.map((one) => [one.room.id, one]));
505
+ const open = list.filter((room) => room.open);
506
+ const asleep = list.filter((room) => !room.open);
507
+ universes = [...open.map((room, i) => ({ room, orbit: INNER, phase: (i / Math.max(1, open.length)) * Math.PI * 2 + 0.6 })),
508
+ ...asleep.map((room, i) => ({ room, orbit: OUTER, phase: (i / Math.max(1, asleep.length)) * Math.PI * 2 + 1.3 }))]
509
+ .map((placing) => {
510
+ const before = kept.get(placing.room.id);
511
+ // A universe is read and painted again only when what it shows has changed.
512
+ const face = `${placing.room.open ? 1 : 0}:${JSON.stringify(placing.room.glance?.census ?? null)}`;
513
+ const same = before && before.face === face && before.dpr === view.dpr ? before : null;
514
+ return {
515
+ ...placing,
516
+ face,
517
+ dpr: view.dpr,
518
+ reach: reachOf(placing.room),
519
+ data: before && before.face === face ? before.data : null,
520
+ full: before && before.face === face ? before.full : null,
521
+ sprite: same?.sprite ?? null,
522
+ settled: before?.settled ?? null,
523
+ loading: before && before.face === face ? before.loading : null,
524
+ dirty: !same || Boolean(before?.dirty),
525
+ };
526
+ });
527
+ // The veins: one per contract whose two rooms are here, kept across refreshes so a pulse in
528
+ // flight is not lost; a crossing counted since the last look sends a new one.
529
+ const veinsKept = new Map(veins.map((vein) => [vein.enlace.id, vein]));
530
+ const byId = new Map(universes.map((one) => [one.room.id, one]));
531
+ if (core.room) byId.set(core.room.id, core);
532
+ veins = (enlaces ?? []).map((enlace) => {
533
+ const a = byId.get(enlace.rooms?.a), b = byId.get(enlace.rooms?.b);
534
+ if (!a || !b) return null;
535
+ const before = veinsKept.get(enlace.id);
536
+ const vein = { enlace, a, b, seed: (hash(enlace.id) % 628) / 100, pulses: before?.pulses ?? [], lastAt: before ? before.lastAt : enlace.traffic?.lastAt ?? null };
537
+ if (before && enlace.traffic?.lastAt && enlace.traffic.lastAt !== before.lastAt) {
538
+ vein.lastAt = enlace.traffic.lastAt;
539
+ if (vein.pulses.length < VEIN_PULSES) vein.pulses.push({ born: clock, back: enlace.direction === 'b→a' });
540
+ }
541
+ return vein;
542
+ }).filter(Boolean);
543
+ sub.textContent = words.sub(list);
544
+ if (focus) focus = focus.centre ? (core.room ? core : null) : universes.find((one) => one.room.id === focus.room.id) ?? null;
545
+ if (live && (!focus || live.one.room.id !== focus.room.id)) leaveLive();
546
+ if (live && focus) live.one = focus;
547
+ if (focus) drawCard(focus);
548
+ }
549
+
550
+ function size() {
551
+ const rect = layer.getBoundingClientRect();
552
+ view.w = rect.width;
553
+ view.h = rect.height;
554
+ view.dpr = Math.min(2, window.devicePixelRatio || 1);
555
+ canvas.width = Math.round(view.w * view.dpr);
556
+ canvas.height = Math.round(view.h * view.dpr);
557
+ nebula = paintNebula(view.w, view.h, view.dpr, accent);
558
+ stars = [paintStars(11, 260, 0.9, view.dpr), paintStars(23, 90, 1.4, view.dpr), paintStars(37, 30, 2, view.dpr)];
559
+ }
560
+
561
+ // What a universe is made of, read from its room through this one. Without `full` no words
562
+ // cross: the far view is drawn from kinds, zones and sizes. The chosen universe asks for them.
563
+ function load(one, full = false) {
564
+ const key = full ? 'full' : 'data';
565
+ if (one[key]) return Promise.resolve(one[key]);
566
+ if (one.loading?.[key]) return one.loading[key];
567
+ const asked = fetch(`/api/salas/universe?id=${encodeURIComponent(one.room.id)}${full ? '&full=1' : ''}`, { cache: 'no-store' })
568
+ .then((response) => (response.ok ? response.json() : null))
569
+ .catch(() => null)
570
+ .then((answer) => {
571
+ const now = universes.find((item) => item.room.id === one.room.id);
572
+ if (!now || now.face !== one.face) return null;
573
+ now[key] = answer ?? { unreachable: true };
574
+ if (now.loading) delete now.loading[key];
575
+ if (!full) { now.dirty = true; redraw(); }
576
+ return now[key];
577
+ });
578
+ one.loading = { ...(one.loading ?? {}), [key]: asked };
579
+ return asked;
580
+ }
581
+
582
+ // One universe is painted a frame, the chosen one first, so a Mac with many rooms never stalls.
583
+ function paintNext() {
584
+ const next = [focus, ...universes].find((one) => one && one.dirty);
585
+ if (!next) return false;
586
+ if (!next.data) { if (!next.loading?.data) void load(next); return false; }
587
+ next.sprite = paintUniverse(next, view.dpr, next.shown ?? null);
588
+ next.dirty = false;
589
+ return true;
590
+ }
591
+
592
+ // The slow textures, a band a frame, shared by every scene. Once they are whole, every sprite
593
+ // painted before them is painted again.
594
+ function tick() {
595
+ if (!textures.ready) {
596
+ magmaMap(12);
597
+ if (texturesReady()) {
598
+ textures.ready = true;
599
+ for (const one of universes) if (one.sprite && !one.sprite.ready) one.dirty = true;
600
+ }
601
+ }
602
+ paintNext();
603
+ return !textures.ready || universes.some((one) => one.dirty && (one.data || one.loading?.data));
604
+ }
605
+
606
+ // Where a universe is now: on its orbit round the Mac, the far side smaller and fainter.
607
+ function placed(one) {
608
+ if (one.centre) return { x: 0, y: 0, scale: 1, light: 1, depth: 1 };
609
+ const angle = one.phase + (reduced ? 0 : clock * TURN);
610
+ const span = Math.min(view.w, view.h * 1.7);
611
+ const depth = (Math.sin(angle) + 1) / 2; // 0 far, 1 near
612
+ return {
613
+ x: Math.cos(angle) * span * one.orbit,
614
+ y: Math.sin(angle) * span * one.orbit * TILT,
615
+ scale: 0.72 + 0.42 * depth,
616
+ light: 0.36 + 0.64 * depth,
617
+ depth,
618
+ };
619
+ }
620
+ const toScreen = (x, y) => ({ x: view.w / 2 + (x - camera.x) * camera.zoom, y: view.h * 0.54 + (y - camera.y) * camera.zoom });
621
+ const toWorld = (sx, sy) => ({ x: camera.x + (sx - view.w / 2) / camera.zoom, y: camera.y + (sy - view.h * 0.54) / camera.zoom });
622
+
623
+ // ---- The live scene: the chosen universe as its room draws it.
624
+ function enterLive(one) {
625
+ if (!one.room.open) return;
626
+ void load(one, true).then((data) => {
627
+ if (!data?.memories || focus !== one || live?.one === one) return;
628
+ const own = canvasOf(LIVE_SIDE * view.dpr, LIVE_SIDE * view.dpr);
629
+ const { scene, engine } = sceneFor(own, LIVE_SIDE, LIVE_SIDE, view.dpr, reduced);
630
+ engine.buildNodes(data);
631
+ // The bodies start where the sprite left them, so the close look continues the far one.
632
+ const far = one.sprite?.scene;
633
+ if (far) {
634
+ const was = new Map(far.nodes.map((node) => [node.memory.id, node]));
635
+ for (const node of scene.nodes) {
636
+ const from = was.get(node.memory.id);
637
+ if (from?.placed) { node.wx = from.wx; node.wy = from.wy; node.wz = from.wz; node.wvx = 0; node.wvy = 0; node.wvz = 0; node.placed = true; }
638
+ }
639
+ }
640
+ engine.placeNostromo();
641
+ scene.words = words;
642
+ if (data.sealed) scene.cage = { at: 0, closed: true };
643
+ // The pointer reaches it through its own canvas, which is nowhere on the page: it answers
644
+ // from the origin, and the pointer is moved into its frame before it is asked.
645
+ own.getBoundingClientRect = () => ({ left: 0, top: 0, width: LIVE_SIDE, height: LIVE_SIDE });
646
+ live = { one, canvas: own, scene, engine, last: performance.now(), screen: null };
647
+ if (reduced) { stepLive(performance.now()); redraw(); }
648
+ else if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
649
+ });
650
+ }
651
+ function stepLive(now) {
652
+ const { scene, engine } = live;
653
+ const dt = Math.min(48, now - live.last) / 16;
654
+ live.last = now;
655
+ const t = now / 1000;
656
+ if (!reduced && !scene.selected && !drag.turning) scene.yaw += dt * LIVE_TURN;
657
+ engine.stepNostromo(dt, t);
658
+ engine.focusNostromo(dt, t);
659
+ engine.projectNostromo();
660
+ engine.fitNostromo();
661
+ engine.drawNostromo(t);
662
+ }
663
+ // Leaving keeps where the bodies settled, so the sprite painted next continues the live one.
664
+ function leaveLive() {
665
+ if (!live) return;
666
+ live.one.settled = new Map(live.scene.nodes.map((node) => [node.memory.id, { wx: node.wx, wy: node.wy, wz: node.wz, wvx: 0, wvy: 0, wvz: 0 }]));
667
+ live.one.dirty = true;
668
+ live = null;
669
+ closeMemories();
670
+ }
671
+
672
+ // ---- The far universes, alive. Each one that is awake and on screen is drawn again at most
673
+ // FAR_FPS times a second, the least recently drawn first, until the frame's few milliseconds
674
+ // are spent: with many rooms each one simply moves a little less often, and nothing stalls.
675
+ function animateFar(now) {
676
+ const started = performance.now();
677
+ const due = universes
678
+ .filter((one) => one.room.open && one.sprite?.engine && one.onScreen && live?.one !== one && now - (one.drawnAt ?? 0) >= 1000 / FAR_FPS)
679
+ .sort((a, b) => (a.drawnAt ?? 0) - (b.drawnAt ?? 0));
680
+ for (const one of due) {
681
+ if (performance.now() - started > FAR_BUDGET) break;
682
+ const sprite = one.sprite;
683
+ if (one.shown && Math.abs(Math.log(Math.max(SIDE_MIN, one.shown) / sprite.css)) > Math.log(SIDE_STEP)) resizeUniverse(sprite, one.shown, view.dpr);
684
+ const { scene, engine } = sprite;
685
+ const dt = Math.min(48, now - (one.drawnAt || now - 16)) / 16;
686
+ one.drawnAt = now;
687
+ scene.yaw = (scene.yaw ?? 0) + dt * FAR_TURN;
688
+ engine.stepNostromo(dt, now / 1000);
689
+ engine.projectNostromo();
690
+ engine.drawNostromo(now / 1000);
691
+ }
692
+ }
693
+ // The pointer, in the live scene's own frame: what it is over there, or null when it is outside.
694
+ function liveAt(event) {
695
+ if (!live?.screen) return null;
696
+ const rect = canvas.getBoundingClientRect();
697
+ const k = LIVE_SIDE / live.screen.side;
698
+ const lx = (event.clientX - rect.left - live.screen.x) * k;
699
+ const ly = (event.clientY - rect.top - live.screen.y) * k;
700
+ if (lx < 0 || ly < 0 || lx > LIVE_SIDE || ly > LIVE_SIDE) return { inside: false, at: null };
701
+ return { inside: true, at: live.engine.nostromoAt({ clientX: lx, clientY: ly }) };
702
+ }
703
+
704
+ function frame(now) {
705
+ advance(now);
706
+ raf = reduced && !camera.to ? 0 : requestAnimationFrame(frame);
707
+ }
708
+ // One frame's worth of everything, without asking for the next: what frame() does, and what
709
+ // whoever measures the macroverse (`__macroverso.step`) runs when the screen gives no frames.
710
+ function advance(now) {
711
+ const dt = Math.min(64, now - last) / 1000;
712
+ last = now;
713
+ clock += dt;
714
+ tick();
715
+ if (camera.to) {
716
+ const k = Math.min(1, (now - camera.to.at) / camera.to.ms);
717
+ const e = ease(k);
718
+ camera.x = camera.to.from.x + (camera.to.x - camera.to.from.x) * e;
719
+ camera.y = camera.to.from.y + (camera.to.y - camera.to.from.y) * e;
720
+ camera.zoom = camera.to.from.zoom + (camera.to.zoom - camera.to.from.zoom) * e;
721
+ if (k >= 1) camera.to = null;
722
+ } else if (focus && follow && !camera.manual) {
723
+ // A chosen universe keeps the middle while it travels.
724
+ const at = placed(focus);
725
+ camera.x = at.x + aside;
726
+ camera.y = at.y;
727
+ }
728
+ // The space falls to black round a chosen universe, and comes back when it is let go.
729
+ const want = focus && !focus.centre ? FOCUS_DARK : 0; // BISHOP chosen: its card, not the dark
730
+ dark = reduced ? want : dark + (want - dark) * Math.min(1, dt * 3.5);
731
+ if (live) stepLive(now);
732
+ if (!reduced) {
733
+ animateFar(now);
734
+ // A vein is alive while its contract is: a pulse every couple of seconds even when no
735
+ // crossing was counted, more of them the more it is read.
736
+ for (const vein of veins) {
737
+ const busy = 1 + 2 * Math.min(1, (vein.enlace.traffic?.reads1h ?? 0) / 6);
738
+ if (vein.pulses.length < VEIN_PULSES && Math.random() < dt * VEIN_AMBIENT * busy) {
739
+ vein.pulses.push({ born: clock, back: vein.enlace.direction === 'b→a' || (vein.enlace.direction === 'both' && Math.random() < 0.5) });
740
+ }
741
+ }
742
+ }
743
+ draw();
744
+ }
745
+ // One frame when something changed, for a still macroverse; and the next while there is still a
746
+ // sprite to paint or a texture to finish.
747
+ function redraw() {
748
+ if (raf || queued) return;
749
+ queued = true;
750
+ requestAnimationFrame(() => { queued = false; const more = tick(); draw(); if (more) redraw(); });
751
+ }
752
+
753
+ function draw() {
754
+ const ctx = canvas.getContext('2d');
755
+ ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
756
+ ctx.globalCompositeOperation = 'source-over';
757
+ ctx.globalAlpha = 1;
758
+ if (nebula) ctx.drawImage(nebula, 0, 0, view.w, view.h);
759
+ // Stars: the near ones move with the camera, the far ones barely. The tiles repeat under any
760
+ // movement, so the space has no edge.
761
+ stars.forEach((tile, depth) => {
762
+ const speed = [0.04, 0.1, 0.22][depth] * camera.zoom;
763
+ const ox = (((-camera.x * speed - clock * (reduced ? 0 : 1.5 * (depth + 1))) % tile.side) + tile.side) % tile.side;
764
+ const oy = (((-camera.y * speed) % tile.side) + tile.side) % tile.side;
765
+ for (let x = ox - tile.side; x < view.w; x += tile.side) {
766
+ for (let y = oy - tile.side; y < view.h; y += tile.side) ctx.drawImage(tile.canvas, x, y, tile.side, tile.side);
767
+ }
768
+ });
769
+
770
+ // The orbits, faint, round BISHOP at the centre.
771
+ const centre = toScreen(0, 0);
772
+ const span = Math.min(view.w, view.h * 1.7);
773
+ ctx.lineWidth = 1;
774
+ for (const orbit of [INNER, OUTER]) {
775
+ ctx.strokeStyle = 'rgba(242, 241, 238, .11)';
776
+ ctx.setLineDash(orbit === OUTER ? [3, 7] : []);
777
+ ctx.beginPath();
778
+ ctx.ellipse(centre.x, centre.y, span * orbit * camera.zoom, span * orbit * TILT * camera.zoom, 0, 0, Math.PI * 2);
779
+ ctx.stroke();
780
+ }
781
+ ctx.setLineDash([]);
782
+ const beat = reduced ? 0.4 : 0.5 + 0.5 * Math.sin(clock * 5.46);
783
+ drawVeins(ctx);
784
+ drawCentre(ctx, beat);
785
+
786
+ // The universes, far ones first.
787
+ const order = universes.map((one) => ({ one, at: placed(one) })).sort((a, b) => a.at.depth - b.at.depth);
788
+ // The chosen universe stands in front of the dark; everything else falls behind it.
789
+ const lifted = live?.one ?? focus;
790
+ for (const { one, at } of order) if (one !== lifted) drawUniverse(ctx, one, at, beat);
791
+ // The space falls to black round a chosen universe — all of it, edge to edge, never a square
792
+ // round the universe — and further while one of its memories is held (the live scene darkens
793
+ // its own bodies by the same amount, so the two meet without a seam).
794
+ const held = live?.scene.lock?.fade ?? 0;
795
+ const veil = 1 - (1 - dark) * (1 - FOCUS_VEIL * held);
796
+ if (veil > 0.004) { ctx.fillStyle = `rgba(2, 1, 4, ${veil})`; ctx.fillRect(0, 0, view.w, view.h); }
797
+ const top = order.find((entry) => entry.one === lifted);
798
+ if (top) drawUniverse(ctx, top.one, top.at, beat);
799
+ }
800
+
801
+ // One universe on screen: MOTHER's warmth, the live scene or its own living canvas, its rings
802
+ // and its name.
803
+ function drawUniverse(ctx, one, at, beat) {
804
+ const p = toScreen(at.x, at.y);
805
+ const glance = one.room.glance ?? {};
806
+ const awake = Boolean(one.room.open);
807
+ const isLive = live?.one === one;
808
+ // On screen: the live scene at its own size, or the sprite at the universe's.
809
+ const breath = glance.working?.length && !reduced && !isLive ? 1 + 0.035 * Math.sin(clock * 3.2) : 1;
810
+ const side = isLive ? LIVE_SIDE * (camera.zoom / FOCUS_ZOOM) : one.reach * 2 * at.scale * camera.zoom * breath;
811
+ const r = isLive ? side * 0.19 : one.reach * at.scale * camera.zoom * 0.2; // MOTHER's radius on screen, for the rings and the names
812
+ one.screen = { x: p.x, y: p.y, r: side / 2 };
813
+ one.shown = side;
814
+ one.onScreen = !(p.x < -side || p.x > view.w + side || p.y < -side || p.y > view.h + side);
815
+ if (!one.onScreen) { if (isLive) live.screen = null; return; }
816
+ // MOTHER's warmth on the dark, breathing with her heart; a sleeping room gives off a cold glow.
817
+ ctx.globalCompositeOperation = 'lighter';
818
+ // Far (the back of an orbit, or the camera drawn back) it is smaller and dimmer: the near ones lead.
819
+ const away = at.light * at.light * (0.55 + 0.45 * Math.min(1, camera.zoom));
820
+ const reach = r * (2.3 + 0.2 * beat);
821
+ const halo = ctx.createRadialGradient(p.x, p.y, r * 0.6, p.x, p.y, reach);
822
+ halo.addColorStop(0, awake ? `rgba(226, 40, 22, ${(0.16 + 0.05 * beat) * away})` : `rgba(120, 150, 200, ${0.08 * away})`);
823
+ halo.addColorStop(1, 'rgba(0, 0, 0, 0)');
824
+ ctx.fillStyle = halo;
825
+ ctx.beginPath(); ctx.arc(p.x, p.y, reach, 0, Math.PI * 2); ctx.fill();
826
+ ctx.globalCompositeOperation = 'source-over';
827
+ ctx.globalAlpha = at.light * (hover && hover !== one && !focus ? 0.7 : 1) * (awake ? 1 : 0.6);
828
+ if (isLive) {
829
+ live.screen = { x: p.x - side / 2, y: p.y - side / 2, side };
830
+ ctx.drawImage(live.canvas, p.x - side / 2, p.y - side / 2, side, side);
831
+ } else if (one.sprite) {
832
+ ctx.drawImage(one.sprite.canvas, p.x - side / 2, p.y - side / 2, side, side);
833
+ } else {
834
+ // Not read yet: an ember where it will be.
835
+ const ember = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
836
+ ember.addColorStop(0, awake ? '#ff7a3a' : '#56637a');
837
+ ember.addColorStop(1, awake ? 'rgba(168, 23, 12, 0)' : 'rgba(27, 33, 48, 0)');
838
+ ctx.fillStyle = ember;
839
+ ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill();
840
+ }
841
+ ctx.globalAlpha = 1;
842
+ // Waiting for you: a ring in the accent, breathing.
843
+ if (glance.waitingCount) {
844
+ const pulse = reduced ? 0.6 : 0.45 + 0.35 * Math.sin(clock * 2.4);
845
+ ctx.strokeStyle = accent;
846
+ ctx.globalAlpha = pulse;
847
+ ctx.lineWidth = 2;
848
+ ctx.beginPath(); ctx.ellipse(p.x, p.y, side * 0.56, side * 0.56 * 0.8, 0, 0, Math.PI * 2); ctx.stroke();
849
+ }
850
+ ctx.globalAlpha = 1;
851
+ if ((one === hover && !isLive) || one.room.id === here) {
852
+ ctx.strokeStyle = one === focus ? '#ffffff' : 'rgba(255, 255, 255, .55)';
853
+ ctx.lineWidth = one === focus ? 1.6 : 1;
854
+ ctx.beginPath(); ctx.arc(p.x, p.y, side * 0.52, 0, Math.PI * 2); ctx.stroke();
855
+ }
856
+ // Its name, under it.
857
+ const below = p.y + side * 0.5 + 8;
858
+ ctx.font = `600 ${Math.round(13 + 3 * at.depth)}px Geist, -apple-system, BlinkMacSystemFont, sans-serif`;
859
+ ctx.textAlign = 'center';
860
+ ctx.textBaseline = 'top';
861
+ // Over a galaxy a name needs its own dark edge to be read.
862
+ ctx.lineJoin = 'round';
863
+ ctx.strokeStyle = 'rgba(3, 3, 5, .75)';
864
+ ctx.lineWidth = 4;
865
+ ctx.fillStyle = awake ? 'rgba(242, 241, 238, .95)' : 'rgba(200, 210, 225, .6)';
866
+ // A name keeps more of its light than its body: far, it is dimmer, never unreadable.
867
+ ctx.globalAlpha = 0.62 + 0.38 * at.depth;
868
+ ctx.strokeText(one.room.name, p.x, below);
869
+ ctx.fillText(one.room.name, p.x, below);
870
+ ctx.font = '500 11px Geist, -apple-system, BlinkMacSystemFont, sans-serif';
871
+ ctx.lineWidth = 3;
872
+ ctx.fillStyle = glance.waitingCount ? accent : 'rgba(242, 241, 238, .6)';
873
+ ctx.strokeText(words.state(one.room), p.x, below + 19);
874
+ ctx.fillText(words.state(one.room), p.x, below + 19);
875
+ ctx.globalAlpha = 1;
876
+ }
877
+
878
+ // BISHOP at the centre: a gaseous sphere floating, an eye that sees everything (bishopOrb). Its
879
+ // eye follows what the person looks at — the chosen universe, the one under the pointer, the one
880
+ // being carried — and the pointer itself when nothing is; its pupil opens while a universe is
881
+ // carried onto it. Dim and slow while its room sleeps. Its name and its state under it.
882
+ function drawCentre(ctx, beat) {
883
+ if (!bishop) return;
884
+ if (!orb) orb = bishopOrb();
885
+ const room = core.room;
886
+ const awake = Boolean(room?.open);
887
+ const float = reduced ? 0 : Math.sin(clock * 0.8) * 3;
888
+ const centre = toScreen(0, 0);
889
+ const p = { x: centre.x, y: centre.y + float * Math.max(0.6, camera.zoom) };
890
+ const r = 30 * clamp(camera.zoom, 0.75, 1.8);
891
+ core.screen = { x: p.x, y: p.y, r: r * 1.3 };
892
+ // Where it looks: at the person (the pointer) first; at the universe being carried, or the
893
+ // chosen one, when the pointer is away; wandering slowly when nothing is there. A living eye
894
+ // never holds still: small jumps every second or so.
895
+ const seen = linking?.from ?? (focus && !focus.centre ? focus : null);
896
+ let target = pointer ?? seen?.screen ?? null;
897
+ if (!target && !reduced) target = { x: p.x + Math.cos(clock * 0.21) * 160, y: p.y + Math.sin(clock * 0.34) * 90 };
898
+ if (!reduced && clock > gaze.jumpAt) { gaze.jumpAt = clock + 0.6 + Math.random() * 1.2; gaze.jx = (Math.random() - 0.5) * 0.07; gaze.jy = (Math.random() - 0.5) * 0.05; }
899
+ const toward = target ? { x: target.x - p.x, y: target.y - p.y } : { x: 0, y: 0 };
900
+ const far = Math.hypot(toward.x, toward.y) + 180;
901
+ const step = reduced ? 1 : clamp((clock - gaze.at) * 6, 0, 1);
902
+ gaze.at = clock;
903
+ gaze.x += (clamp(toward.x / far + gaze.jx, -1, 1) - gaze.x) * step;
904
+ gaze.y += (clamp(toward.y / far + gaze.jy, -1, 1) - gaze.y) * step;
905
+ gaze.open += ((linking?.over === core ? 1 : hover === core ? 0.45 : 0) - gaze.open) * step;
906
+ // A blink now and then, sometimes two; the lids close fast and open a little slower.
907
+ if (!reduced && awake && clock > gaze.nextBlink) {
908
+ gaze.blinkAt = clock;
909
+ gaze.twice = !gaze.twice && Math.random() < 0.25;
910
+ gaze.nextBlink = clock + (gaze.twice ? 0.32 : 4 + Math.random() * 6);
911
+ }
912
+ const since = clock - gaze.blinkAt;
913
+ const lid = gaze.blinkAt < 0 || since > 0.26 ? 0 : since < 0.09 ? since / 0.09 : 1 - (since - 0.09) / 0.17;
914
+ if (clock - orb.paintedAt >= 1 / ORB_FPS || orb.paintedAt < 0 || lid > 0) {
915
+ orb.paint(reduced ? 0 : clock, { x: gaze.x, y: gaze.y, open: gaze.open, lid: clamp(lid, 0, 1) * 0.94, asleep: !awake });
916
+ orb.paintedAt = clock;
917
+ }
918
+ const life = awake ? 1 : 0.5;
919
+ // The atmosphere: violet into blue, breathing slowly.
920
+ ctx.globalCompositeOperation = 'lighter';
921
+ const air = ctx.createRadialGradient(p.x, p.y, r * 0.8, p.x, p.y, r * (2.7 + 0.12 * beat));
922
+ air.addColorStop(0, `rgba(122, 58, 238, ${0.3 * life})`);
923
+ air.addColorStop(0.45, `rgba(72, 112, 255, ${0.12 * life})`);
924
+ air.addColorStop(1, 'rgba(40, 20, 120, 0)');
925
+ ctx.fillStyle = air;
926
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * (2.7 + 0.12 * beat), 0, Math.PI * 2); ctx.fill();
927
+ // The wisps, turning two ways at once, as gas does.
928
+ const span = r * orb.reach * 2;
929
+ for (const [speed, alpha] of [[0.05, 0.8], [-0.033, 0.5]]) {
930
+ ctx.save();
931
+ ctx.translate(p.x, p.y);
932
+ ctx.rotate(reduced ? 0 : clock * speed);
933
+ ctx.globalAlpha = alpha * life;
934
+ ctx.drawImage(orb.wisps, -span / 2, -span / 2, span, span);
935
+ ctx.restore();
936
+ }
937
+ // The motes behind it, then the sphere, then the motes in front.
938
+ const drawMotes = (front) => {
939
+ for (const mote of motes) {
940
+ const a = mote.phase + (reduced ? 0 : clock * mote.speed);
941
+ const ahead = Math.sin(a) > 0;
942
+ if (ahead !== front) continue;
943
+ const mx = p.x + Math.cos(a) * r * mote.reach;
944
+ const my = p.y + Math.sin(a) * r * mote.reach * 0.3 + mote.tilt * r * Math.cos(a);
945
+ const size = mote.size * (ahead ? 1 : 0.7) * clamp(camera.zoom, 0.7, 1.4);
946
+ const glow = ctx.createRadialGradient(mx, my, 0, mx, my, size * 3);
947
+ glow.addColorStop(0, `rgba(214, 206, 255, ${(ahead ? 0.6 : 0.25) * life})`);
948
+ glow.addColorStop(1, 'rgba(120, 140, 255, 0)');
949
+ ctx.fillStyle = glow;
950
+ ctx.beginPath(); ctx.arc(mx, my, size * 3, 0, Math.PI * 2); ctx.fill();
951
+ }
952
+ };
953
+ drawMotes(false);
954
+ ctx.globalCompositeOperation = 'source-over';
955
+ ctx.globalAlpha = awake ? 1 : 0.8;
956
+ ctx.drawImage(orb.canvas, p.x - r, p.y - r, r * 2, r * 2);
957
+ ctx.globalAlpha = 1;
958
+ // Its own thin atmosphere, brighter on the side the light comes from.
959
+ ctx.globalCompositeOperation = 'lighter';
960
+ const limb = ctx.createRadialGradient(p.x - r * 0.1, p.y - r * 0.12, r * 0.9, p.x, p.y, r * 1.14);
961
+ limb.addColorStop(0, 'rgba(120, 150, 255, 0)');
962
+ limb.addColorStop(0.55, `rgba(130, 160, 255, ${0.28 * life})`);
963
+ limb.addColorStop(1, 'rgba(90, 60, 220, 0)');
964
+ ctx.fillStyle = limb;
965
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.14, 0, Math.PI * 2); ctx.fill();
966
+ ctx.globalCompositeOperation = 'source-over';
967
+ ctx.globalCompositeOperation = 'lighter';
968
+ drawMotes(true);
969
+ ctx.globalCompositeOperation = 'source-over';
970
+ // A ring in the accent while a universe is carried onto it, and while it waits for the person;
971
+ // a white one while it is pointed at or chosen.
972
+ const target2 = linking?.over === core;
973
+ if (target2 || room?.glance?.waitingCount) {
974
+ ctx.strokeStyle = accent;
975
+ ctx.globalAlpha = target2 ? 0.95 : reduced ? 0.6 : 0.45 + 0.35 * Math.sin(clock * 2.4);
976
+ ctx.lineWidth = 2;
977
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.stroke();
978
+ ctx.globalAlpha = 1;
979
+ } else if (core === hover || core === focus) {
980
+ ctx.strokeStyle = core === focus ? 'rgba(255, 255, 255, .85)' : 'rgba(255, 255, 255, .4)';
981
+ ctx.lineWidth = core === focus ? 1.4 : 1;
982
+ ctx.beginPath(); ctx.arc(p.x, p.y, r * 1.5, 0, Math.PI * 2); ctx.stroke();
983
+ }
984
+ const below = p.y + r * 1.62 + 8;
985
+ ctx.textAlign = 'center';
986
+ ctx.textBaseline = 'top';
987
+ ctx.lineJoin = 'round';
988
+ ctx.strokeStyle = 'rgba(3, 3, 5, .75)';
989
+ ctx.font = '650 15px Geist, -apple-system, BlinkMacSystemFont, sans-serif';
990
+ ctx.lineWidth = 4;
991
+ ctx.fillStyle = 'rgba(242, 241, 238, .95)';
992
+ ctx.strokeText(words.bishop ?? 'Coordinator', p.x, below);
993
+ ctx.fillText(words.bishop ?? 'Coordinator', p.x, below);
994
+ ctx.font = '500 11px Geist, -apple-system, BlinkMacSystemFont, sans-serif';
995
+ ctx.lineWidth = 3;
996
+ ctx.fillStyle = room?.glance?.waitingCount ? accent : 'rgba(242, 241, 238, .6)';
997
+ const said = room ? words.state(room) : (words.asleep ?? '');
998
+ ctx.strokeText(said, p.x, below + 19);
999
+ ctx.fillText(said, p.x, below + 19);
1000
+ }
1001
+
1002
+ // The veins between universes: one per contract, drawn as NOSTROMO draws the vein between two
1003
+ // memories — a wide breath of colour and a thin bright thread — with the current running from
1004
+ // the room that serves to the one that reads, and a pulse for every crossing counted since the
1005
+ // last look. And, while a universe is being carried onto another, the vein it would make.
1006
+ function drawVeins(ctx) {
1007
+ ctx.save();
1008
+ ctx.lineCap = 'round';
1009
+ for (const vein of veins) {
1010
+ const { enlace } = vein;
1011
+ const wa = placed(vein.a), wb = placed(vein.b);
1012
+ const pa = toScreen(wa.x, wa.y), pb = toScreen(wb.x, wb.y);
1013
+ const dx = pb.x - pa.x, dy = pb.y - pa.y;
1014
+ const d = Math.hypot(dx, dy) || 1;
1015
+ const bow = Math.sin((reduced ? 0 : clock * 0.5) + vein.seed) * Math.min(70, d * 0.16);
1016
+ const cx = (pa.x + pb.x) / 2 - (dy / d) * bow;
1017
+ const cy = (pa.y + pb.y) / 2 + (dx / d) * bow;
1018
+ const traffic = enlace.traffic ?? {};
1019
+ const recent = Math.min(1, (traffic.reads1h ?? 0) / 6);
1020
+ const lately = Math.min(1, (traffic.reads24h ?? 0) / 12);
1021
+ const back = enlace.direction === 'b→a';
1022
+ const from = back ? pb : pa, to = back ? pa : pb;
1023
+ // MOTHER's red leaves the room that serves and the accent arrives where it is read; both
1024
+ // ways, red at both ends with the accent between them.
1025
+ const red = '#e22816';
1026
+ const arriving = enlace.direction === 'both' ? red : accent;
1027
+ const head = reduced ? 0.5 : ((clock * (0.06 + 0.2 * recent) + vein.seed) % 1 + 1) % 1;
1028
+ ctx.strokeStyle = currentAlong(ctx, from.x, from.y, to.x, to.y, hexAlpha(red, 0.5 + 0.3 * lately), { mid: hexAlpha(hexMix(red, accent, 0.5), 0.35 + 0.25 * lately), end: hexAlpha(arriving, 0.5 + 0.3 * lately) }, head, 0.2 + 0.7 * recent);
1029
+ ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.quadraticCurveTo(cx, cy, pb.x, pb.y);
1030
+ ctx.globalAlpha = 0.32; ctx.lineWidth = 5 + 3 * lately; ctx.stroke();
1031
+ ctx.globalAlpha = 1; ctx.lineWidth = 1 + 0.8 * lately; ctx.stroke();
1032
+ // The pulses: one per crossing counted, travelling the way the notes did.
1033
+ vein.pulses = vein.pulses.filter((pulse) => clock - pulse.born < VEIN_LIFE);
1034
+ for (const pulse of vein.pulses) {
1035
+ const u = Math.min(1, (clock - pulse.born) / VEIN_LIFE);
1036
+ const at = alongCurve(pa.x, pa.y, cx, cy, pb.x, pb.y, pulse.back ? 1 - u : u);
1037
+ const size = (1.3 + 1 * Math.sin(u * Math.PI)) * Math.max(0.6, Math.min(1.4, camera.zoom));
1038
+ const halo = ctx.createRadialGradient(at.x, at.y, 0, at.x, at.y, size * 3);
1039
+ halo.addColorStop(0, hexAlpha(accent, 0.6));
1040
+ halo.addColorStop(1, hexAlpha(accent, 0));
1041
+ ctx.fillStyle = halo;
1042
+ ctx.beginPath(); ctx.arc(at.x, at.y, size * 3, 0, Math.PI * 2); ctx.fill();
1043
+ ctx.fillStyle = 'rgba(255, 248, 243, .95)';
1044
+ ctx.beginPath(); ctx.arc(at.x, at.y, size, 0, Math.PI * 2); ctx.fill();
1045
+ }
1046
+ }
1047
+ if (linking?.from?.screen) {
1048
+ const from = linking.from.screen;
1049
+ const over = linking.over?.screen;
1050
+ const to = over ? { x: over.x, y: over.y } : { x: linking.x, y: linking.y };
1051
+ ctx.setLineDash([6, 6]);
1052
+ ctx.strokeStyle = hexAlpha(accent, over ? 0.9 : 0.55);
1053
+ ctx.lineWidth = over ? 2 : 1.4;
1054
+ ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.quadraticCurveTo((from.x + to.x) / 2, (from.y + to.y) / 2 - 40, to.x, to.y); ctx.stroke();
1055
+ ctx.setLineDash([]);
1056
+ }
1057
+ ctx.restore();
1058
+ }
1059
+
1060
+ function at(event) {
1061
+ const rect = canvas.getBoundingClientRect();
1062
+ const x = event.clientX - rect.left, y = event.clientY - rect.top;
1063
+ let best = null;
1064
+ for (const one of core.room ? [core, ...universes] : universes) {
1065
+ if (!one.screen) continue;
1066
+ const d = Math.hypot(one.screen.x - x, one.screen.y - y);
1067
+ if (d <= Math.max(26, one.screen.r * 1.1) && (!best || d < best.d)) best = { one, d };
1068
+ }
1069
+ return best?.one ?? null;
1070
+ }
1071
+
1072
+ function fly(to, ms = 900) {
1073
+ camera.to = { from: { x: camera.x, y: camera.y, zoom: camera.zoom }, ...to, at: performance.now(), ms: reduced ? 1 : ms };
1074
+ if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
1075
+ }
1076
+ function home() {
1077
+ camera.manual = false;
1078
+ recenter.hidden = true;
1079
+ if (focus) { follow = true; const where = placed(focus); fly({ x: where.x + aside, y: where.y, zoom: focus.centre ? 1.4 : FOCUS_ZOOM }); }
1080
+ else fly({ x: 0, y: 0, zoom: 1 });
1081
+ }
1082
+
1083
+ // ---- Memory windows. A memory picked inside the chosen universe opens in a window of its own,
1084
+ // as NOSTROMO's cards do: moved by its header, brought forward by a touch, closed with its ×,
1085
+ // several at once (MEMORY_WINDOWS; the oldest gives way). The one in front is the memory the
1086
+ // live scene holds in its sight.
1087
+ const shelf = document.createElement('div');
1088
+ shelf.className = 'mv-windows';
1089
+ layer.append(shelf);
1090
+ function raiseMemory(win) {
1091
+ windows = [...windows.filter((one) => one !== win), win];
1092
+ windows.forEach((one, index) => { one.el.style.zIndex = String(10 + index); one.el.classList.toggle('front', one === win); });
1093
+ if (live) { live.scene.selected = win.node; if (reduced) { stepLive(performance.now()); redraw(); } }
1094
+ }
1095
+ function closeMemory(win) {
1096
+ windows = windows.filter((one) => one !== win);
1097
+ win.el.remove();
1098
+ const front = windows.at(-1);
1099
+ if (front) raiseMemory(front);
1100
+ else if (live) { live.scene.selected = null; if (reduced) { stepLive(performance.now()); redraw(); } }
1101
+ }
1102
+ function closeMemories() {
1103
+ for (const win of windows) win.el.remove();
1104
+ windows = [];
1105
+ }
1106
+ function openMemory(node) {
1107
+ const already = windows.find((win) => win.node.memory.id === node.memory.id);
1108
+ if (already) { raiseMemory(already); return; }
1109
+ while (windows.length >= MEMORY_WINDOWS) closeMemory(windows[0]);
1110
+ const memory = node.memory;
1111
+ const el = document.createElement('aside');
1112
+ el.className = 'mv-mem';
1113
+ el.style.setProperty('--k', node.color);
1114
+ const step = windows.length;
1115
+ // Each one a step right and down of the last, far enough that every header stays in reach.
1116
+ el.style.left = `${40 + step * 44}px`;
1117
+ el.style.top = `${150 + step * 56}px`;
1118
+ const head = document.createElement('header');
1119
+ const kind = document.createElement('b');
1120
+ kind.textContent = words.kind(memory.kind);
1121
+ // The room's close: a sunk track and the cross that turns (styles.css, .x-close).
1122
+ const track = document.createElement('span');
1123
+ track.className = 'surface-modes';
1124
+ const shut = document.createElement('button');
1125
+ shut.type = 'button';
1126
+ shut.className = 'surface-mode close x-close x-only';
1127
+ shut.setAttribute('aria-label', words.close ?? 'Close');
1128
+ const cross = document.createElement('span');
1129
+ cross.className = 'x-icon';
1130
+ cross.innerHTML = '<svg viewBox="0 0 20 20" focusable="false" aria-hidden="true"><path d="M5.5 5.5l9 9M14.5 5.5l-9 9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1131
+ shut.append(cross);
1132
+ track.append(shut);
1133
+ head.append(kind, track);
1134
+ const text = document.createElement('p');
1135
+ text.className = 'mv-mem-text';
1136
+ text.textContent = memory.text ?? '';
1137
+ const meta = document.createElement('p');
1138
+ meta.className = 'mv-mem-meta';
1139
+ const span = memory.fromSequence ? (memory.fromSequence === memory.throughSequence ? `#${memory.fromSequence}` : `#${memory.fromSequence}–#${memory.throughSequence}`) : null;
1140
+ meta.textContent = [span, memory.agent ? `@${memory.agent}` : null, memory.created ? new Date(memory.created).toLocaleString() : null].filter(Boolean).join(' · ');
1141
+ el.append(head, text);
1142
+ if (meta.textContent) el.append(meta);
1143
+ shelf.append(el);
1144
+ const win = { node, el };
1145
+ windows.push(win);
1146
+ shut.addEventListener('click', (event) => { event.stopPropagation(); closeMemory(win); });
1147
+ el.addEventListener('pointerdown', () => { if (windows.at(-1) !== win) raiseMemory(win); });
1148
+ // Moved by its header, inside the macroverse.
1149
+ head.addEventListener('pointerdown', (event) => {
1150
+ if (event.button !== 0 || event.target.closest('button')) return;
1151
+ event.preventDefault();
1152
+ const room = layer.getBoundingClientRect();
1153
+ const box = el.getBoundingClientRect();
1154
+ const dx = event.clientX - box.left, dy = event.clientY - box.top;
1155
+ el.classList.add('moving');
1156
+ try { head.setPointerCapture(event.pointerId); } catch { /* the pointer already left */ }
1157
+ const move = (moving) => {
1158
+ el.style.left = `${clamp(moving.clientX - room.left - dx, 0, room.width - 80)}px`;
1159
+ el.style.top = `${clamp(moving.clientY - room.top - dy, 0, room.height - 48)}px`;
1160
+ };
1161
+ const drop = () => { head.removeEventListener('pointermove', move); head.removeEventListener('pointerup', drop); head.removeEventListener('pointercancel', drop); el.classList.remove('moving'); };
1162
+ head.addEventListener('pointermove', move);
1163
+ head.addEventListener('pointerup', drop);
1164
+ head.addEventListener('pointercancel', drop);
1165
+ });
1166
+ raiseMemory(win);
1167
+ }
1168
+
1169
+ function drawCard(one) {
1170
+ if (one.centre) { drawCentreCard(); return; }
1171
+ const room = one.room;
1172
+ const glance = room.glance ?? {};
1173
+ const census = glance.census;
1174
+ card.replaceChildren();
1175
+ const name = document.createElement('h3');
1176
+ name.textContent = room.name;
1177
+ const state = document.createElement('p');
1178
+ state.className = 'mv-state';
1179
+ state.textContent = words.state(room);
1180
+ card.append(name, state);
1181
+ if (room.root) { const path = document.createElement('p'); path.className = 'mv-path'; path.textContent = words.path(room.root); card.append(path); }
1182
+ if (glance.waiting?.length) { const wait = document.createElement('p'); wait.className = 'mv-wait'; wait.textContent = words.ask(glance.waiting[0]); card.append(wait); }
1183
+ if (census?.memories) {
1184
+ const list = document.createElement('ul');
1185
+ list.className = 'mv-kinds';
1186
+ for (const kind of KINDS) {
1187
+ const n = census.kinds?.[kind] ?? 0;
1188
+ if (!n) continue;
1189
+ const row = document.createElement('li');
1190
+ row.style.setProperty('--k', MEMORY_COLORS[kind]);
1191
+ const bar = document.createElement('i');
1192
+ bar.style.width = `${Math.max(3, (n / census.memories) * 100)}%`;
1193
+ const label = document.createElement('span');
1194
+ label.textContent = `${words.kind(kind)} · ${n}`;
1195
+ row.append(label, bar);
1196
+ list.append(row);
1197
+ }
1198
+ card.append(list);
1199
+ }
1200
+ const counts = document.createElement('p');
1201
+ counts.className = 'mv-counts';
1202
+ const veins = one.data?.links?.length ?? one.full?.links?.length ?? null;
1203
+ counts.textContent = [words.counts(room), veins !== null && words.veins ? words.veins(veins) : null].filter(Boolean).join(' · ');
1204
+ card.append(counts);
1205
+ // Its links, as seen from here: who reads whom, and what crosses.
1206
+ const mine = (enlaces ?? []).filter((enlace) => enlace.rooms?.a === room.id || enlace.rooms?.b === room.id);
1207
+ if (mine.length) {
1208
+ const links = document.createElement('ul');
1209
+ links.className = 'mv-links';
1210
+ for (const enlace of mine) {
1211
+ const row = document.createElement('li');
1212
+ row.textContent = words.linkRow?.(enlace, room.id) ?? enlace.id;
1213
+ links.append(row);
1214
+ }
1215
+ card.append(links);
1216
+ }
1217
+ const row = document.createElement('div');
1218
+ row.className = 'mv-actions';
1219
+ if (room.open) {
1220
+ const go = document.createElement('a');
1221
+ go.className = 'sm-btn primary';
1222
+ go.textContent = room.id === here ? words.backToRoom : words.enter;
1223
+ go.href = room.id === here ? '/' : `http://127.0.0.1:${room.open.port}/`;
1224
+ row.append(go);
1225
+ } else if (room.root) {
1226
+ const wake = document.createElement('button');
1227
+ wake.type = 'button';
1228
+ wake.className = 'sm-btn primary';
1229
+ wake.textContent = words.wake;
1230
+ wake.addEventListener('click', async () => { wake.disabled = true; wake.textContent = words.waking; await actions.wake(room); });
1231
+ row.append(wake);
1232
+ }
1233
+ if (actions.link && words.link) {
1234
+ // From the card, this room as the one that would read: the sheet asks the rest.
1235
+ const link = document.createElement('button');
1236
+ link.type = 'button';
1237
+ link.className = 'sm-btn ghost';
1238
+ link.textContent = words.link;
1239
+ link.addEventListener('click', () => actions.link(null, room));
1240
+ row.append(link);
1241
+ }
1242
+ const out = document.createElement('button');
1243
+ out.type = 'button';
1244
+ out.className = 'sm-btn ghost';
1245
+ out.textContent = words.all;
1246
+ out.addEventListener('click', () => choose(null));
1247
+ row.append(out);
1248
+ card.append(row);
1249
+ card.hidden = false;
1250
+ }
1251
+
1252
+ // BISHOP's card: its state, the rooms it reads (a vein each) and the ones it does not yet, each a
1253
+ // key that asks for the link; the way into its room.
1254
+ function drawCentreCard() {
1255
+ const room = core.room;
1256
+ card.replaceChildren();
1257
+ const name = document.createElement('h3');
1258
+ name.textContent = words.bishop ?? 'Coordinator';
1259
+ const state = document.createElement('p');
1260
+ state.className = 'mv-state';
1261
+ state.textContent = room ? words.state(room) : (words.asleep ?? '');
1262
+ card.append(name, state);
1263
+ if (words.bishopAbout) { const about = document.createElement('p'); about.className = 'mv-about'; about.textContent = words.bishopAbout; card.append(about); }
1264
+ if (room?.glance?.waiting?.length) { const wait = document.createElement('p'); wait.className = 'mv-wait'; wait.textContent = words.ask(room.glance.waiting[0]); card.append(wait); }
1265
+ const mine = (enlaces ?? []).filter((enlace) => enlace.rooms?.a === bishop.id || enlace.rooms?.b === bishop.id);
1266
+ const reads = document.createElement('p');
1267
+ reads.className = 'mv-counts';
1268
+ reads.textContent = words.bishopReads?.(mine.length) ?? '';
1269
+ card.append(reads);
1270
+ if (mine.length) {
1271
+ const links = document.createElement('ul');
1272
+ links.className = 'mv-links';
1273
+ for (const enlace of mine) {
1274
+ const row = document.createElement('li');
1275
+ row.textContent = words.linkRow?.(enlace, bishop.id) ?? enlace.id;
1276
+ links.append(row);
1277
+ }
1278
+ card.append(links);
1279
+ }
1280
+ // The rooms it does not read yet: a key each, that opens the sheet with BISHOP as the reader.
1281
+ const linked = new Set(mine.flatMap((enlace) => [enlace.rooms?.a, enlace.rooms?.b]));
1282
+ const unlinked = universes.filter((one) => !linked.has(one.room.id));
1283
+ if (unlinked.length && actions.link && room) {
1284
+ const label = document.createElement('p');
1285
+ label.className = 'mv-counts';
1286
+ label.textContent = words.notLinked ?? '';
1287
+ const keys = document.createElement('div');
1288
+ keys.className = 'mv-actions mv-unlinked';
1289
+ for (const one of unlinked.slice(0, 8)) {
1290
+ const key = document.createElement('button');
1291
+ key.type = 'button';
1292
+ key.className = 'sm-btn small';
1293
+ key.textContent = one.room.name;
1294
+ key.addEventListener('click', () => actions.link(one.room, room));
1295
+ keys.append(key);
1296
+ }
1297
+ card.append(label, keys);
1298
+ }
1299
+ const row = document.createElement('div');
1300
+ row.className = 'mv-actions';
1301
+ if (room?.open) {
1302
+ const go = document.createElement('a');
1303
+ go.className = 'sm-btn primary';
1304
+ go.textContent = words.talk ?? words.enter;
1305
+ go.href = room.id === here ? '/' : `http://127.0.0.1:${room.open.port}/`;
1306
+ row.append(go);
1307
+ } else if (room) {
1308
+ const wake = document.createElement('button');
1309
+ wake.type = 'button';
1310
+ wake.className = 'sm-btn primary';
1311
+ wake.textContent = words.wake;
1312
+ wake.addEventListener('click', async () => { wake.disabled = true; wake.textContent = words.waking; await actions.wake(room); });
1313
+ row.append(wake);
1314
+ }
1315
+ const out = document.createElement('button');
1316
+ out.type = 'button';
1317
+ out.className = 'sm-btn';
1318
+ out.textContent = words.all;
1319
+ out.addEventListener('click', () => choose(null));
1320
+ row.append(out);
1321
+ card.append(row);
1322
+ card.hidden = false;
1323
+ }
1324
+
1325
+ function choose(one) {
1326
+ if (focus && focus !== one) leaveLive();
1327
+ focus = one;
1328
+ camera.manual = false;
1329
+ recenter.hidden = true;
1330
+ if (!one) {
1331
+ card.hidden = true;
1332
+ follow = false;
1333
+ fly({ x: 0, y: 0, zoom: 1 });
1334
+ return;
1335
+ }
1336
+ drawCard(one);
1337
+ // BISHOP is not a universe to enter: the camera comes a little closer and its card opens.
1338
+ const zoom = one.centre ? 1.4 : FOCUS_ZOOM;
1339
+ aside = (view.w * 0.16) / zoom;
1340
+ follow = true;
1341
+ const where = placed(one);
1342
+ fly({ x: where.x + aside, y: where.y, zoom });
1343
+ if (!one.centre) enterLive(one);
1344
+ }
1345
+
1346
+ // Pointer: a drag on the space moves it; a drag on the chosen universe turns it; a click picks a
1347
+ // universe, or a memory inside the chosen one; the wheel brings the space closer round the pointer.
1348
+ canvas.addEventListener('pointerdown', (event) => {
1349
+ if (event.button !== 0) return;
1350
+ drag.active = true;
1351
+ drag.moved = false;
1352
+ drag.turning = Boolean(liveAt(event)?.inside);
1353
+ // A universe under the pointer may be carried onto another, to link them.
1354
+ drag.link = drag.turning ? null : at(event);
1355
+ drag.x = drag.x0 = event.clientX;
1356
+ drag.y = drag.y0 = event.clientY;
1357
+ try { canvas.setPointerCapture(event.pointerId); } catch { /* the pointer already left */ }
1358
+ });
1359
+ canvas.addEventListener('pointermove', (event) => {
1360
+ const box = canvas.getBoundingClientRect();
1361
+ pointer = { x: event.clientX - box.left, y: event.clientY - box.top };
1362
+ if (drag.active) {
1363
+ const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
1364
+ if (Math.hypot(dx, dy) > 3) drag.moved = true;
1365
+ if (!drag.moved) return;
1366
+ drag.x = event.clientX;
1367
+ drag.y = event.clientY;
1368
+ if (drag.link && !drag.turning) {
1369
+ // A universe carried onto another: the vein it would make, until the pointer lets go.
1370
+ if (!linking && Math.hypot(event.clientX - drag.x0, event.clientY - drag.y0) < LINK_DRAG) return;
1371
+ const rect = canvas.getBoundingClientRect();
1372
+ const over = at(event);
1373
+ linking = { from: drag.link, x: event.clientX - rect.left, y: event.clientY - rect.top, over: over && over !== drag.link ? over : null };
1374
+ canvas.style.cursor = linking.over ? 'copy' : 'grabbing';
1375
+ redraw();
1376
+ return;
1377
+ }
1378
+ if (drag.turning && live) {
1379
+ if (live.scene.lock) live.scene.lock.aim = false;
1380
+ live.scene.yaw = (live.scene.yaw ?? 0) + dx * TURN_PER_PIXEL;
1381
+ live.scene.pitch = clamp((live.scene.pitch ?? 0) + dy * TURN_PER_PIXEL, -PITCH_LIMIT, PITCH_LIMIT);
1382
+ if (reduced) { stepLive(performance.now()); redraw(); }
1383
+ } else {
1384
+ camera.to = null;
1385
+ camera.x -= dx / camera.zoom;
1386
+ camera.y -= dy / camera.zoom;
1387
+ camera.manual = true;
1388
+ recenter.hidden = false;
1389
+ redraw();
1390
+ }
1391
+ canvas.style.cursor = 'grabbing';
1392
+ return;
1393
+ }
1394
+ const now = at(event);
1395
+ const over = liveAt(event);
1396
+ const node = over?.at && over.at !== 'core' ? over.at : null;
1397
+ if (live && live.scene.hover !== node) { live.scene.hover = node; if (reduced) { stepLive(performance.now()); redraw(); } }
1398
+ if (now !== hover) { hover = now; redraw(); }
1399
+ canvas.style.cursor = node || over?.at === 'core' ? 'pointer' : now && now !== focus ? 'pointer' : over?.inside ? 'grab' : 'default';
1400
+ });
1401
+ const release = (event) => {
1402
+ // Let go over another universe: the notes of the one carried would travel to it.
1403
+ if (linking?.over && event?.type === 'pointerup') actions.link?.(linking.from.room, linking.over.room);
1404
+ linking = null;
1405
+ drag.active = false;
1406
+ drag.turning = false;
1407
+ drag.link = null;
1408
+ canvas.style.cursor = 'default';
1409
+ redraw();
1410
+ };
1411
+ canvas.addEventListener('pointerup', release);
1412
+ canvas.addEventListener('pointercancel', release);
1413
+ canvas.addEventListener('pointerleave', () => { hover = null; pointer = null; if (live) live.scene.hover = null; redraw(); });
1414
+ canvas.addEventListener('click', (event) => {
1415
+ if (drag.moved) { drag.moved = false; return; }
1416
+ const over = liveAt(event);
1417
+ if (over?.inside && live) {
1418
+ // Inside the chosen universe: a memory is picked and read on the card; the dark lets go of it on the next click.
1419
+ // A memory opens in its own window; the dark lets go of the one in the sight, and the
1420
+ // windows stay open until they are closed.
1421
+ const node = over.at && over.at !== 'core' ? over.at : null;
1422
+ if (node) openMemory(node);
1423
+ else { live.scene.selected = null; for (const win of windows) win.el.classList.remove('front'); }
1424
+ if (reduced) { stepLive(performance.now()); redraw(); }
1425
+ if (node || over.at === 'core') return;
1426
+ }
1427
+ const one = at(event);
1428
+ choose(one && one !== focus ? one : focus && !one ? null : one);
1429
+ });
1430
+ canvas.addEventListener('wheel', (event) => {
1431
+ event.preventDefault();
1432
+ const rect = canvas.getBoundingClientRect();
1433
+ const px = event.clientX - rect.left, py = event.clientY - rect.top;
1434
+ const before = toWorld(px, py);
1435
+ camera.to = null;
1436
+ camera.zoom = clamp(camera.zoom * Math.exp(-event.deltaY * 0.0012), ZOOM_MIN, ZOOM_MAX);
1437
+ const after = toWorld(px, py);
1438
+ camera.x += before.x - after.x;
1439
+ camera.y += before.y - after.y;
1440
+ camera.manual = true;
1441
+ recenter.hidden = false;
1442
+ redraw();
1443
+ }, { passive: false });
1444
+ canvas.addEventListener('keydown', (event) => {
1445
+ if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
1446
+ event.preventDefault();
1447
+ const list = universes;
1448
+ if (!list.length) return;
1449
+ const i = focus ? list.indexOf(focus) : -1;
1450
+ choose(list[(i + (event.key === 'ArrowRight' ? 1 : list.length - 1) + (i < 0 ? 1 : 0)) % list.length]);
1451
+ }
1452
+ });
1453
+ function onKey(event) {
1454
+ if (event.key !== 'Escape') return;
1455
+ event.preventDefault();
1456
+ if (windows.length) closeMemory(windows.at(-1));
1457
+ else if (live?.scene.selected) { live.scene.selected = null; if (reduced) { stepLive(performance.now()); redraw(); } }
1458
+ else if (focus) choose(null);
1459
+ else close();
1460
+ }
1461
+ document.addEventListener('keydown', onKey);
1462
+ recenter.addEventListener('click', home);
1463
+ // A window being resized is settled before anything is painted again: the sky and the stars at
1464
+ // the new size, the sprites only if the density changed.
1465
+ const onResize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { size(); layout(rooms); redraw(); }, 150); };
1466
+ window.addEventListener('resize', onResize);
1467
+ const onVisible = () => { if (!document.hidden && !raf && !reduced) { last = performance.now(); if (live) live.last = last; raf = requestAnimationFrame(frame); } else if (document.hidden && raf) { cancelAnimationFrame(raf); raf = 0; } };
1468
+ document.addEventListener('visibilitychange', onVisible);
1469
+
1470
+ function close() {
1471
+ cancelAnimationFrame(raf);
1472
+ raf = 0;
1473
+ clearTimeout(resizeTimer);
1474
+ live = null;
1475
+ document.removeEventListener('keydown', onKey);
1476
+ window.removeEventListener('resize', onResize);
1477
+ document.removeEventListener('visibilitychange', onVisible);
1478
+ layer.classList.add('leaving');
1479
+ setTimeout(() => layer.remove(), reduced ? 0 : 260);
1480
+ actions.closed?.();
1481
+ }
1482
+ back.addEventListener('click', close);
1483
+
1484
+ size();
1485
+ layout(rooms);
1486
+ camera.zoom = 0.6;
1487
+ fly({ x: 0, y: 0, zoom: 1 }, 1200);
1488
+ redraw();
1489
+ canvas.focus({ preventScroll: true });
1490
+ // A window on the macroverse for whoever is measuring it, the way the room leaves `__pulse`.
1491
+ globalThis.__macroverso = { peek: () => ({ live, universes, veins, camera, dark, windows, textures: { ...textures, whole: texturesReady() }, raf }), step: (now) => advance(now), choose: (index) => choose(universes[index] ?? null), open: (node) => openMemory(node) };
1492
+
1493
+ return {
1494
+ update(next, nextEnlaces = enlaces) { rooms = next; enlaces = nextEnlaces; layout(next); redraw(); },
1495
+ close,
1496
+ };
1497
+ }