@fusion.dev/helix-graph 0.184.0 → 0.186.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.
@@ -0,0 +1,591 @@
1
+ import { linkKey } from '../connect-graph/diff.js';
2
+ import { mixRgb } from '../connect-graph/looks.js';
3
+ import { clearOf, ease, gesturesHeld, isLight } from '../overlay/overlay.js';
4
+ import { depthOrder, floorLine, ISOMETRIC, LOWEST_TILT, layered, plate, projectionOf, snapToCells, solid, solidOf, } from './geometry.js';
5
+ const SVG = 'http://www.w3.org/2000/svg';
6
+ const STEP = 250;
7
+ const ZOOM = 1.5;
8
+ const PADDING = 32;
9
+ const LARGEST = 120;
10
+ const SMALLEST = 4;
11
+ const FOOTPRINT = 0.62;
12
+ const MARGIN = 0.55;
13
+ const NAME = 12;
14
+ const GROUP = 10;
15
+ const DRAG = 4;
16
+ const heightOf = (size) => 0.3 + 0.16 * Math.max(size, 0);
17
+ const escaped = (text) => text.replace(/[&<>"']/g, (character) => ({ "'": '&#39;', '"': '&quot;', '&': '&amp;', '<': '&lt;', '>': '&gt;' })[character]);
18
+ const widthOf = (text, size) => text.length * size * 0.56;
19
+ const shaded = (color, darken) => darken <= 0 ? color : mixRgb(color, 'rgb(0, 0, 0)', darken);
20
+ const TURN_PER_PIXEL = 0.5;
21
+ const TILT_PER_PIXEL = 0.35;
22
+ const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
23
+ const edgeOf = (face) => mixRgb(face, isLight(face) ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)', 0.25);
24
+ export const isometricDriver = (container, options = {}) => {
25
+ const svg = document.createElementNS(SVG, 'svg');
26
+ svg.setAttribute('aria-hidden', 'true');
27
+ svg.setAttribute('data-helix-isometric', '');
28
+ svg.style.cssText =
29
+ 'display:block;inline-size:100%;block-size:100%;overflow:hidden;user-select:none;-webkit-user-select:none';
30
+ const layer = (name) => {
31
+ const group = document.createElementNS(SVG, 'g');
32
+ group.setAttribute('data-layer', name);
33
+ svg.append(group);
34
+ return group;
35
+ };
36
+ const plates = layer('plates');
37
+ const lines = layer('links');
38
+ const notes = layer('notes');
39
+ const names = layer('names');
40
+ names.setAttribute('paint-order', 'stroke');
41
+ names.setAttribute('stroke-linejoin', 'round');
42
+ names.setAttribute('pointer-events', 'none');
43
+ container.append(svg);
44
+ const nodes = new Map();
45
+ const links = new Map();
46
+ let looks;
47
+ let groups = [];
48
+ let hovered;
49
+ let mode = 'free';
50
+ let view = { scale: 48, x: 0, y: 0 };
51
+ let camera = {
52
+ tilt: Math.min(Math.max(options.tilt ?? ISOMETRIC.tilt, LOWEST_TILT), 90),
53
+ turn: options.turn ?? ISOMETRIC.turn,
54
+ };
55
+ let projection = projectionOf(camera);
56
+ let turning = 0;
57
+ let framed = false;
58
+ let measured;
59
+ let gliding = 0;
60
+ let press;
61
+ const held = gesturesHeld(container);
62
+ const sizeOfBox = () => {
63
+ const box = svg.getBoundingClientRect();
64
+ return { height: box.height || 1, width: box.width || 1 };
65
+ };
66
+ const groupOf = (id) => groups.find((group) => group.ids.includes(id))?.name;
67
+ const shapeOf = (id) => solidOf(looks?.node(id).shape ?? options.shape ?? 'square');
68
+ const tallness = (node) => (options.flat ? 0 : heightOf(node.size));
69
+ const solids = () => depthOrder(projection, [...nodes.values()].map((node) => ({
70
+ drawn: solid(projection, shapeOf(node.id), node.x, node.y, FOOTPRINT, tallness(node)),
71
+ height: tallness(node),
72
+ node,
73
+ x: node.x,
74
+ y: node.y,
75
+ })));
76
+ const boundsOf = (ids) => {
77
+ const chosen = ids ? ids.flatMap((id) => nodes.get(id) ?? []) : [...nodes.values()];
78
+ if (chosen.length === 0) {
79
+ return undefined;
80
+ }
81
+ const points = [];
82
+ for (const node of chosen) {
83
+ const half = FOOTPRINT / 2 + (ids ? 0 : MARGIN);
84
+ for (const [dx, dy] of [
85
+ [-1, -1],
86
+ [1, -1],
87
+ [1, 1],
88
+ [-1, 1],
89
+ ]) {
90
+ points.push(projection.at(node.x + dx * half, node.y + dy * half), projection.at(node.x + dx * half, node.y + dy * half, tallness(node) + 0.5));
91
+ }
92
+ }
93
+ if (!ids) {
94
+ for (const group of groups) {
95
+ const members = group.ids.flatMap((id) => nodes.get(id) ?? []);
96
+ if (members.length === 0) {
97
+ continue;
98
+ }
99
+ const reach = MARGIN + FOOTPRINT / 2;
100
+ const xs = members.map((node) => node.x);
101
+ const ys = members.map((node) => node.y);
102
+ for (const x of [Math.min(...xs) - reach, Math.max(...xs) + reach]) {
103
+ for (const y of [Math.min(...ys) - reach, Math.max(...ys) + reach]) {
104
+ points.push(projection.at(x, y));
105
+ }
106
+ }
107
+ }
108
+ }
109
+ const xs = points.map((point) => point.x);
110
+ const ys = points.map((point) => point.y);
111
+ return {
112
+ bottom: Math.max(...ys),
113
+ left: Math.min(...xs),
114
+ right: Math.max(...xs),
115
+ top: Math.min(...ys),
116
+ };
117
+ };
118
+ const show = () => {
119
+ const { height, width } = sizeOfBox();
120
+ const across = width / view.scale;
121
+ const down = height / view.scale;
122
+ svg.setAttribute('viewBox', `${view.x - across / 2} ${view.y - down / 2} ${across} ${down}`);
123
+ drawNames();
124
+ };
125
+ const go = (next, animate, whole = false) => {
126
+ framed = whole;
127
+ cancelAnimationFrame(gliding);
128
+ if (!animate) {
129
+ view = next;
130
+ show();
131
+ return;
132
+ }
133
+ const from = view;
134
+ const started = performance.now();
135
+ const frame = (now) => {
136
+ const at = Math.min((now - started) / STEP, 1);
137
+ const eased = ease(at);
138
+ view = {
139
+ scale: from.scale * (next.scale / from.scale) ** eased,
140
+ x: from.x + (next.x - from.x) * eased,
141
+ y: from.y + (next.y - from.y) * eased,
142
+ };
143
+ show();
144
+ if (at < 1) {
145
+ gliding = requestAnimationFrame(frame);
146
+ }
147
+ };
148
+ gliding = requestAnimationFrame(frame);
149
+ };
150
+ const framing = (box) => {
151
+ const { height, width } = sizeOfBox();
152
+ const across = Math.max(box.right - box.left, 0.001);
153
+ const down = Math.max(box.bottom - box.top, 0.001);
154
+ const scale = Math.min(Math.max(Math.min((width - PADDING * 2) / across, (height - PADDING * 2) / down), SMALLEST), LARGEST);
155
+ return { scale, x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 };
156
+ };
157
+ const drawPlates = () => {
158
+ const own = new Set(nodes.keys());
159
+ const marks = [];
160
+ for (const group of groups) {
161
+ const places = group.ids.flatMap((id) => {
162
+ const node = nodes.get(id);
163
+ return node && own.has(id) && !looks?.node(id).hidden ? [{ x: node.x, y: node.y }] : [];
164
+ });
165
+ const drawn = plate(projection, places, MARGIN + FOOTPRINT / 2);
166
+ if (!drawn) {
167
+ continue;
168
+ }
169
+ const fill = group.faded ? 0.04 : 0.1;
170
+ const stroke = group.faded ? 0.18 : 0.5;
171
+ marks.push(`<path d="${drawn.d}" fill="${group.color}" fill-opacity="${fill}" stroke="${group.color}" stroke-opacity="${stroke}" stroke-width="1" vector-effect="non-scaling-stroke"/>`);
172
+ }
173
+ plates.innerHTML = marks.join('');
174
+ };
175
+ const drawLinks = () => {
176
+ const rest = [];
177
+ const raised = [];
178
+ for (const link of links.values()) {
179
+ const from = nodes.get(link.source);
180
+ const to = nodes.get(link.target);
181
+ if (!from || !to) {
182
+ continue;
183
+ }
184
+ const look = looks?.link(link.source, link.target);
185
+ if (look?.hidden) {
186
+ continue;
187
+ }
188
+ const color = look?.color ?? 'currentColor';
189
+ const width = look?.raised ? 2 : 1.25;
190
+ const dx = to.x - from.x;
191
+ const dy = to.y - from.y;
192
+ const length = Math.hypot(dx, dy) || 1;
193
+ const along = { x: dx / length, y: dy / length };
194
+ const side = { x: -along.y, y: along.x };
195
+ const middle = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
196
+ const tip = projection.at(middle.x + along.x * 0.14, middle.y + along.y * 0.14);
197
+ const wing = (sign) => projection.at(middle.x - along.x * 0.14 + side.x * 0.12 * sign, middle.y - along.y * 0.14 + side.y * 0.12 * sign);
198
+ const [one, other] = [wing(1), wing(-1)];
199
+ (look?.raised ? raised : rest).push(`<path d="${floorLine(projection, from, to)}" fill="none" stroke="${color}" stroke-linecap="round" stroke-width="${width}" vector-effect="non-scaling-stroke"/>` +
200
+ `<path d="M${one.x} ${one.y}L${tip.x} ${tip.y}L${other.x} ${other.y}Z" fill="${color}"/>`);
201
+ }
202
+ lines.innerHTML = rest.join('') + raised.join('');
203
+ };
204
+ const drawNotes = () => {
205
+ const marks = [];
206
+ for (const { drawn, node } of solids()) {
207
+ const look = looks?.node(node.id);
208
+ if (look?.hidden) {
209
+ continue;
210
+ }
211
+ const color = look?.color ?? 'rgb(128, 128, 128)';
212
+ const faces = drawn.faces
213
+ .map((face) => {
214
+ const fill = shaded(color, face.darken);
215
+ return `<path d="${face.d}" fill="${fill}" stroke="${edgeOf(fill)}" stroke-linejoin="round" stroke-width="1" vector-effect="non-scaling-stroke"/>`;
216
+ })
217
+ .join('');
218
+ const ink = looks?.ink ?? 'currentColor';
219
+ const ringed = look?.selected ? 2.5 : node.id === hovered || look?.keyed ? 1.5 : 0;
220
+ const glow = look?.selected
221
+ ? `<path d="${solid(projection, shapeOf(node.id), node.x, node.y, FOOTPRINT + 0.5, 0).outline}" fill="${color}" fill-opacity="0.22"/>`
222
+ : '';
223
+ const ring = ringed > 0
224
+ ? `<path d="${drawn.outline}" fill="none" stroke="${ink}" stroke-linejoin="round" stroke-width="${ringed}" vector-effect="non-scaling-stroke"/>`
225
+ : '';
226
+ marks.push(`<g data-id="${escaped(node.id)}" style="cursor:pointer">${glow}${faces}${ring}</g>`);
227
+ }
228
+ notes.innerHTML = marks.join('');
229
+ };
230
+ function drawNames() {
231
+ if (!looks) {
232
+ names.innerHTML = '';
233
+ return;
234
+ }
235
+ const current = looks;
236
+ const size = NAME / view.scale;
237
+ const taken = [];
238
+ const marks = [];
239
+ const order = solids()
240
+ .filter(({ node }) => {
241
+ const look = current.node(node.id);
242
+ return !look.hidden && (look.label !== 'never' || node.id === hovered);
243
+ })
244
+ .sort((one, other) => {
245
+ const rank = (id) => {
246
+ const look = current.node(id);
247
+ return look.selected || id === hovered ? 2 : look.label === 'always' ? 1 : 0;
248
+ };
249
+ return rank(other.node.id) - rank(one.node.id) || other.height - one.height;
250
+ });
251
+ for (const { drawn, node } of order) {
252
+ const look = current.node(node.id);
253
+ const wide = widthOf(node.label, size);
254
+ const line = drawn.crown - size * 0.55;
255
+ const box = {
256
+ bottom: line + size * 0.3,
257
+ left: drawn.peak.x - wide / 2,
258
+ right: drawn.peak.x + wide / 2,
259
+ top: line - size,
260
+ };
261
+ const forced = look.selected || look.label === 'always' || node.id === hovered;
262
+ if (!forced && !clearOf(box, taken)) {
263
+ continue;
264
+ }
265
+ taken.push(box);
266
+ const strong = look.selected || look.raised || node.id === hovered;
267
+ marks.push(`<text fill="${strong ? current.ink : current.quiet}" font-weight="${strong ? 600 : 500}" stroke="${current.ground}" text-anchor="middle" x="${drawn.peak.x}" y="${line}">${escaped(node.label)}</text>`);
268
+ }
269
+ if (options.groupLabels !== false) {
270
+ for (const group of groups) {
271
+ const places = group.ids.flatMap((id) => {
272
+ const node = nodes.get(id);
273
+ return node && !current.node(id).hidden ? [{ x: node.x, y: node.y }] : [];
274
+ });
275
+ const drawn = plate(projection, places, MARGIN + FOOTPRINT / 2);
276
+ if (!drawn) {
277
+ continue;
278
+ }
279
+ const small = GROUP / view.scale;
280
+ const length = group.name.length * small * 0.74;
281
+ const end = {
282
+ x: drawn.start.x + drawn.along.x * length,
283
+ y: drawn.start.y + drawn.along.y * length,
284
+ };
285
+ const box = {
286
+ bottom: Math.max(drawn.start.y, end.y) + small * 0.6,
287
+ left: Math.min(drawn.start.x, end.x),
288
+ right: Math.max(drawn.start.x, end.x),
289
+ top: Math.min(drawn.start.y, end.y) - small * 0.6,
290
+ };
291
+ if (!clearOf(box, taken)) {
292
+ continue;
293
+ }
294
+ taken.push(box);
295
+ marks.push(`<text fill="${group.color}" fill-opacity="${group.faded ? 0.4 : 0.95}" font-size="${small}" font-weight="650" letter-spacing="${small * 0.12}" stroke="none" transform="${drawn.transform}">${escaped(group.name.toLocaleUpperCase())}</text>`);
296
+ }
297
+ }
298
+ names.setAttribute('font-family', current.font);
299
+ names.setAttribute('font-size', String(size));
300
+ names.setAttribute('stroke-width', String(3 / view.scale));
301
+ names.innerHTML = marks.join('');
302
+ }
303
+ const render = () => {
304
+ drawPlates();
305
+ drawLinks();
306
+ drawNotes();
307
+ drawNames();
308
+ };
309
+ const fitAll = (animate) => {
310
+ const box = boundsOf();
311
+ if (box) {
312
+ go(framing(box), animate, true);
313
+ }
314
+ };
315
+ const showsAll = () => {
316
+ const box = boundsOf();
317
+ if (!box) {
318
+ return false;
319
+ }
320
+ const { height, width } = sizeOfBox();
321
+ const across = width / view.scale / 2;
322
+ const down = height / view.scale / 2;
323
+ const slack = 1 / view.scale;
324
+ return (box.left >= view.x - across - slack &&
325
+ box.right <= view.x + across + slack &&
326
+ box.top >= view.y - down - slack &&
327
+ box.bottom <= view.y + down + slack);
328
+ };
329
+ const place = (next, whole) => {
330
+ const middle = projection.floorAt({ x: view.x, y: view.y });
331
+ camera = { tilt: Math.min(Math.max(next.tilt, LOWEST_TILT), 90), turn: next.turn };
332
+ projection = projectionOf(camera);
333
+ const box = whole ? boundsOf() : undefined;
334
+ if (box) {
335
+ view = framing(box);
336
+ framed = true;
337
+ }
338
+ else {
339
+ const at = projection.at(middle.x, middle.y);
340
+ view = { ...view, x: at.x, y: at.y };
341
+ }
342
+ render();
343
+ show();
344
+ };
345
+ const aim = (next, animate) => {
346
+ cancelAnimationFrame(turning);
347
+ const from = camera;
348
+ const whole = framed || showsAll();
349
+ const turn = from.turn + ((((next.turn - from.turn) % 360) + 540) % 360) - 180;
350
+ if (!animate) {
351
+ place({ tilt: next.tilt, turn }, whole);
352
+ return;
353
+ }
354
+ const started = performance.now();
355
+ const frame = (now) => {
356
+ const at = Math.min((now - started) / (STEP * 2), 1);
357
+ const eased = ease(at);
358
+ place({
359
+ tilt: from.tilt + (next.tilt - from.tilt) * eased,
360
+ turn: from.turn + (turn - from.turn) * eased,
361
+ }, whole);
362
+ if (at < 1) {
363
+ turning = requestAnimationFrame(frame);
364
+ }
365
+ };
366
+ turning = requestAnimationFrame(frame);
367
+ };
368
+ let dragging;
369
+ const onDown = (event) => {
370
+ if (event.button !== 0 ||
371
+ mode === 'off' ||
372
+ (mode === 'cooperative' && event.pointerType === 'touch')) {
373
+ dragging = undefined;
374
+ return;
375
+ }
376
+ dragging = {
377
+ id: event.pointerId,
378
+ moved: false,
379
+ turning: event.shiftKey,
380
+ whole: event.shiftKey && (framed || showsAll()),
381
+ x: event.clientX,
382
+ y: event.clientY,
383
+ };
384
+ };
385
+ const onMove = (event) => {
386
+ const target = event.target?.closest?.('[data-id]');
387
+ const over = target?.getAttribute('data-id') ?? undefined;
388
+ if (over !== hovered && !dragging?.moved) {
389
+ hovered = over;
390
+ drawNotes();
391
+ drawNames();
392
+ }
393
+ if (!dragging || dragging.id !== event.pointerId) {
394
+ return;
395
+ }
396
+ const dx = event.clientX - dragging.x;
397
+ const dy = event.clientY - dragging.y;
398
+ if (!dragging.moved && Math.hypot(dx, dy) < DRAG) {
399
+ return;
400
+ }
401
+ if (!dragging.moved) {
402
+ dragging.moved = true;
403
+ try {
404
+ svg.setPointerCapture?.(event.pointerId);
405
+ }
406
+ catch {
407
+ }
408
+ }
409
+ dragging.x = event.clientX;
410
+ dragging.y = event.clientY;
411
+ cancelAnimationFrame(gliding);
412
+ if (dragging.turning) {
413
+ cancelAnimationFrame(turning);
414
+ place({
415
+ tilt: camera.tilt + dy * TILT_PER_PIXEL,
416
+ turn: camera.turn - dx * TURN_PER_PIXEL,
417
+ }, dragging.whole);
418
+ return;
419
+ }
420
+ framed = false;
421
+ view = { ...view, x: view.x - dx / view.scale, y: view.y - dy / view.scale };
422
+ show();
423
+ };
424
+ const onUp = (event) => {
425
+ const was = dragging;
426
+ dragging = undefined;
427
+ if (!was || was.moved || was.id !== event.pointerId) {
428
+ return;
429
+ }
430
+ const target = event.target?.closest?.('[data-id]');
431
+ press?.(target?.getAttribute('data-id') ?? undefined);
432
+ };
433
+ const onLeave = () => {
434
+ if (hovered !== undefined) {
435
+ hovered = undefined;
436
+ drawNotes();
437
+ drawNames();
438
+ }
439
+ };
440
+ const onWheel = (event) => {
441
+ if (mode === 'off') {
442
+ return;
443
+ }
444
+ event.preventDefault();
445
+ const box = svg.getBoundingClientRect();
446
+ const px = event.clientX - box.left - box.width / 2;
447
+ const py = event.clientY - box.top - box.height / 2;
448
+ const factor = Math.exp(-event.deltaY * 0.0015);
449
+ const scale = Math.min(Math.max(view.scale * factor, SMALLEST), LARGEST);
450
+ const at = { x: view.x + px / view.scale, y: view.y + py / view.scale };
451
+ cancelAnimationFrame(gliding);
452
+ framed = false;
453
+ view = { scale, x: at.x - px / scale, y: at.y - py / scale };
454
+ show();
455
+ };
456
+ svg.addEventListener('pointerdown', onDown);
457
+ svg.addEventListener('pointermove', onMove);
458
+ svg.addEventListener('pointerup', onUp);
459
+ svg.addEventListener('pointerleave', onLeave);
460
+ svg.addEventListener('wheel', onWheel, { passive: false });
461
+ const resized = () => {
462
+ const next = sizeOfBox();
463
+ const before = measured;
464
+ measured = next;
465
+ if (framed) {
466
+ fitAll(false);
467
+ return;
468
+ }
469
+ if (before) {
470
+ const ratio = Math.min(next.width, next.height) / Math.min(before.width, before.height);
471
+ view = { ...view, scale: Math.min(Math.max(view.scale * ratio, SMALLEST), LARGEST) };
472
+ }
473
+ show();
474
+ };
475
+ const sized = typeof ResizeObserver === 'function' ? new ResizeObserver(resized) : undefined;
476
+ sized?.observe(svg);
477
+ const set = (list) => {
478
+ for (const node of list) {
479
+ nodes.set(node.id, node);
480
+ }
481
+ };
482
+ return {
483
+ camera: () => ({ tilt: camera.tilt, turn: ((camera.turn % 360) + 360) % 360 }),
484
+ center: (id, animate) => {
485
+ const node = nodes.get(id);
486
+ if (node) {
487
+ const peak = projection.at(node.x, node.y, tallness(node) / 2);
488
+ go({ ...view, x: peak.x, y: peak.y }, animate);
489
+ }
490
+ },
491
+ draw: (graph) => {
492
+ nodes.clear();
493
+ links.clear();
494
+ set(graph.nodes);
495
+ for (const link of graph.links) {
496
+ links.set(linkKey(link), link);
497
+ }
498
+ render();
499
+ fitAll(false);
500
+ },
501
+ fit: (ids, animate) => {
502
+ const box = boundsOf(ids);
503
+ if (box) {
504
+ const next = framing(box);
505
+ go({ ...next, scale: Math.min(next.scale, 72) }, animate);
506
+ }
507
+ },
508
+ focusTarget: () => null,
509
+ gestures: (next) => {
510
+ mode = next;
511
+ held.set(next);
512
+ },
513
+ groups: (next) => {
514
+ groups = next;
515
+ drawPlates();
516
+ if (framed) {
517
+ fitAll(false);
518
+ }
519
+ else {
520
+ drawNames();
521
+ }
522
+ },
523
+ look: (next) => {
524
+ looks = next;
525
+ render();
526
+ },
527
+ onPress: (listener) => {
528
+ press = listener;
529
+ return () => {
530
+ press = undefined;
531
+ };
532
+ },
533
+ panBy: (x, y, animate) => {
534
+ go({ ...view, x: view.x + x / view.scale, y: view.y + y / view.scale }, animate);
535
+ },
536
+ settle: (unplaced, placed) => {
537
+ const seeded = new Map([...nodes].map(([id, node]) => [id, { x: node.x, y: node.y }]));
538
+ const cells = unplaced.size === nodes.size
539
+ ? layered([...nodes.keys()].map((id) => ({ group: groupOf(id), id })), [...links.values()])
540
+ : snapToCells(seeded, unplaced);
541
+ for (const [id, cell] of cells) {
542
+ const node = nodes.get(id);
543
+ if (node) {
544
+ nodes.set(id, { ...node, x: cell.x, y: cell.y });
545
+ }
546
+ }
547
+ render();
548
+ fitAll(false);
549
+ const told = setTimeout(() => {
550
+ placed(new Map([...nodes].map(([id, node]) => [id, { x: node.x, y: node.y }])));
551
+ }, 0);
552
+ return () => clearTimeout(told);
553
+ },
554
+ stop: () => {
555
+ cancelAnimationFrame(gliding);
556
+ cancelAnimationFrame(turning);
557
+ sized?.disconnect();
558
+ held.stop();
559
+ svg.removeEventListener('pointerdown', onDown);
560
+ svg.removeEventListener('pointermove', onMove);
561
+ svg.removeEventListener('pointerup', onUp);
562
+ svg.removeEventListener('pointerleave', onLeave);
563
+ svg.removeEventListener('wheel', onWheel);
564
+ svg.remove();
565
+ },
566
+ turnBy: (degrees, animate = !still()) => {
567
+ aim({ tilt: camera.tilt, turn: camera.turn + degrees }, animate);
568
+ },
569
+ turnTo: (next, animate = !still()) => {
570
+ aim({ tilt: next.tilt ?? camera.tilt, turn: next.turn ?? camera.turn }, animate);
571
+ },
572
+ update: (changes) => {
573
+ for (const id of changes.removeNodes) {
574
+ nodes.delete(id);
575
+ }
576
+ for (const link of changes.removeLinks) {
577
+ links.delete(linkKey(link));
578
+ }
579
+ set(changes.addNodes);
580
+ set(changes.changeNodes);
581
+ for (const link of changes.addLinks) {
582
+ links.set(linkKey(link), link);
583
+ }
584
+ render();
585
+ },
586
+ zoomBy: (direction, animate) => {
587
+ const scale = Math.min(Math.max(view.scale * (direction > 0 ? ZOOM : 1 / ZOOM), SMALLEST), LARGEST);
588
+ go({ ...view, scale }, animate);
589
+ },
590
+ };
591
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * How many WebGL contexts the three.js driver keeps live on one page.
3
+ *
4
+ * **A browser holds only so many, and drops the oldest without asking**:
5
+ * Chrome about sixteen on a desktop and fewer on a phone. A page of 3D panels
6
+ * past that lost a graph it could not get back, and the maps, Aura, and
7
+ * Strands on the same page count against the same limit. Six leaves them
8
+ * room.
9
+ */
10
+ export declare const LIVE = 6;
11
+ /** One drawing that holds a context: it gives the context back, and takes it again. */
12
+ export interface Holder {
13
+ release(): void;
14
+ restore(): void;
15
+ }
16
+ /**
17
+ * Keeps the contexts a page's drawings hold under `cap`.
18
+ *
19
+ * **A drawing near the screen always keeps its context.** Past the cap, the
20
+ * drawings off screen give theirs back, the one away longest first, and each
21
+ * takes its context again as it comes near. So a page of any number of
22
+ * graphs holds as many contexts as it shows at once, and never more than
23
+ * `cap` beyond that.
24
+ */
25
+ export declare const contextBudget: (cap?: number) => {
26
+ /** A drawing far from the screen, from `now`. */
27
+ far(holder: Holder, now: number): void;
28
+ /** How many drawings hold a context now. */
29
+ readonly holding: number;
30
+ /** A new drawing, which made a context to draw with. */
31
+ join(holder: Holder): void;
32
+ /** A drawing that ended and freed its context itself. */
33
+ leave(holder: Holder): void;
34
+ /** A drawing near the screen, which takes its context again where it gave it back. */
35
+ near(holder: Holder): void;
36
+ };
37
+ /** The budget every three.js driver on the page shares. */
38
+ export declare const pageBudget: {
39
+ /** A drawing far from the screen, from `now`. */
40
+ far(holder: Holder, now: number): void;
41
+ /** How many drawings hold a context now. */
42
+ readonly holding: number;
43
+ /** A new drawing, which made a context to draw with. */
44
+ join(holder: Holder): void;
45
+ /** A drawing that ended and freed its context itself. */
46
+ leave(holder: Holder): void;
47
+ /** A drawing near the screen, which takes its context again where it gave it back. */
48
+ near(holder: Holder): void;
49
+ };
@@ -0,0 +1,43 @@
1
+ export const LIVE = 6;
2
+ export const contextBudget = (cap = LIVE) => {
3
+ const holding = new Set();
4
+ const away = new Map();
5
+ const balance = () => {
6
+ const longest = [...away]
7
+ .filter(([holder]) => holding.has(holder))
8
+ .sort((one, other) => one[1] - other[1]);
9
+ for (const [holder] of longest) {
10
+ if (holding.size <= cap) {
11
+ return;
12
+ }
13
+ holding.delete(holder);
14
+ holder.release();
15
+ }
16
+ };
17
+ return {
18
+ far(holder, now) {
19
+ away.set(holder, now);
20
+ balance();
21
+ },
22
+ get holding() {
23
+ return holding.size;
24
+ },
25
+ join(holder) {
26
+ holding.add(holder);
27
+ balance();
28
+ },
29
+ leave(holder) {
30
+ holding.delete(holder);
31
+ away.delete(holder);
32
+ },
33
+ near(holder) {
34
+ away.delete(holder);
35
+ if (!holding.has(holder)) {
36
+ holding.add(holder);
37
+ holder.restore();
38
+ }
39
+ balance();
40
+ },
41
+ };
42
+ };
43
+ export const pageBudget = contextBudget();