visualproof 0.0.0-stage → 0.1.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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,787 @@
1
+ // The scene graph: positions as a tree that mirrors the cluster tree (CanvasSpec.md, decision 2).
2
+ //
3
+ // A cluster's position is local to its parent's origin, a node's to its cluster's. World positions
4
+ // are summed down the tree, so moving a cluster carries everything in it, folded or not.
5
+ //
6
+ // Three positions per body, all local:
7
+ // home — what the server laid out (or a pin the reader dropped it at);
8
+ // anchor — where it should be now: home, unless folding elsewhere made room and it moved in;
9
+ // current — where it is drawn, which follows the anchor (C3 replaces the easing by motion).
10
+ //
11
+ // Folding is a layout event. A folded cluster takes the room of a small disc, so its parent's
12
+ // other children slide inwards along their own direction from the parent's origin until they
13
+ // touch something; the arrangement stays recognisable and the parent shrinks. With nothing folded
14
+ // the anchors are exactly the server's layout.
15
+ //
16
+ // The scene may hold room for what is not the board's: the discs the reader's visitors and their
17
+ // temporary clusters take (ViewSpec decision 27; web/src/visits.ts). What they cover makes way, as
18
+ // from a drop; they are never pins, and when they go everything is where it was.
19
+ import { Separator } from "../../shared/separate.js";
20
+ /** collision radius of a declaration, as the server packs them */
21
+ export const LEAF_R = 5;
22
+ const GAP = 2.5;
23
+ /** easing time constant for folding and disc radii, ms (positions move by motion.ts) */
24
+ const TAU = 110;
25
+ export class Scene {
26
+ layout;
27
+ n;
28
+ nc;
29
+ parent;
30
+ nodeCluster;
31
+ kidsC;
32
+ kidsN;
33
+ rootC = [];
34
+ rootN = [];
35
+ homeCX;
36
+ homeCY;
37
+ homeR;
38
+ homeNX;
39
+ homeNY;
40
+ /** where the reader dropped things, local */
41
+ pinC = new Map();
42
+ pinN = new Map();
43
+ /** body id (cluster c, node nc + i) → when it was dropped; later drops win where drops overlap */
44
+ dropSeq = new Map();
45
+ dropNext = 1;
46
+ /** clusters whose children must be arranged again (something in them moved, folded or grew) */
47
+ dirty;
48
+ rootDirty = false;
49
+ separator = new Separator(2 * LEAF_R + GAP);
50
+ /** room held in a cluster (-1: the top level) for what is not the board's, in its coordinates:
51
+ * the discs of the reader's visitors (setHeld) */
52
+ held = new Map();
53
+ /** where each held disc is after making way among the others (x, y pairs, as `held` orders them) */
54
+ heldAt = new Map();
55
+ /** what the reader asked to be folded */
56
+ folded = new Set();
57
+ /** folded and gathered: drawn as one disc. Lags `folded` while the contents flow in. */
58
+ shut = new Set();
59
+ /** cluster → outermost shut cluster containing it (itself included), or -1 */
60
+ rep;
61
+ /** node → shut cluster hiding it, or -1 */
62
+ nodeRep;
63
+ ancCX;
64
+ ancCY;
65
+ ancNX;
66
+ ancNY;
67
+ /** radius the cluster needs now, given what inside it is folded */
68
+ effR;
69
+ curCX;
70
+ curCY;
71
+ curNX;
72
+ curNY;
73
+ /** radius easing towards effR (the home radius, or what folding made it) */
74
+ curR;
75
+ /** the circle as drawn: never smaller than what it holds needs right now (CanvasSpec,
76
+ * "circles mean containment"); recomputed with the world positions */
77
+ drawnR;
78
+ /** how far a cluster's contents are spread: 1 open, 0 gathered into its origin */
79
+ bloom;
80
+ bloomTo;
81
+ /** world positions and the scale a cluster's local frame is drawn at */
82
+ cwx;
83
+ cwy;
84
+ nwx;
85
+ nwy;
86
+ scale;
87
+ /** something is still easing or moving */
88
+ moving = false;
89
+ /** set by the canvas: moves bodies towards their anchors, elastically */
90
+ motion = null;
91
+ /** bumped whenever which clusters are shut changes, so the canvas can follow */
92
+ repVersion = 0;
93
+ constructor(layout) {
94
+ this.layout = layout;
95
+ const nc = (this.nc = layout.clusters.length);
96
+ const n = (this.n = layout.x.length);
97
+ this.parent = Int32Array.from(layout.clusters.map((c) => c.parent));
98
+ this.nodeCluster = Int32Array.from(layout.cluster);
99
+ this.kidsC = layout.clusters.map(() => []);
100
+ this.kidsN = layout.clusters.map(() => []);
101
+ layout.clusters.forEach((c, i) => (c.parent >= 0 ? this.kidsC[c.parent].push(i) : this.rootC.push(i)));
102
+ layout.cluster.forEach((c, i) => (c >= 0 ? this.kidsN[c].push(i) : this.rootN.push(i)));
103
+ this.homeCX = Float32Array.from(layout.clusters.map((c) => c.lx));
104
+ this.homeCY = Float32Array.from(layout.clusters.map((c) => c.ly));
105
+ this.homeR = Float32Array.from(layout.clusters.map((c) => c.r));
106
+ this.homeNX = Float32Array.from(layout.lx);
107
+ this.homeNY = Float32Array.from(layout.ly);
108
+ this.ancCX = this.homeCX.slice();
109
+ this.ancCY = this.homeCY.slice();
110
+ this.ancNX = this.homeNX.slice();
111
+ this.ancNY = this.homeNY.slice();
112
+ this.effR = this.homeR.slice();
113
+ this.curCX = this.homeCX.slice();
114
+ this.curCY = this.homeCY.slice();
115
+ this.curNX = this.homeNX.slice();
116
+ this.curNY = this.homeNY.slice();
117
+ this.curR = this.homeR.slice();
118
+ this.drawnR = this.homeR.slice();
119
+ this.bloom = new Float32Array(nc).fill(1);
120
+ this.bloomTo = new Float32Array(nc).fill(1);
121
+ this.cwx = new Float32Array(nc);
122
+ this.cwy = new Float32Array(nc);
123
+ this.nwx = new Float32Array(n);
124
+ this.nwy = new Float32Array(n);
125
+ this.scale = new Float32Array(nc).fill(1);
126
+ this.rep = new Int32Array(nc).fill(-1);
127
+ this.nodeRep = new Int32Array(n).fill(-1);
128
+ this.dirty = new Uint8Array(nc);
129
+ this.computeWorld();
130
+ }
131
+ // ---------------------------------------------------------------- state from outside
132
+ /** Pins and folds by name, as saved; applied at once, without easing. */
133
+ restore(pins, foldedKeys, names) {
134
+ const byKey = new Map(this.layout.clusters.map((c, i) => [c.key, i]));
135
+ for (const [key, p] of Object.entries(pins.clusters)) {
136
+ const c = byKey.get(key);
137
+ if (c !== undefined)
138
+ this.pinC.set(c, p);
139
+ }
140
+ const byName = new Map();
141
+ for (let i = 0; i < this.n; i++)
142
+ byName.set(names(i), i);
143
+ for (const [name, p] of Object.entries(pins.nodes)) {
144
+ const i = byName.get(name);
145
+ if (i !== undefined)
146
+ this.pinN.set(i, p);
147
+ }
148
+ for (const k of foldedKeys) {
149
+ const c = byKey.get(k);
150
+ if (c !== undefined) {
151
+ this.folded.add(c);
152
+ this.shut.add(c);
153
+ this.bloom[c] = this.bloomTo[c] = 0;
154
+ }
155
+ }
156
+ // places saved under an earlier layout (or before drags were confined) may lie outside the
157
+ // parent: they are brought in, as a drag to them would have been
158
+ for (const [c, [x, y]] of this.pinC)
159
+ this.pinC.set(c, this.inside(this.parent[c], this.homeSize(c), x, y));
160
+ for (const [i, [x, y]] of this.pinN)
161
+ this.pinN.set(i, this.inside(this.nodeCluster[i], LEAF_R, x, y));
162
+ for (const c of this.pinC.keys())
163
+ this.dropSeq.set(c, this.dropNext++);
164
+ for (const i of this.pinN.keys())
165
+ this.dropSeq.set(this.nc + i, this.dropNext++);
166
+ this.computeRep();
167
+ this.computeAnchors(true);
168
+ this.snap();
169
+ }
170
+ /** Pins as another reader left them (CollabSpec decision 2): what changed is set, what is gone
171
+ * is let go, in the order given (later drops win); the rest glides to its new place. True
172
+ * when anything changed. */
173
+ applyPins(pins, names) {
174
+ const byKey = new Map(this.layout.clusters.map((c, i) => [c.key, i]));
175
+ const byName = new Map();
176
+ for (let i = 0; i < this.n; i++)
177
+ byName.set(names(i), i);
178
+ const wantC = new Map();
179
+ for (const [key, [x, y]] of Object.entries(pins.clusters)) {
180
+ const c = byKey.get(key);
181
+ if (c !== undefined)
182
+ wantC.set(c, this.inside(this.parent[c], this.homeSize(c), x, y));
183
+ }
184
+ const wantN = new Map();
185
+ for (const [name, [x, y]] of Object.entries(pins.nodes)) {
186
+ const i = byName.get(name);
187
+ if (i !== undefined)
188
+ wantN.set(i, this.inside(this.nodeCluster[i], LEAF_R, x, y));
189
+ }
190
+ const same = (a, b) => !!a && Math.abs(a[0] - b[0]) < 0.05 && Math.abs(a[1] - b[1]) < 0.05;
191
+ let changed = false;
192
+ for (const c of [...this.pinC.keys()])
193
+ if (!wantC.has(c)) {
194
+ this.pinC.delete(c);
195
+ this.touch(this.parent[c]);
196
+ changed = true;
197
+ }
198
+ for (const i of [...this.pinN.keys()])
199
+ if (!wantN.has(i)) {
200
+ this.pinN.delete(i);
201
+ this.touch(this.nodeCluster[i]);
202
+ changed = true;
203
+ }
204
+ for (const [c, p] of wantC) {
205
+ if (same(this.pinC.get(c), p))
206
+ continue;
207
+ this.pinC.set(c, p);
208
+ this.dropSeq.set(c, this.dropNext++);
209
+ this.touch(this.parent[c]);
210
+ changed = true;
211
+ }
212
+ for (const [i, p] of wantN) {
213
+ if (same(this.pinN.get(i), p))
214
+ continue;
215
+ this.pinN.set(i, p);
216
+ this.dropSeq.set(this.nc + i, this.dropNext++);
217
+ this.touch(this.nodeCluster[i]);
218
+ changed = true;
219
+ }
220
+ if (!changed)
221
+ return false;
222
+ this.computeAnchors();
223
+ this.motion?.wakeDisplaced();
224
+ this.moving = true;
225
+ return true;
226
+ }
227
+ pins(names) {
228
+ const out = { nodes: {}, clusters: {} };
229
+ const r = (v) => Math.round(v * 10) / 10;
230
+ // in the order they were dropped, so that a reload keeps which of two overlapping drops wins
231
+ const when = (id) => this.dropSeq.get(id) ?? 0;
232
+ for (const c of [...this.pinC.keys()].sort((a, b) => when(a) - when(b))) {
233
+ const [x, y] = this.pinC.get(c);
234
+ out.clusters[this.layout.clusters[c].key] = [r(x), r(y)];
235
+ }
236
+ for (const i of [...this.pinN.keys()].sort((a, b) => when(this.nc + a) - when(this.nc + b))) {
237
+ const [x, y] = this.pinN.get(i);
238
+ out.nodes[names(i)] = [r(x), r(y)];
239
+ }
240
+ return out;
241
+ }
242
+ foldedKeys() {
243
+ return [...this.folded].map((c) => this.layout.clusters[c].key);
244
+ }
245
+ /** Fold or unfold; the contents flow in or out, the neighbours make or take the room. */
246
+ setFolded(next) {
247
+ for (const c of [...this.folded]) {
248
+ if (next.has(c))
249
+ continue;
250
+ // unfold: open at once, then let the contents grow out of the origin
251
+ this.folded.delete(c);
252
+ this.shut.delete(c);
253
+ this.bloomTo[c] = 1;
254
+ this.touch(this.parent[c]);
255
+ }
256
+ for (const c of next) {
257
+ if (this.folded.has(c))
258
+ continue;
259
+ this.folded.add(c);
260
+ this.bloomTo[c] = 0;
261
+ this.touch(this.parent[c]);
262
+ }
263
+ this.computeRep();
264
+ this.computeAnchors();
265
+ this.motion?.wakeDisplaced();
266
+ this.moving = true;
267
+ }
268
+ /** Drop a cluster at a world position: it becomes its home, its subtree comes along. It stays
269
+ * inside its parent's circle: dragged further, it follows the pointer along the edge. */
270
+ placeCluster(c, wx, wy) {
271
+ const p = this.parent[c];
272
+ const s = p >= 0 ? this.scale[p] : 1;
273
+ const [lx, ly] = this.inside(p, this.rho(c), (wx - (p >= 0 ? this.cwx[p] : 0)) / s, (wy - (p >= 0 ? this.cwy[p] : 0)) / s);
274
+ if (this.motion && this.motion.drag !== c)
275
+ this.motion.dragFrom = [this.ancCX[c], this.ancCY[c]];
276
+ this.pinC.set(c, [lx, ly]);
277
+ this.dropSeq.set(c, this.dropNext++);
278
+ this.ancCX[c] = this.curCX[c] = lx;
279
+ this.ancCY[c] = this.curCY[c] = ly;
280
+ this.touch(p);
281
+ // while the pointer holds it, collisions make way (motion.ts); the arrangement follows on release
282
+ if (!this.motion)
283
+ this.computeAnchors();
284
+ if (this.motion) {
285
+ this.motion.drag = c;
286
+ this.motion.wakeBody(c);
287
+ this.motion.wakeDisplaced();
288
+ }
289
+ this.moving = true;
290
+ }
291
+ placeNode(i, wx, wy) {
292
+ const c = this.nodeCluster[i];
293
+ const s = c >= 0 ? this.scale[c] : 1;
294
+ const [lx, ly] = this.inside(c, LEAF_R, (wx - (c >= 0 ? this.cwx[c] : 0)) / s, (wy - (c >= 0 ? this.cwy[c] : 0)) / s);
295
+ if (this.motion && this.motion.drag !== this.nc + i)
296
+ this.motion.dragFrom = [this.ancNX[i], this.ancNY[i]];
297
+ this.pinN.set(i, [lx, ly]);
298
+ this.dropSeq.set(this.nc + i, this.dropNext++);
299
+ this.ancNX[i] = this.curNX[i] = lx;
300
+ this.ancNY[i] = this.curNY[i] = ly;
301
+ this.touch(c);
302
+ if (!this.motion)
303
+ this.computeAnchors();
304
+ if (this.motion) {
305
+ this.motion.drag = this.nc + i;
306
+ this.motion.wakeBody(this.nc + i);
307
+ this.motion.wakeDisplaced();
308
+ }
309
+ this.moving = true;
310
+ }
311
+ /** The pointer let go: what was dragged stays as a pin, the rest springs back. */
312
+ endDrag() {
313
+ this.computeAnchors();
314
+ if (this.motion) {
315
+ this.motion.drag = null;
316
+ this.motion.dragFrom = null;
317
+ this.motion.wakeDisplaced();
318
+ }
319
+ this.moving = true;
320
+ }
321
+ unpinNode(i) {
322
+ this.pinN.delete(i);
323
+ this.touch(this.nodeCluster[i]);
324
+ this.computeAnchors();
325
+ this.motion?.wakeDisplaced();
326
+ this.moving = true;
327
+ }
328
+ unpinCluster(c) {
329
+ this.pinC.delete(c);
330
+ this.touch(this.parent[c]);
331
+ this.computeAnchors();
332
+ this.motion?.wakeDisplaced();
333
+ this.moving = true;
334
+ }
335
+ isShut(c) {
336
+ return this.shut.has(c);
337
+ }
338
+ /** Folded, or inside a folded cluster, as the reader asked (ahead of the animation). */
339
+ collapsed(c) {
340
+ for (let x = c; x >= 0; x = this.parent[x])
341
+ if (this.folded.has(x))
342
+ return true;
343
+ return false;
344
+ }
345
+ isPinned(i) {
346
+ return this.pinN.has(i);
347
+ }
348
+ /** Hold room in clusters for what is not the board's (ViewSpec decision 27): per cluster (-1: the
349
+ * top level), the discs the reader's visitors take there, in its coordinates, the later of two
350
+ * holding against the earlier. What they cover makes way; nothing of it is a pin, and an empty map
351
+ * gives every place back. `snap`: at once, for a scene just made. */
352
+ setHeld(next, snap = false) {
353
+ const same = (a, b) => a.length === b.length && a.every((d, k) => d.x === b[k].x && d.y === b[k].y && d.r === b[k].r);
354
+ let changed = false;
355
+ for (const c of new Set([...this.held.keys(), ...next.keys()])) {
356
+ if (same(this.held.get(c) ?? [], next.get(c) ?? []))
357
+ continue;
358
+ this.touch(c);
359
+ changed = true;
360
+ }
361
+ if (!changed)
362
+ return;
363
+ this.held = new Map([...next].filter(([, d]) => d.length).map(([c, d]) => [c, [...d]]));
364
+ for (const c of [...this.heldAt.keys()])
365
+ if (!this.held.has(c))
366
+ this.heldAt.delete(c);
367
+ this.computeAnchors();
368
+ if (snap)
369
+ return this.snap();
370
+ this.motion?.wakeDisplaced();
371
+ this.moving = true;
372
+ }
373
+ /** Whether room is held anywhere (setHeld). */
374
+ holds() {
375
+ return this.held.size > 0;
376
+ }
377
+ /** How far from a cluster's origin its contents may reach, leaving room for its name (Infinity
378
+ * for the top level, -1). */
379
+ roomOf(c) {
380
+ return c < 0 ? Infinity : this.homeR[c] - this.padOf(c);
381
+ }
382
+ /** A cluster's children (-1: the top level's) as discs at their anchors, in its coordinates: a
383
+ * sub-cluster the room it takes now, a declaration its own. */
384
+ discsIn(c) {
385
+ const out = [];
386
+ for (const k of c < 0 ? this.rootC : this.kidsC[c])
387
+ out.push({ x: this.ancCX[k], y: this.ancCY[k], r: this.rho(k) });
388
+ for (const i of c < 0 ? this.rootN : this.kidsN[c])
389
+ out.push({ x: this.ancNX[i], y: this.ancNY[i], r: LEAF_R });
390
+ return out;
391
+ }
392
+ /** A cluster's anchor in the world, as the board arranges it with nothing folded gathering it
393
+ * (-1: the origin): where a place in its coordinates is, whatever is folded now. */
394
+ anchorWorld(c) {
395
+ let x = 0, y = 0;
396
+ for (let k = c; k >= 0; k = this.parent[k]) {
397
+ x += this.ancCX[k];
398
+ y += this.ancCY[k];
399
+ }
400
+ return [x, y];
401
+ }
402
+ /** A local position for a body of radius `r` in cluster `p`, brought inside the room the
403
+ * cluster has at home: a drag never grows the circle it happens in, so circles keep meaning
404
+ * containment without one of them swallowing the canvas. At the top level, anywhere. */
405
+ inside(p, r, lx, ly) {
406
+ if (p < 0)
407
+ return [lx, ly];
408
+ const room = Math.max(0, this.homeR[p] - this.padOf(p) - r);
409
+ const d = Math.hypot(lx, ly);
410
+ return d > room ? [(lx * room) / d, (ly * room) / d] : [lx, ly];
411
+ }
412
+ /** Room between a cluster's contents and its circle, for its label. */
413
+ padOf(c) {
414
+ return 10 + Math.min(14, Math.sqrt(this.layout.clusters[c].count));
415
+ }
416
+ homeSize(c) {
417
+ return this.folded.has(c) ? this.bubbleR(c) : this.homeR[c];
418
+ }
419
+ /** Radius of a folded cluster's disc: grows with the square root of what it holds. */
420
+ bubbleR(c) {
421
+ return Math.min(this.homeR[c], 9 + 2.2 * Math.sqrt(this.layout.clusters[c].count));
422
+ }
423
+ // ---------------------------------------------------------------- the layout event
424
+ computeRep() {
425
+ this.repVersion++;
426
+ const nc = this.nc;
427
+ for (let c = 0; c < nc; c++) {
428
+ const p = this.parent[c];
429
+ const up = p >= 0 ? this.rep[p] : -1;
430
+ this.rep[c] = up >= 0 ? up : this.shut.has(c) ? c : -1;
431
+ }
432
+ for (let i = 0; i < this.n; i++) {
433
+ const c = this.nodeCluster[i];
434
+ this.nodeRep[i] = c >= 0 ? this.rep[c] : -1;
435
+ }
436
+ }
437
+ /** Room each child takes in its parent: a folded cluster its disc, an open one its extent. */
438
+ rho(c) {
439
+ return this.folded.has(c) ? this.bubbleR(c) : this.effR[c];
440
+ }
441
+ touch(c) {
442
+ if (c < 0)
443
+ this.rootDirty = true;
444
+ else
445
+ this.dirty[c] = 1;
446
+ }
447
+ /** Anchors for the children of every cluster marked, bottom-up (children's sizes first); a
448
+ * cluster whose size changed marks its parent. `all`: every cluster. */
449
+ computeAnchors(all = false) {
450
+ for (let c = this.nc - 1; c >= 0; c--) {
451
+ if (!all && !this.dirty[c])
452
+ continue;
453
+ this.dirty[c] = 0;
454
+ // the contents of a folded cluster keep their places; only its disc matters outside
455
+ const was = this.effR[c];
456
+ this.effR[c] = this.arrange(this.kidsC[c], this.kidsN[c], this.homeR[c], c);
457
+ if (Math.abs(this.effR[c] - was) > 1e-3)
458
+ this.touch(this.parent[c]);
459
+ }
460
+ if (all || this.rootDirty) {
461
+ this.rootDirty = false;
462
+ this.arrange(this.rootC, this.rootN, Infinity, -1);
463
+ }
464
+ }
465
+ /** Lay out one cluster's children; returns the radius the cluster needs. Room held in it
466
+ * (setHeld) is made after the board's own arrangement, so that it moves only what it covers. */
467
+ arrange(kc, kn, homeR, self) {
468
+ const R = this.arrangeBoard(kc, kn, homeR, self);
469
+ const held = this.held.get(self);
470
+ return held?.length ? this.makeWay(kc, kn, homeR, self, held, R) : R;
471
+ }
472
+ /** The board's own arrangement of one cluster's children; returns the radius the cluster needs. */
473
+ arrangeBoard(kc, kn, homeR, self) {
474
+ // Nothing changed size and nothing is pinned: the server's layout as it is.
475
+ const shrunk = kc.some((c) => this.rho(c) < this.homeR[c] - 0.5);
476
+ const grown = kc.some((c) => this.rho(c) > this.homeR[c] + 0.5);
477
+ const pinned = kc.some((c) => this.pinC.has(c)) || kn.some((i) => this.pinN.has(i));
478
+ if (!shrunk && !grown && !pinned) {
479
+ for (const c of kc) {
480
+ this.ancCX[c] = this.homeCX[c];
481
+ this.ancCY[c] = this.homeCY[c];
482
+ }
483
+ for (const i of kn) {
484
+ this.ancNX[i] = this.homeNX[i];
485
+ this.ancNY[i] = this.homeNY[i];
486
+ }
487
+ return homeR;
488
+ }
489
+ const items = [];
490
+ for (const c of kc) {
491
+ const pin = this.pinC.get(c);
492
+ const hx = pin ? pin[0] : this.homeCX[c], hy = pin ? pin[1] : this.homeCY[c];
493
+ // a cluster that grew because something was dropped far out inside it holds its place,
494
+ // like a pin: what was dropped stays under the pointer and its siblings make way
495
+ const holds = !!pin || this.rho(c) > this.homeR[c] + 0.5;
496
+ items.push({ c, i: -1, hx, hy, r: this.rho(c), pinned: holds, seq: pin ? this.dropSeq.get(c) ?? 1 : 0, x: hx, y: hy });
497
+ }
498
+ for (const i of kn) {
499
+ const pin = this.pinN.get(i);
500
+ const hx = pin ? pin[0] : this.homeNX[i], hy = pin ? pin[1] : this.homeNY[i];
501
+ items.push({ c: -1, i, hx, hy, r: LEAF_R, pinned: !!pin, seq: pin ? this.dropSeq.get(this.nc + i) ?? 1 : 0, x: hx, y: hy });
502
+ }
503
+ const DENSITY = 0.72;
504
+ let area = 0;
505
+ for (const it of (shrunk ? items : []).filter((x) => !x.pinned).sort((a, b) => a.hx * a.hx + a.hy * a.hy - (b.hx * b.hx + b.hy * b.hy))) {
506
+ const len = Math.hypot(it.hx, it.hy);
507
+ const ux = len > 1e-6 ? it.hx / len : 1, uy = len > 1e-6 ? it.hy / len : 0;
508
+ area += (Math.PI * (it.r + GAP / 2) ** 2) / DENSITY;
509
+ const d = Math.min(len, Math.max(0, Math.sqrt(area / Math.PI) - it.r));
510
+ it.x = ux * d;
511
+ it.y = uy * d;
512
+ }
513
+ // separate what overlaps
514
+ const n = items.length;
515
+ const B = {
516
+ n,
517
+ x: Float64Array.from(items, (it) => it.x),
518
+ y: Float64Array.from(items, (it) => it.y),
519
+ r: Float64Array.from(items, (it) => it.r),
520
+ pinned: Uint8Array.from(items, (it) => (it.pinned ? 1 : 0)),
521
+ seq: Float64Array.from(items, (it) => it.seq),
522
+ big: Int32Array.from(items.flatMap((it, k) => (it.r > LEAF_R + 0.1 ? [k] : []))),
523
+ small: Int32Array.from(items.flatMap((it, k) => (it.r > LEAF_R + 0.1 ? [] : [k]))),
524
+ };
525
+ // How far out a child may be pushed. Folding alone never pushes anything outwards: each child
526
+ // stays within its home distance, so the parent never grows because something inside it shrank.
527
+ // A drop inside a cluster keeps what it pushes within the cluster's room as well.
528
+ const inward = shrunk && !grown && !pinned;
529
+ const room = self >= 0 ? homeR - this.padOf(self) : Infinity;
530
+ const limit = Float64Array.from(items, (it) => {
531
+ const len = Math.hypot(it.hx, it.hy);
532
+ return inward ? len : Math.max(len, room - it.r);
533
+ });
534
+ const sep = this.separator;
535
+ // drops can stack up against each other: give them more rounds
536
+ const rounds = pinned ? 64 : 16;
537
+ sep.separate(B, rounds, limit);
538
+ if (sep.overlaps(B)) {
539
+ if (inward) {
540
+ // What still touches eases back towards the home layout, where nothing does (every child
541
+ // is there at its home size or smaller).
542
+ const fx = B.x.slice(), fy = B.y.slice();
543
+ const at = (t) => {
544
+ for (let k = 0; k < n; k++) {
545
+ B.x[k] = fx[k] + (items[k].hx - fx[k]) * t;
546
+ B.y[k] = fy[k] + (items[k].hy - fy[k]) * t;
547
+ }
548
+ };
549
+ let lo = 0, hi = 1;
550
+ for (let s = 0; s < 7; s++) {
551
+ const mid = (lo + hi) / 2;
552
+ at(mid);
553
+ if (sep.overlaps(B))
554
+ lo = mid;
555
+ else
556
+ hi = mid;
557
+ }
558
+ at(hi);
559
+ }
560
+ else {
561
+ // no room left inside: the circle grows rather than let two siblings overlap
562
+ sep.separate(B, rounds, null);
563
+ }
564
+ }
565
+ items.forEach((it, k) => {
566
+ it.x = B.x[k];
567
+ it.y = B.y[k];
568
+ });
569
+ let extent = 0;
570
+ for (const it of items) {
571
+ extent = Math.max(extent, Math.hypot(it.x, it.y) + it.r);
572
+ if (it.c >= 0) {
573
+ this.ancCX[it.c] = it.x;
574
+ this.ancCY[it.c] = it.y;
575
+ }
576
+ else {
577
+ this.ancNX[it.i] = it.x;
578
+ this.ancNY[it.i] = it.y;
579
+ }
580
+ }
581
+ if (self < 0)
582
+ return 0;
583
+ return Math.max(extent + this.padOf(self), this.bubbleR(self));
584
+ }
585
+ /** The room held in a cluster (setHeld), made in its children as they are arranged: each held
586
+ * disc stays where it was put, against the children (pins included: it is the latest drop) and
587
+ * the earlier held ones; what it covers makes way within the cluster's room, as from a drop, and
588
+ * only where that cannot fit does the circle grow. A disc put where the cluster has free room
589
+ * covers nothing and moves nothing. Returns the radius the cluster needs. */
590
+ makeWay(kc, kn, homeR, self, held, R) {
591
+ const nk = kc.length + kn.length;
592
+ const n = nk + held.length;
593
+ const x = new Float64Array(n), y = new Float64Array(n), r = new Float64Array(n);
594
+ const pinned = new Uint8Array(n), seq = new Float64Array(n);
595
+ let k = 0;
596
+ for (const c of kc) {
597
+ [x[k], y[k], r[k]] = [this.ancCX[c], this.ancCY[c], this.rho(c)];
598
+ pinned[k] = this.pinC.has(c) || this.rho(c) > this.homeR[c] + 0.5 ? 1 : 0;
599
+ seq[k++] = this.dropSeq.get(c) ?? 0;
600
+ }
601
+ for (const i of kn) {
602
+ [x[k], y[k], r[k]] = [this.ancNX[i], this.ancNY[i], LEAF_R];
603
+ pinned[k] = this.pinN.has(i) ? 1 : 0;
604
+ seq[k++] = this.dropSeq.get(this.nc + i) ?? 0;
605
+ }
606
+ // later than every drop there can be
607
+ held.forEach((d, j) => {
608
+ [x[k], y[k], r[k]] = [d.x, d.y, d.r];
609
+ pinned[k] = 1;
610
+ seq[k++] = 1e12 + j;
611
+ });
612
+ const idx = Array.from({ length: n }, (_, j) => j);
613
+ const B = { n, x, y, r, pinned, seq, big: Int32Array.from(idx.filter((j) => r[j] > LEAF_R + 0.1)), small: Int32Array.from(idx.filter((j) => r[j] <= LEAF_R + 0.1)) };
614
+ const room = self >= 0 ? homeR - this.padOf(self) : Infinity;
615
+ const limit = Float64Array.from(idx, (j) => Math.max(Math.hypot(x[j], y[j]), room - r[j]));
616
+ // how far the children reach before they make way: the circle grows by what they are pushed out
617
+ let reach = 0;
618
+ for (let j = 0; j < nk; j++)
619
+ reach = Math.max(reach, Math.hypot(x[j], y[j]) + r[j]);
620
+ // only what a held disc covers makes way: overlaps the board's own arrangement has are its own
621
+ // (a layout may hold some), and stay as they are where the held discs cover nothing
622
+ let covers = false;
623
+ for (let h = nk; h < n && !covers; h++)
624
+ for (let j = 0; j < n && !covers; j++)
625
+ if (j !== h && Math.hypot(x[j] - x[h], y[j] - y[h]) < r[j] + r[h] - 0.5)
626
+ covers = true;
627
+ const sep = this.separator;
628
+ if (covers) {
629
+ sep.separate(B, 64, limit);
630
+ // no room left inside: the circle grows rather than let two overlap
631
+ if (sep.overlaps(B))
632
+ sep.separate(B, 64, null);
633
+ }
634
+ k = 0;
635
+ for (const c of kc) {
636
+ this.ancCX[c] = x[k];
637
+ this.ancCY[c] = y[k++];
638
+ }
639
+ for (const i of kn) {
640
+ this.ancNX[i] = x[k];
641
+ this.ancNY[i] = y[k++];
642
+ }
643
+ const at = new Float64Array(2 * held.length);
644
+ for (let j = 0; j < held.length; j++)
645
+ [at[2 * j], at[2 * j + 1]] = [x[nk + j], y[nk + j]];
646
+ this.heldAt.set(self, at);
647
+ if (self < 0)
648
+ return 0;
649
+ let pushed = 0, own = 0;
650
+ for (let j = 0; j < n; j++) {
651
+ const d = Math.hypot(x[j], y[j]) + r[j];
652
+ if (j < nk)
653
+ pushed = Math.max(pushed, d);
654
+ else
655
+ own = Math.max(own, d);
656
+ }
657
+ return Math.max(R, R + pushed - reach, own + this.padOf(self), this.bubbleR(self));
658
+ }
659
+ // ---------------------------------------------------------------- easing and world positions
660
+ /** Jump to the anchors, for a scene that just appeared. */
661
+ snap() {
662
+ this.curCX.set(this.ancCX);
663
+ this.curCY.set(this.ancCY);
664
+ this.curNX.set(this.ancNX);
665
+ this.curNY.set(this.ancNY);
666
+ for (let c = 0; c < this.nc; c++)
667
+ this.curR[c] = this.shut.has(c) ? this.bubbleR(c) : this.effR[c];
668
+ this.bloom.set(this.bloomTo);
669
+ this.moving = false;
670
+ this.computeWorld();
671
+ }
672
+ /** Advance folding, disc radii and motion by dt ms; returns true while anything still moves. */
673
+ step(dt) {
674
+ const a = 1 - Math.exp(-dt / TAU);
675
+ let max = 0;
676
+ let committed = false;
677
+ for (let c = 0; c < this.nc; c++) {
678
+ const targetR = this.folded.has(c) ? this.bubbleR(c) : this.effR[c];
679
+ const dr = targetR - this.curR[c];
680
+ this.curR[c] += dr * a;
681
+ if (Math.abs(dr) > max)
682
+ max = Math.abs(dr);
683
+ const db = this.bloomTo[c] - this.bloom[c];
684
+ this.bloom[c] += db * a * 1.4;
685
+ if (Math.abs(db) > 0.004)
686
+ max = Math.max(max, 1);
687
+ // gathered enough: from now on it is one disc
688
+ if (this.bloomTo[c] === 0 && this.bloom[c] < 0.03 && !this.shut.has(c)) {
689
+ this.bloom[c] = 0;
690
+ this.shut.add(c);
691
+ committed = true;
692
+ }
693
+ }
694
+ if (committed) {
695
+ this.computeRep();
696
+ // what just went out of sight rests at its anchor, ready to bloom out again
697
+ for (let i = 0; i < this.n; i++) {
698
+ if (this.nodeRep[i] < 0)
699
+ continue;
700
+ this.curNX[i] = this.ancNX[i];
701
+ this.curNY[i] = this.ancNY[i];
702
+ }
703
+ }
704
+ if (this.motion) {
705
+ this.motion.step(dt);
706
+ }
707
+ else {
708
+ // no motion attached (tests): ease straight to the anchors
709
+ for (let c = 0; c < this.nc; c++) {
710
+ this.curCX[c] += (this.ancCX[c] - this.curCX[c]) * a;
711
+ this.curCY[c] += (this.ancCY[c] - this.curCY[c]) * a;
712
+ max = Math.max(max, Math.abs(this.ancCX[c] - this.curCX[c]), Math.abs(this.ancCY[c] - this.curCY[c]));
713
+ }
714
+ for (let i = 0; i < this.n; i++) {
715
+ this.curNX[i] += (this.ancNX[i] - this.curNX[i]) * a;
716
+ this.curNY[i] += (this.ancNY[i] - this.curNY[i]) * a;
717
+ max = Math.max(max, Math.abs(this.ancNX[i] - this.curNX[i]), Math.abs(this.ancNY[i] - this.curNY[i]));
718
+ }
719
+ }
720
+ this.computeWorld();
721
+ this.moving = max > 0.05 || (this.motion?.moving ?? false);
722
+ if (!this.moving && !this.motion)
723
+ this.snap();
724
+ return this.moving;
725
+ }
726
+ computeWorld() {
727
+ for (let c = 0; c < this.nc; c++) {
728
+ const p = this.parent[c];
729
+ if (p >= 0) {
730
+ const s = this.scale[p];
731
+ this.cwx[c] = this.cwx[p] + this.curCX[c] * s;
732
+ this.cwy[c] = this.cwy[p] + this.curCY[c] * s;
733
+ this.scale[c] = s * this.bloom[c];
734
+ }
735
+ else {
736
+ this.cwx[c] = this.curCX[c];
737
+ this.cwy[c] = this.curCY[c];
738
+ this.scale[c] = this.bloom[c];
739
+ }
740
+ }
741
+ for (let i = 0; i < this.n; i++) {
742
+ const c = this.nodeCluster[i];
743
+ if (c >= 0) {
744
+ const s = this.scale[c];
745
+ this.nwx[i] = this.cwx[c] + this.curNX[i] * s;
746
+ this.nwy[i] = this.cwy[c] + this.curNY[i] * s;
747
+ }
748
+ else {
749
+ this.nwx[i] = this.curNX[i];
750
+ this.nwy[i] = this.curNY[i];
751
+ }
752
+ }
753
+ this.computeExtents();
754
+ }
755
+ /** Every open cluster's circle encloses what it holds, as drawn now: bottom-up, children's
756
+ * circles first. A shut cluster is its bubble. */
757
+ computeExtents() {
758
+ const MARGIN = 6;
759
+ for (let c = this.nc - 1; c >= 0; c--) {
760
+ if (this.rep[c] === c) {
761
+ this.drawnR[c] = this.curR[c];
762
+ continue;
763
+ }
764
+ if (this.rep[c] >= 0)
765
+ continue;
766
+ let reach = 0;
767
+ const x = this.cwx[c], y = this.cwy[c];
768
+ for (const k of this.kidsC[c])
769
+ reach = Math.max(reach, Math.hypot(this.cwx[k] - x, this.cwy[k] - y) + this.drawnR[k]);
770
+ for (const i of this.kidsN[c]) {
771
+ if (this.nodeRep[i] >= 0)
772
+ continue;
773
+ reach = Math.max(reach, Math.hypot(this.nwx[i] - x, this.nwy[i] - y) + LEAF_R);
774
+ }
775
+ // what is held in it is inside it too: a visitor's temporary clusters (setHeld)
776
+ const held = this.held.get(c), at = this.heldAt.get(c);
777
+ if (held && at)
778
+ for (let j = 0; j < held.length; j++)
779
+ reach = Math.max(reach, (Math.hypot(at[2 * j], at[2 * j + 1]) + held[j].r) * this.scale[c]);
780
+ this.drawnR[c] = Math.max(this.curR[c], reach > 0 ? reach + MARGIN : 0);
781
+ }
782
+ }
783
+ /** The world scale a cluster's contents are drawn at (below 1 while it gathers or blooms). */
784
+ scaleOf(c) {
785
+ return this.scale[c];
786
+ }
787
+ }