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.
- package/README.md +40 -26
- package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
- package/dist/assets/webfonts/metrics.json +1 -0
- package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
- package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
- package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
- package/dist/server/agent.js +84 -86
- package/dist/server/agentdocs.js +1 -1
- package/dist/server/artcmd.js +335 -0
- package/dist/server/live.js +6 -0
- package/dist/server/main.js +136 -8
- package/dist/server/mcptools.js +4 -1
- package/dist/server/pipeline.js +15 -9
- package/dist/server/readonlygate.js +1 -1
- package/dist/server/review.js +4 -1
- package/dist/server/reviewapi.js +35 -1
- package/dist/server/snapshot.js +10 -1
- package/dist/server/view.js +0 -7
- package/dist/server/views.js +24 -0
- package/dist/shared/artfonts.js +81 -0
- package/dist/shared/artrecipe.js +316 -0
- package/dist/shared/collab.js +7 -2
- package/dist/shared/declfind.js +95 -0
- package/dist/shared/lostfound.js +1 -0
- package/dist/shared/pastgraph.js +6 -6
- package/dist/shared/review.js +62 -5
- package/dist/web/bundle/app.js +713 -164
- package/dist/web/bundle/edges.js +1 -1
- package/dist/web/src/agentmark.js +14 -0
- package/dist/web/src/artwork.js +1000 -0
- package/dist/web/src/bundles.js +151 -0
- package/dist/web/src/canvas.js +3694 -0
- package/dist/web/src/drag.js +61 -0
- package/dist/web/src/edgequeue.js +204 -0
- package/dist/web/src/gl/gl.js +123 -0
- package/dist/web/src/gl/renderer.js +451 -0
- package/dist/web/src/halo.js +95 -0
- package/dist/web/src/highlight.js +108 -0
- package/dist/web/src/overview.js +393 -0
- package/dist/web/src/reviewqueue.js +250 -0
- package/dist/web/src/selection.js +272 -0
- package/dist/web/src/status.js +80 -0
- package/dist/web/src/visitors.js +318 -0
- package/dist/web/src/visits.js +174 -0
- package/dist/web/style.css +7 -0
- package/docs/agent-api/README.md +1 -1
- package/docs/agent-api/export.md +41 -2
- package/docs/agent-api/history.md +2 -1
- package/docs/agent-api/mcp.md +1 -1
- package/docs/agent-api/reading.md +2 -1
- 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
|
+
}
|