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