visualproof 0.2.0 → 0.3.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 (59) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  3. package/dist/assets/webfonts/metrics.json +1 -0
  4. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  5. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  6. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  7. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  8. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  9. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  10. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  11. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  12. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  13. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  14. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  15. package/dist/server/agent.js +84 -86
  16. package/dist/server/agentdocs.js +1 -1
  17. package/dist/server/artcmd.js +335 -0
  18. package/dist/server/live.js +6 -0
  19. package/dist/server/main.js +136 -8
  20. package/dist/server/mcptools.js +4 -1
  21. package/dist/server/pipeline.js +15 -9
  22. package/dist/server/readonlygate.js +1 -1
  23. package/dist/server/review.js +4 -1
  24. package/dist/server/reviewapi.js +35 -1
  25. package/dist/server/snapshot.js +10 -1
  26. package/dist/server/view.js +0 -7
  27. package/dist/server/views.js +24 -0
  28. package/dist/shared/artfonts.js +81 -0
  29. package/dist/shared/artrecipe.js +316 -0
  30. package/dist/shared/collab.js +7 -2
  31. package/dist/shared/declfind.js +95 -0
  32. package/dist/shared/lostfound.js +1 -0
  33. package/dist/shared/pastgraph.js +6 -6
  34. package/dist/shared/review.js +62 -5
  35. package/dist/web/bundle/app.js +713 -164
  36. package/dist/web/bundle/edges.js +1 -1
  37. package/dist/web/src/agentmark.js +14 -0
  38. package/dist/web/src/artwork.js +1000 -0
  39. package/dist/web/src/bundles.js +151 -0
  40. package/dist/web/src/canvas.js +3694 -0
  41. package/dist/web/src/drag.js +61 -0
  42. package/dist/web/src/edgequeue.js +204 -0
  43. package/dist/web/src/gl/gl.js +123 -0
  44. package/dist/web/src/gl/renderer.js +451 -0
  45. package/dist/web/src/halo.js +95 -0
  46. package/dist/web/src/highlight.js +108 -0
  47. package/dist/web/src/overview.js +393 -0
  48. package/dist/web/src/reviewqueue.js +250 -0
  49. package/dist/web/src/selection.js +272 -0
  50. package/dist/web/src/status.js +80 -0
  51. package/dist/web/src/visitors.js +318 -0
  52. package/dist/web/src/visits.js +174 -0
  53. package/dist/web/style.css +7 -0
  54. package/docs/agent-api/README.md +1 -1
  55. package/docs/agent-api/export.md +41 -2
  56. package/docs/agent-api/history.md +2 -1
  57. package/docs/agent-api/mcp.md +1 -1
  58. package/docs/agent-api/reading.md +2 -1
  59. package/package.json +3 -1
@@ -0,0 +1,3694 @@
1
+ // The canvas: every node as a point, the active ones as cards. Shapes are drawn with WebGL
2
+ // (gl/renderer.ts), text on a 2D overlay, cards are DOM elements laid over both. Positions come
3
+ // from the scene graph (scene.ts): local to each cluster, summed into world positions.
4
+ //
5
+ // The reader's visitors (ViewSpec decision 27; web/src/visitors.ts, web/src/visits.ts) —
6
+ // declarations the board does not hold, gone to from here — are shown in their own places in the
7
+ // cluster hierarchy: in the board's cluster, or in temporary clusters (dashed, named in italics) the
8
+ // scene holds room for. Their edges are bundled through those clusters as any edge is; the points and
9
+ // the names are drawn on the 2D overlay. The layout, its memory and the pins never see them, and the
10
+ // folds the reader's selected visitor opens are the reader's alone.
11
+ import { SITE_BODY, SITE_STMT } from "../../shared/graph.js";
12
+ import { rgba } from "./gl/gl.js";
13
+ import { FLAG_DASHED, FLAG_GLOW, FLAG_HALO, FLAG_RADIAL, FLAG_SHADED, Renderer, UNIT_DOT, UNIT_WORLD, bundle, bundlePath, circle, segment } from "./gl/renderer.js";
14
+ import { joinChains, pathOf } from "./bundles.js";
15
+ import { EdgeQueue } from "./edgequeue.js";
16
+ import { DIM, aim, blendPoints, ease, weightGap, zoomPoint } from "./overview.js";
17
+ import { MODE_SITES } from "../../shared/viewgraph.js";
18
+ import { escapeHtml } from "./highlight.js";
19
+ import { ART_DISPLAY, ART_SANS, ART_SERIF, spacedWidth, writeNames } from "./halo.js";
20
+ import { artNameId } from "../../shared/collab.js";
21
+ import { dragKeyHeld, dragMoves, menuClick } from "./drag.js";
22
+ import { Motion } from "./motion.js";
23
+ import { ST_APPROVED, ST_BUILDING, ST_COMMENTED, ST_FAILED, ST_SORRY, ST_TRUSTED, ST_UNREVIEWED, STATUSES } from "./status.js";
24
+ import { LEAF_R, Scene } from "./scene.js";
25
+ import { markWords, PAST_CHANGED, PAST_GHOST, PAST_RENAMED } from "./past.js";
26
+ import { clearOfBoxes } from "./visitors.js";
27
+ import { openFor, placeVisits, planOn, visitChain, visitWorld } from "./visits.js";
28
+ import { fitFrame } from "../../shared/ring.js";
29
+ import { spotBetween } from "../../shared/doc/order.js";
30
+ import { ZOOM_LEAST, ZOOM_MOST } from "../../shared/sticky.js";
31
+ export const KINDS = [
32
+ "def", "abbrev", "structure", "class", "inductive", "instance", "theorem", "opaque", "axiom", "notation",
33
+ ];
34
+ /** the cells' territories' hues, a top-level namespace's each by its size, the outside world's apart */
35
+ const CELL_HUES = ["#62a8ff", "#7fe0a6", "#ffc46b", "#ff7d92", "#62dada", "#b994ff", "#f0a982", "#cbdc66"];
36
+ const CELL_OUTSIDE = "#8a93a6";
37
+ /** the starfield's lines: its constellations' figures and its clusters' bounds */
38
+ const STAR_LINE = "rgba(175, 198, 255, 1)";
39
+ /** How an artwork writes its names, by its style (ExportSpec decision 19, the owner's decisions of
40
+ * 2026-10-15): a cluster's along the top of its circle where it fits, a top-level one in spaced
41
+ * capitals of Jost — the title's face, in every style — the others in italics; in Source Sans 3,
42
+ * or the starfield's in Source Serif 4. */
43
+ function artText(style, th) {
44
+ const serif = style === "stars";
45
+ const fam = serif ? ART_SERIF : ART_SANS;
46
+ return {
47
+ /** the halo's colour: the theme's ground, or the style's (artwork.ts, drawBackdrop) */
48
+ halo: style === "cells" ? "#0f1520" : style === "stars" ? "#090d1a" : null,
49
+ clusterFill: style === "board" ? th.label : style === "cells" ? "rgba(226, 232, 246, 0.85)" : "rgba(238, 228, 200, 0.88)",
50
+ declFill: style === "board" ? th.text : style === "cells" ? "rgba(205, 214, 232, 0.8)" : "rgba(206, 216, 236, 0.76)",
51
+ top: (size) => `${serif ? 400 : 500} ${size}px ${ART_DISPLAY}`,
52
+ sub: (size) => `italic 400 ${size}px ${fam}`,
53
+ decl: serif ? `italic 400 11.5px ${ART_SERIF}` : `400 11px ${ART_SANS}`,
54
+ target: serif ? `italic 600 12px ${ART_SERIF}` : `600 11px ${ART_SANS}`,
55
+ spacing: (top, size) => size * (top ? 0.2 : 0.03),
56
+ };
57
+ }
58
+ /** A cluster can be pointed at when its circle is at least this large on screen; declarations
59
+ * when they are at least NODE_MIN_PX apart. Below that the enclosing cluster is the target. */
60
+ const CLUSTER_MIN_PX = 20;
61
+ const NODE_MIN_PX = 6;
62
+ /** a press becomes a drag only after this much movement, so a click never moves anything */
63
+ const DRAG_PX = 6;
64
+ /** a second click on a cluster within this long, and as near as a drag starts, is a double-click */
65
+ const DOUBLE_MS = 450;
66
+ /** how long a finger held still on the board opens the menu there, ms */
67
+ const HOLD_MS = 500;
68
+ function readTheme() {
69
+ const cs = getComputedStyle(document.body);
70
+ return themeOf((name) => cs.getPropertyValue(name).trim());
71
+ }
72
+ /** The board's colours from the page's custom properties as `v` reads them: the browser's (the theme
73
+ * on show), or the style sheet's read without one (the artwork command, ExportSpec decision 21:
74
+ * web/src/artwork.ts, cssTheme). */
75
+ export function themeOf(v) {
76
+ const kind = {};
77
+ for (const k of KINDS)
78
+ kind[k] = rgba(v(`--k-${k}`));
79
+ return {
80
+ bg: rgba(v("--canvas")),
81
+ disc: rgba(v("--disc")),
82
+ discLine: rgba(v("--disc-line")),
83
+ label: v("--label"),
84
+ text: v("--text-2"),
85
+ pin: rgba(v("--label")),
86
+ edgeFrom: rgba(v("--edge-from")),
87
+ edgeTo: rgba(v("--edge-to")),
88
+ kind,
89
+ ext: rgba(v("--k-external")),
90
+ lit: rgba(v("--accent")),
91
+ status: STATUSES.map((st) => rgba(v(`--status-${st}`))),
92
+ star: v("--target-star"),
93
+ starEdge: v("--target-star-edge"),
94
+ key: v("--key-mark"),
95
+ accent: v("--accent"),
96
+ tmGone: rgba(v("--tm-gone")),
97
+ tmChanged: rgba(v("--tm-changed")),
98
+ tmRenamed: rgba(v("--tm-renamed")),
99
+ };
100
+ }
101
+ /** Whether two things the pointer can be on are the same one. */
102
+ function sameTarget(a, b) {
103
+ if (a.kind !== b.kind)
104
+ return false;
105
+ if (a.kind === "node")
106
+ return a.i === b.i;
107
+ if (a.kind === "cluster")
108
+ return a.c === b.c;
109
+ return a.k === b.k;
110
+ }
111
+ /** A colour at `k` of its opacity. */
112
+ const fade = (c, k) => [c[0], c[1], c[2], c[3] * k];
113
+ /** How many lit declarations the board gives a card at zoom `k`: a card's name is what an artwork
114
+ * writes in its place (it has no cards). */
115
+ const cardCap = (k) => (k < 0.12 ? 0 : k < 0.35 ? 40 : k < 0.8 ? 90 : CARD_CAP);
116
+ export const FONT = `-apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", sans-serif`;
117
+ const CELL = 24;
118
+ const CARD_CAP = 180;
119
+ /** position ids kept past the scene's for the visitors and their temporary clusters, whose edges are
120
+ * bundled through them (web/src/visits.ts, visitChain) */
121
+ const VISIT_SLOTS = 256;
122
+ /** A file cluster's own build trouble, as a Status code (failed, building, sorry), or -1: what its
123
+ * outline shows in the Status scheme. Trouble inherited from below (blocked, out of date because
124
+ * something under it changed) is left out, so that one failure does not ring every file above it. */
125
+ function troubleOf(f) {
126
+ switch (f?.state) {
127
+ case "failed":
128
+ case "unbuilt":
129
+ return ST_FAILED;
130
+ case "building":
131
+ return ST_BUILDING;
132
+ case "sorry":
133
+ return ST_SORRY;
134
+ default:
135
+ return -1;
136
+ }
137
+ }
138
+ /** A late zoom point is caught up at up to this many times the zoom's own pace: the drawn blend moves
139
+ * by at most CATCH_UP · |Δz| of weight in a frame where the camera zoomed by Δz zoom points. */
140
+ const CATCH_UP = 2;
141
+ /** The camera's zoom has come to rest once it has not changed for this long, ms: what is drawn is
142
+ * then settled to the camera's own blend, there being no zoom left to ease along. */
143
+ const ZOOM_REST = 60;
144
+ /** A fast zoom is asked for where it will be by the time the answer comes, at most this many frames
145
+ * ahead. */
146
+ const LEAD = 6;
147
+ export class GraphCanvas {
148
+ canvas;
149
+ gl;
150
+ labels;
151
+ ctx;
152
+ cardsEl;
153
+ dpr = 1;
154
+ w = 0;
155
+ h = 0;
156
+ theme;
157
+ g;
158
+ ev;
159
+ layout;
160
+ scene;
161
+ cam = { x: 0, y: 0, k: 1 };
162
+ /** where the camera animation in progress ends */
163
+ camGoal = null;
164
+ /** screen space covered by panels; fitting and flying aim at the area between them */
165
+ insets = { left: 0, right: 0, top: 0, bottom: 0 };
166
+ /** whose edges a selection draws (selection.ts, edgeMode) */
167
+ mode = "tcb";
168
+ /** the declarations the selection's picks start from (setActivation): ringed, never a card */
169
+ focus = [];
170
+ active = null;
171
+ activeList = [];
172
+ /** active nodes inside each cluster, at any depth */
173
+ clusterActive = new Int32Array(0);
174
+ edges = [];
175
+ /** under the Keys step of Rests on, the key graph's edges among what is lit, as pairs (from, to)
176
+ * of nodes, drawn dashed in place of the references among it (setActivation); null otherwise */
177
+ keyEdges = null;
178
+ showExternals = true;
179
+ grid = new Map();
180
+ cards = new Map();
181
+ /** the layout is locked (the reader's, setLocked; the default since 2026-10-09): a drag pans the
182
+ * view, never moves a thing */
183
+ locked = true;
184
+ /** which of the places beside its declaration each card took last (SIDES), by card key */
185
+ cardSide = new Map();
186
+ /** the leads from lit declarations to their cards, this frame: x0, y0, x1, y1, node */
187
+ leaders = [];
188
+ /** where the cards of folded clusters are this frame, by cluster: a selected one's ring goes
189
+ * round its card */
190
+ clusterBoxes = new Map();
191
+ /** the clusters selected (setActivation): ringed, as the declarations selected are */
192
+ picked = [];
193
+ /** what the panel tells of — a declaration or a cluster — drawn with a lead to the panel instead
194
+ * of a card (setLead) */
195
+ lead = null;
196
+ leadEnd = () => null;
197
+ /** where the lead ended in the last frame: a lead whose end moves is drawn again */
198
+ leadWas = null;
199
+ /** measured card size */
200
+ cardSize = new Map();
201
+ dirty = true;
202
+ /** the instance buffers need rebuilding: content changed, not just the camera */
203
+ sceneDirty = true;
204
+ anim = null;
205
+ repSeen = -1;
206
+ /** each file cluster's own build trouble (troubleOf), for its outline in the Status scheme */
207
+ clusterTrouble = new Int8Array(0);
208
+ files = new Map();
209
+ /** how the layout on show clusters: a file's own build trouble outlines its cluster only when
210
+ * clusters are files (a namespace may share a module's name without being that file) */
211
+ clustering = "namespace";
212
+ /** how the points are coloured (setScheme): by kind, or by where each stands (CanvasSpec,
213
+ * "Colour schemes", decided 2026-10-08) */
214
+ scheme = "kind";
215
+ /** per node, its state in the Status scheme (web/src/status.ts), from setStatus */
216
+ statusCode = new Uint8Array(0);
217
+ /** per cluster, of what it holds that needs review: how many are approved, commented, and in
218
+ * all (the Status scheme's progress arcs) */
219
+ clusterProgress = new Int32Array(0);
220
+ /** the highlighted target: what a click or a drag acts on */
221
+ hoverTarget = null;
222
+ /** where the pointer is over the canvas (null when it is elsewhere), and whether it is down */
223
+ pointer = null;
224
+ pressing = false;
225
+ /** declaration names' widths at the label font, measured once per graph */
226
+ nameW = new Float32Array(0);
227
+ /** the review's targets, marked with a gold star (ReviewSpec) */
228
+ targetList = [];
229
+ /** key declarations, marked with a small diamond (ExportSpec decision 4) */
230
+ keyList = [];
231
+ /** of each of them, the targets it is key for (setKeys): an artwork's targets chosen keep their
232
+ * own keys' diamonds */
233
+ keyOf = [];
234
+ /** what other readers have selected (CollabSpec "On the canvas"): per node, their colours and
235
+ * initials */
236
+ peerMarks = new Map();
237
+ /** a past graph's marks per node (web/src/past.ts, PAST_*), and the names now of those renamed
238
+ * since; null on the graph of now */
239
+ pastMarks = null;
240
+ nowNames = new Map();
241
+ /** a preview of a view at another build than its own (web/src/views.ts): a drag pans, whatever the
242
+ * lock */
243
+ frozen = false;
244
+ /** the ring is the panel's preview, not the pointer's */
245
+ previewing = false;
246
+ /** the camera the highlight was last picked under */
247
+ hoverCam = { x: 0, y: 0, k: 1 };
248
+ /** edges with nothing selected: on by default; statements' and bodies', never proofs' (CanvasSpec
249
+ * "Edges") */
250
+ showEdges = true;
251
+ /** the artwork's look while its mode is on (ExportSpec decision 19): the board is the picture */
252
+ art = null;
253
+ /** the cells' hues of the layout's clusters (hueOf), and the use of the declaration used most
254
+ * (mostUsed) */
255
+ hues = null;
256
+ used = null;
257
+ /** of its names shown by selection, the clusters' keys and the declarations' full names */
258
+ artClusters = new Set();
259
+ artDecls = [];
260
+ /** the names a recipe's rule chose (ExportSpec decision 20): clusters by key, declarations */
261
+ artRuled = new Set();
262
+ artRuledDecls = [];
263
+ /** what the names the last frame wrote name (artNameId), on an artwork: what its list of names
264
+ * shows ticked */
265
+ namesWritten = new Set();
266
+ /** where the names and marks are drawn other than on the screen (an artwork's file): the transform
267
+ * from the screen's CSS pixels to that context's */
268
+ labelBase = null;
269
+ /** smoothed time between animation frames, ms; and the last draw's cost */
270
+ frameMs = 16;
271
+ drawMs = 0;
272
+ /** world positions of clusters then nodes, for the bundled edges */
273
+ posData = new Float32Array(0);
274
+ posDirty = true;
275
+ /** what the overview's points are valid for: the scene, what is shut, and the toggles */
276
+ overviewBase = "";
277
+ /** the scene on show, counted: a new one names other clusters, and what was drawn of the old goes */
278
+ sceneSerial = 0;
279
+ /** the graph whose edges the overview's chooser holds */
280
+ overviewGraph = null;
281
+ /** the overview's zoom points (a quarter of a step apart: what is chosen there, from the two steps
282
+ * blended as the point lies between them), chosen off the page's thread where a worker can be had
283
+ * (web/src/edgequeue.ts, web/src/edgeworker.ts; the owner's request, 2026-10-10) */
284
+ edgeQ;
285
+ /** what the overview draws: weights of zoom points. The camera between two points draws both,
286
+ * blended by where it is — so that zooming changes what is drawn as it goes, never at an instant —
287
+ * and while a point it needs is late, what is at hand, caught up with the zoom when it comes
288
+ * (drawOverview). Null until a base's first points come */
289
+ drawnW = null;
290
+ /** the camera's zoom point at the last frame; when its zoom last changed, which way and by how
291
+ * much */
292
+ overviewZ = NaN;
293
+ zoomAt = 0;
294
+ zoomDir = 0;
295
+ zoomLast = 0;
296
+ restTimer = null;
297
+ /** when the points were last chosen again for a view that moved, and the wait before the next */
298
+ overviewAt = 0;
299
+ overviewTimer = null;
300
+ /** what the points' looks were made for (the selection, the mode), counted as it changes (and at
301
+ * every rebuild of the scene's instances), and what was last drawn of them */
302
+ overviewStyled = "";
303
+ overviewStyle = 0;
304
+ overviewDrawn = "";
305
+ /** the reader's visitors (setVisitors), and those of them selected (ringed as a selected point is) */
306
+ visitors = [];
307
+ visitorSel = [];
308
+ /** the board's hidden levels the visitors were placed under, where they are placed, and where that
309
+ * is drawn this frame */
310
+ visitHidden = [];
311
+ visits = null;
312
+ visitW = null;
313
+ /** the board's clusters round the selected visitors' places, and those of them folded on the board
314
+ * that are open on this page for the reader (ViewSpec decision 27): the board's folds are the
315
+ * scene's and these */
316
+ openAround = new Set();
317
+ tempOpen = new Set();
318
+ /** drawn without a browser: no graphics card, no input, no frames but those asked for (`settle`) */
319
+ headless;
320
+ /** A board on the page's canvas; or, with `host` (and nulls for the elements), the same board drawn
321
+ * without a browser — its renderer kept to its data, which an artwork's SVG reads (ExportSpec
322
+ * decision 21). */
323
+ constructor(canvas, labels, cardsEl, g, ev, host) {
324
+ this.headless = !!host;
325
+ this.canvas = canvas;
326
+ this.gl = new Renderer(host ? null : canvas);
327
+ this.labels = labels;
328
+ this.ctx = host ? host.ctx : labels.getContext("2d");
329
+ // layers draw in this order
330
+ // (an artwork's: the starfield's figures under the edges, a glow under the points)
331
+ for (const [name, kind] of [["discs", "circles"], ["figures", "lines"], ["overview", "bundles"], ["selection", "bundles"], ["glow", "points"], ["dots", "points"],
332
+ ["rings", "points"], ["bubbles", "circles"], ["active", "points"], ["hoverC", "circles"], ["hoverP", "points"]])
333
+ this.gl.layer(name, kind);
334
+ this.cardsEl = cardsEl;
335
+ this.g = g;
336
+ this.ev = ev;
337
+ this.edgeQ = new EdgeQueue(() => (host || typeof Worker === "undefined" ? null : new Worker("/edges.js", { type: "module" })), () => this.request());
338
+ if (host) {
339
+ // a screen of its own, at one pixel each; the edges chosen at once (EdgeQueue, no worker)
340
+ this.theme = host.theme;
341
+ [this.w, this.h, this.dpr] = [host.w, host.h, 1];
342
+ return;
343
+ }
344
+ this.theme = readTheme();
345
+ // light or dark changed (the reader's choice, or the system's): the colours are read again
346
+ new MutationObserver(() => {
347
+ this.theme = readTheme();
348
+ this.invalidate();
349
+ }).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
350
+ new ResizeObserver(() => this.resize()).observe(this.canvas);
351
+ this.resize();
352
+ this.bindInput();
353
+ let last = performance.now();
354
+ const loop = (t) => {
355
+ const dt = Math.min(64, t - last);
356
+ this.frameMs = this.frameMs * 0.9 + (t - last) * 0.1;
357
+ last = t;
358
+ if (this.scene?.moving) {
359
+ this.scene.step(dt);
360
+ this.afterMotion();
361
+ }
362
+ if (this.dirty) {
363
+ this.dirty = false;
364
+ // the view moved under a resting pointer (fit, a flight to a search hit): what it points
365
+ // at changed with it
366
+ const c = this.cam;
367
+ if (this.pointer && (c.x !== this.hoverCam.x || c.y !== this.hoverCam.y || c.k !== this.hoverCam.k))
368
+ this.refreshHover();
369
+ this.hoverCam = { ...c };
370
+ this.draw();
371
+ }
372
+ requestAnimationFrame(loop);
373
+ };
374
+ requestAnimationFrame(loop);
375
+ }
376
+ request() {
377
+ this.dirty = true;
378
+ }
379
+ /** Without a browser (ExportSpec decision 21): draw until nothing more comes — the scene at rest,
380
+ * the overview's points chosen and blended, nothing asking for another frame — at most `most`
381
+ * frames; whether it came to rest. */
382
+ settle(most = 60) {
383
+ for (let n = 0; n < most; n++) {
384
+ if (this.scene?.moving) {
385
+ this.scene.step(64);
386
+ this.afterMotion();
387
+ }
388
+ this.dirty = false;
389
+ this.draw();
390
+ if (!this.dirty && !this.scene?.moving)
391
+ return true;
392
+ }
393
+ return false;
394
+ }
395
+ /** Called after every frame drawn: what lies over the canvas and follows it (the notes,
396
+ * web/src/sticky.ts) is placed again. */
397
+ onFrame = null;
398
+ /** the clusters' names as the last frame wrote them, each box in the world (left, top, right,
399
+ * bottom, four numbers a box) */
400
+ labelBoxes = [];
401
+ /** of those, where each open cluster's own name is (its first number's index) */
402
+ labelOfC = new Map();
403
+ /** what Fit to screen frames besides the board, at a zoom: the notes' icons, and with `cards` the
404
+ * cards of the notes that show expanded there, each its box on the screen (web/src/sticky.ts,
405
+ * fitBoxes) */
406
+ fitExtra = null;
407
+ /** The canvas element: what is laid over it sends it the wheel. */
408
+ get element() {
409
+ return this.canvas;
410
+ }
411
+ /** Content changed (not just the camera): rebuild the GPU instances before the next frame. */
412
+ invalidate() {
413
+ this.sceneDirty = true;
414
+ this.dirty = true;
415
+ }
416
+ // ------------------------------------------------------------------ positions, via the scene
417
+ /** The layout as the board draws it: each cluster and declaration at its place in its parent as
418
+ * the scene has it (its pin, or where pins and folds pushed it), not where the layout put it.
419
+ * Only the local places are the drawing's; the world ones are left as they were. What made way
420
+ * for the reader's visitors, and the folds opened for one, are not the board's (ViewSpec decision
421
+ * 27): then the board's own arrangement is taken from its pins and folds, as a reload has it. */
422
+ drawnLayout() {
423
+ const L = this.layout;
424
+ let s = this.scene;
425
+ if (s.holds() || this.tempOpen.size) {
426
+ s = new Scene(L);
427
+ s.restore(this.scene.pins((i) => this.pinName(i)), this.foldedKeys(), (i) => this.pinName(i));
428
+ }
429
+ return { ...L, lx: Array.from(s.ancNX), ly: Array.from(s.ancNY), clusters: L.clusters.map((c, k) => ({ ...c, lx: s.ancCX[k], ly: s.ancCY[k] })) };
430
+ }
431
+ get px() {
432
+ return this.scene.nwx;
433
+ }
434
+ get py() {
435
+ return this.scene.nwy;
436
+ }
437
+ /** The board's folds: the scene's, and those open on this page for the reader's selected visitor
438
+ * (ViewSpec decision 27), which are folded on the board all the same. */
439
+ get folded() {
440
+ return this.tempOpen.size ? new Set([...this.scene.folded, ...this.tempOpen]) : this.scene.folded;
441
+ }
442
+ get nodeRep() {
443
+ return this.scene.nodeRep;
444
+ }
445
+ isPinned(i) {
446
+ return this.scene.isPinned(i);
447
+ }
448
+ /** After the scene moved: redraw; when what is shut changed, what is drawn changed too. */
449
+ afterMotion() {
450
+ this.posDirty = true;
451
+ if (this.scene.repVersion !== this.repSeen) {
452
+ this.repSeen = this.scene.repVersion;
453
+ this.recomputeEdges();
454
+ this.clearCards();
455
+ }
456
+ if (!this.scene.moving) {
457
+ this.rebuildGrid();
458
+ // what came to rest under a still pointer is what it now points at
459
+ this.refreshHover();
460
+ }
461
+ this.invalidate();
462
+ }
463
+ // ------------------------------------------------------------------ graph, build, layout
464
+ /** A new graph for the same project: everything indexed by node starts over. setLayout
465
+ * follows. */
466
+ setGraph(g) {
467
+ this.g = g;
468
+ this.statusCode = new Uint8Array(g.n).fill(ST_UNREVIEWED);
469
+ this.nameW = new Float32Array(0);
470
+ this.focus = [];
471
+ this.picked = [];
472
+ this.active = null;
473
+ this.activeList = [];
474
+ this.edges = [];
475
+ this.keyEdges = null;
476
+ // a past graph's marks are set again for the graph they are of
477
+ this.pastMarks = null;
478
+ this.nowNames = new Map();
479
+ // a visitor's edges were of the graph before: the page gives them again (setVisitors)
480
+ this.visitors = this.visitors.map((v) => ({ ...v, board: [], visitors: [] }));
481
+ this.clearCards();
482
+ }
483
+ setBuild(files) {
484
+ this.files = new Map((files ?? []).map((f) => [f.module, f]));
485
+ this.recomputeStates();
486
+ this.clearCards();
487
+ this.invalidate();
488
+ }
489
+ /** Which nodes are the review's targets: each carries a gold star. */
490
+ setTargets(targets) {
491
+ this.targetList = targets;
492
+ this.clearCards();
493
+ this.invalidate();
494
+ }
495
+ /** Which nodes are key declarations: each carries a small diamond beside its point. `of`: the
496
+ * targets each is key for, in the same order. */
497
+ setKeys(keys, of = []) {
498
+ const same = (a, b) => !!b && a.length === b.length && a.every((k, j) => k === b[j]);
499
+ if (same(keys, this.keyList) && of.length === this.keyOf.length && of.every((ts, j) => same(ts, this.keyOf[j])))
500
+ return;
501
+ this.keyList = keys;
502
+ this.keyOf = of;
503
+ this.clearCards();
504
+ this.invalidate();
505
+ }
506
+ isKey(i) {
507
+ return this.keyList.includes(i);
508
+ }
509
+ /** Where each node of the canvas's graph stands (web/src/status.ts, statusCodes): its colour in
510
+ * the Status scheme, and the clusters' progress. */
511
+ setStatus(codes) {
512
+ this.statusCode = codes.length === this.g.n ? codes : new Uint8Array(this.g.n).fill(ST_UNREVIEWED);
513
+ this.countProgress();
514
+ if (this.scheme === "status")
515
+ this.clearCards();
516
+ this.invalidate();
517
+ }
518
+ /** Colour the points by kind or by status. Rings, the target's star and the others' marks are
519
+ * the same in both. */
520
+ setScheme(s) {
521
+ if (s === this.scheme)
522
+ return;
523
+ this.scheme = s;
524
+ this.clearCards();
525
+ this.invalidate();
526
+ }
527
+ /** A node's state in the Status scheme (an index into STATUSES). */
528
+ statusOf(i) {
529
+ return this.statusCode[i] ?? ST_UNREVIEWED;
530
+ }
531
+ /** What is on the board, counted for the legend: per state of the Status scheme (STATUSES) and
532
+ * per kind (KINDS, then the outside world). The outside world counts only while it is shown. */
533
+ counts() {
534
+ const status = new Array(STATUSES.length).fill(0);
535
+ const kinds = new Array(KINDS.length + 1).fill(0);
536
+ const g = this.g;
537
+ for (let i = 0; i < g.n; i++) {
538
+ const ext = g.isExt(i);
539
+ if (ext && !this.showExternals)
540
+ continue;
541
+ status[this.statusCode[i] ?? ST_UNREVIEWED]++;
542
+ const k = ext ? KINDS.length : KINDS.indexOf(g.nodes[i].kind);
543
+ kinds[k < 0 ? KINDS.length : k]++;
544
+ }
545
+ return { status, kinds };
546
+ }
547
+ /** Each cluster's progress, for its arc in the Status scheme: of what it holds that needs review
548
+ * (what is not trusted), how many are approved and how many commented. */
549
+ countProgress() {
550
+ if (!this.layout)
551
+ return;
552
+ const L = this.layout;
553
+ const P = (this.clusterProgress = new Int32Array(3 * L.clusters.length));
554
+ for (let i = 0; i < this.g.n; i++) {
555
+ const st = this.statusCode[i];
556
+ if (st === ST_TRUSTED || (this.g.isExt(i) && !this.showExternals))
557
+ continue;
558
+ const k = st === ST_APPROVED ? 0 : st === ST_COMMENTED ? 1 : -1;
559
+ for (let c = L.cluster[i]; c >= 0; c = L.clusters[c].parent) {
560
+ if (k >= 0)
561
+ P[3 * c + k]++;
562
+ P[3 * c + 2]++;
563
+ }
564
+ }
565
+ }
566
+ /** A past graph's marks (HistorySpec decision 17), in this graph's numbering: a ghost of what is
567
+ * gone now, a ring round what changed or was renamed since; the name now on a renamed one's card.
568
+ * Null on the graph of now. */
569
+ setPast(marks, now = new Map()) {
570
+ this.pastMarks = marks && marks.length === this.g.n ? marks : null;
571
+ this.nowNames = now;
572
+ this.clearCards();
573
+ this.invalidate();
574
+ }
575
+ /** A declaration's marks against now, or 0. */
576
+ pastMarkOf(i) {
577
+ return this.pastMarks?.[i] ?? 0;
578
+ }
579
+ /** The name now of a declaration renamed since, on a past graph. */
580
+ nowNameOf(i) {
581
+ return this.nowNames.get(i);
582
+ }
583
+ /** A preview, which nothing arranges: a drag pans, as on a locked layout, even with the drag key
584
+ * held. */
585
+ setFrozen(on) {
586
+ this.frozen = on;
587
+ }
588
+ /** Other readers' selections: a ring in each one's colour, their initials beside it. */
589
+ setPeers(marks) {
590
+ this.peerMarks.clear();
591
+ for (const m of marks) {
592
+ const e = this.peerMarks.get(m.i) ?? { colors: [], labels: [] };
593
+ e.colors.push(m.color);
594
+ e.labels.push(m.label);
595
+ this.peerMarks.set(m.i, e);
596
+ }
597
+ this.request();
598
+ }
599
+ isTarget(i) {
600
+ return this.targetList.includes(i);
601
+ }
602
+ fileOf(i) {
603
+ return this.g.isExt(i) ? undefined : this.files.get(this.g.nodes[i].module);
604
+ }
605
+ recomputeStates() {
606
+ if (!this.layout)
607
+ return;
608
+ this.clusterTrouble = new Int8Array(this.layout.clusters.length);
609
+ // in the file layout a cluster is a module (or a directory of them); under namespaces a
610
+ // cluster's key is a namespace, which can read like a module's name without being its file
611
+ if (this.clustering !== "module")
612
+ return;
613
+ this.layout.clusters.forEach((c, k) => (this.clusterTrouble[k] = troubleOf(this.files.get(c.key))));
614
+ }
615
+ setLayout(layout, places, foldedKeys, clustering) {
616
+ this.layout = layout;
617
+ this.clustering = clustering;
618
+ this.scene = new Scene(layout);
619
+ this.labelBoxes = [];
620
+ // the visitors on the new layout (ViewSpec decision 27): the folds round a selected one stay open
621
+ // and their room is held, at once
622
+ const plan = this.visitors.length ? planOn(layout, clustering, this.visitHidden, this.g, this.visitors) : null;
623
+ this.openAround = plan ? openFor(plan, this.scene.parent, this.visitorSel) : new Set();
624
+ const byKey = new Map(layout.clusters.map((c, i) => [c.key, i]));
625
+ this.tempOpen = new Set(foldedKeys.map((k) => byKey.get(k) ?? -1).filter((c) => this.openAround.has(c)));
626
+ this.scene.restore(places, foldedKeys.filter((k) => !this.tempOpen.has(byKey.get(k) ?? -1)), (i) => this.pinName(i));
627
+ const motion = new Motion(this.scene, this.g);
628
+ motion.near = (wx, wy, r) => this.nodesNear(wx, wy, r);
629
+ this.scene.motion = motion;
630
+ // a new layout (a mode switch, a live update): nothing is under the pointer any more
631
+ if (this.ev)
632
+ this.setHover(null, 0, 0);
633
+ this.posData = new Float32Array(2 * (this.scene.nc + this.scene.n + VISIT_SLOTS));
634
+ this.posDirty = true;
635
+ this.sceneSerial++;
636
+ this.visits = null;
637
+ if (plan)
638
+ this.placeOn(plan, true);
639
+ this.repSeen = this.scene.repVersion;
640
+ this.countActive();
641
+ this.recomputeStates();
642
+ this.countProgress();
643
+ this.rebuildGrid();
644
+ this.recomputeEdges();
645
+ this.clearCards();
646
+ this.invalidate();
647
+ }
648
+ pinsRecord() {
649
+ return this.scene.pins((i) => this.pinName(i));
650
+ }
651
+ /** Pins as the workspace now has them (another reader dropped or let go of something). */
652
+ applyPins(pins) {
653
+ if (this.scene.applyPins(pins, (i) => this.pinName(i)))
654
+ this.request();
655
+ }
656
+ /** The name a declaration's pin is kept under: its name, or on a past graph its name now where a
657
+ * rename since gave it one — a view's pins speak the names of now, as a move keeps them
658
+ * (HistorySpec decision 8), so that a pin dropped at a past build is the same pin at the latest. */
659
+ pinName(i) {
660
+ return this.nowNames.get(i) ?? this.g.nodes[i].name;
661
+ }
662
+ unpin(i) {
663
+ this.scene.unpinNode(i);
664
+ this.request();
665
+ }
666
+ foldedKeys() {
667
+ return [...this.folded].map((c) => this.layout.clusters[c].key);
668
+ }
669
+ /** Folds as the board now has them (another reader folded or opened something, ViewSpec
670
+ * decision 2): applied without foldChanged, which tells the others of this reader's own. True
671
+ * when anything changed. */
672
+ setFoldedKeys(keys) {
673
+ const byKey = new Map(this.layout.clusters.map((c, i) => [c.key, i]));
674
+ const next = new Set();
675
+ for (const k of keys) {
676
+ const c = byKey.get(k);
677
+ if (c !== undefined)
678
+ next.add(c);
679
+ }
680
+ const now = this.folded;
681
+ if (next.size === now.size && [...next].every((c) => now.has(c)))
682
+ return false;
683
+ this.foldScene(next);
684
+ return true;
685
+ }
686
+ /** Fold the scene as the board's folds `B` say, but for those open for the reader's selected
687
+ * visitor (ViewSpec decision 27): open on this page alone, folded on the board all the same. */
688
+ foldScene(B) {
689
+ this.tempOpen = new Set([...B].filter((c) => this.openAround.has(c)));
690
+ const next = new Set([...B].filter((c) => !this.tempOpen.has(c)));
691
+ const now = this.scene.folded;
692
+ if (next.size === now.size && [...next].every((c) => now.has(c)))
693
+ return;
694
+ this.scene.setFolded(next);
695
+ this.afterMotion();
696
+ }
697
+ /** Where a declaration (by name) or a cluster (by key) is on the screen now — a declaration in a
698
+ * folded cluster where that cluster is; null when the board does not hold it. */
699
+ screenOf(kind, key) {
700
+ if (kind === "cluster") {
701
+ const c = this.layout.clusters.findIndex((x) => x.key === key);
702
+ return c < 0 ? null : this.toScreen(this.scene.cwx[c], this.scene.cwy[c]);
703
+ }
704
+ const i = this.g.byName.get(key);
705
+ return i === undefined ? null : this.toScreen(...this.posOf(i));
706
+ }
707
+ /** Where cluster `key`'s circle is drawn on the screen: its centre and radius. */
708
+ clusterDisc(key) {
709
+ const c = this.layout.clusters.findIndex((x) => x.key === key);
710
+ if (c < 0)
711
+ return null;
712
+ const [sx, sy] = this.toScreen(this.scene.cwx[c], this.scene.cwy[c]);
713
+ return [sx, sy, this.scene.drawnR[c] * this.cam.k];
714
+ }
715
+ /** A declaration's colour in the scheme on show, as CSS. */
716
+ colourOf(i) {
717
+ const [r, g, b, a] = this.colorOf(i);
718
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
719
+ }
720
+ /** Where the board draws everything at rest, nothing folded — its layout and its pins, as the
721
+ * server reads it for the document (server/boardspots.ts) — in the board's world. */
722
+ atRest() {
723
+ const S = new Scene(this.layout);
724
+ S.restore(this.pinsRecord(), [], (i) => this.pinName(i));
725
+ return { nx: S.nwx, ny: S.nwy, cx: S.cwx, cy: S.cwy, r: S.drawnR };
726
+ }
727
+ /** Where `a` goes between `prev` and `next` in the board's order — `prev` read before it, so
728
+ * lower on the board; `next` after, higher (shared/doc/order.ts) — as the board draws things at
729
+ * rest: at a height between theirs, where it is across; on their row, between them, where they
730
+ * are level; below the first, above the last. All three are of one cluster, `parent` (-1: none).
731
+ * Null where neither is given. */
732
+ orderSpot(a, prev, next, R = this.atRest()) {
733
+ const disc = (b) => (b.kind === "spot" ? { x: b.x, y: b.y, r: LEAF_R } : b.kind === "node" ? { x: R.nx[b.k], y: R.ny[b.k], r: LEAF_R } : { x: R.cx[b.k], y: R.cy[b.k], r: R.r[b.k] });
734
+ return spotBetween(disc(a), prev && disc(prev), next && disc(next));
735
+ }
736
+ /** Pin declaration or circle `a` between `prev` and `next` in the board's order (orderSpot), as a
737
+ * drop there would, for everyone on the board. A section of the document standing for a chain of
738
+ * circles is read where its innermost is (`a.k`) and moved by its outermost (`move`), which
739
+ * carries the rest. False where nothing moved. */
740
+ pinInOrder(a, prev, next) {
741
+ const R = this.atRest();
742
+ const spot = this.orderSpot(a, prev, next, R);
743
+ if (!spot)
744
+ return false;
745
+ return this.pinAtSpots([{ a, spot }], R);
746
+ }
747
+ /** Pin each declaration or circle at a spot of the board at rest (`R`, read before any of them
748
+ * moved), as a drop there would, for everyone on the board, in one change: the linear form
749
+ * reorders a run of siblings so (ExportSpec decision 18). A circle is read where `k` is and
750
+ * moved by `move`, as in pinInOrder. */
751
+ pinAtSpots(moves, R = this.atRest()) {
752
+ for (const p of this.localPins(moves, R))
753
+ this.scene.pinAt(p.kind, p.m, p.lx, p.ly);
754
+ if (!moves.length)
755
+ return false;
756
+ this.posDirty = true;
757
+ this.ev.pinsChanged();
758
+ this.request();
759
+ return true;
760
+ }
761
+ /** Each move as a pin: what is moved, and where, in its parent's coordinates (which nothing folded
762
+ * scales at rest). */
763
+ localPins(moves, R) {
764
+ return moves.map(({ a, spot }) => {
765
+ const m = a.kind === "cluster" ? (a.move ?? a.k) : a.k;
766
+ const [ax, ay] = a.kind === "node" ? [R.nx[a.k], R.ny[a.k]] : [R.cx[a.k], R.cy[a.k]];
767
+ const [mx, my] = a.kind === "node" ? [ax, ay] : [R.cx[m], R.cy[m]];
768
+ const p = a.kind === "node" ? this.scene.nodeCluster[m] : this.scene.parent[m];
769
+ const [ox, oy] = p >= 0 ? [R.cx[p], R.cy[p]] : [0, 0];
770
+ return { kind: a.kind, m, lx: mx + spot[0] - ax - ox, ly: my + spot[1] - ay - oy };
771
+ });
772
+ }
773
+ /** Where the board would draw everything at rest were `moves` pinned too, the latest drops, as
774
+ * the server would read it (atRest): what the linear form checks a reorder by before making
775
+ * it — a drop is brought inside its parent and the rest make way, which can undo an order.
776
+ * Nothing is pinned. */
777
+ atRestWith(moves, R = this.atRest()) {
778
+ const was = this.pinsRecord();
779
+ const pins = { nodes: { ...was.nodes }, clusters: { ...was.clusters } };
780
+ for (const p of this.localPins(moves, R)) {
781
+ const bag = p.kind === "cluster" ? pins.clusters : pins.nodes;
782
+ const key = p.kind === "cluster" ? this.layout.clusters[p.m].key : this.pinName(p.m);
783
+ // the latest drop: last in the record
784
+ delete bag[key];
785
+ bag[key] = [p.lx, p.ly];
786
+ }
787
+ const S = new Scene(this.layout);
788
+ S.restore(pins, [], (i) => this.pinName(i));
789
+ return { nx: S.nwx, ny: S.nwy, cx: S.cwx, cy: S.cwy, r: S.drawnR };
790
+ }
791
+ /** A spot of the board at rest (atRest) where the board draws it now: moved with its cluster
792
+ * `key` (null: none), as folds and easing have moved that. */
793
+ restToNow(key, spot, R = this.atRest()) {
794
+ const c = key === null ? -1 : this.layout.clusters.findIndex((x) => x.key === key);
795
+ if (c < 0)
796
+ return spot;
797
+ return [spot[0] - R.cx[c] + this.scene.cwx[c], spot[1] - R.cy[c] + this.scene.cwy[c]];
798
+ }
799
+ /** Folded, or inside a folded cluster: not drawn as itself. */
800
+ isCollapsed(c) {
801
+ const B = this.folded;
802
+ for (let x = c; x >= 0; x = this.scene.parent[x])
803
+ if (B.has(x))
804
+ return true;
805
+ return false;
806
+ }
807
+ toggleFold(c) {
808
+ const next = new Set(this.folded);
809
+ if (next.has(c))
810
+ next.delete(c);
811
+ else
812
+ next.add(c);
813
+ this.applyFolds(next);
814
+ }
815
+ /** The deepest cluster level of the current layout. */
816
+ maxDepth() {
817
+ return this.layout.clusters.reduce((m, c) => Math.max(m, c.depth), 0);
818
+ }
819
+ /** How many levels are open: the depth of the shallowest folded cluster, maxDepth() + 1 when
820
+ * nothing is folded. */
821
+ openLevels() {
822
+ let d = this.maxDepth() + 1;
823
+ for (const c of this.folded)
824
+ d = Math.min(d, this.layout.clusters[c].depth);
825
+ return d;
826
+ }
827
+ /** One level more (+1) or less (−1) for the whole view. A step opens or folds the next level
828
+ * that has a cluster large enough to see at this zoom, so every step changes the picture;
829
+ * levels of only tiny clusters are passed over, both ways. */
830
+ stepLevels(dir) {
831
+ const max = this.maxDepth() + 1;
832
+ const now = this.openLevels();
833
+ const seen = new Uint8Array(max);
834
+ this.layout.clusters.forEach((c) => {
835
+ if (c.r * this.cam.k >= CLUSTER_MIN_PX)
836
+ seen[c.depth] = 1;
837
+ });
838
+ if (dir > 0) {
839
+ if (now >= max)
840
+ return;
841
+ // the next level that folds something large enough to see; past the last, everything opens
842
+ let d = now + 1;
843
+ while (d < max && !seen[d])
844
+ d++;
845
+ this.foldAt(d >= max ? null : d);
846
+ return;
847
+ }
848
+ if (now <= 0)
849
+ return;
850
+ let d = now - 1;
851
+ while (d > 0 && !seen[d])
852
+ d--;
853
+ this.foldAt(d);
854
+ }
855
+ /** Fold every cluster at `depth` (0 = top level); `null` unfolds everything. */
856
+ foldAt(depth) {
857
+ const next = new Set();
858
+ if (depth !== null)
859
+ this.layout.clusters.forEach((c, i) => c.depth === depth && next.add(i));
860
+ this.applyFolds(next);
861
+ }
862
+ applyFolds(next) {
863
+ this.foldScene(next);
864
+ this.ev.foldChanged();
865
+ }
866
+ visibleNode(i) {
867
+ if (this.scene.nodeRep[i] >= 0)
868
+ return false;
869
+ if (this.g.isExt(i))
870
+ return this.showExternals || (this.active?.[i] ?? 0) > 0;
871
+ return true;
872
+ }
873
+ rebuildGrid() {
874
+ this.grid.clear();
875
+ if (!this.scene)
876
+ return;
877
+ for (let i = 0; i < this.g.n; i++) {
878
+ if (!this.visibleNode(i))
879
+ continue;
880
+ const key = Math.floor(this.px[i] / CELL) * 100003 + Math.floor(this.py[i] / CELL);
881
+ let cell = this.grid.get(key);
882
+ if (!cell)
883
+ this.grid.set(key, (cell = []));
884
+ cell.push(i);
885
+ }
886
+ }
887
+ // ------------------------------------------------------------------ visitors
888
+ /** The reader's visitors (ViewSpec decision 27), each in its own place in the cluster hierarchy,
889
+ * and which of them are selected: the folded clusters round a selected one's place open for the
890
+ * reader alone. `hidden`: the board's hidden levels under its clustering. Returns each visitor's
891
+ * place — where the outermost of what it brings stands — for the trail to keep and presence to
892
+ * carry: the one it had, or one chosen now where it had none in the cluster it is in. */
893
+ setVisitors(vs, selected, hidden) {
894
+ this.visitors = vs;
895
+ this.visitorSel = selected;
896
+ this.visitHidden = hidden;
897
+ const h = this.hoverTarget;
898
+ if (h?.kind === "visitor" && h.k >= vs.length)
899
+ this.hoverTarget = null;
900
+ // no board yet: the first one places them (setLayout)
901
+ if (!this.scene)
902
+ return [];
903
+ const plan = vs.length ? planOn(this.layout, this.clustering, hidden, this.g, vs) : null;
904
+ this.openAround = plan ? openFor(plan, this.scene.parent, selected) : new Set();
905
+ this.foldScene(this.folded);
906
+ if (plan)
907
+ this.placeOn(plan, false);
908
+ else {
909
+ this.visits = null;
910
+ this.scene.setHeld(new Map());
911
+ }
912
+ this.posDirty = true;
913
+ this.invalidate();
914
+ return this.visits?.places ?? [];
915
+ }
916
+ /** Place the visitors as `plan` says, on the scene as it is now, and hold their room in it; a new
917
+ * spot keeps clear of the clusters' names as the last frame wrote them. */
918
+ placeOn(plan, snap) {
919
+ const vs = this.visitors.map((v) => ({ name: v.name, module: v.module, ext: v.ext, place: v.place, near: v.board.map(([j]) => j) }));
920
+ this.visits = placeVisits(this.scene, this.layout, plan, vs, (x, y, r) => clearOfBoxes(this.labelBoxes, x, y, r));
921
+ this.scene.setHeld(this.visits.held, snap);
922
+ }
923
+ /** Whether cluster `c`, folded on the board, is open on this page for the reader's selected
924
+ * visitor (ViewSpec decision 27). */
925
+ openForVisitor(c) {
926
+ return this.tempOpen.has(c);
927
+ }
928
+ /** The folded cluster whose card hides visitor `k`, or -1. */
929
+ visitorCard(k) {
930
+ return this.visits && k >= 0 && k < this.visits.plan.at.length ? visitWorld(this.visits, this.scene).vrep[k] : -1;
931
+ }
932
+ /** The board's cluster visitor `k` is placed in (its temporary clusters inside it are the reader's
933
+ * alone), or -1 at the top level or where there is no such visitor. */
934
+ visitorIn(k) {
935
+ return this.visits && k >= 0 && k < this.visits.plan.at.length ? this.visits.plan.at[k].in : -1;
936
+ }
937
+ /** Where visitor `k` is drawn in the world: its point, or the folded cluster whose card hides it. */
938
+ visitorPos(k) {
939
+ if (!this.visits || k < 0 || k >= this.visits.plan.at.length)
940
+ return null;
941
+ const W = visitWorld(this.visits, this.scene);
942
+ const c = W.vrep[k];
943
+ return c >= 0 ? [this.scene.cwx[c], this.scene.cwy[c]] : [W.vx[k], W.vy[k]];
944
+ }
945
+ /** A visitor's radius on the screen: a little larger than a lit point, never too small to hit. */
946
+ visitorR() {
947
+ return Math.max(5, this.dotPx() * 1.5);
948
+ }
949
+ /** Where a folded card's visitor marker is on the screen: at the upper left of its disc (a note's
950
+ * marker is at the upper right). */
951
+ visitMarkAt(c) {
952
+ const S = this.scene;
953
+ const r = Math.max(S.drawnR[c], 6 / this.cam.k);
954
+ return this.toScreen(S.cwx[c] - r * 0.62, S.cwy[c] - r * 0.62);
955
+ }
956
+ /** The visitor under a point of the canvas, or null: a visitor's point, or the marker of a folded
957
+ * card that hides some, which stands for the newest of them. */
958
+ visitorAt(sx, sy) {
959
+ const W = this.visitW;
960
+ if (!W)
961
+ return null;
962
+ const r = this.visitorR() + 4;
963
+ let best = null;
964
+ let bestD = Infinity;
965
+ for (let k = 0; k < this.visitors.length && k < W.vx.length; k++) {
966
+ const c = W.vrep[k];
967
+ if (c >= 0 && !this.clusterVisible(c))
968
+ continue;
969
+ const [x, y] = c >= 0 ? this.visitMarkAt(c) : this.toScreen(W.vx[k], W.vy[k]);
970
+ const d = (x - sx) ** 2 + (y - sy) ** 2;
971
+ if (d <= (c >= 0 ? 10 * 10 : r * r) && d <= bestD) {
972
+ bestD = d;
973
+ best = k;
974
+ }
975
+ }
976
+ return best;
977
+ }
978
+ /** Where open cluster `c`'s name was written in the last frame, on the screen (left, top, right,
979
+ * bottom), or null where it was not: a note's card keeps off its own cluster's name where it can
980
+ * (web/src/sticky.ts). */
981
+ labelOf(c) {
982
+ const j = this.labelOfC.get(c);
983
+ if (j === undefined)
984
+ return null;
985
+ const b = this.labelBoxes;
986
+ const [x0, y0] = this.toScreen(b[j], b[j + 1]);
987
+ const [x1, y1] = this.toScreen(b[j + 2], b[j + 3]);
988
+ return [x0, y0, x1, y1];
989
+ }
990
+ // ------------------------------------------------------------------ activation
991
+ /** What the panel tells of — a declaration, or a cluster — has a ring and a lead to the panel,
992
+ * which show where it is, so that the canvas does not say again what the panel says, nor cover
993
+ * what is around it. `end` is where the lead ends on the screen (the panel's edge, level with its
994
+ * title), null while the panel is away. */
995
+ setLead(t, end) {
996
+ this.lead = t;
997
+ this.leadEnd = end;
998
+ this.request();
999
+ }
1000
+ /** Lock the layout or let it go: locked (the default), a drag on a declaration or a circle pans
1001
+ * the view as a drag on empty space does; a click still selects, a double-click still folds.
1002
+ * The drag key (⌘ on a Mac, Ctrl elsewhere) held at the press does the other (CanvasSpec, "Hover,
1003
+ * click, drag"; web/src/drag.ts). */
1004
+ setLocked(on) {
1005
+ this.locked = on;
1006
+ }
1007
+ /** What is lit: `closure`, the selection's set; the edges drawn are `mode`'s, or the key graph's
1008
+ * (`keyEdges`, pairs of nodes: ViewSpec decision 20) under the Keys step of Rests on. The roots of
1009
+ * the selection's picks — declarations (`focus`) and clusters (`clusters`) — have a ring and no
1010
+ * card. */
1011
+ setActivation(mode, focus, closure, clusters = [], keyEdges = null) {
1012
+ this.mode = mode;
1013
+ this.keyEdges = closure ? keyEdges : null;
1014
+ this.focus = focus;
1015
+ this.picked = clusters;
1016
+ this.active = closure;
1017
+ // the theme's colours are read again (an edge's colours are the theme's, the same in every mode);
1018
+ // without a browser they are the host's
1019
+ if (!this.headless)
1020
+ requestAnimationFrame(() => {
1021
+ this.theme = readTheme();
1022
+ this.invalidate();
1023
+ });
1024
+ this.activeList = [];
1025
+ if (closure)
1026
+ for (let i = 0; i < closure.length; i++)
1027
+ if (closure[i])
1028
+ this.activeList.push(i);
1029
+ this.countActive();
1030
+ this.clearCards();
1031
+ this.recomputeEdges();
1032
+ this.rebuildGrid();
1033
+ this.invalidate();
1034
+ }
1035
+ /** The board's drawing of the screen's rectangle `rect` at `f` pixels for each of the screen's,
1036
+ * its rows `y0` to `y0 + rows`, onto `out` from its top left: an artwork's PNG, a band at a time
1037
+ * (ExportSpec decision 19). The renderer draws it a tile at a time on the board's own canvas —
1038
+ * through `cam`, the camera the picture was framed with, its soft rims as many pixels wide as the
1039
+ * screen's (Renderer.render, `aa`) — and the names and marks are written over it as the screen has
1040
+ * them; the screen is drawn again after. */
1041
+ paintArtBand(out, rect, f, y0, rows, cam) {
1042
+ const was = { cam: this.cam, ctx: this.ctx, w: this.canvas.width, h: this.canvas.height };
1043
+ const W = out.canvas.width;
1044
+ const tile = Math.min(2048, this.gl.maxSide());
1045
+ this.cam = { ...cam };
1046
+ try {
1047
+ const dotR = this.dotPx();
1048
+ for (let x0 = 0; x0 < W; x0 += tile) {
1049
+ const tw = Math.min(tile, W - x0);
1050
+ if (this.canvas.width !== tw || this.canvas.height !== rows)
1051
+ [this.canvas.width, this.canvas.height] = [tw, rows];
1052
+ const cw = tw / f, ch = rows / f;
1053
+ const [wx, wy] = this.toWorld(rect.x + x0 / f + cw / 2, rect.y + y0 / f + ch / 2);
1054
+ this.gl.render({ x: wx, y: wy, k: cam.k }, cw, ch, f, dotR, (0.35 * this.dpr) / f);
1055
+ out.drawImage(this.canvas, 0, 0, tw, rows, x0, 0, tw, rows);
1056
+ }
1057
+ // the names and the marks, on a canvas of their own laid over it
1058
+ const lab = document.createElement("canvas");
1059
+ [lab.width, lab.height] = [W, rows];
1060
+ this.ctx = lab.getContext("2d");
1061
+ this.labelBase = [f, 0, 0, f, -rect.x * f, -(rect.y * f + y0)];
1062
+ this.drawLabels((sx, sy, pad) => sx > -pad && sy > -pad && sx < this.w + pad && sy < this.h + pad, dotR);
1063
+ out.drawImage(lab, 0, 0);
1064
+ }
1065
+ finally {
1066
+ this.cam = was.cam;
1067
+ this.ctx = was.ctx;
1068
+ this.labelBase = null;
1069
+ [this.canvas.width, this.canvas.height] = [was.w, was.h];
1070
+ this.dirty = true;
1071
+ this.draw();
1072
+ }
1073
+ }
1074
+ /** The names and marks the board writes over its renderer's drawing, written with `ctx` (an
1075
+ * artwork's SVG, web/src/artwork.ts: it records what is drawn), in the screen's CSS pixels. */
1076
+ drawMarks(ctx) {
1077
+ const was = this.ctx;
1078
+ this.ctx = ctx;
1079
+ this.labelBase = [1, 0, 0, 1, 0, 0];
1080
+ try {
1081
+ this.drawLabels((sx, sy, pad) => sx > -pad && sy > -pad && sx < this.w + pad && sy < this.h + pad, this.dotPx());
1082
+ }
1083
+ finally {
1084
+ this.ctx = was;
1085
+ this.labelBase = null;
1086
+ this.dirty = true;
1087
+ this.request();
1088
+ }
1089
+ }
1090
+ /** What the renderer draws that reaches the screen's rectangle `rect`, as shapes in the screen's
1091
+ * pixels, in its order: what an artwork's SVG writes as vectors (ExportSpec decision 19). */
1092
+ artShapes(rect) {
1093
+ const { x: cx, y: cy, k } = this.cam;
1094
+ const sx = (x) => (x - cx) * k + this.w / 2, sy = (y) => (y - cy) * k + this.h / 2;
1095
+ const dot = this.dotPx();
1096
+ const meets = (x0, y0, x1, y1) => x1 >= rect.x && y1 >= rect.y && x0 <= rect.x + rect.w && y0 <= rect.y + rect.h;
1097
+ const P = this.posData;
1098
+ const out = [];
1099
+ const four = (d, o) => [d[o], d[o + 1], d[o + 2], d[o + 3]];
1100
+ for (const L of this.gl.drawn()) {
1101
+ const d = L.data;
1102
+ for (let n = 0; n < L.count; n++) {
1103
+ const o = n * L.stride;
1104
+ if (L.kind === "circles" || L.kind === "points") {
1105
+ const unit = d[o + 3];
1106
+ const r = Math.max(unit < 0.5 ? d[o + 2] * k : unit < 1.5 ? d[o + 2] : d[o + 2] * dot, d[o + 15]);
1107
+ const x = sx(d[o]), y = sy(d[o + 1]);
1108
+ const ring = d[o + 12], ringR = r + d[o + 14];
1109
+ const ext = Math.max(r, ringR + ring / 2);
1110
+ if (!meets(x - ext, y - ext, x + ext, y + ext))
1111
+ continue;
1112
+ const fill = four(d, o + 4), stroke = ring > 0 && d[o + 11] > 0 ? four(d, o + 8) : null;
1113
+ if (fill[3] <= 0.002 && !stroke)
1114
+ continue;
1115
+ const flags = Math.round(d[o + 13]);
1116
+ out.push({
1117
+ kind: "circle", x, y, r, fill, stroke, ring, ringR, dashed: (flags & FLAG_DASHED) !== 0,
1118
+ ...(flags & FLAG_GLOW ? { look: "glow" } : flags & FLAG_RADIAL ? { look: "radial" } : {}),
1119
+ ...(flags & FLAG_HALO ? { halo: true } : {}),
1120
+ });
1121
+ }
1122
+ else if (L.kind === "lines") {
1123
+ const x0 = sx(d[o]), y0 = sy(d[o + 1]), x1 = sx(d[o + 2]), y1 = sy(d[o + 3]), w = d[o + 8];
1124
+ if (!meets(Math.min(x0, x1) - w, Math.min(y0, y1) - w, Math.max(x0, x1) + w, Math.max(y0, y1) + w))
1125
+ continue;
1126
+ out.push({ kind: "line", x0, y0, x1, y1, colour: four(d, o + 4), width: w });
1127
+ }
1128
+ else {
1129
+ const len = Math.round(d[o + 8]);
1130
+ const ctrl = [];
1131
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1132
+ for (let j = 0; j < len; j++) {
1133
+ const id = Math.round(d[o + j]);
1134
+ const x = sx(P[2 * id]), y = sy(P[2 * id + 1]);
1135
+ ctrl.push(x, y);
1136
+ [x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)];
1137
+ }
1138
+ // a B-spline keeps within its control points: one whose box misses the frame misses it
1139
+ const w = d[o + 10];
1140
+ if (!meets(x0 - w, y0 - w, x1 + w, y1 + w))
1141
+ continue;
1142
+ out.push({ kind: "bundle", ctrl, beta: d[o + 9], width: w, c0: four(d, o + 12), c1: four(d, o + 16) });
1143
+ }
1144
+ }
1145
+ }
1146
+ return out;
1147
+ }
1148
+ /** What the names the board wrote in its last frame name (shared/collab.ts, artNameId), on an
1149
+ * artwork: those its names by selection hold now (ExportSpec decision 19). */
1150
+ writtenNames() {
1151
+ return this.namesWritten;
1152
+ }
1153
+ /** The names an artwork's frame `rect` (the screen's) can hold, for its list of names (ExportSpec
1154
+ * decision 19): the clusters' the board would write where their names fall in it, and those chosen
1155
+ * already, in the board's order; then the declarations' whose points are in it, nearest its middle
1156
+ * first. */
1157
+ artNames(rect) {
1158
+ const S = this.scene;
1159
+ if (!S)
1160
+ return [];
1161
+ const inside = (x, y) => x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
1162
+ const out = [];
1163
+ for (let c = 0; c < S.nc; c++) {
1164
+ const spot = this.chosenSpot(c);
1165
+ const cl = this.layout.clusters[c];
1166
+ if (!spot || !inside(spot.x, spot.y) || (!spot.board && !this.artClusters.has(cl.key) && !this.artRuled.has(cl.key)))
1167
+ continue;
1168
+ out.push({ id: artNameId("cluster", cl.key), label: cl.label, full: cl.key.replace("⟨external⟩", "External"), cluster: true });
1169
+ }
1170
+ const mx = rect.x + rect.w / 2, my = rect.y + rect.h / 2;
1171
+ const decls = [];
1172
+ const dist = new Map();
1173
+ for (let i = 0; i < this.g.n; i++) {
1174
+ if (!this.visibleNode(i))
1175
+ continue;
1176
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
1177
+ if (!inside(sx, sy))
1178
+ continue;
1179
+ decls.push(i);
1180
+ dist.set(i, (sx - mx) ** 2 + (sy - my) ** 2);
1181
+ }
1182
+ decls.sort((a, b) => dist.get(a) - dist.get(b));
1183
+ for (const i of decls)
1184
+ out.push({ id: artNameId("decl", this.g.nodes[i].name), label: this.g.short(i), full: this.g.nodes[i].name, cluster: false });
1185
+ return out;
1186
+ }
1187
+ /** Where cluster `c`'s name is written when an artwork's names are chosen: as the board writes it,
1188
+ * under a folded cluster's disc or inside the top of an open one's circle, and above a circle too
1189
+ * small to hold it; whether the board would write it at all (`board`: the circle large enough);
1190
+ * null where the cluster is not drawn. */
1191
+ chosenSpot(c) {
1192
+ const S = this.scene;
1193
+ const rep = S.rep[c];
1194
+ if ((rep >= 0 && rep !== c) || !this.clusterVisible(c))
1195
+ return null;
1196
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
1197
+ if (rep === c) {
1198
+ const r = Math.max(6, S.drawnR[c] * this.cam.k);
1199
+ return { x: sx, y: sy + r + 13, size: 11, font: `500 11px ${FONT}`, board: r > 10 };
1200
+ }
1201
+ const sr = S.drawnR[c] * this.cam.k;
1202
+ const size = Math.max(10, Math.min(20, 9 + sr / 40));
1203
+ const font = `${this.layout.clusters[c].depth === 0 ? 600 : 500} ${size}px ${FONT}`;
1204
+ return sr > 38 ? { x: sx, y: sy - sr + size + 6, size, font, board: true } : { x: sx, y: sy - sr - 4, size, font, board: false };
1205
+ }
1206
+ /** The targets an artwork chose, by their nodes. */
1207
+ chosenTargets() {
1208
+ const on = new Set();
1209
+ for (const n of this.art?.shownTargets ?? []) {
1210
+ const i = this.g.byName.get(n);
1211
+ if (i !== undefined)
1212
+ on.add(i);
1213
+ }
1214
+ return on;
1215
+ }
1216
+ /** The targets marked, their stars drawn and their names in bold: every one, but where an artwork
1217
+ * marks none (As any) or only those it chose (By selection). */
1218
+ markedTargets() {
1219
+ const t = this.art?.targets;
1220
+ if (t === "plain")
1221
+ return [];
1222
+ if (t !== "chosen")
1223
+ return this.targetList;
1224
+ const on = this.chosenTargets();
1225
+ return this.targetList.filter((i) => on.has(i));
1226
+ }
1227
+ /** Draw the board as an artwork (null: as ever). */
1228
+ setArt(look) {
1229
+ this.art = look;
1230
+ this.artClusters = new Set();
1231
+ this.artDecls = [];
1232
+ this.artRuled = new Set();
1233
+ this.artRuledDecls = [];
1234
+ for (const id of look?.shown ?? []) {
1235
+ if (id.startsWith("c:"))
1236
+ this.artClusters.add(id.slice(2));
1237
+ else if (id.startsWith("d:"))
1238
+ this.artDecls.push(id.slice(2));
1239
+ }
1240
+ for (const id of look?.ruled ?? []) {
1241
+ if (id.startsWith("c:"))
1242
+ this.artRuled.add(id.slice(2));
1243
+ else if (id.startsWith("d:"))
1244
+ this.artRuledDecls.push(id.slice(2));
1245
+ }
1246
+ this.overviewBase = "";
1247
+ this.setHover(null, 0, 0);
1248
+ this.invalidate();
1249
+ this.request();
1250
+ }
1251
+ setEdges(show) {
1252
+ this.showEdges = show;
1253
+ this.request();
1254
+ }
1255
+ setShowExternals(on) {
1256
+ this.showExternals = on;
1257
+ this.countProgress();
1258
+ this.rebuildGrid();
1259
+ this.invalidate();
1260
+ }
1261
+ /** Whether the edge u → v (of sites `site`) is drawn lit, as the board draws it (recomputeEdges):
1262
+ * between two lit declarations, of the sites the mode follows; none while the key graph's edges
1263
+ * are drawn instead (keyLinks). The linear form's arcs follow it (web/src/linear.ts). */
1264
+ edgeLit(u, v, site) {
1265
+ const act = this.active;
1266
+ return !this.keyEdges && !!act && !!act[u] && !!act[v] && (site & MODE_SITES[this.mode]) !== 0;
1267
+ }
1268
+ /** The key graph's edges drawn under the Keys step of Rests on, as pairs of nodes (the one that
1269
+ * rests on, then the one it rests on); null where they are not drawn. */
1270
+ get keyLinks() {
1271
+ return this.keyEdges;
1272
+ }
1273
+ /** Edges between active nodes that the mode follows, as triples (u, v, sites); none where the key
1274
+ * graph's edges are drawn instead. */
1275
+ recomputeEdges() {
1276
+ this.edges = [];
1277
+ const act = this.active;
1278
+ if (!act || !this.scene || this.keyEdges)
1279
+ return;
1280
+ const sites = MODE_SITES[this.mode];
1281
+ const g = this.g;
1282
+ const rep = this.scene.nodeRep;
1283
+ const seen = new Set();
1284
+ for (const u of this.activeList) {
1285
+ for (let e = g.outStart[u]; e < g.outStart[u + 1]; e++) {
1286
+ const v = g.outDst[e];
1287
+ const s = g.outSite[e] & sites;
1288
+ if (!s || !act[v])
1289
+ continue;
1290
+ const ru = rep[u];
1291
+ const rv = rep[v];
1292
+ if (ru >= 0 || rv >= 0) {
1293
+ if (ru >= 0 && ru === rv)
1294
+ continue;
1295
+ const key = (ru >= 0 ? -(ru + 1) : u) * 1_000_003 + (rv >= 0 ? -(rv + 1) : v);
1296
+ if (seen.has(key))
1297
+ continue;
1298
+ seen.add(key);
1299
+ }
1300
+ this.edges.push(u, v, s);
1301
+ if (this.edges.length > 90_000)
1302
+ return;
1303
+ }
1304
+ }
1305
+ }
1306
+ // ------------------------------------------------------------------ camera
1307
+ resize() {
1308
+ const r = this.canvas.getBoundingClientRect();
1309
+ this.dpr = window.devicePixelRatio || 1;
1310
+ this.w = r.width;
1311
+ this.h = r.height;
1312
+ for (const c of [this.canvas, this.labels]) {
1313
+ c.width = Math.round(r.width * this.dpr);
1314
+ c.height = Math.round(r.height * this.dpr);
1315
+ }
1316
+ this.request();
1317
+ }
1318
+ /** The canvas's size and the room the panels leave around the board: where a share link's camera
1319
+ * is read as the region it shows, and fitted (shared/sharesnap.ts). */
1320
+ get screen() {
1321
+ return { w: this.w, h: this.h, insets: { ...this.insets } };
1322
+ }
1323
+ toScreen(x, y) {
1324
+ return [(x - this.cam.x) * this.cam.k + this.w / 2, (y - this.cam.y) * this.cam.k + this.h / 2];
1325
+ }
1326
+ toWorld(sx, sy) {
1327
+ return [(sx - this.w / 2) / this.cam.k + this.cam.x, (sy - this.h / 2) / this.cam.k + this.cam.y];
1328
+ }
1329
+ /** Where a node is drawn: itself, or the folded cluster hiding it. */
1330
+ posOf(i) {
1331
+ const r = this.scene.nodeRep[i];
1332
+ if (r >= 0)
1333
+ return [this.scene.cwx[r], this.scene.cwy[r]];
1334
+ return [this.px[i], this.py[i]];
1335
+ }
1336
+ clusterVisible(c) {
1337
+ return !this.layout.clusters[c].ext || this.showExternals || (this.active !== null && this.clusterHasActive(c));
1338
+ }
1339
+ /** Whether cluster `c` is drawn on this board: the outside world's are hidden where the board
1340
+ * leaves it out. */
1341
+ isClusterShown(c) {
1342
+ return this.clusterVisible(c);
1343
+ }
1344
+ /** Frame the whole board in the room the bars and panels leave: its top-level circles and
1345
+ * declarations, the notes' icons — on the ring around it, at its top level and in its clusters —
1346
+ * and their cards (CanvasSpec, "Notes"), which keep their size on the screen as the zoom goes;
1347
+ * without the cards where they would not fit, and without the notes where their icons would not. */
1348
+ fit(animate = true) {
1349
+ const S = this.scene;
1350
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1351
+ for (const c of S.rootC) {
1352
+ if (!this.clusterVisible(c))
1353
+ continue;
1354
+ const r = S.drawnR[c];
1355
+ x0 = Math.min(x0, S.cwx[c] - r);
1356
+ y0 = Math.min(y0, S.cwy[c] - r);
1357
+ x1 = Math.max(x1, S.cwx[c] + r);
1358
+ y1 = Math.max(y1, S.cwy[c] + r);
1359
+ }
1360
+ for (const i of S.rootN) {
1361
+ x0 = Math.min(x0, this.px[i] - 8);
1362
+ y0 = Math.min(y0, this.py[i] - 8);
1363
+ x1 = Math.max(x1, this.px[i] + 8);
1364
+ y1 = Math.max(y1, this.py[i] + 8);
1365
+ }
1366
+ if (!isFinite(x0))
1367
+ return;
1368
+ const vw = this.w - this.insets.left - this.insets.right;
1369
+ const vh = this.h - this.insets.top - this.insets.bottom;
1370
+ const corners = [
1371
+ { x: x0, y: y0, dx0: 0, dy0: 0, dx1: 0, dy1: 0 },
1372
+ { x: x1, y: y1, dx0: 0, dy0: 0, dx1: 0, dy1: 0 },
1373
+ ];
1374
+ const extra = this.fitExtra;
1375
+ const framed = (cards) => (extra ? fitFrame((k) => [...corners, ...extra(k, cards)], vw * 0.94, vh * 0.94, 4) : null);
1376
+ const f = framed(true) ?? framed(false);
1377
+ if (f)
1378
+ return this.moveTo(f.x, f.y, f.k, animate);
1379
+ const k = Math.min(vw / (x1 - x0), vh / (y1 - y0)) * 0.94;
1380
+ this.moveTo((x0 + x1) / 2, (y0 + y1) / 2, Math.min(k, 4), animate);
1381
+ }
1382
+ /** The camera that holds the whole board — its top-level circles and declarations — in the
1383
+ * screen's rectangle `rect` (an artwork's frame, web/src/artframe.ts); null for a board with
1384
+ * nothing on it. */
1385
+ fitCam(rect) {
1386
+ const S = this.scene;
1387
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1388
+ for (const c of S.rootC) {
1389
+ if (!this.clusterVisible(c))
1390
+ continue;
1391
+ const r = S.drawnR[c];
1392
+ [x0, y0, x1, y1] = [Math.min(x0, S.cwx[c] - r), Math.min(y0, S.cwy[c] - r), Math.max(x1, S.cwx[c] + r), Math.max(y1, S.cwy[c] + r)];
1393
+ }
1394
+ for (const i of S.rootN)
1395
+ [x0, y0, x1, y1] = [Math.min(x0, this.px[i] - 8), Math.min(y0, this.py[i] - 8), Math.max(x1, this.px[i] + 8), Math.max(y1, this.py[i] + 8)];
1396
+ if (!isFinite(x0))
1397
+ return null;
1398
+ const k = Math.min(ZOOM_MOST, Math.min(rect.w / (x1 - x0), rect.h / (y1 - y0)) * 0.94);
1399
+ // the board's middle at the rectangle's
1400
+ return { x: (x0 + x1) / 2 - (rect.x + rect.w / 2 - this.w / 2) / k, y: (y0 + y1) / 2 - (rect.y + rect.h / 2 - this.h / 2) / k, k };
1401
+ }
1402
+ /** Bring the camera to `cam` over `dur` ms, easing in and out (an artwork's frame closing in on the
1403
+ * board, and opening out again: ExportSpec decision 19). */
1404
+ glideTo(cam, dur) {
1405
+ this.animate(cam, dur);
1406
+ }
1407
+ /** Put the camera at `cam` at once, ending any motion in progress, which would carry it off: a
1408
+ * share link's region, framed (shared/sharesnap.ts). */
1409
+ jumpTo(cam) {
1410
+ if (this.anim)
1411
+ cancelAnimationFrame(this.anim);
1412
+ this.anim = this.camGoal = null;
1413
+ this.cam = { ...cam };
1414
+ this.request();
1415
+ }
1416
+ /** Zoom by `f` around the middle of the unobstructed area (the zoom buttons, + and -). */
1417
+ zoomBy(f) {
1418
+ const cx = this.insets.left + (this.w - this.insets.left - this.insets.right) / 2;
1419
+ const cy = this.insets.top + (this.h - this.insets.top - this.insets.bottom) / 2;
1420
+ // aim from where an animation in progress is going, so that quick presses add up
1421
+ const base = this.camGoal ?? this.cam;
1422
+ const wx = base.x + (cx - this.w / 2) / base.k;
1423
+ const wy = base.y + (cy - this.h / 2) / base.k;
1424
+ this.moveTo(wx, wy, Math.min(ZOOM_MOST, Math.max(ZOOM_LEAST, base.k * f)), true, 240);
1425
+ }
1426
+ flyTo(i) {
1427
+ const [x, y] = this.posOf(i);
1428
+ this.moveTo(x, y, Math.max(this.cam.k, 1.1), true);
1429
+ }
1430
+ /** Bring a point of the world to the middle, at least as near as now, and at least at zoom `least`
1431
+ * (by default where declarations can be pointed at). */
1432
+ flyToPoint(x, y, least = 0.6) {
1433
+ this.moveTo(x, y, Math.min(ZOOM_MOST, Math.max(this.cam.k, least)), true);
1434
+ }
1435
+ /** Bring cluster `c` to the middle, filling much of the screen; at least so near that its radius
1436
+ * is `minPx` on the screen, where that is asked (a note in it, readable, web/src/sticky.ts). */
1437
+ flyToCluster(c, minPx = 0) {
1438
+ const S = this.scene;
1439
+ const k = Math.min(4, (Math.min(this.w, this.h) * 0.42) / Math.max(S.drawnR[c], 20));
1440
+ this.moveTo(S.cwx[c], S.cwy[c], Math.min(ZOOM_MOST, Math.max(k, minPx / Math.max(S.drawnR[c], 1e-6))), true);
1441
+ }
1442
+ /** Bring world point (x, y) to the middle of the unobstructed area at zoom k. */
1443
+ moveTo(px, py, k, animate, dur = 520) {
1444
+ if (this.anim)
1445
+ cancelAnimationFrame(this.anim);
1446
+ const cx = this.insets.left + (this.w - this.insets.left - this.insets.right) / 2;
1447
+ const cy = this.insets.top + (this.h - this.insets.top - this.insets.bottom) / 2;
1448
+ const x = px - (cx - this.w / 2) / k;
1449
+ const y = py - (cy - this.h / 2) / k;
1450
+ if (!animate) {
1451
+ this.camGoal = null;
1452
+ this.cam = { x, y, k };
1453
+ this.request();
1454
+ return;
1455
+ }
1456
+ this.animate({ x, y, k }, dur);
1457
+ }
1458
+ /** The camera carried to `goal` over `dur` ms, easing in and out. */
1459
+ animate(goal, dur) {
1460
+ if (this.anim)
1461
+ cancelAnimationFrame(this.anim);
1462
+ const { x, y, k } = goal;
1463
+ this.camGoal = { x, y, k };
1464
+ const from = { ...this.cam };
1465
+ const t0 = performance.now();
1466
+ const step = (t) => {
1467
+ const u = Math.min(1, (t - t0) / dur);
1468
+ const e = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
1469
+ // zoom interpolated geometrically, so the motion feels even
1470
+ this.cam = {
1471
+ x: from.x + (x - from.x) * e,
1472
+ y: from.y + (y - from.y) * e,
1473
+ k: from.k * Math.pow(k / from.k, e),
1474
+ };
1475
+ this.request();
1476
+ if (u < 1)
1477
+ this.anim = requestAnimationFrame(step);
1478
+ else
1479
+ this.anim = this.camGoal = null;
1480
+ };
1481
+ this.anim = requestAnimationFrame(step);
1482
+ }
1483
+ // ------------------------------------------------------------------ hit testing
1484
+ nodeAt(sx, sy) {
1485
+ const [wx, wy] = this.toWorld(sx, sy);
1486
+ const rad = Math.max(7, 10 / this.cam.k);
1487
+ const r2 = (Math.max(8, 4 * this.cam.k) / this.cam.k) ** 2;
1488
+ let best = null;
1489
+ let bestD = r2;
1490
+ const c0x = Math.floor((wx - rad) / CELL), c1x = Math.floor((wx + rad) / CELL);
1491
+ const c0y = Math.floor((wy - rad) / CELL), c1y = Math.floor((wy + rad) / CELL);
1492
+ for (let cx = c0x; cx <= c1x; cx++) {
1493
+ for (let cy = c0y; cy <= c1y; cy++) {
1494
+ const cell = this.grid.get(cx * 100003 + cy);
1495
+ if (!cell)
1496
+ continue;
1497
+ for (const i of cell) {
1498
+ const d = (this.px[i] - wx) ** 2 + (this.py[i] - wy) ** 2;
1499
+ if (d < bestD) {
1500
+ bestD = d;
1501
+ best = i;
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+ return best;
1507
+ }
1508
+ /** Visible nodes within r of a world point, from the hit grid. */
1509
+ nodesNear(wx, wy, r) {
1510
+ const out = [];
1511
+ const c0x = Math.floor((wx - r) / CELL), c1x = Math.floor((wx + r) / CELL);
1512
+ const c0y = Math.floor((wy - r) / CELL), c1y = Math.floor((wy + r) / CELL);
1513
+ for (let cx = c0x; cx <= c1x; cx++) {
1514
+ for (let cy = c0y; cy <= c1y; cy++) {
1515
+ for (const i of this.grid.get(cx * 100003 + cy) ?? []) {
1516
+ if ((this.px[i] - wx) ** 2 + (this.py[i] - wy) ** 2 <= r * r)
1517
+ out.push(i);
1518
+ }
1519
+ }
1520
+ }
1521
+ return out;
1522
+ }
1523
+ /** A folded cluster's disc under the pointer (they are drawn at least 6 px across). */
1524
+ foldedAt(sx, sy) {
1525
+ const [wx, wy] = this.toWorld(sx, sy);
1526
+ const S = this.scene;
1527
+ let best = null;
1528
+ for (const c of S.folded) {
1529
+ if (S.rep[c] !== c || !this.clusterVisible(c))
1530
+ continue;
1531
+ const r = Math.max(S.drawnR[c], 6 / this.cam.k);
1532
+ if ((S.cwx[c] - wx) ** 2 + (S.cwy[c] - wy) ** 2 <= r * r)
1533
+ best = c;
1534
+ }
1535
+ return best;
1536
+ }
1537
+ /** The innermost open cluster containing a point, among those large enough on screen. */
1538
+ clusterAt(sx, sy) {
1539
+ const [wx, wy] = this.toWorld(sx, sy);
1540
+ const S = this.scene;
1541
+ const k = this.cam.k;
1542
+ let best = null;
1543
+ for (let c = 0; c < S.nc; c++) {
1544
+ if (S.rep[c] >= 0 || !this.clusterVisible(c))
1545
+ continue;
1546
+ const r = S.drawnR[c];
1547
+ if (r * k < CLUSTER_MIN_PX)
1548
+ continue;
1549
+ if ((S.cwx[c] - wx) ** 2 + (S.cwy[c] - wy) ** 2 > r * r)
1550
+ continue;
1551
+ if (best === null || this.layout.clusters[c].depth > this.layout.clusters[best].depth)
1552
+ best = c;
1553
+ }
1554
+ return best;
1555
+ }
1556
+ /** The most specific thing under the pointer that can be told apart at this zoom: a
1557
+ * declaration, a folded cluster, or the innermost open cluster large enough on screen. */
1558
+ targetAt(sx, sy) {
1559
+ // a visitor is drawn over the board: it is what the pointer is on there
1560
+ const v = this.visitorAt(sx, sy);
1561
+ if (v !== null)
1562
+ return { kind: "visitor", k: v };
1563
+ if (2 * LEAF_R * this.cam.k >= NODE_MIN_PX) {
1564
+ const i = this.nodeAt(sx, sy);
1565
+ if (i !== null)
1566
+ return { kind: "node", i };
1567
+ }
1568
+ const b = this.foldedAt(sx, sy);
1569
+ if (b !== null)
1570
+ return { kind: "cluster", c: b };
1571
+ const c = this.clusterAt(sx, sy);
1572
+ return c !== null ? { kind: "cluster", c } : null;
1573
+ }
1574
+ setHover(t, x, y) {
1575
+ const same = (a, b) => a === b || (!!a && !!b && sameTarget(a, b));
1576
+ this.previewing = false;
1577
+ if (!same(t, this.hoverTarget)) {
1578
+ this.hoverTarget = t;
1579
+ for (const el of this.cards.values()) {
1580
+ const mine = t && ((t.kind === "node" && el.dataset.node === String(t.i)) || (t.kind === "cluster" && el.dataset.cluster === String(t.c)));
1581
+ el.classList.toggle("hover", !!mine);
1582
+ }
1583
+ this.request();
1584
+ }
1585
+ this.ev.hover(t, x, y);
1586
+ }
1587
+ /** Ring a declaration without moving the view (a row under the pointer in the panel). */
1588
+ preview(i) {
1589
+ this.previewTarget(i === null ? null : { kind: "node", i });
1590
+ }
1591
+ /** Light a cluster's circle up without moving the view (a row of the cluster's panel). */
1592
+ previewCluster(c) {
1593
+ this.previewTarget(c === null ? null : { kind: "cluster", c });
1594
+ }
1595
+ previewTarget(t) {
1596
+ if (t === null) {
1597
+ if (this.previewing) {
1598
+ this.previewing = false;
1599
+ this.hoverTarget = null;
1600
+ this.request();
1601
+ }
1602
+ return;
1603
+ }
1604
+ this.previewing = true;
1605
+ const h = this.hoverTarget;
1606
+ if (h && sameTarget(h, t))
1607
+ return;
1608
+ this.hoverTarget = t;
1609
+ this.request();
1610
+ }
1611
+ refreshHover() {
1612
+ const p = this.pointer;
1613
+ if (!p || this.pressing)
1614
+ return;
1615
+ this.setHover(this.targetAt(p.sx, p.sy), p.x, p.y);
1616
+ }
1617
+ // ------------------------------------------------------------------ input
1618
+ bindInput() {
1619
+ const el = this.canvas;
1620
+ let down = null;
1621
+ const local = (e) => {
1622
+ const r = el.getBoundingClientRect();
1623
+ return [e.clientX - r.left, e.clientY - r.top];
1624
+ };
1625
+ /** Press on a target: it becomes a drag only after DRAG_PX of movement. What is dragged keeps
1626
+ * its offset from the pointer (a card sits beside its point). */
1627
+ const press = (e, t) => {
1628
+ const [sx, sy] = local(e);
1629
+ const [wx, wy] = this.toWorld(sx, sy);
1630
+ const S = this.scene;
1631
+ // locked (the default), or the drag key held on an unlocked layout, or a preview: what it is on
1632
+ // stays where it is (on a preview, which would have moved it, the page says why)
1633
+ // a finger never moves anything: on a touch screen the board is read, and the lock never lets go
1634
+ const moves = e.pointerType !== "touch" && dragMoves(this.locked, dragKeyHeld(e));
1635
+ const pan = !this.frozen && moves ? undefined : { cx: this.cam.x, cy: this.cam.y };
1636
+ const refused = this.frozen && moves;
1637
+ if (t.kind === "visitor")
1638
+ return { kind: "visitor", x: e.clientX, y: e.clientY, k: t.k, moved: false, pan: { cx: this.cam.x, cy: this.cam.y } };
1639
+ if (t.kind === "node")
1640
+ return { kind: "node", x: e.clientX, y: e.clientY, i: t.i, dx: wx - S.nwx[t.i], dy: wy - S.nwy[t.i], moved: false, pan, refused };
1641
+ return { kind: "cluster", x: e.clientX, y: e.clientY, c: t.c, dx: wx - S.cwx[t.c], dy: wy - S.cwy[t.c], moved: false, pan, refused };
1642
+ };
1643
+ const pan = (e) => ({ kind: "pan", x: e.clientX, y: e.clientY, cx: this.cam.x, cy: this.cam.y, moved: false });
1644
+ const cardTarget = (card) => card.dataset.cluster ? { kind: "cluster", c: Number(card.dataset.cluster) } : { kind: "node", i: Number(card.dataset.node) };
1645
+ /** The last click on a cluster: a second one soon after, where it was, makes a double-click. */
1646
+ let last = null;
1647
+ const soon = (e) => !!last && performance.now() - last.t < DOUBLE_MS && Math.hypot(e.clientX - last.x, e.clientY - last.y) < DRAG_PX;
1648
+ /** The second press of a double-click is on the cluster of the first, whatever that click lit
1649
+ * up under the pointer meanwhile (a card, a lit point). */
1650
+ const again = (e) => (e.button === 0 && soon(e) ? { kind: "cluster", c: last.c } : null);
1651
+ /** The press is a Mac's Ctrl+click: let go where it was pressed, it is the menu's (contextmenu). */
1652
+ let menu = false;
1653
+ // Touch (PhoneSpec decisions 3 and 4): the fingers on the canvas, by pointer. Two make a pinch,
1654
+ // which zooms about their middle and moves with it; what the fingers leave after a pinch is no
1655
+ // tap. A finger held still on the board opens the menu there; a second tap on empty board, soon
1656
+ // after the first, zooms in there.
1657
+ const touches = new Map();
1658
+ let pinch = null;
1659
+ let pinched = false;
1660
+ let hold = null;
1661
+ let lastTap = null;
1662
+ const unhold = () => {
1663
+ if (hold)
1664
+ clearTimeout(hold);
1665
+ hold = null;
1666
+ };
1667
+ const startPinch = () => {
1668
+ const [a, b] = [...touches.values()];
1669
+ const [sx, sy] = local({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 });
1670
+ const [wx, wy] = this.toWorld(sx, sy);
1671
+ pinch = { d0: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), k0: this.cam.k, wx, wy };
1672
+ };
1673
+ // a finger on the board is the board's alone (PhoneSpec decision 5): iOS would take one held
1674
+ // still for its own, and select the text it then finds under it (the menu, which opens there)
1675
+ // or offer the canvas as an image. Nothing on the board waits for a click; a field being typed
1676
+ // in loses the focus, as it would to a press anywhere else.
1677
+ el.addEventListener("touchstart", (e) => {
1678
+ e.preventDefault();
1679
+ const a = document.activeElement;
1680
+ if (a instanceof HTMLInputElement || a instanceof HTMLTextAreaElement)
1681
+ a.blur();
1682
+ }, { passive: false });
1683
+ // the right click is the page's: its menu, on what is under the pointer (a card's, on a card)
1684
+ el.addEventListener("contextmenu", (e) => {
1685
+ e.preventDefault();
1686
+ const [sx, sy] = local(e);
1687
+ this.ev.menu?.(this.targetAt(sx, sy), e.clientX, e.clientY, this.toWorld(sx, sy));
1688
+ });
1689
+ this.cardsEl.addEventListener("contextmenu", (e) => {
1690
+ const card = e.target.closest(".card");
1691
+ if (!card)
1692
+ return;
1693
+ e.preventDefault();
1694
+ const [sx, sy] = local(e);
1695
+ this.ev.menu?.(cardTarget(card), e.clientX, e.clientY, this.toWorld(sx, sy));
1696
+ });
1697
+ el.addEventListener("pointerdown", (e) => {
1698
+ // the right button opens the menu (contextmenu, above) and moves nothing
1699
+ if (e.button === 2)
1700
+ return;
1701
+ if (e.pointerType === "touch") {
1702
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
1703
+ el.setPointerCapture(e.pointerId);
1704
+ if (touches.size >= 2) {
1705
+ // a second finger: whatever the first began is a pinch now
1706
+ unhold();
1707
+ down = null;
1708
+ this.pressing = false;
1709
+ pinched = true;
1710
+ if (touches.size === 2)
1711
+ startPinch();
1712
+ return;
1713
+ }
1714
+ pinched = false;
1715
+ }
1716
+ menu = menuClick(e);
1717
+ const [sx, sy] = local(e);
1718
+ // the middle button: the view pans whatever is under the pointer (Space is the source
1719
+ // window's, CanvasSpec "Hover, click, drag")
1720
+ const t = e.button === 0 ? again(e) ?? this.targetAt(sx, sy) : null;
1721
+ down = t ? press(e, t) : pan(e);
1722
+ this.pressing = true;
1723
+ el.setPointerCapture(e.pointerId);
1724
+ // a finger held still: the menu, on what it is on
1725
+ if (e.pointerType === "touch") {
1726
+ unhold();
1727
+ const at = { x: e.clientX, y: e.clientY };
1728
+ hold = setTimeout(() => {
1729
+ hold = null;
1730
+ if (!down || down.moved || touches.size !== 1)
1731
+ return;
1732
+ down = null;
1733
+ this.pressing = false;
1734
+ pinched = true;
1735
+ navigator.vibrate?.(10);
1736
+ this.ev.menu?.(t, at.x, at.y, this.toWorld(sx, sy));
1737
+ }, HOLD_MS);
1738
+ }
1739
+ });
1740
+ // a card is a target of its own
1741
+ this.cardsEl.addEventListener("pointerdown", (e) => {
1742
+ const card = e.target.closest(".card");
1743
+ if (!card || e.button !== 0)
1744
+ return;
1745
+ if (e.target.closest("button"))
1746
+ return;
1747
+ e.preventDefault();
1748
+ menu = menuClick(e);
1749
+ down = press(e, again(e) ?? cardTarget(card));
1750
+ this.pressing = true;
1751
+ el.setPointerCapture(e.pointerId);
1752
+ });
1753
+ this.cardsEl.addEventListener("pointerover", (e) => {
1754
+ const card = e.target.closest(".card");
1755
+ if (!card || down)
1756
+ return;
1757
+ this.setHover(cardTarget(card), e.clientX, e.clientY);
1758
+ });
1759
+ el.addEventListener("pointermove", (e) => {
1760
+ if (e.pointerType === "touch" && touches.has(e.pointerId)) {
1761
+ touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
1762
+ if (pinch && touches.size >= 2) {
1763
+ const [a, b] = [...touches.values()];
1764
+ const k = Math.min(ZOOM_MOST, Math.max(ZOOM_LEAST, (pinch.k0 * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.d0));
1765
+ const [mx, my] = local({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 });
1766
+ // the point that was under the fingers' middle stays under it
1767
+ this.cam = { x: pinch.wx - (mx - this.w / 2) / k, y: pinch.wy - (my - this.h / 2) / k, k };
1768
+ this.request();
1769
+ return;
1770
+ }
1771
+ if (hold && down && Math.hypot(e.clientX - down.x, e.clientY - down.y) >= DRAG_PX)
1772
+ unhold();
1773
+ }
1774
+ const [sx, sy] = local(e);
1775
+ if (!down) {
1776
+ if (e.pointerType === "touch")
1777
+ return;
1778
+ this.pointer = { sx, sy, x: e.clientX, y: e.clientY };
1779
+ this.refreshHover();
1780
+ el.style.cursor = this.hoverTarget ? "pointer" : "grab";
1781
+ return;
1782
+ }
1783
+ const dx = e.clientX - down.x;
1784
+ const dy = e.clientY - down.y;
1785
+ if (!down.moved && dx * dx + dy * dy < DRAG_PX * DRAG_PX)
1786
+ return;
1787
+ if (!down.moved && down.kind !== "pan" && down.refused)
1788
+ this.ev.frozenDrag?.();
1789
+ down.moved = true;
1790
+ this.ev.hover(null, 0, 0);
1791
+ const [wx, wy] = this.toWorld(sx, sy);
1792
+ el.style.cursor = "grabbing";
1793
+ const from = down.kind === "pan" ? { cx: down.cx, cy: down.cy } : down.pan;
1794
+ if (from) {
1795
+ this.cam.x = from.cx - dx / this.cam.k;
1796
+ this.cam.y = from.cy - dy / this.cam.k;
1797
+ this.request();
1798
+ }
1799
+ else if (down.kind === "node") {
1800
+ this.scene.placeNode(down.i, wx - down.dx, wy - down.dy);
1801
+ this.invalidate();
1802
+ }
1803
+ else if (down.kind === "cluster") {
1804
+ this.scene.placeCluster(down.c, wx - down.dx, wy - down.dy);
1805
+ this.invalidate();
1806
+ }
1807
+ });
1808
+ el.addEventListener("pointerleave", () => {
1809
+ this.pointer = null;
1810
+ if (!down)
1811
+ this.setHover(null, 0, 0);
1812
+ });
1813
+ // Anywhere but the canvas and the cards, nothing is highlighted. Leaving a card for a panel
1814
+ // reaches neither the canvas nor the card's own handlers, and a card can vanish under a still
1815
+ // pointer; this catches both.
1816
+ document.addEventListener("pointermove", (e) => {
1817
+ if (down || !this.hoverTarget || this.previewing)
1818
+ return;
1819
+ const t = e.target;
1820
+ if (t === el || (t instanceof Element && t.closest(".card")))
1821
+ return;
1822
+ this.pointer = null;
1823
+ this.setHover(null, 0, 0);
1824
+ });
1825
+ document.documentElement.addEventListener("pointerleave", () => {
1826
+ if (down)
1827
+ return;
1828
+ this.pointer = null;
1829
+ this.setHover(null, 0, 0);
1830
+ });
1831
+ const end = (e) => {
1832
+ if (e.pointerType === "touch") {
1833
+ touches.delete(e.pointerId);
1834
+ unhold();
1835
+ if (touches.size < 2)
1836
+ pinch = null;
1837
+ // what a pinch, or a menu held open, leaves is no tap
1838
+ if (pinched) {
1839
+ if (touches.size === 0)
1840
+ pinched = false;
1841
+ down = null;
1842
+ this.pressing = false;
1843
+ return;
1844
+ }
1845
+ // a second tap on empty board soon after the first, near it: zoom in there
1846
+ if (down?.kind === "pan" && !down.moved) {
1847
+ const now = performance.now();
1848
+ if (lastTap && now - lastTap.t < DOUBLE_MS && Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) < 30) {
1849
+ lastTap = null;
1850
+ down = null;
1851
+ this.pressing = false;
1852
+ const [sx, sy] = local(e);
1853
+ const [wx, wy] = this.toWorld(sx, sy);
1854
+ const k = Math.min(ZOOM_MOST, this.cam.k * 2);
1855
+ this.moveTo(wx - (sx - this.w / 2) / k + (this.insets.left - this.insets.right) / 2 / k, wy - (sy - this.h / 2) / k + (this.insets.top - this.insets.bottom) / 2 / k, k, true, 260);
1856
+ return;
1857
+ }
1858
+ lastTap = { t: now, x: e.clientX, y: e.clientY };
1859
+ }
1860
+ }
1861
+ if (!down)
1862
+ return;
1863
+ const d = down;
1864
+ down = null;
1865
+ this.pressing = false;
1866
+ el.style.cursor = "grab";
1867
+ if (d.moved) {
1868
+ if (d.kind !== "pan" && !d.pan) {
1869
+ this.scene.endDrag();
1870
+ this.ev.pinsChanged();
1871
+ }
1872
+ }
1873
+ else if (menu) {
1874
+ // a Mac's Ctrl+click is its right click: the menu opened on it, and it is no click
1875
+ }
1876
+ else if (d.kind === "visitor") {
1877
+ this.ev.visitorClick?.(d.k, e.shiftKey);
1878
+ }
1879
+ else if (d.kind === "node") {
1880
+ // a click on the highlighted target selects it, or lets it go
1881
+ this.ev.nodeClick(d.i, e.shiftKey);
1882
+ }
1883
+ else if (d.kind === "cluster") {
1884
+ // a second click on a cluster soon after the first, in the same place, folds or opens it;
1885
+ // not a tap, which changes nothing of the board everyone shares
1886
+ if (e.pointerType !== "touch" && last?.c === d.c && soon(e)) {
1887
+ last = null;
1888
+ this.toggleFold(d.c);
1889
+ }
1890
+ else {
1891
+ last = { c: d.c, t: performance.now(), x: e.clientX, y: e.clientY };
1892
+ this.ev.clusterClick(d.c, e.shiftKey);
1893
+ }
1894
+ }
1895
+ else if (e.button === 0) {
1896
+ this.ev.backgroundClick();
1897
+ }
1898
+ // a finger leaves nothing highlighted: there is no pointer over the board once it is lifted
1899
+ if (e.pointerType === "touch") {
1900
+ this.pointer = null;
1901
+ this.setHover(null, 0, 0);
1902
+ return;
1903
+ }
1904
+ // highlight what is under the pointer now: the gesture may have changed it, and while the
1905
+ // pointer was captured no enter or leave reached the canvas
1906
+ const under = document.elementFromPoint(e.clientX, e.clientY);
1907
+ const card = under instanceof HTMLElement ? under.closest(".card") : null;
1908
+ const [sx, sy] = local(e);
1909
+ this.pointer = under === el ? { sx, sy, x: e.clientX, y: e.clientY } : null;
1910
+ if (card)
1911
+ this.setHover(cardTarget(card), e.clientX, e.clientY);
1912
+ else if (this.pointer)
1913
+ this.refreshHover();
1914
+ else
1915
+ this.setHover(null, 0, 0);
1916
+ };
1917
+ // pointerup reaches the canvas through capture, also when the gesture began on a card
1918
+ window.addEventListener("pointerup", end);
1919
+ // the browser took the touch (a system gesture): nothing of it is a tap
1920
+ window.addEventListener("pointercancel", (e) => {
1921
+ if (e.pointerType !== "touch")
1922
+ return;
1923
+ touches.delete(e.pointerId);
1924
+ unhold();
1925
+ if (touches.size < 2)
1926
+ pinch = null;
1927
+ down = null;
1928
+ this.pressing = false;
1929
+ pinched = touches.size > 0;
1930
+ });
1931
+ el.addEventListener("wheel", (e) => {
1932
+ e.preventDefault();
1933
+ const [sx, sy] = local(e);
1934
+ const [wx, wy] = this.toWorld(sx, sy);
1935
+ const f = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0016));
1936
+ this.cam.k = Math.min(ZOOM_MOST, Math.max(ZOOM_LEAST, this.cam.k * f));
1937
+ // keep the point under the cursor fixed
1938
+ this.cam.x = wx - (sx - this.w / 2) / this.cam.k;
1939
+ this.cam.y = wy - (sy - this.h / 2) / this.cam.k;
1940
+ // what is under the pointer changes with the zoom
1941
+ this.pointer = { sx, sy, x: e.clientX, y: e.clientY };
1942
+ this.refreshHover();
1943
+ this.request();
1944
+ }, { passive: false });
1945
+ }
1946
+ // ------------------------------------------------------------------ drawing
1947
+ /** Screen radius of a node's dot at the current zoom. */
1948
+ dotPx() {
1949
+ return Math.max(1.3, Math.min(5, 3 * Math.sqrt(this.cam.k)));
1950
+ }
1951
+ /** Fill the GPU layers from the current state. Positions are in world units, so this runs
1952
+ * when content changes or the scene moves, never because the camera moved. */
1953
+ buildScene() {
1954
+ const th = this.theme;
1955
+ const act = this.active;
1956
+ const S = this.scene;
1957
+ const g = this.gl;
1958
+ const dots = g.layer("dots", "points");
1959
+ const rings = g.layer("rings", "points");
1960
+ const bubbles = g.layer("bubbles", "circles");
1961
+ for (const L of [dots, rings, bubbles])
1962
+ L.clear();
1963
+ // open clusters as circles, parents first so children sit on top; their circles contain
1964
+ // exactly what they hold (scene.drawnR). Folded clusters are bodies: shaded discs.
1965
+ const discs = g.layer("discs", "circles");
1966
+ discs.clear();
1967
+ // In the Status scheme a file whose own build failed, is being built or has a sorry has its
1968
+ // outline in that state's colour, thin: its declarations say the rest. By kind, every circle is
1969
+ // plain (decided 2026-10-08: status is read by switching schemes, not from rings).
1970
+ const byStatus = this.scheme === "status";
1971
+ // an artwork's style (ExportSpec decision 19): its clusters drawn as cells, each top-level
1972
+ // namespace a territory of its own hue — a glow round the outermost, a fill fading from a light,
1973
+ // the deeper ones only their rims; or as a starfield's, the outermost two levels' bounds dotted
1974
+ const style = this.art?.style ?? "board";
1975
+ const glow = g.layer("glow", "points"), figures = g.layer("figures", "lines");
1976
+ glow.clear();
1977
+ figures.clear();
1978
+ for (let c = 0; c < S.nc; c++) {
1979
+ if (S.rep[c] >= 0 || !this.clusterVisible(c))
1980
+ continue;
1981
+ const [x, y, r] = [S.cwx[c], S.cwy[c], S.drawnR[c]];
1982
+ const depth = this.layout.clusters[c].depth;
1983
+ if (style === "cells") {
1984
+ const h = this.hueOf(c);
1985
+ if (depth === 0) {
1986
+ circle(discs, x, y, r, UNIT_WORLD, [0, 0, 0, 0], fade(h, 0.3), 26, FLAG_HALO);
1987
+ circle(discs, x, y, r, UNIT_WORLD, fade(h, 0.2), fade(h, 0.6), 1.3, FLAG_RADIAL);
1988
+ }
1989
+ else if (depth === 1)
1990
+ circle(discs, x, y, r, UNIT_WORLD, fade(h, 0.12), fade(h, 0.35), 0.9, FLAG_RADIAL);
1991
+ else
1992
+ circle(discs, x, y, r, UNIT_WORLD, [0, 0, 0, 0], fade(h, 0.18), 0.6);
1993
+ continue;
1994
+ }
1995
+ if (style === "stars") {
1996
+ if (depth <= 1)
1997
+ circle(discs, x, y, r, UNIT_WORLD, [0, 0, 0, 0], rgba(STAR_LINE, depth === 0 ? 0.24 : 0.14), 1, FLAG_DASHED);
1998
+ continue;
1999
+ }
2000
+ const t = byStatus ? this.clusterTrouble[c] ?? -1 : -1;
2001
+ if (t < 0)
2002
+ circle(discs, x, y, r, UNIT_WORLD, th.disc, th.discLine, 1);
2003
+ else
2004
+ circle(discs, x, y, r, UNIT_WORLD, th.disc, th.status[t], 1.5);
2005
+ }
2006
+ for (let c = 0; c < S.nc; c++) {
2007
+ if (S.rep[c] !== c || !this.clusterVisible(c))
2008
+ continue;
2009
+ const lit = act !== null && this.clusterHasActive(c);
2010
+ const base = lit ? th.lit : style === "cells" ? this.hueOf(c) : style === "stars" ? rgba(STAR_LINE) : th.discLine;
2011
+ const a = lit ? 1 : style === "board" ? Math.min(1, base[3] * 3 + 0.15) * (act ? 0.5 : 1) : (act ? 0.2 : 0.4);
2012
+ const fill = [base[0], base[1], base[2], a];
2013
+ // a folded cluster: a shaded body; in a starfield, a glow, as a far cluster of stars
2014
+ if (style === "stars" && !lit)
2015
+ circle(bubbles, S.cwx[c], S.cwy[c], S.drawnR[c] * 1.4, UNIT_WORLD, fill, null, 0, FLAG_GLOW, 0, 8);
2016
+ else
2017
+ circle(bubbles, S.cwx[c], S.cwy[c], S.drawnR[c], UNIT_WORLD, fill, null, 0, FLAG_SHADED, 0, 6);
2018
+ }
2019
+ if (!this.art)
2020
+ this.tempDiscs(discs);
2021
+ // edges of the selection, bundled along the tree, on top of the overview
2022
+ const sel = g.layer("selection", "bundles");
2023
+ sel.clear();
2024
+ if (act && this.edges.length && this.art?.edges !== "none") {
2025
+ const count = this.edges.length / 3;
2026
+ const fade = count < 250 ? 1 : Math.max(0.25, Math.sqrt(250 / count));
2027
+ const width = count > 4000 ? 1 : 1.6;
2028
+ const rep = S.nodeRep;
2029
+ const id = (i) => (rep[i] >= 0 ? rep[i] : S.nc + i);
2030
+ for (let e = 0; e < this.edges.length; e += 3) {
2031
+ const a = id(this.edges[e]);
2032
+ const b = id(this.edges[e + 1]);
2033
+ if (a === b)
2034
+ continue;
2035
+ // coloured from the end that uses to the end that is used; an edge that only occurs
2036
+ // inside a proof, or on grammar, is drawn fainter: no TCB follows it
2037
+ const f = (this.edges[e + 2] & (SITE_STMT | SITE_BODY) ? 1 : 0.33) * fade;
2038
+ const [from, to] = this.edgeEnds(a, b, th);
2039
+ const c0 = [from[0], from[1], from[2], from[3] * f];
2040
+ const c1 = [to[0], to[1], to[2], to[3] * f];
2041
+ bundle(sel, pathOf(S, a, b), 0.85, width, c0, c1);
2042
+ }
2043
+ }
2044
+ if (!this.art)
2045
+ this.visitBundles(sel);
2046
+ // points: dots for declarations, rings for the world outside the project, in the scheme's
2047
+ // colours; what is lit is drawn over them in draw(). An artwork's cells give each a glow; its
2048
+ // starfield makes each a star, as bright as it is used, its colour the scheme's towards white, and
2049
+ // each cluster a constellation
2050
+ const dim = act ? 0.22 : 0.9;
2051
+ const most = style === "stars" ? this.mostUsed() : 1;
2052
+ for (let i = 0; i < this.g.n; i++) {
2053
+ if (!this.visibleNode(i) || (act && act[i]))
2054
+ continue;
2055
+ if (style === "stars") {
2056
+ const x = this.px[i], y = this.py[i];
2057
+ if (this.g.isExt(i)) {
2058
+ circle(dots, x, y, 0.6, UNIT_DOT, [0.72, 0.77, 0.88, 0.4 * dim]);
2059
+ continue;
2060
+ }
2061
+ const c = this.colorOf(i);
2062
+ const star = [c[0] + (1 - c[0]) * 0.55, c[1] + (1 - c[1]) * 0.55, c[2] + (1 - c[2]) * 0.55, 0.95 * dim];
2063
+ const k = 0.65 + 1.15 * (Math.log1p(this.g.inStart[i + 1] - this.g.inStart[i]) / Math.log1p(most));
2064
+ circle(glow, x, y, k * 3.4, UNIT_DOT, fade(star, 0.2), null, 0, FLAG_GLOW);
2065
+ circle(dots, x, y, k, UNIT_DOT, star);
2066
+ continue;
2067
+ }
2068
+ if (style === "cells") {
2069
+ const c = this.colorOf(i);
2070
+ circle(glow, this.px[i], this.py[i], 2.8, UNIT_DOT, [c[0], c[1], c[2], 0.16 * dim], null, 0, FLAG_GLOW);
2071
+ }
2072
+ this.point(dots, i, dim, false);
2073
+ }
2074
+ if (style === "stars")
2075
+ this.constellations(figures, act);
2076
+ // a pinned point carries a thin ring: where it is was chosen by hand. Nothing else rings a
2077
+ // point but a past graph's marks; status is the Status scheme's (decided 2026-10-08)
2078
+ for (let i = 0; i < this.g.n; i++) {
2079
+ if (this.visibleNode(i) && S.isPinned(i))
2080
+ circle(rings, this.px[i], this.py[i], 1, UNIT_DOT, [0, 0, 0, 0], th.pin, 1, 0, 2.5);
2081
+ }
2082
+ // on a past graph, what changed since and what was renamed since have a ring of their own
2083
+ const M = this.pastMarks;
2084
+ if (M)
2085
+ for (let i = 0; i < this.g.n; i++) {
2086
+ if (!(M[i] & (PAST_CHANGED | PAST_RENAMED)) || !this.visibleNode(i))
2087
+ continue;
2088
+ const faded = act && !act[i] ? 0.45 : 1;
2089
+ if (M[i] & PAST_CHANGED)
2090
+ circle(rings, this.px[i], this.py[i], 1, UNIT_DOT, [0, 0, 0, 0], fade(th.tmChanged, faded), 1.6, 0, 2.2);
2091
+ // a rename is the lighter news: its ring is thinner and fainter than a change's
2092
+ if (M[i] & PAST_RENAMED)
2093
+ circle(rings, this.px[i], this.py[i], 1, UNIT_DOT, [0, 0, 0, 0], fade(th.tmRenamed, 0.7 * faded), 1.1, 0, M[i] & PAST_CHANGED ? 4.4 : 2.2);
2094
+ }
2095
+ }
2096
+ /** Of a cluster, its top-level namespace's hue among the cells' (CELL_HUES, by their size); the
2097
+ * outside world's apart. Read again for each layout. */
2098
+ hueOf(c) {
2099
+ if (this.hues?.layout !== this.layout) {
2100
+ const C = this.layout.clusters;
2101
+ const top = C.map((_, j) => {
2102
+ let t = j;
2103
+ while (C[t].parent >= 0)
2104
+ t = C[t].parent;
2105
+ return t;
2106
+ });
2107
+ const rank = new Map(C.map((cl, j) => [j, cl]).filter(([, cl]) => cl.depth === 0 && !cl.ext).sort((a, b) => b[1].count - a[1].count).map(([j], k) => [j, k]));
2108
+ const hues = CELL_HUES.map((h) => rgba(h)), outside = rgba(CELL_OUTSIDE);
2109
+ this.hues = { layout: this.layout, of: top.map((t) => (C[t].ext ? outside : hues[(rank.get(t) ?? 0) % hues.length])) };
2110
+ }
2111
+ return this.hues.of[c] ?? rgba(CELL_OUTSIDE);
2112
+ }
2113
+ /** The hue of what a bundled edge's end is (a cluster, or a declaration, past the clusters' ids). */
2114
+ hueOfId(id) {
2115
+ const S = this.scene;
2116
+ return this.hueOf(id < S.nc ? id : this.layout.cluster[id - S.nc]);
2117
+ }
2118
+ /** How often the declaration used most is used: what a star's size is measured against. */
2119
+ mostUsed() {
2120
+ if (this.used?.g !== this.g) {
2121
+ let m = 1;
2122
+ for (let i = 0; i < this.g.n; i++)
2123
+ m = Math.max(m, this.g.inStart[i + 1] - this.g.inStart[i]);
2124
+ this.used = { g: this.g, most: m };
2125
+ }
2126
+ return this.used.most;
2127
+ }
2128
+ /** The starfield's constellations (ExportSpec decision 19): in each open cluster, the stars it
2129
+ * holds itself — the seven used most — joined by the shortest lines that join them all. */
2130
+ constellations(L, act) {
2131
+ const S = this.scene;
2132
+ const own = new Map();
2133
+ for (let i = 0; i < this.g.n; i++) {
2134
+ if (!this.visibleNode(i) || this.g.isExt(i))
2135
+ continue;
2136
+ const c = this.layout.cluster[i];
2137
+ if (c < 0 || S.rep[c] >= 0)
2138
+ continue;
2139
+ let m = own.get(c);
2140
+ if (!m)
2141
+ own.set(c, (m = []));
2142
+ m.push(i);
2143
+ }
2144
+ const used = (i) => this.g.inStart[i + 1] - this.g.inStart[i];
2145
+ const colour = rgba(STAR_LINE, act ? 0.16 : 0.38);
2146
+ for (const members of own.values()) {
2147
+ if (members.length < 2)
2148
+ continue;
2149
+ const stars = members.sort((a, b) => used(b) - used(a) || a - b).slice(0, 7);
2150
+ // Prim's: from the brightest, each time the nearest star not yet joined
2151
+ const joined = [stars[0]];
2152
+ const rest = stars.slice(1);
2153
+ while (rest.length) {
2154
+ let best = Infinity, from = -1, k = -1;
2155
+ for (const a of joined)
2156
+ for (let j = 0; j < rest.length; j++) {
2157
+ const d = (this.px[a] - this.px[rest[j]]) ** 2 + (this.py[a] - this.py[rest[j]]) ** 2;
2158
+ if (d < best)
2159
+ [best, from, k] = [d, a, j];
2160
+ }
2161
+ const b = rest.splice(k, 1)[0];
2162
+ segment(L, this.px[from], this.py[from], this.px[b], this.py[b], colour, 0.9);
2163
+ joined.push(b);
2164
+ }
2165
+ }
2166
+ }
2167
+ /** A node's colour in the scheme in force. */
2168
+ colorOf(i) {
2169
+ const th = this.theme;
2170
+ if (this.scheme === "status")
2171
+ return th.status[this.statusCode[i] ?? ST_UNREVIEWED];
2172
+ return this.g.isExt(i) ? th.ext : th.kind[this.g.nodes[i].kind] ?? th.ext;
2173
+ }
2174
+ /** Draw node `i` as a point into `L`: a dot in its colour, `alpha` its opacity. The world outside
2175
+ * the project is a ring in either scheme, so the project's boundary reads by shape too. In the
2176
+ * Status scheme what nobody has reviewed yet is a plain light grey dot (2026-10-09), set apart
2177
+ * from trusted's pale green by hue and from failed's grey by lightness. `lit` points are larger,
2178
+ * on a halo of the canvas's colour. */
2179
+ point(L, i, alpha, lit) {
2180
+ const c = this.colorOf(i);
2181
+ const a = (x) => [c[0], c[1], c[2], c[3] * x];
2182
+ const x = this.px[i], y = this.py[i];
2183
+ // on a past graph (HistorySpec decision 17): a ghost of what is gone now, pale in a ring of its
2184
+ // own colour. Lit, it is drawn lit as any point is, its mark a ring around it: a faint point read
2185
+ // as one left out of the selection
2186
+ const m = this.pastMarks?.[i] ?? 0;
2187
+ if (lit) {
2188
+ circle(L, x, y, 1.4, UNIT_DOT, c, this.theme.bg, 1.5, 0, 0.75);
2189
+ if (m & PAST_GHOST)
2190
+ circle(L, x, y, 1.4, UNIT_DOT, [0, 0, 0, 0], this.theme.tmGone, 1.3, 0, 2.6);
2191
+ return;
2192
+ }
2193
+ if (m & PAST_GHOST) {
2194
+ circle(L, x, y, 1, UNIT_DOT, a(0.28 * alpha), fade(this.theme.tmGone, alpha), 1.5, 0, -0.4);
2195
+ return;
2196
+ }
2197
+ if (this.g.isExt(i))
2198
+ circle(L, x, y, 0.8, UNIT_DOT, [0, 0, 0, 0], a(alpha), 1.3, 0, -0.6);
2199
+ else
2200
+ circle(L, x, y, 1, UNIT_DOT, a(alpha));
2201
+ }
2202
+ draw() {
2203
+ if (!this.scene)
2204
+ return;
2205
+ const t0 = performance.now();
2206
+ this.visitW = this.visits ? visitWorld(this.visits, this.scene) : null;
2207
+ if (this.sceneDirty) {
2208
+ this.sceneDirty = false;
2209
+ this.buildScene();
2210
+ }
2211
+ const dotR = this.dotPx();
2212
+ // which active nodes are on screen decides cards and the bright points, every frame
2213
+ const onScreen = (sx, sy, pad) => sx > -pad && sy > -pad && sx < this.w + pad && sy < this.h + pad;
2214
+ const activeDots = [];
2215
+ if (this.active) {
2216
+ for (const i of this.activeList) {
2217
+ if (!this.visibleNode(i))
2218
+ continue;
2219
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2220
+ if (onScreen(sx, sy, 8))
2221
+ activeDots.push(i);
2222
+ }
2223
+ }
2224
+ this.drawCards(activeDots);
2225
+ const th = this.theme;
2226
+ const active = this.gl.layer("active", "points");
2227
+ active.clear();
2228
+ // every lit point, those with a card too: the card is beside it, not over it
2229
+ for (const i of activeDots)
2230
+ this.point(active, i, 1, true);
2231
+ this.drawHover();
2232
+ this.drawOverview();
2233
+ if (this.posDirty) {
2234
+ this.posDirty = false;
2235
+ const S = this.scene;
2236
+ const P = this.posData;
2237
+ for (let c = 0; c < S.nc; c++) {
2238
+ P[2 * c] = S.cwx[c];
2239
+ P[2 * c + 1] = S.cwy[c];
2240
+ }
2241
+ for (let i = 0; i < S.n; i++) {
2242
+ P[2 * (S.nc + i)] = S.nwx[i];
2243
+ P[2 * (S.nc + i) + 1] = S.nwy[i];
2244
+ }
2245
+ // the visitors' own ids, past the scene's (web/src/visits.ts, visitChain)
2246
+ const W = this.visitW;
2247
+ if (W && W.tx.length + W.vx.length <= VISIT_SLOTS) {
2248
+ const base = S.nc + S.n, T = W.tx.length;
2249
+ for (let t = 0; t < T; t++)
2250
+ [P[2 * (base + t)], P[2 * (base + t) + 1]] = [W.tx[t], W.ty[t]];
2251
+ for (let k = 0; k < W.vx.length; k++)
2252
+ [P[2 * (base + T + k)], P[2 * (base + T + k) + 1]] = [W.vx[k], W.vy[k]];
2253
+ }
2254
+ this.gl.setPositions(P);
2255
+ }
2256
+ this.gl.render(this.cam, this.w, this.h, this.dpr, dotR);
2257
+ this.drawLabels(onScreen, dotR);
2258
+ this.onFrame?.();
2259
+ this.drawMs = performance.now() - t0;
2260
+ }
2261
+ /** For the frame meter: what one frame costs and what it draws. */
2262
+ stats() {
2263
+ const L = (n, k) => this.gl.layer(n, k).count;
2264
+ return {
2265
+ fps: Math.round(1000 / Math.max(1, this.frameMs)),
2266
+ drawMs: Math.round(this.drawMs * 10) / 10,
2267
+ dots: L("dots", "points"),
2268
+ circles: L("discs", "circles"),
2269
+ bundles: L("overview", "bundles") + L("selection", "bundles"),
2270
+ awake: this.scene?.moving ? 1 : 0,
2271
+ };
2272
+ }
2273
+ /** The edges with nothing selected: statement and body edges (proofs when asked) between the
2274
+ * units visible at this zoom, aggregated and bundled. Everything the overview draws follows the
2275
+ * zoom continuously (the owner's request, 2026-10-10): what is drawn is chosen at zoom points a
2276
+ * quarter of a step (a step: a half octave) apart, and the camera, between two of them, draws both,
2277
+ * blended by where it is — an edge the zoom brings in grows in, one it takes out fades, a bundle
2278
+ * that splits into finer edges dissolves into them, as the zoom goes on and back as it comes back.
2279
+ * At each point, the aggregations of the two steps it lies between are both chosen from, blended
2280
+ * the same way (web/src/overview.ts). An edge both of two blended sets hold is drawn once, between
2281
+ * its two looks. What a point draws is chosen for what is on screen: zoomed out, the heaviest
2282
+ * bundles of the whole project; zoomed in, the dependencies among the declarations in view, and
2283
+ * what they depend on or are used by beyond it. Chosen again when the view has moved by a sixth of
2284
+ * itself, at most every 150 ms, and once more when it stops; again when a fold, a toggle, the
2285
+ * selection or the mode changes.
2286
+ *
2287
+ * The points are chosen off the page's thread (web/src/edgequeue.ts; the owner's request,
2288
+ * 2026-10-10): the page asks for the two the camera needs and the next one in the direction it
2289
+ * zooms, and a frame never waits for one. While a point the camera needs is not there, what is at
2290
+ * hand is drawn — the nearest points there on either side, blended by where the camera lies between
2291
+ * them, or the nearest one at full weight; when it comes, what is drawn moves on to the camera's own
2292
+ * blend as the zoom goes on, at up to twice the zoom's pace (CATCH_UP), never by time. A camera
2293
+ * whose zoom has come to rest (ZOOM_REST) has no zoom left to ease along: what is drawn is settled
2294
+ * to its blend at once. */
2295
+ drawOverview() {
2296
+ const S = this.scene;
2297
+ const L = this.gl.layer("overview", "bundles");
2298
+ const Q = this.edgeQ;
2299
+ if (!this.showEdges || (this.art && this.art.edges !== "all")) {
2300
+ L.clear();
2301
+ this.overviewBase = "";
2302
+ this.drawnW = null;
2303
+ this.overviewDrawn = "";
2304
+ return;
2305
+ }
2306
+ if (this.overviewGraph !== this.g) {
2307
+ this.overviewGraph = this.g;
2308
+ Q.setGraph(this.g);
2309
+ this.overviewBase = "";
2310
+ }
2311
+ const base = `${this.sceneSerial}|${S.repVersion}|${this.showExternals}`;
2312
+ if (base !== this.overviewBase) {
2313
+ // what was drawn stays until the new points come (after a fold, a toggle), unless it is of
2314
+ // another scene, whose clusters it names
2315
+ if (!this.overviewBase.startsWith(`${this.sceneSerial}|`))
2316
+ L.clear();
2317
+ this.overviewBase = base;
2318
+ Q.setBase(S, { externals: this.showExternals });
2319
+ this.drawnW = null;
2320
+ this.overviewDrawn = "";
2321
+ }
2322
+ // the overview steps back under a selection: the points chosen before are scaled to it as they
2323
+ // are drawn, and asked again once a selection comes or goes; a rebuilt scene, another click or
2324
+ // another mode choose nothing anew (a fold's animation rebuilds the scene every frame)
2325
+ const style = this.active ? "dim" : "";
2326
+ if (style !== this.overviewStyled) {
2327
+ this.overviewStyled = style;
2328
+ this.overviewStyle++;
2329
+ }
2330
+ const cam = this.cam;
2331
+ const now = performance.now();
2332
+ const z = zoomPoint(cam.k);
2333
+ const i0 = Math.floor(z);
2334
+ const dz = isNaN(this.overviewZ) ? 0 : z - this.overviewZ;
2335
+ // the zoom's pace where it keeps going (this frame's change and the one before alike, within
2336
+ // 100 ms): the smaller of the two. A single jump (a wheel's notch) has none
2337
+ const goes = dz !== 0 && Math.sign(dz) === Math.sign(this.zoomLast) && now - this.zoomAt < 100;
2338
+ const pace = goes ? Math.sign(dz) * Math.min(Math.abs(dz), Math.abs(this.zoomLast)) : 0;
2339
+ // slowing down by as much a frame: it comes to rest about pace² / 2·slowing further on
2340
+ const slowing = goes ? Math.abs(this.zoomLast) - Math.abs(dz) : 0;
2341
+ if (dz !== 0) {
2342
+ this.zoomAt = now;
2343
+ this.zoomDir = Math.sign(dz);
2344
+ this.zoomLast = dz;
2345
+ }
2346
+ this.overviewZ = z;
2347
+ const local = Q.onPageThread;
2348
+ // where the zoom will be by the time an answer comes (as long as answers have been taking, in
2349
+ // frames, and half a frame more): when that is a point or more away, the camera's own points would
2350
+ // be passed before they came, and the point it heads for is asked for instead — the camera blends
2351
+ // towards it from what it has
2352
+ let lead = local ? 0 : pace * Math.min(LEAD, Math.max(1, Q.wait / Math.max(8, this.frameMs) + 0.5));
2353
+ if (slowing > 0)
2354
+ lead = Math.sign(lead) * Math.min(Math.abs(lead), (pace * pace) / (2 * slowing));
2355
+ const fast = Math.abs(lead) >= 1;
2356
+ const head = lead > 0 ? Math.ceil(z + lead) : Math.floor(z + lead);
2357
+ // the points kept: the camera's two and one on either side for the way back; with a worker, one
2358
+ // more either way for a point asked ahead, those about the point the zoom heads for, and all
2359
+ // between the camera and what is drawn while that is late (an answer there is still news)
2360
+ let lo = i0 - (local ? 1 : 2), hi = i0 + (local ? 2 : 3);
2361
+ if (fast) {
2362
+ lo = Math.min(lo, head - 2);
2363
+ hi = Math.max(hi, head + 2);
2364
+ }
2365
+ for (const x of this.drawnW ?? []) {
2366
+ lo = Math.min(lo, x.point);
2367
+ hi = Math.max(hi, x.point);
2368
+ }
2369
+ // a camera flying somewhere (fit, a search hit, the zoom buttons) two points or more away: where it
2370
+ // lands is asked for first, for its centre there, so that it lands on points of its own
2371
+ const goal = this.camGoal;
2372
+ const zg = goal ? zoomPoint(goal.k) : NaN;
2373
+ const g0 = Math.floor(zg);
2374
+ const flying = !local && goal !== null && Math.abs(zg - z) >= 2;
2375
+ if (flying) {
2376
+ lo = Math.min(lo, g0);
2377
+ hi = Math.max(hi, g0 + 1);
2378
+ }
2379
+ Q.near(lo, hi);
2380
+ const dim = this.active !== null;
2381
+ const askAt = (i, x, y, pri) => Q.ask({ point: i, x, y, w: this.w, h: this.h, dim, style: this.overviewStyle, pri }, () => S);
2382
+ const ask = (i, pri) => askAt(i, cam.x, cam.y, pri);
2383
+ if (flying)
2384
+ for (const i of [g0, g0 + 1]) {
2385
+ const latest = Q.asked(i) ?? Q.points.get(i);
2386
+ const away = latest && (Math.abs(goal.x - latest.x) * goal.k >= this.w / 6 || Math.abs(goal.y - latest.y) * goal.k >= this.h / 6);
2387
+ if (!latest || latest.style !== this.overviewStyle || away)
2388
+ askAt(i, goal.x, goal.y, 0);
2389
+ }
2390
+ // a point is chosen again once the view has moved a sixth of itself since it was chosen (or
2391
+ // asked for), or the selection or the mode changed since
2392
+ const stale = (p) => Math.abs(cam.x - p.x) * cam.k >= this.w / 6 || Math.abs(cam.y - p.y) * cam.k >= this.h / 6;
2393
+ let waiting = false;
2394
+ for (const i of fast ? [head] : [i0, i0 + 1]) {
2395
+ const latest = Q.asked(i) ?? Q.points.get(i);
2396
+ if (!latest || latest.style !== this.overviewStyle)
2397
+ ask(i, 0);
2398
+ else if (stale(latest)) {
2399
+ if (performance.now() - this.overviewAt < 150)
2400
+ waiting = true;
2401
+ else if (ask(i, 0))
2402
+ this.overviewAt = performance.now();
2403
+ }
2404
+ }
2405
+ if (waiting && this.overviewTimer === null) {
2406
+ // throttled: keep what is chosen, and look again once the wait is over, even if the view has
2407
+ // stopped by then
2408
+ this.overviewTimer = setTimeout(() => {
2409
+ this.overviewTimer = null;
2410
+ this.request();
2411
+ }, 150);
2412
+ }
2413
+ // ahead in the direction the zoom goes, so that the camera finds its next point there: the next
2414
+ // one, and the one after it while the zoom is fast
2415
+ if (!local && this.zoomDir !== 0) {
2416
+ const d = this.zoomDir;
2417
+ const from = fast ? head : d > 0 ? i0 + 1 : i0;
2418
+ for (let n = 1; n <= 2 && (n === 1 || Math.abs(dz) >= 0.5); n++) {
2419
+ const i = from + n * d;
2420
+ if (!Q.points.has(i) && !Q.asked(i))
2421
+ ask(i, 1);
2422
+ }
2423
+ }
2424
+ const target = aim(z, Q.points.keys(), [lo, hi]);
2425
+ // nothing chosen yet: what was drawn stays
2426
+ if (!target)
2427
+ return;
2428
+ const atRest = now - this.zoomAt >= ZOOM_REST;
2429
+ let W = this.drawnW && this.drawnW.every((x) => Q.points.has(x.point)) ? this.drawnW : null;
2430
+ if (W)
2431
+ W = ease(W, target, atRest ? Infinity : CATCH_UP * Math.abs(dz));
2432
+ else {
2433
+ // a new base: what was drawn stays until the camera's own points are there, or, while it
2434
+ // zooms, any
2435
+ const own = Q.points.has(i0) && (z === i0 || Q.points.has(i0 + 1));
2436
+ if (!own && atRest)
2437
+ return;
2438
+ W = target;
2439
+ }
2440
+ this.drawnW = W;
2441
+ // late, and still zooming: once the zoom rests, what is drawn is settled to the camera's blend
2442
+ if (!atRest && this.restTimer === null && weightGap(W, target) > 1e-9) {
2443
+ this.restTimer = setTimeout(() => {
2444
+ this.restTimer = null;
2445
+ this.request();
2446
+ }, this.zoomAt + ZOOM_REST - now + 1);
2447
+ }
2448
+ // the points far from the camera are let go, but not one still drawn
2449
+ Q.evict((i) => (i >= lo && i <= hi) || W.some((x) => x.point === i));
2450
+ const dimNow = dim ? DIM : 1;
2451
+ const sets = W.map(({ point, w }) => {
2452
+ const p = Q.points.get(point);
2453
+ return { looks: p.looks, index: Q.indexOf(p), w, scale: dimNow / (p.dim ? DIM : 1) };
2454
+ });
2455
+ const drawn = `${this.overviewStyle}|${dim}|${W.map((x) => `${Q.points.get(x.point).serial}:${x.w.toFixed(3)}`).join(",")}`;
2456
+ if (drawn === this.overviewDrawn)
2457
+ return;
2458
+ this.overviewDrawn = drawn;
2459
+ const c0 = [0, 0, 0, 0], c1 = [0, 0, 0, 0];
2460
+ const styled = !!this.art && this.art.style !== "board";
2461
+ L.clear();
2462
+ blendPoints(sets, (P, j, beta, width, alpha) => {
2463
+ // from the part that uses to the part it uses
2464
+ // the theme's colours at the overview's opacity; an artwork style's at theirs times it
2465
+ const [from, to] = this.edgeEnds(P.path[8 * j], P.path[8 * j + P.len[j] - 1], this.theme);
2466
+ [c0[0], c0[1], c0[2], c0[3]] = [from[0], from[1], from[2], (styled ? from[3] : 1) * alpha];
2467
+ [c1[0], c1[1], c1[2], c1[3]] = [to[0], to[1], to[2], (styled ? to[3] : 1) * alpha];
2468
+ bundlePath(L, P.path, 8 * j, P.len[j], beta, width, c0, c1);
2469
+ });
2470
+ }
2471
+ /** The colours of a bundled edge from `a` to `b` (ids of the scene's clusters, and of its
2472
+ * declarations past them), from the end that uses: the theme's; an artwork's cells', from one
2473
+ * territory's hue to the other's; its starfield's, a faint starlight. Their opacity is the most
2474
+ * the edge has. */
2475
+ edgeEnds(a, b, th) {
2476
+ const style = this.art?.style ?? "board";
2477
+ if (style === "cells")
2478
+ return [fade(this.hueOfId(a), 0.7), fade(this.hueOfId(b), 0.7)];
2479
+ if (style === "stars")
2480
+ return [rgba(STAR_LINE, 0.2), rgba(STAR_LINE, 0.2)];
2481
+ return [th.edgeFrom, th.edgeTo];
2482
+ }
2483
+ /** The highlight of what the pointer is on: a cluster's circle lit, or a ring round a point. */
2484
+ drawHover() {
2485
+ const S = this.scene;
2486
+ const th = this.theme;
2487
+ const C = this.gl.layer("hoverC", "circles");
2488
+ const P = this.gl.layer("hoverP", "points");
2489
+ C.clear();
2490
+ P.clear();
2491
+ const t = this.hoverTarget;
2492
+ if (!t || t.kind === "visitor" || this.art)
2493
+ return;
2494
+ const a = th.lit;
2495
+ if (t.kind === "cluster") {
2496
+ if (S.rep[t.c] >= 0 && S.rep[t.c] !== t.c)
2497
+ return;
2498
+ const shut = S.rep[t.c] === t.c;
2499
+ circle(C, S.cwx[t.c], S.cwy[t.c], S.drawnR[t.c], UNIT_WORLD, [a[0], a[1], a[2], shut ? 0 : 0.05], [a[0], a[1], a[2], 0.9], 2, 0, shut ? 3 : 0, 6);
2500
+ }
2501
+ else if (this.visibleNode(t.i)) {
2502
+ circle(P, this.px[t.i], this.py[t.i], 1, UNIT_DOT, [0, 0, 0, 0], [a[0], a[1], a[2], 0.95], 2, 0, 3.5);
2503
+ }
2504
+ }
2505
+ /** Text: cluster labels, labels of folded clusters, names up close. */
2506
+ drawLabels(onScreen, dotR) {
2507
+ const ctx = this.ctx;
2508
+ const k = this.cam.k;
2509
+ const th = this.theme;
2510
+ const S = this.scene;
2511
+ ctx.setTransform(...(this.labelBase ?? [this.dpr, 0, 0, this.dpr, 0, 0]));
2512
+ ctx.clearRect(0, 0, this.w, this.h);
2513
+ // under everything else written here: the names, marks and rings read over them
2514
+ this.drawKeyEdges(dotR);
2515
+ const act = this.active;
2516
+ ctx.textAlign = "center";
2517
+ // A label that would cover one already drawn is left out. Parents come before their children,
2518
+ // so where a sub-cluster touches the top of its parent, the parent keeps its name; declaration
2519
+ // names come last. Boxes are kept in a screen grid, so the check stays cheap with hundreds.
2520
+ const CELL = 40;
2521
+ const cols = Math.ceil(this.w / CELL) + 1, rows = Math.ceil(this.h / CELL) + 1;
2522
+ const grid = Array.from({ length: cols * rows }, () => []);
2523
+ const boxes = [];
2524
+ const cells = (x0, y0, x1, y1, f) => {
2525
+ const c0 = Math.max(0, Math.floor(x0 / CELL)), c1 = Math.min(cols - 1, Math.floor(x1 / CELL));
2526
+ const r0 = Math.max(0, Math.floor(y0 / CELL)), r1 = Math.min(rows - 1, Math.floor(y1 / CELL));
2527
+ for (let r = r0; r <= r1; r++)
2528
+ for (let c = c0; c <= c1; c++)
2529
+ if (f(grid[r * cols + c]) === true)
2530
+ return true;
2531
+ return false;
2532
+ };
2533
+ const free = (x0, y0, x1, y1) => !cells(x0, y0, x1, y1, (cell) => cell.some((j) => x0 < boxes[j + 2] && x1 > boxes[j] && y0 < boxes[j + 3] && y1 > boxes[j + 1]));
2534
+ const take = (x0, y0, x1, y1) => {
2535
+ const j = boxes.length;
2536
+ boxes.push(x0, y0, x1, y1);
2537
+ cells(x0, y0, x1, y1, (cell) => void cell.push(j));
2538
+ };
2539
+ // the clusters' names written, in the world: where a visitor is not put (`crowded`), and where a
2540
+ // note's card keeps off its own cluster's name (`labelOf`)
2541
+ const labels = [];
2542
+ const named = new Map();
2543
+ const art = this.art;
2544
+ // an artwork's names by selection (the owner's decision of 2026-10-15): those shown written first,
2545
+ // wherever they fall; then As shown, or nothing, without those hidden — whose room stays empty,
2546
+ // so that hiding a name brings no other in its place
2547
+ const chosen = art?.labels === "chosen";
2548
+ const asShown = !art || art.labels === "board" || (chosen && art.namesFrom === "board");
2549
+ const hidden = (id) => chosen && art.hidden.has(id);
2550
+ const extra = (id) => chosen && art.shown.has(id);
2551
+ // the names, written last, over every mark (writeNames); and, on an artwork, what each names
2552
+ const written = [];
2553
+ const wrote = new Set();
2554
+ const write = (text, x, y, id) => {
2555
+ written.push({ text, x, y, font: ctx.font, align: ctx.textAlign, fill: String(ctx.fillStyle) });
2556
+ if (id)
2557
+ wrote.add(id);
2558
+ };
2559
+ /** a name centred on `cx`, where it covers no other; `id`, what it names; `force`, written all
2560
+ * the same; hidden, its room kept and nothing written */
2561
+ const place = (text, cx, baseline, size, id = "", force = false) => {
2562
+ const w = ctx.measureText(text).width / 2 + 3;
2563
+ const x0 = cx - w, x1 = cx + w, y0 = baseline - size, y1 = baseline + size * 0.3;
2564
+ if (!force && !free(x0, y0, x1, y1))
2565
+ return false;
2566
+ take(x0, y0, x1, y1);
2567
+ if (hidden(id))
2568
+ return true;
2569
+ write(text, cx, baseline, id);
2570
+ labels.push(...this.toWorld(x0, y0), ...this.toWorld(x1, y1));
2571
+ return true;
2572
+ };
2573
+ // a declaration's name beside its point, where it covers no other name (`force`: all the same);
2574
+ // hidden, its room kept
2575
+ const nameAt = (i, text, w, force = false) => {
2576
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2577
+ const x0 = sx + dotR + 2, y0 = sy - 7, x1 = x0 + w + 2, y1 = sy + 5;
2578
+ if (!force && !free(x0, y0, x1, y1))
2579
+ return false;
2580
+ take(x0, y0, x1, y1);
2581
+ const id = art ? artNameId("decl", this.g.nodes[i].name) : "";
2582
+ if (hidden(id))
2583
+ return false;
2584
+ write(text, sx + dotR + 3, sy + 3.5, id);
2585
+ return true;
2586
+ };
2587
+ const shown = (i) => {
2588
+ if (!this.visibleNode(i))
2589
+ return false;
2590
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2591
+ return onScreen(sx, sy, 8);
2592
+ };
2593
+ const targets = art ? this.markedTargets() : [];
2594
+ // an artwork's names (the owner's decisions of 2026-10-15): in its fonts and its style's colours;
2595
+ // a cluster's along the top of its circle where it fits there (arc labels), a top-level one in
2596
+ // spaced capitals, the others in italics; under a folded one's disc, or inside the top of a
2597
+ // circle too small for the arc, straight
2598
+ const look = art ? artText(art.style, th) : null;
2599
+ /** `how`: written all the same (true); where the board writes it, wherever it falls, but only
2600
+ * where it covers no name already written ("fit": a recipe's rule chose it); or as the board
2601
+ * writes names (false) */
2602
+ const artCluster = (c, how) => {
2603
+ const force = how === true, anywhere = how !== false;
2604
+ const L = look;
2605
+ const cl = this.layout.clusters[c];
2606
+ const id = artNameId("cluster", cl.key);
2607
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2608
+ ctx.fillStyle = L.clusterFill;
2609
+ ctx.textAlign = "center";
2610
+ if (S.rep[c] === c) {
2611
+ const r = Math.max(6, S.drawnR[c] * k);
2612
+ if (!anywhere && (r <= 10 || !onScreen(sx, sy, r) || (act && this.clusterHasActive(c))))
2613
+ return false;
2614
+ ctx.font = L.sub(11);
2615
+ return place(cl.label, sx, sy + r + 13, 11, id, force);
2616
+ }
2617
+ const sr = S.drawnR[c] * k;
2618
+ if (!anywhere && (sr <= 38 || !onScreen(sx, sy, sr)))
2619
+ return false;
2620
+ const top = cl.depth === 0;
2621
+ const size = Math.max(10, Math.min(top ? 22 : 16, 9 + sr / 40));
2622
+ const text = top ? cl.label.toUpperCase() : cl.label;
2623
+ const font = top ? L.top(size) : L.sub(size);
2624
+ const sp = L.spacing(top, size);
2625
+ ctx.font = font;
2626
+ const len = spacedWidth(ctx, text, sp);
2627
+ const R = sr - size * 0.35 - 6;
2628
+ const half = len / Math.max(R, 1) / 2;
2629
+ let box;
2630
+ let item;
2631
+ if (sr > 60 && half < Math.PI * 0.45) {
2632
+ const reach = R * Math.sin(half) + 4;
2633
+ box = [sx - reach, sy - sr, sx + reach, sy - R * Math.cos(half) + size * 0.4];
2634
+ item = { text, x: sx, y: sy, font, align: "center", fill: L.clusterFill, spacing: sp, arc: { cx: sx, cy: sy, r: R } };
2635
+ }
2636
+ else {
2637
+ const by = sr > 38 ? sy - sr + size + 6 : sy - sr - 4;
2638
+ box = [sx - len / 2 - 3, by - size, sx + len / 2 + 3, by + size * 0.3];
2639
+ item = { text, x: sx, y: by, font, align: "center", fill: L.clusterFill, spacing: sp };
2640
+ }
2641
+ if (!force && !free(...box))
2642
+ return false;
2643
+ take(...box);
2644
+ if (hidden(id))
2645
+ return true;
2646
+ written.push(item);
2647
+ wrote.add(id);
2648
+ labels.push(...this.toWorld(box[0], box[1]), ...this.toWorld(box[2], box[3]));
2649
+ named.set(c, labels.length - 4);
2650
+ return true;
2651
+ };
2652
+ ctx.fillStyle = th.label;
2653
+ // the clusters' shown, where the board writes them
2654
+ if (chosen)
2655
+ for (let c = 0; c < S.nc; c++) {
2656
+ const cl = this.layout.clusters[c];
2657
+ if (!this.artClusters.has(cl.key) || (S.rep[c] >= 0 && S.rep[c] !== c) || !this.clusterVisible(c))
2658
+ continue;
2659
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2660
+ if (onScreen(sx, sy, 400))
2661
+ artCluster(c, true);
2662
+ }
2663
+ // the declarations' shown, beside their points: a target marked in bold
2664
+ if (chosen) {
2665
+ const bold = new Set(targets);
2666
+ ctx.textAlign = "left";
2667
+ for (const name of this.artDecls) {
2668
+ const i = this.g.byName.get(name);
2669
+ if (i === undefined || !shown(i))
2670
+ continue;
2671
+ ctx.font = bold.has(i) ? look.target : look.decl;
2672
+ ctx.fillStyle = bold.has(i) ? look.clusterFill : look.declFill;
2673
+ nameAt(i, this.g.short(i), ctx.measureText(this.g.short(i)).width, true);
2674
+ }
2675
+ ctx.textAlign = "center";
2676
+ ctx.fillStyle = th.label;
2677
+ }
2678
+ // the names a recipe's rule chose (ExportSpec decision 20; the owner's decision of 2026-10-15:
2679
+ // never one over another): after those shown, each where the board writes it, only where it
2680
+ // covers no name already written — a parent's before its children's
2681
+ if (chosen) {
2682
+ for (let c = 0; c < S.nc; c++) {
2683
+ const cl = this.layout.clusters[c];
2684
+ if (!this.artRuled.has(cl.key) || this.artClusters.has(cl.key) || (S.rep[c] >= 0 && S.rep[c] !== c) || !this.clusterVisible(c))
2685
+ continue;
2686
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2687
+ if (onScreen(sx, sy, 400))
2688
+ artCluster(c, "fit");
2689
+ }
2690
+ ctx.textAlign = "left";
2691
+ for (const name of this.artRuledDecls) {
2692
+ const i = this.g.byName.get(name);
2693
+ if (i === undefined || !shown(i))
2694
+ continue;
2695
+ ctx.font = look.decl;
2696
+ ctx.fillStyle = look.declFill;
2697
+ nameAt(i, this.g.short(i), ctx.measureText(this.g.short(i)).width);
2698
+ }
2699
+ ctx.textAlign = "center";
2700
+ ctx.fillStyle = th.label;
2701
+ }
2702
+ for (let c = 0; c < S.nc; c++) {
2703
+ if (art?.labels === "none" || (chosen && !asShown))
2704
+ break;
2705
+ if (chosen && (this.artClusters.has(this.layout.clusters[c].key) || this.artRuled.has(this.layout.clusters[c].key)))
2706
+ continue;
2707
+ const rep = S.rep[c];
2708
+ if (!this.clusterVisible(c))
2709
+ continue;
2710
+ if (art) {
2711
+ if (rep < 0 || rep === c)
2712
+ artCluster(c, false);
2713
+ continue;
2714
+ }
2715
+ const cl = this.layout.clusters[c];
2716
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2717
+ if (rep === c) {
2718
+ const r = Math.max(6, S.drawnR[c] * k);
2719
+ if (r > 10 && onScreen(sx, sy, r) && !(act && this.clusterHasActive(c))) {
2720
+ ctx.font = `500 11px ${FONT}`;
2721
+ place(cl.label, sx, sy + r + 13, 11, art ? artNameId("cluster", cl.key) : "");
2722
+ }
2723
+ continue;
2724
+ }
2725
+ if (rep >= 0)
2726
+ continue;
2727
+ const sr = S.drawnR[c] * k;
2728
+ if (sr <= 38 || !onScreen(sx, sy, sr))
2729
+ continue;
2730
+ const size = Math.max(10, Math.min(20, 9 + sr / 40));
2731
+ ctx.font = `${cl.depth === 0 ? 600 : 500} ${size}px ${FONT}`;
2732
+ // covered at the top: one line lower, still inside its own circle
2733
+ const id = art ? artNameId("cluster", cl.key) : "";
2734
+ if (place(cl.label, sx, sy - sr + size + 6, size, id) || (sr > 4 * size && place(cl.label, sx, sy - sr + 2 * size + 10, size, id)))
2735
+ named.set(c, labels.length - 4);
2736
+ }
2737
+ // what is the reader's own is not drawn over an artwork: visitors and their clusters, others
2738
+ if (!art)
2739
+ this.tempLabels(place, onScreen);
2740
+ this.labelBoxes = labels;
2741
+ this.labelOfC = named;
2742
+ this.drawProgress(onScreen);
2743
+ this.drawLeaders();
2744
+ // an artwork's key marks have a switch of their own (the owner's decision of 2026-10-15)
2745
+ if (!art || art.keys)
2746
+ this.drawKeys(onScreen, dotR);
2747
+ this.drawStars(onScreen, dotR);
2748
+ if (!art) {
2749
+ this.drawVisitors(onScreen);
2750
+ this.drawPeers(onScreen, dotR);
2751
+ }
2752
+ this.drawSelection(dotR);
2753
+ // an artwork's (ExportSpec decision 19): its targets' names, marked, before any other; and where
2754
+ // something is lit, the lit declarations' names where the board would give them cards, nearest
2755
+ // the middle first
2756
+ const marked = art && art.labels !== "none" && asShown ? targets.filter((i) => shown(i) && !extra(artNameId("decl", this.g.nodes[i].name))) : [];
2757
+ ctx.textAlign = "left";
2758
+ ctx.font = look ? look.target : `600 11px ${FONT}`;
2759
+ ctx.fillStyle = look ? look.clusterFill : th.label;
2760
+ for (const i of marked)
2761
+ nameAt(i, this.g.short(i), ctx.measureText(this.g.short(i)).width);
2762
+ const seen = new Set(marked);
2763
+ if (chosen)
2764
+ for (const name of this.artDecls) {
2765
+ const i = this.g.byName.get(name);
2766
+ if (i !== undefined)
2767
+ seen.add(i);
2768
+ }
2769
+ if (art && asShown && act) {
2770
+ ctx.font = look.decl;
2771
+ ctx.fillStyle = look.declFill;
2772
+ const cx = this.w / 2, cy = this.h / 2;
2773
+ const lit = this.activeList.filter((i) => !seen.has(i) && shown(i));
2774
+ const d = (i) => {
2775
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2776
+ return (sx - cx) ** 2 + (sy - cy) ** 2;
2777
+ };
2778
+ lit.sort((a, b) => d(a) - d(b));
2779
+ let budget = cardCap(k);
2780
+ for (const i of lit) {
2781
+ if (budget <= 0)
2782
+ break;
2783
+ if (nameAt(i, this.g.short(i), ctx.measureText(this.g.short(i)).width))
2784
+ budget--;
2785
+ }
2786
+ }
2787
+ if (asShown && k > 1.1 && !act) {
2788
+ ctx.font = look ? look.decl : `11px ${FONT}`;
2789
+ ctx.textAlign = "left";
2790
+ ctx.fillStyle = look ? look.declFill : th.text;
2791
+ // declaration names, nearest the middle of the screen first; one that would cover another
2792
+ // name (or a cluster's) is left out, so what is written can be read and the edges show
2793
+ const cx = this.w / 2, cy = this.h / 2;
2794
+ const near = [];
2795
+ const dist = [];
2796
+ for (let i = 0; i < this.g.n; i++) {
2797
+ if (!this.visibleNode(i))
2798
+ continue;
2799
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2800
+ if (!onScreen(sx, sy, 8))
2801
+ continue;
2802
+ near.push(i);
2803
+ dist[i] = (sx - cx) ** 2 + (sy - cy) ** 2;
2804
+ }
2805
+ near.sort((a, b) => dist[a] - dist[b]);
2806
+ if (this.nameW.length !== this.g.n)
2807
+ this.nameW = new Float32Array(this.g.n).fill(-1);
2808
+ let budget = 700;
2809
+ for (let t = 0; t < near.length && t < 4000 && budget > 0; t++) {
2810
+ const i = near[t];
2811
+ if (seen.has(i))
2812
+ continue;
2813
+ let w = this.nameW[i];
2814
+ // (the widths kept are in the board's own font; an artwork writes in its own)
2815
+ if (look)
2816
+ w = ctx.measureText(this.g.short(i)).width;
2817
+ else if (!(w >= 0))
2818
+ w = this.nameW[i] = ctx.measureText(this.g.short(i)).width;
2819
+ if (nameAt(i, this.g.short(i), w))
2820
+ budget--;
2821
+ }
2822
+ }
2823
+ this.writeNames(written, look?.halo ?? null);
2824
+ this.namesWritten = wrote;
2825
+ }
2826
+ /** The names, over everything else written on the board, each on its halo of the background
2827
+ * (web/src/halo.ts). */
2828
+ writeNames(names, halo) {
2829
+ const bg = this.theme.bg;
2830
+ writeNames(this.ctx, names, halo ?? `rgb(${Math.round(bg[0] * 255)}, ${Math.round(bg[1] * 255)}, ${Math.round(bg[2] * 255)})`);
2831
+ }
2832
+ /** In the Status scheme, how far the review of each cluster on screen has come, on its outline: an
2833
+ * arc from the top, clockwise, for the share approved, then one for the share commented, of what
2834
+ * it holds that needs review. Only on circles large enough to read it, and only where something
2835
+ * has been said. */
2836
+ drawProgress(onScreen) {
2837
+ if (this.scheme !== "status" || !this.clusterProgress.length)
2838
+ return;
2839
+ const S = this.scene;
2840
+ const P = this.clusterProgress;
2841
+ const ctx = this.ctx;
2842
+ const th = this.theme;
2843
+ const css = (c) => `rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${c[3]})`;
2844
+ const colors = [css(th.status[ST_APPROVED]), css(th.status[ST_COMMENTED])];
2845
+ ctx.save();
2846
+ // thin and a little faded: a summary to glance at, quieter than the selection's ring
2847
+ ctx.lineWidth = 2;
2848
+ ctx.globalAlpha = 0.85;
2849
+ ctx.lineCap = "butt";
2850
+ for (let c = 0; c < S.nc; c++) {
2851
+ const rep = S.rep[c];
2852
+ if ((rep >= 0 && rep !== c) || !this.clusterVisible(c))
2853
+ continue;
2854
+ const total = P[3 * c + 2];
2855
+ if (!total || P[3 * c] + P[3 * c + 1] === 0)
2856
+ continue;
2857
+ const r = rep === c ? Math.max(6, S.drawnR[c] * this.cam.k) : S.drawnR[c] * this.cam.k;
2858
+ if (r < 18)
2859
+ continue;
2860
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2861
+ if (!onScreen(sx, sy, r))
2862
+ continue;
2863
+ let a = -Math.PI / 2;
2864
+ for (let k = 0; k < 2; k++) {
2865
+ const share = P[3 * c + k] / total;
2866
+ if (!share)
2867
+ continue;
2868
+ ctx.strokeStyle = colors[k];
2869
+ ctx.beginPath();
2870
+ ctx.arc(sx, sy, r, a, a + share * 2 * Math.PI);
2871
+ ctx.stroke();
2872
+ a += share * 2 * Math.PI;
2873
+ }
2874
+ }
2875
+ ctx.restore();
2876
+ }
2877
+ /** A gold five-pointed star on every target: on its point, or on the edge of the folded cluster
2878
+ * hiding it. Never smaller than can be seen. */
2879
+ drawStars(onScreen, dotR) {
2880
+ const targets = this.markedTargets();
2881
+ if (!targets.length)
2882
+ return;
2883
+ const ctx = this.ctx;
2884
+ const S = this.scene;
2885
+ const star = (x, y, R) => {
2886
+ ctx.beginPath();
2887
+ for (let k = 0; k < 10; k++) {
2888
+ const a = -Math.PI / 2 + (k * Math.PI) / 5;
2889
+ const r = k % 2 ? R * 0.46 : R;
2890
+ const px = x + r * Math.cos(a), py = y + r * Math.sin(a);
2891
+ if (k)
2892
+ ctx.lineTo(px, py);
2893
+ else
2894
+ ctx.moveTo(px, py);
2895
+ }
2896
+ ctx.closePath();
2897
+ ctx.fill();
2898
+ ctx.stroke();
2899
+ };
2900
+ ctx.save();
2901
+ ctx.fillStyle = this.theme.star;
2902
+ ctx.strokeStyle = this.theme.starEdge;
2903
+ ctx.lineWidth = 1.2;
2904
+ ctx.lineJoin = "round";
2905
+ const seen = new Set();
2906
+ for (const i of targets) {
2907
+ if (this.visibleNode(i)) {
2908
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2909
+ if (onScreen(sx, sy, 20))
2910
+ star(sx, sy, Math.max(9, dotR * 2.2));
2911
+ continue;
2912
+ }
2913
+ // hidden in a folded cluster: one star on the edge of its disc
2914
+ const c = S.nodeRep[i];
2915
+ if (c < 0 || seen.has(c))
2916
+ continue;
2917
+ seen.add(c);
2918
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2919
+ const r = Math.max(6, S.drawnR[c] * this.cam.k);
2920
+ if (onScreen(sx, sy, r + 20))
2921
+ star(sx + r * 0.71, sy - r * 0.71, 7);
2922
+ }
2923
+ ctx.restore();
2924
+ }
2925
+ /** A small diamond on every key declaration, at the top left of its point — the point's colour
2926
+ * still shows, and its name, written to the right, stays clear; hidden in a folded cluster, one
2927
+ * on the edge of its disc, opposite the star's place. Never smaller than can be seen. An artwork
2928
+ * whose targets are chosen draws only the keys of those it chose. */
2929
+ drawKeys(onScreen, dotR) {
2930
+ if (!this.keyList.length)
2931
+ return;
2932
+ const on = this.art?.targets === "chosen" ? this.chosenTargets() : null;
2933
+ const ctx = this.ctx;
2934
+ const S = this.scene;
2935
+ const diamond = (x, y, R) => {
2936
+ ctx.beginPath();
2937
+ ctx.moveTo(x, y - R);
2938
+ ctx.lineTo(x + R * 0.72, y);
2939
+ ctx.lineTo(x, y + R);
2940
+ ctx.lineTo(x - R * 0.72, y);
2941
+ ctx.closePath();
2942
+ ctx.fill();
2943
+ ctx.stroke();
2944
+ };
2945
+ ctx.save();
2946
+ ctx.fillStyle = this.theme.key;
2947
+ ctx.strokeStyle = this.theme.starEdge;
2948
+ ctx.lineWidth = 1;
2949
+ ctx.lineJoin = "round";
2950
+ const seen = new Set();
2951
+ const R = Math.max(4.5, dotR * 1.1);
2952
+ for (let j = 0; j < this.keyList.length; j++) {
2953
+ const i = this.keyList[j];
2954
+ if (on && !this.keyOf[j]?.some((t) => on.has(t)))
2955
+ continue;
2956
+ if (this.visibleNode(i)) {
2957
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
2958
+ if (onScreen(sx, sy, 12))
2959
+ diamond(sx - dotR - R * 0.4, sy - dotR - R * 0.3, R);
2960
+ continue;
2961
+ }
2962
+ const c = S.nodeRep[i];
2963
+ if (c < 0 || seen.has(c))
2964
+ continue;
2965
+ seen.add(c);
2966
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
2967
+ const r = Math.max(6, S.drawnR[c] * this.cam.k);
2968
+ if (onScreen(sx, sy, r + 12))
2969
+ diamond(sx - r * 0.71, sy - r * 0.71, 5);
2970
+ }
2971
+ ctx.restore();
2972
+ }
2973
+ /** Others' selections: a ring around the point, split between the readers on it, and their
2974
+ * initials beside; hidden in a folded cluster, the same on the edge of its disc. */
2975
+ drawPeers(onScreen, dotR) {
2976
+ if (!this.peerMarks.size)
2977
+ return;
2978
+ const ctx = this.ctx;
2979
+ const S = this.scene;
2980
+ ctx.save();
2981
+ ctx.lineWidth = 2.5;
2982
+ ctx.font = `600 9.5px ${FONT}`;
2983
+ ctx.textBaseline = "middle";
2984
+ ctx.textAlign = "center";
2985
+ const mark = (x, y, r, m) => {
2986
+ const n = m.colors.length;
2987
+ m.colors.forEach((c, k) => {
2988
+ ctx.strokeStyle = c;
2989
+ ctx.beginPath();
2990
+ ctx.arc(x, y, r, -Math.PI / 2 + (k * 2 * Math.PI) / n, -Math.PI / 2 + ((k + 1) * 2 * Math.PI) / n);
2991
+ ctx.stroke();
2992
+ });
2993
+ // the initials, side by side, at the lower right
2994
+ m.labels.forEach((label, k) => {
2995
+ const bx = x + r * 0.75 + 9 + k * 19, by = y + r * 0.75 + 6;
2996
+ ctx.fillStyle = m.colors[k];
2997
+ ctx.beginPath();
2998
+ ctx.arc(bx, by, 8.5, 0, 2 * Math.PI);
2999
+ ctx.fill();
3000
+ ctx.fillStyle = "#fff";
3001
+ ctx.fillText(label, bx, by + 0.5);
3002
+ });
3003
+ };
3004
+ const folded = new Map();
3005
+ for (const [i, m] of this.peerMarks) {
3006
+ if (i < 0 || i >= this.g.n)
3007
+ continue;
3008
+ if (this.visibleNode(i)) {
3009
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
3010
+ if (onScreen(sx, sy, 30))
3011
+ mark(sx, sy, Math.max(7, dotR + 4), m);
3012
+ continue;
3013
+ }
3014
+ const c = S.nodeRep[i];
3015
+ if (c < 0)
3016
+ continue;
3017
+ const f = folded.get(c) ?? { colors: [], labels: [] };
3018
+ f.colors.push(...m.colors);
3019
+ f.labels.push(...m.labels);
3020
+ folded.set(c, f);
3021
+ }
3022
+ for (const [c, m] of folded) {
3023
+ const [sx, sy] = this.toScreen(S.cwx[c], S.cwy[c]);
3024
+ const r = Math.max(6, S.drawnR[c] * this.cam.k);
3025
+ if (onScreen(sx, sy, r + 30))
3026
+ mark(sx - r * 0.71, sy + r * 0.71, 6, m);
3027
+ }
3028
+ ctx.restore();
3029
+ }
3030
+ /** The visitors' temporary clusters (ViewSpec decision 27): circles as the board's are, their
3031
+ * outlines dashed in the labels' colour, under what they hold; none inside a folded cluster. */
3032
+ tempDiscs(L) {
3033
+ const V = this.visits, W = this.visitW;
3034
+ if (!V || !W)
3035
+ return;
3036
+ const th = this.theme;
3037
+ const line = fade(th.pin, 0.75);
3038
+ for (let t = 0; t < V.plan.temps.length; t++) {
3039
+ const c = V.plan.temps[t].in;
3040
+ if (W.trep[t] >= 0 || (c >= 0 && !this.clusterVisible(c)))
3041
+ continue;
3042
+ circle(L, W.tx[t], W.ty[t], W.tr[t], UNIT_WORLD, th.disc, line, 1.2, FLAG_DASHED);
3043
+ }
3044
+ }
3045
+ /** The temporary clusters' names (ViewSpec decision 27), in italics: inside the top of a circle
3046
+ * large enough on the screen, as a cluster's is, else just above it, or below where that is taken,
3047
+ * each kept off the names already written. */
3048
+ tempLabels(place, onScreen) {
3049
+ const V = this.visits, W = this.visitW;
3050
+ if (!V || !W)
3051
+ return;
3052
+ const ctx = this.ctx;
3053
+ const k = this.cam.k;
3054
+ ctx.fillStyle = this.theme.label;
3055
+ V.plan.temps.forEach((t, j) => {
3056
+ if (W.trep[j] >= 0 || (t.in >= 0 && !this.clusterVisible(t.in)))
3057
+ return;
3058
+ const [sx, sy] = this.toScreen(W.tx[j], W.ty[j]);
3059
+ const sr = W.tr[j] * k;
3060
+ if (sr < 3 || !onScreen(sx, sy, sr + 60))
3061
+ return;
3062
+ const size = sr > 38 ? Math.max(10, Math.min(20, 9 + sr / 40)) : 11;
3063
+ ctx.font = `italic 500 ${size}px ${FONT}`;
3064
+ if (sr > 38 && place(t.label, sx, sy - sr + size + 6, size))
3065
+ return;
3066
+ if (!place(t.label, sx, sy - sr - 4, size))
3067
+ place(t.label, sx, sy + sr + size + 2, size);
3068
+ });
3069
+ }
3070
+ /** The visitors' edges (ViewSpec decision 27), bundled as any edge is: through the visitor's
3071
+ * temporary clusters and the board's tree to what the board holds that it uses or that uses it,
3072
+ * and to the other visitors; coloured from the end that uses to the end used, a reference only in a
3073
+ * proof, or on grammar, fainter. An end inside a folded cluster is on its card, and two ends in one
3074
+ * card draw nothing. */
3075
+ visitBundles(L) {
3076
+ const V = this.visits, W = this.visitW;
3077
+ if (!V || !W || W.tx.length + W.vx.length > VISIT_SLOTS)
3078
+ return;
3079
+ const S = this.scene;
3080
+ const th = this.theme;
3081
+ const base = S.nc + S.n;
3082
+ const up = (c, out) => {
3083
+ for (; c >= 0; c = S.parent[c])
3084
+ out.push(c);
3085
+ return out;
3086
+ };
3087
+ const nodeChain = (j) => {
3088
+ if (j < 0 || j >= this.g.n)
3089
+ return null;
3090
+ const r = S.nodeRep[j];
3091
+ if (r >= 0)
3092
+ return this.clusterVisible(r) ? up(r, []) : null;
3093
+ return this.visibleNode(j) ? up(S.nodeCluster[j], [S.nc + j]) : null;
3094
+ };
3095
+ const chains = this.visitors.map((_, k) => (k < W.vx.length ? visitChain(V, W, S, base, k) : null));
3096
+ const paths = new Map();
3097
+ const add = (path, strong) => {
3098
+ if (path[0] === path[path.length - 1])
3099
+ return;
3100
+ const key = path.join(",");
3101
+ const was = paths.get(key);
3102
+ if (!was || (strong && !was[1]))
3103
+ paths.set(key, [path, strong]);
3104
+ };
3105
+ this.visitors.forEach((v, k) => {
3106
+ const ck = chains[k];
3107
+ if (!ck)
3108
+ return;
3109
+ for (const [j, incoming, strong] of v.board) {
3110
+ const cj = nodeChain(j);
3111
+ if (cj)
3112
+ add(incoming ? joinChains(cj, ck) : joinChains(ck, cj), strong);
3113
+ }
3114
+ // each pair once: from the side of the visitor that uses the other
3115
+ for (const [o, incoming, strong] of v.visitors)
3116
+ if (!incoming && chains[o])
3117
+ add(joinChains(ck, chains[o]), strong);
3118
+ });
3119
+ const n = paths.size;
3120
+ const fadeBy = n < 250 ? 1 : Math.max(0.25, Math.sqrt(250 / n));
3121
+ const width = n > 4000 ? 1 : 1.6;
3122
+ for (const [path, strong] of paths.values()) {
3123
+ const f = (strong ? 1 : 0.33) * fadeBy;
3124
+ bundle(L, path, 0.85, width, fade(th.edgeFrom, f), fade(th.edgeTo, f));
3125
+ }
3126
+ }
3127
+ /** The visitors themselves (ViewSpec decision 27): a point in its colour inside a dashed ring —
3128
+ * temporary, not of the board — on a halo of the canvas's colour, its name beside it in italics, a
3129
+ * target's star and a key declaration's diamond as on any point. One inside a folded cluster is not
3130
+ * drawn: the cluster's card carries a marker, a dashed ring with how many it hides. */
3131
+ drawVisitors(onScreen) {
3132
+ const W = this.visitW;
3133
+ if (!this.visitors.length || !W)
3134
+ return;
3135
+ const ctx = this.ctx;
3136
+ const th = this.theme;
3137
+ const css = (c, a = 1) => `rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${Math.min(1, c[3] * a)})`;
3138
+ const R = this.visitorR();
3139
+ ctx.save();
3140
+ ctx.textAlign = "left";
3141
+ ctx.textBaseline = "middle";
3142
+ ctx.font = `italic 500 11px ${FONT}`;
3143
+ const hidden = new Map();
3144
+ this.visitors.forEach((v, k) => {
3145
+ if (k >= W.vx.length)
3146
+ return;
3147
+ if (W.vrep[k] >= 0)
3148
+ return void hidden.set(W.vrep[k], (hidden.get(W.vrep[k]) ?? 0) + 1);
3149
+ const [x, y] = this.toScreen(W.vx[k], W.vy[k]);
3150
+ if (!onScreen(x, y, 200))
3151
+ return;
3152
+ const c = this.scheme === "status" ? th.status[v.status] ?? th.status[ST_UNREVIEWED] : v.kind === "external" ? th.ext : th.kind[v.kind] ?? th.ext;
3153
+ ctx.fillStyle = css(th.bg);
3154
+ ctx.beginPath();
3155
+ ctx.arc(x, y, R + 3, 0, 2 * Math.PI);
3156
+ ctx.fill();
3157
+ ctx.fillStyle = css(c, 0.9);
3158
+ ctx.beginPath();
3159
+ ctx.arc(x, y, R - 1.5, 0, 2 * Math.PI);
3160
+ ctx.fill();
3161
+ // the dashed ring in the labels' colour, which reads on either theme whatever the point's
3162
+ ctx.setLineDash([3, 2.2]);
3163
+ ctx.lineWidth = 1.5;
3164
+ ctx.strokeStyle = th.label;
3165
+ ctx.beginPath();
3166
+ ctx.arc(x, y, R + 1, 0, 2 * Math.PI);
3167
+ ctx.stroke();
3168
+ ctx.setLineDash([]);
3169
+ // its name, legible over whatever is under it
3170
+ ctx.lineWidth = 3;
3171
+ ctx.strokeStyle = css(th.bg, 0.9);
3172
+ ctx.strokeText(v.label, x + R + 6, y);
3173
+ ctx.fillStyle = th.text;
3174
+ ctx.fillText(v.label, x + R + 6, y);
3175
+ if (v.target || v.key) {
3176
+ ctx.lineWidth = 1;
3177
+ ctx.lineJoin = "round";
3178
+ ctx.strokeStyle = th.starEdge;
3179
+ ctx.beginPath();
3180
+ if (v.target) {
3181
+ ctx.fillStyle = th.star;
3182
+ const S = Math.max(6, R * 1.1);
3183
+ for (let q = 0; q < 10; q++) {
3184
+ const a = -Math.PI / 2 + (q * Math.PI) / 5;
3185
+ const r = q % 2 ? S * 0.46 : S;
3186
+ const px = x + R * 0.9 + r * Math.cos(a), py = y - R * 0.9 + r * Math.sin(a);
3187
+ if (q)
3188
+ ctx.lineTo(px, py);
3189
+ else
3190
+ ctx.moveTo(px, py);
3191
+ }
3192
+ }
3193
+ else {
3194
+ ctx.fillStyle = th.key;
3195
+ const D = Math.max(4, R * 0.8), cx = x - R * 0.9, cy = y - R * 0.9;
3196
+ ctx.moveTo(cx, cy - D);
3197
+ ctx.lineTo(cx + D * 0.72, cy);
3198
+ ctx.lineTo(cx, cy + D);
3199
+ ctx.lineTo(cx - D * 0.72, cy);
3200
+ }
3201
+ ctx.closePath();
3202
+ ctx.fill();
3203
+ ctx.stroke();
3204
+ }
3205
+ });
3206
+ // the folded clusters that hide visitors: a marker on each card, as a visitor is drawn, and how
3207
+ // many where more than one
3208
+ ctx.font = `italic 600 10px ${FONT}`;
3209
+ for (const [c, n] of hidden) {
3210
+ if (!this.clusterVisible(c))
3211
+ continue;
3212
+ const [x, y] = this.visitMarkAt(c);
3213
+ if (!onScreen(x, y, 20))
3214
+ continue;
3215
+ ctx.fillStyle = css(th.bg);
3216
+ ctx.beginPath();
3217
+ ctx.arc(x, y, 8, 0, 2 * Math.PI);
3218
+ ctx.fill();
3219
+ ctx.fillStyle = th.label;
3220
+ ctx.beginPath();
3221
+ ctx.arc(x, y, 2.5, 0, 2 * Math.PI);
3222
+ ctx.fill();
3223
+ ctx.setLineDash([2.5, 2]);
3224
+ ctx.lineWidth = 1.4;
3225
+ ctx.strokeStyle = th.label;
3226
+ ctx.beginPath();
3227
+ ctx.arc(x, y, 6.5, 0, 2 * Math.PI);
3228
+ ctx.stroke();
3229
+ ctx.setLineDash([]);
3230
+ if (n > 1) {
3231
+ ctx.lineWidth = 3;
3232
+ ctx.strokeStyle = css(th.bg, 0.9);
3233
+ ctx.strokeText(String(n), x + 10, y);
3234
+ ctx.fillStyle = th.text;
3235
+ ctx.fillText(String(n), x + 10, y);
3236
+ }
3237
+ }
3238
+ ctx.restore();
3239
+ }
3240
+ /** The leads from lit declarations to their cards (drawCards): thin, quiet, under the cards. */
3241
+ /** The key graph's edges among what is lit, under the Keys step of Rests on (ViewSpec decision
3242
+ * 20): dashed, since each says "rests on, through declarations that are not key" rather than a
3243
+ * reference, and slightly bowed rather than bundled along the tree — there are a dozen or so,
3244
+ * and a path through the tree would say nothing of the declarations between. Coloured as every
3245
+ * edge is, from the end that rests on to the end it rests on, and stopped short of both points.
3246
+ * An end inside a folded cluster is on its disc; two ends in one folded cluster draw nothing. */
3247
+ drawKeyEdges(dotR) {
3248
+ const E = this.keyEdges;
3249
+ if (!E || !E.length)
3250
+ return;
3251
+ const S = this.scene;
3252
+ const k = this.cam.k;
3253
+ /** an end on the screen, and how far short of it the edge stops; null where it is not drawn */
3254
+ const end = (i) => {
3255
+ if (i < 0 || i >= this.g.n)
3256
+ return null;
3257
+ if (this.visibleNode(i)) {
3258
+ const [x, y] = this.toScreen(this.px[i], this.py[i]);
3259
+ return [x, y, dotR + 4, -1 - i];
3260
+ }
3261
+ const c = S.nodeRep[i];
3262
+ if (c < 0 || !this.clusterVisible(c))
3263
+ return null;
3264
+ const [x, y] = this.toScreen(S.cwx[c], S.cwy[c]);
3265
+ return [x, y, Math.max(6, S.drawnR[c] * k) + 3, c];
3266
+ };
3267
+ const ctx = this.ctx;
3268
+ const th = this.theme;
3269
+ const css = (c) => `rgba(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)}, ${Math.min(1, c[3] + 0.1)})`;
3270
+ const from = css(th.edgeFrom), to = css(th.edgeTo);
3271
+ ctx.save();
3272
+ ctx.lineWidth = 1.8;
3273
+ ctx.lineCap = "round";
3274
+ ctx.setLineDash([6, 4]);
3275
+ const drawn = new Set();
3276
+ for (let e = 0; e < E.length; e += 2) {
3277
+ const a = end(E[e]), b = end(E[e + 1]);
3278
+ if (!a || !b || a[3] === b[3])
3279
+ continue;
3280
+ const id = `${a[3]} ${b[3]}`;
3281
+ if (drawn.has(id))
3282
+ continue;
3283
+ drawn.add(id);
3284
+ const dx = b[0] - a[0], dy = b[1] - a[1];
3285
+ const len = Math.hypot(dx, dy);
3286
+ if (len <= a[2] + b[2] + 4)
3287
+ continue;
3288
+ // nothing of it on screen: left out
3289
+ if (Math.max(a[0], b[0]) < 0 || Math.min(a[0], b[0]) > this.w || Math.max(a[1], b[1]) < 0 || Math.min(a[1], b[1]) > this.h)
3290
+ continue;
3291
+ // bowed to the left of the way it goes, so that two declarations resting on each other show
3292
+ // two edges
3293
+ const bow = Math.min(36, len * 0.12);
3294
+ const ux = dx / len, uy = dy / len;
3295
+ const cx = (a[0] + b[0]) / 2 + uy * bow, cy = (a[1] + b[1]) / 2 - ux * bow;
3296
+ const toward = (p) => {
3297
+ const vx = cx - p[0], vy = cy - p[1];
3298
+ const d = Math.hypot(vx, vy) || 1;
3299
+ return [p[0] + (vx / d) * p[2], p[1] + (vy / d) * p[2]];
3300
+ };
3301
+ const [x0, y0] = toward(a), [x1, y1] = toward(b);
3302
+ const grad = ctx.createLinearGradient(x0, y0, x1, y1);
3303
+ grad.addColorStop(0, from);
3304
+ grad.addColorStop(1, to);
3305
+ ctx.strokeStyle = grad;
3306
+ ctx.beginPath();
3307
+ ctx.moveTo(x0, y0);
3308
+ ctx.quadraticCurveTo(cx, cy, x1, y1);
3309
+ ctx.stroke();
3310
+ }
3311
+ ctx.restore();
3312
+ }
3313
+ drawLeaders() {
3314
+ const L = this.leaders;
3315
+ if (!L.length)
3316
+ return;
3317
+ const ctx = this.ctx;
3318
+ ctx.save();
3319
+ ctx.strokeStyle = this.theme.text;
3320
+ ctx.globalAlpha = 0.55;
3321
+ ctx.lineWidth = 1;
3322
+ ctx.beginPath();
3323
+ for (let k = 0; k < L.length; k += 5) {
3324
+ ctx.moveTo(L[k], L[k + 1]);
3325
+ ctx.lineTo(L[k + 2], L[k + 3]);
3326
+ }
3327
+ ctx.stroke();
3328
+ ctx.restore();
3329
+ }
3330
+ /** The selection on the canvas: a ring round every declaration and every cluster selected (a
3331
+ * cluster's circle, or a folded one's card), and a lead from what the panel tells of to the
3332
+ * panel — a curve that leaves its ring towards the panel and meets the panel's edge level. It is
3333
+ * drawn again while its end moves (the panel sliding in, or scrolled). What lies inside a folded
3334
+ * cluster is ringed on that cluster. */
3335
+ drawSelection(dotR) {
3336
+ const S = this.scene;
3337
+ const k = this.cam.k;
3338
+ const pointR = Math.max(7, dotR + 4);
3339
+ /** a folded cluster's ring: round its card while it has one, else round its body */
3340
+ const body = (c) => {
3341
+ const b = this.clusterBoxes.get(c);
3342
+ if (b)
3343
+ return { box: [b[0] - 3, b[1] - 3, b[2] + 3, b[3] + 3], c };
3344
+ const [x, y] = this.toScreen(S.cwx[c], S.cwy[c]);
3345
+ return { x, y, r: Math.max(6, S.drawnR[c] * k) + 3 };
3346
+ };
3347
+ const markOf = (t) => {
3348
+ if (t.kind === "visitor") {
3349
+ const W = this.visitW;
3350
+ if (!W || t.k < 0 || t.k >= W.vx.length)
3351
+ return null;
3352
+ // hidden in a folded cluster: ringed on its card, as a declaration there is
3353
+ if (W.vrep[t.k] >= 0)
3354
+ return this.clusterVisible(W.vrep[t.k]) ? body(W.vrep[t.k]) : null;
3355
+ const [x, y] = this.toScreen(W.vx[t.k], W.vy[t.k]);
3356
+ return { x, y, r: this.visitorR() + 5 };
3357
+ }
3358
+ if (t.kind === "node") {
3359
+ if (t.i < 0 || t.i >= this.g.n)
3360
+ return null;
3361
+ if (!this.visibleNode(t.i))
3362
+ return S.nodeRep[t.i] < 0 ? null : body(S.nodeRep[t.i]);
3363
+ const [x, y] = this.toScreen(this.px[t.i], this.py[t.i]);
3364
+ return { x, y, r: pointR };
3365
+ }
3366
+ if (t.c < 0 || t.c >= S.nc || !this.clusterVisible(t.c))
3367
+ return null;
3368
+ if (S.rep[t.c] >= 0)
3369
+ return body(S.rep[t.c]);
3370
+ const [x, y] = this.toScreen(S.cwx[t.c], S.cwy[t.c]);
3371
+ return { x, y, r: S.drawnR[t.c] * k + 3 };
3372
+ };
3373
+ const all = [...this.focus.map((i) => ({ kind: "node", i })), ...this.picked.map((c) => ({ kind: "cluster", c })), ...this.visitorSel.map((k) => ({ kind: "visitor", k }))];
3374
+ // the pointer on a visitor rings it too: the board's highlight is drawn on the GPU's layers
3375
+ if (this.hoverTarget?.kind === "visitor")
3376
+ all.push(this.hoverTarget);
3377
+ if (this.lead)
3378
+ all.push(this.lead);
3379
+ const ctx = this.ctx;
3380
+ ctx.save();
3381
+ ctx.strokeStyle = this.theme.accent;
3382
+ ctx.fillStyle = this.theme.accent;
3383
+ ctx.lineWidth = 2;
3384
+ // one ring per place: several declarations in one folded cluster share its ring. A card's ring
3385
+ // is the card's own outline (.cluster-card.picked): drawn here, on its lit body, it would not
3386
+ // show
3387
+ const drawn = new Set();
3388
+ const carded = new Set();
3389
+ for (const t of all) {
3390
+ const m = markOf(t);
3391
+ if (!m)
3392
+ continue;
3393
+ if ("box" in m) {
3394
+ carded.add(m.c);
3395
+ continue;
3396
+ }
3397
+ const key = `${m.x},${m.y},${m.r}`;
3398
+ if (drawn.has(key))
3399
+ continue;
3400
+ drawn.add(key);
3401
+ ctx.beginPath();
3402
+ ctx.arc(m.x, m.y, m.r, 0, 2 * Math.PI);
3403
+ ctx.stroke();
3404
+ }
3405
+ for (const el of this.cards.values())
3406
+ if (el.dataset.cluster !== undefined)
3407
+ el.classList.toggle("picked", carded.has(Number(el.dataset.cluster)));
3408
+ // none in an artwork, whose panels are put away (ExportSpec decision 19)
3409
+ const lead = !this.art && this.lead && markOf(this.lead);
3410
+ if (!lead)
3411
+ return ctx.restore();
3412
+ const box = this.canvas.getBoundingClientRect();
3413
+ const at = this.leadEnd();
3414
+ const end = at ? { x: at.x - box.left, y: at.y - box.top } : null;
3415
+ const was = this.leadWas;
3416
+ if (!end ? was !== null : !was || Math.abs(was.x - end.x) > 0.5 || Math.abs(was.y - end.y) > 0.5)
3417
+ this.request();
3418
+ this.leadWas = end;
3419
+ // the lead leaves the ring where it faces the panel: a card's end, a circle's point nearest the
3420
+ // panel's edge. None while the panel is away, or when the ring lies under the panel itself.
3421
+ let sx, sy, room;
3422
+ if (!end)
3423
+ room = false;
3424
+ else if ("box" in lead) {
3425
+ [sx, sy] = [lead.box[2], (lead.box[1] + lead.box[3]) / 2];
3426
+ room = end.x > sx + 12;
3427
+ }
3428
+ else {
3429
+ const a = Math.atan2(end.y - lead.y, end.x - lead.x);
3430
+ [sx, sy] = [lead.x + lead.r * Math.cos(a), lead.y + lead.r * Math.sin(a)];
3431
+ room = end.x > sx + 12 && Math.hypot(end.x - lead.x, end.y - lead.y) > lead.r + 12;
3432
+ }
3433
+ if (end && room) {
3434
+ const dx = (end.x - sx) * 0.5;
3435
+ ctx.lineWidth = 1.5;
3436
+ ctx.globalAlpha = 0.8;
3437
+ ctx.beginPath();
3438
+ ctx.moveTo(sx, sy);
3439
+ ctx.bezierCurveTo(sx + dx, sy, end.x - dx, end.y, end.x - 3, end.y);
3440
+ ctx.stroke();
3441
+ ctx.globalAlpha = 1;
3442
+ ctx.beginPath();
3443
+ ctx.arc(end.x - 3, end.y, 3, 0, 2 * Math.PI);
3444
+ ctx.fill();
3445
+ }
3446
+ ctx.restore();
3447
+ }
3448
+ clusterHasActive(c) {
3449
+ return this.clusterActive[c] > 0;
3450
+ }
3451
+ countActive() {
3452
+ this.clusterActive = new Int32Array(this.layout ? this.layout.clusters.length : 0);
3453
+ if (!this.layout)
3454
+ return;
3455
+ for (const i of this.activeList) {
3456
+ for (let x = this.layout.cluster[i]; x >= 0; x = this.layout.clusters[x].parent)
3457
+ this.clusterActive[x]++;
3458
+ }
3459
+ }
3460
+ clearCards() {
3461
+ for (const el of this.cards.values())
3462
+ el.remove();
3463
+ this.cards.clear();
3464
+ this.cardSide.clear();
3465
+ this.leaders = [];
3466
+ // a card that was highlighted is gone
3467
+ if (this.hoverTarget && !this.pointer)
3468
+ this.setHover(null, 0, 0);
3469
+ this.cardSize.clear();
3470
+ }
3471
+ /** Place the cards for this frame; returns the nodes that got one. */
3472
+ drawCards(activeDots) {
3473
+ // without a browser there are no cards (ExportSpec decision 21): they are elements of the page
3474
+ if (this.headless)
3475
+ return new Set();
3476
+ const k = this.cam.k;
3477
+ const S = this.scene;
3478
+ // what is selected has no card: a ring says where it is (drawSelection)
3479
+ const chosen = new Set(this.focus);
3480
+ if (this.lead?.kind === "node")
3481
+ chosen.add(this.lead.i);
3482
+ // which active nodes get a card: the nearest to the screen centre
3483
+ const cx = this.w / 2;
3484
+ const cy = this.h / 2;
3485
+ const cap = this.art ? 0 : cardCap(k);
3486
+ const ranked = activeDots
3487
+ .filter((i) => !chosen.has(i))
3488
+ .map((i) => {
3489
+ const [sx, sy] = this.toScreen(this.px[i], this.py[i]);
3490
+ return { i, d: (sx - cx) ** 2 + (sy - cy) ** 2 };
3491
+ })
3492
+ .sort((a, b) => a.d - b.d)
3493
+ .slice(0, cap)
3494
+ .map((x) => x.i);
3495
+ const want = new Map();
3496
+ // folded clusters that hold active nodes get a card of their own, on their body; a selected one
3497
+ // first, since the selection's ring goes round its card
3498
+ if (this.active) {
3499
+ const picked = this.lead?.kind === "cluster" ? [this.lead.c, ...this.picked] : this.picked;
3500
+ for (const c of picked)
3501
+ if (S.rep[c] === c && this.clusterHasActive(c))
3502
+ want.set(`c${c}`, c);
3503
+ }
3504
+ for (const i of ranked)
3505
+ want.set(`n${i}`, i);
3506
+ if (this.active) {
3507
+ for (const c of S.folded) {
3508
+ if (S.rep[c] === c && this.clusterHasActive(c))
3509
+ want.set(`c${c}`, c);
3510
+ }
3511
+ }
3512
+ for (const [key, el] of this.cards) {
3513
+ if (!want.has(key)) {
3514
+ // the highlighted card goes away under the pointer: so does its highlight
3515
+ if (el.classList.contains("hover") && !this.pointer)
3516
+ this.setHover(null, 0, 0);
3517
+ el.remove();
3518
+ this.cards.delete(key);
3519
+ this.cardSize.delete(el);
3520
+ }
3521
+ }
3522
+ // place cards in priority order, each beside its declaration with a lead to it, so that the
3523
+ // point stays in sight: at the first of eight places around it that covers no card already
3524
+ // placed, no lit point and no ring of the selection. One with no such place stays a point,
3525
+ // so the canvas never shows a stack of unreadable cards.
3526
+ const grid = new BoxGrid();
3527
+ const shown = new Set();
3528
+ const dotR = this.dotPx();
3529
+ this.leaders = [];
3530
+ this.clusterBoxes.clear();
3531
+ // the selection has no card, but nothing covers its rings either (drawSelection)
3532
+ const lr = Math.max(7, dotR + 4) + 4;
3533
+ for (const i of chosen) {
3534
+ if (!this.visibleNode(i))
3535
+ continue;
3536
+ const [lx, ly] = this.toScreen(this.px[i], this.py[i]);
3537
+ grid.add(lx - lr, ly - lr, lx + lr, ly + lr);
3538
+ }
3539
+ // what lies over the canvas: a card under a panel is no card
3540
+ const box0 = this.canvas.getBoundingClientRect();
3541
+ for (const r of this.ev.covers?.() ?? [])
3542
+ if (r.width && r.height)
3543
+ grid.add(r.left - box0.left, r.top - box0.top, r.right - box0.left, r.bottom - box0.top);
3544
+ // the lit points themselves
3545
+ const pr = Math.max(3, dotR) + 2;
3546
+ for (const i of activeDots) {
3547
+ const [x, y] = this.toScreen(this.px[i], this.py[i]);
3548
+ grid.add(x - pr, y - pr, x + pr, y + pr);
3549
+ }
3550
+ const gap = Math.max(8, dotR + 9);
3551
+ for (const [key, idx] of want) {
3552
+ let el = this.cards.get(key);
3553
+ const isCluster = key[0] === "c";
3554
+ if (!el) {
3555
+ el = isCluster ? this.clusterCard(idx) : this.nodeCard(idx);
3556
+ this.cards.set(key, el);
3557
+ this.cardsEl.appendChild(el);
3558
+ }
3559
+ el.classList.toggle("pinned", !isCluster && S.isPinned(idx));
3560
+ let size = this.cardSize.get(el);
3561
+ if (!size) {
3562
+ el.style.display = "";
3563
+ size = [el.offsetWidth, el.offsetHeight];
3564
+ this.cardSize.set(el, size);
3565
+ }
3566
+ const [w, h] = size;
3567
+ const [wx, wy] = isCluster ? [S.cwx[idx], S.cwy[idx]] : [this.px[idx], this.py[idx]];
3568
+ const [sx, sy] = this.toScreen(wx, wy);
3569
+ let box = null;
3570
+ if (isCluster) {
3571
+ // a folded cluster's card sits on its body, which it stands for
3572
+ const b = [sx - w / 2 - 3, sy - h / 2 - 3, sx + w / 2 + 3, sy + h / 2 + 3];
3573
+ if (!grid.hits(...b))
3574
+ box = b;
3575
+ }
3576
+ else {
3577
+ // the place it had last first, so that cards do not jump about as the view moves
3578
+ const last = this.cardSide.get(key) ?? 0;
3579
+ for (let t = 0; t < SIDES.length && !box; t++) {
3580
+ const side = t === 0 ? last : t <= last ? t - 1 : t;
3581
+ const b = besideBox(sx, sy, w, h, gap, SIDES[side]);
3582
+ if (b[0] < 0 || b[1] < 0 || b[2] > this.w || b[3] > this.h || grid.hits(b[0] - 3, b[1] - 3, b[2] + 3, b[3] + 3))
3583
+ continue;
3584
+ box = b;
3585
+ this.cardSide.set(key, side);
3586
+ }
3587
+ }
3588
+ if (!box) {
3589
+ el.style.display = "none";
3590
+ continue;
3591
+ }
3592
+ el.style.display = "";
3593
+ grid.add(box[0] - 3, box[1] - 3, box[2] + 3, box[3] + 3);
3594
+ if (isCluster) {
3595
+ el.style.transform = `translate(${(sx - w / 2).toFixed(1)}px, ${(sy - h / 2).toFixed(1)}px)`;
3596
+ this.clusterBoxes.set(idx, [sx - w / 2, sy - h / 2, sx + w / 2, sy + h / 2]);
3597
+ }
3598
+ else {
3599
+ shown.add(idx);
3600
+ el.style.transform = `translate(${box[0].toFixed(1)}px, ${box[1].toFixed(1)}px)`;
3601
+ // the lead: from the point's edge to the nearest point of the card
3602
+ const nx = Math.min(Math.max(sx, box[0]), box[2]), ny = Math.min(Math.max(sy, box[1]), box[3]);
3603
+ const d = Math.hypot(nx - sx, ny - sy) || 1;
3604
+ const r0 = Math.max(2, dotR) + 1;
3605
+ this.leaders.push(sx + ((nx - sx) / d) * r0, sy + ((ny - sy) / d) * r0, nx, ny, idx);
3606
+ }
3607
+ }
3608
+ return shown;
3609
+ }
3610
+ nodeCard(i) {
3611
+ const g = this.g;
3612
+ const v = g.nodes[i];
3613
+ const el = document.createElement("div");
3614
+ // its dot has its point's colour, in the scheme in force (style.css, .card.s-*); nothing else on
3615
+ // a card tells of its status
3616
+ const st = this.scheme === "status" ? ` ss s-${STATUSES[this.statusCode[i] ?? ST_UNREVIEWED]}` : "";
3617
+ el.className = `card k-${g.isExt(i) ? "external" : v.kind}${st}${this.targetList.includes(i) ? " target" : this.keyList.includes(i) ? " key" : ""}`;
3618
+ el.dataset.node = String(i);
3619
+ // on a past graph, its marks against now: a renamed one's name now among them
3620
+ const marks = this.pastMarks?.[i] ?? 0;
3621
+ const now = this.nowNames.get(i);
3622
+ const words = marks ? markWords(marks, now ? now.split(".").pop() : undefined) : [];
3623
+ if (marks & PAST_GHOST)
3624
+ el.classList.add("gone");
3625
+ el.innerHTML =
3626
+ `<div class="card-head"><span class="dot"></span><span class="card-name">${escapeHtml(g.short(i))}</span>` +
3627
+ `<span class="card-kind">${g.isExt(i) ? "external" : v.kind}</span></div>` +
3628
+ (words.length ? `<div class="card-tm"${now ? ` title="${escapeHtml(now)}"` : ""}>${escapeHtml(words.join(" · "))}</div>` : "");
3629
+ return el;
3630
+ }
3631
+ clusterCard(c) {
3632
+ const cl = this.layout.clusters[c];
3633
+ const el = document.createElement("div");
3634
+ el.className = "card cluster-card";
3635
+ el.dataset.cluster = String(c);
3636
+ const n = this.clusterActive[c];
3637
+ el.innerHTML =
3638
+ `<div class="card-head"><span class="dot"></span><span class="card-name">${escapeHtml(cl.label)}</span>` +
3639
+ `<span class="card-kind">${n} / ${cl.count}</span></div>`;
3640
+ el.title = `${cl.key} — folded; click to select it, double-click to open it, drag to move it with everything in it`;
3641
+ return el;
3642
+ }
3643
+ }
3644
+ /** The eight places beside a point a card can take, the most readable first: right, left, the
3645
+ * four corners, above, below (x, y: which way from the point). */
3646
+ const SIDES = [[1, 0], [-1, 0], [1, -1], [1, 1], [-1, -1], [-1, 1], [0, -1], [0, 1]];
3647
+ /** A card of size w × h beside the point (sx, sy), `gap` away, on `side`: [x0, y0, x1, y1]. */
3648
+ function besideBox(sx, sy, w, h, gap, side) {
3649
+ const [ax, ay] = side;
3650
+ const g = ax && ay ? gap * 0.7 : gap;
3651
+ const x0 = ax > 0 ? sx + g : ax < 0 ? sx - g - w : sx - w / 2;
3652
+ const y0 = ay > 0 ? sy + g : ay < 0 ? sy - g - h : sy - h / 2;
3653
+ return [x0, y0, x0 + w, y0 + h];
3654
+ }
3655
+ /** Boxes on the screen, kept in a grid of cells so that asking whether a box covers any of
3656
+ * hundreds stays cheap every frame. */
3657
+ class BoxGrid {
3658
+ cells = new Map();
3659
+ boxes = [];
3660
+ static CELL = 48;
3661
+ add(x0, y0, x1, y1) {
3662
+ const k = this.boxes.length / 4;
3663
+ this.boxes.push(x0, y0, x1, y1);
3664
+ this.each(x0, y0, x1, y1, (c) => {
3665
+ const list = this.cells.get(c);
3666
+ if (list)
3667
+ list.push(k);
3668
+ else
3669
+ this.cells.set(c, [k]);
3670
+ });
3671
+ }
3672
+ hits(x0, y0, x1, y1) {
3673
+ const B = this.boxes;
3674
+ let hit = false;
3675
+ this.each(x0, y0, x1, y1, (c) => {
3676
+ if (hit)
3677
+ return;
3678
+ for (const k of this.cells.get(c) ?? []) {
3679
+ if (x0 < B[4 * k + 2] && B[4 * k] < x1 && y0 < B[4 * k + 3] && B[4 * k + 1] < y1) {
3680
+ hit = true;
3681
+ return;
3682
+ }
3683
+ }
3684
+ });
3685
+ return hit;
3686
+ }
3687
+ each(x0, y0, x1, y1, f) {
3688
+ const C = BoxGrid.CELL;
3689
+ const c0 = Math.floor(x0 / C), c1 = Math.floor(x1 / C), r0 = Math.floor(y0 / C), r1 = Math.floor(y1 / C);
3690
+ for (let r = r0; r <= r1; r++)
3691
+ for (let c = c0; c <= c1; c++)
3692
+ f(r * 100003 + c);
3693
+ }
3694
+ }