@fnndsc/orrery 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +12 -0
  2. package/dist/controls/cameraRig.d.ts +223 -0
  3. package/dist/controls/cameraRig.js +353 -0
  4. package/dist/controls/cameraRig.js.map +1 -0
  5. package/dist/controls/gestures.d.ts +133 -0
  6. package/dist/controls/gestures.js +282 -0
  7. package/dist/controls/gestures.js.map +1 -0
  8. package/dist/controls/index.d.ts +9 -0
  9. package/dist/controls/index.js +10 -0
  10. package/dist/controls/index.js.map +1 -0
  11. package/dist/controls/picker.d.ts +117 -0
  12. package/dist/controls/picker.js +167 -0
  13. package/dist/controls/picker.js.map +1 -0
  14. package/dist/controls/replay.d.ts +94 -0
  15. package/dist/controls/replay.js +147 -0
  16. package/dist/controls/replay.js.map +1 -0
  17. package/dist/draw/censusField.d.ts +113 -0
  18. package/dist/draw/censusField.js +241 -0
  19. package/dist/draw/censusField.js.map +1 -0
  20. package/dist/draw/constants.d.ts +13 -0
  21. package/dist/draw/constants.js +14 -0
  22. package/dist/draw/constants.js.map +1 -0
  23. package/dist/draw/geometry.d.ts +33 -0
  24. package/dist/draw/geometry.js +55 -0
  25. package/dist/draw/geometry.js.map +1 -0
  26. package/dist/draw/handoff.d.ts +11 -0
  27. package/dist/draw/handoff.js +12 -0
  28. package/dist/draw/handoff.js.map +1 -0
  29. package/dist/draw/handoffField.d.ts +74 -0
  30. package/dist/draw/handoffField.js +151 -0
  31. package/dist/draw/handoffField.js.map +1 -0
  32. package/dist/draw/index.d.ts +20 -0
  33. package/dist/draw/index.js +21 -0
  34. package/dist/draw/index.js.map +1 -0
  35. package/dist/draw/labelField.d.ts +63 -0
  36. package/dist/draw/labelField.js +149 -0
  37. package/dist/draw/labelField.js.map +1 -0
  38. package/dist/draw/lamps.d.ts +16 -0
  39. package/dist/draw/lamps.js +17 -0
  40. package/dist/draw/lamps.js.map +1 -0
  41. package/dist/draw/nebula.d.ts +8 -0
  42. package/dist/draw/nebula.js +28 -0
  43. package/dist/draw/nebula.js.map +1 -0
  44. package/dist/draw/palette.d.ts +41 -0
  45. package/dist/draw/palette.js +32 -0
  46. package/dist/draw/palette.js.map +1 -0
  47. package/dist/draw/sphereField.d.ts +98 -0
  48. package/dist/draw/sphereField.js +168 -0
  49. package/dist/draw/sphereField.js.map +1 -0
  50. package/dist/draw/starField.d.ts +127 -0
  51. package/dist/draw/starField.js +267 -0
  52. package/dist/draw/starField.js.map +1 -0
  53. package/dist/draw/stars.d.ts +65 -0
  54. package/dist/draw/stars.js +132 -0
  55. package/dist/draw/stars.js.map +1 -0
  56. package/dist/draw/timing.d.ts +11 -0
  57. package/dist/draw/timing.js +12 -0
  58. package/dist/draw/timing.js.map +1 -0
  59. package/dist/draw/tubeField.d.ts +131 -0
  60. package/dist/draw/tubeField.js +352 -0
  61. package/dist/draw/tubeField.js.map +1 -0
  62. package/dist/draw/tubes.d.ts +41 -0
  63. package/dist/draw/tubes.js +116 -0
  64. package/dist/draw/tubes.js.map +1 -0
  65. package/dist/index.d.ts +15 -0
  66. package/dist/index.js +15 -0
  67. package/dist/index.js.map +1 -0
  68. package/dist/layout/accretion.d.ts +31 -0
  69. package/dist/layout/accretion.js +384 -0
  70. package/dist/layout/accretion.js.map +1 -0
  71. package/dist/layout/constellations.d.ts +25 -0
  72. package/dist/layout/constellations.js +197 -0
  73. package/dist/layout/constellations.js.map +1 -0
  74. package/dist/layout/galaxy.d.ts +12 -0
  75. package/dist/layout/galaxy.js +99 -0
  76. package/dist/layout/galaxy.js.map +1 -0
  77. package/dist/layout/hierarchy.d.ts +45 -0
  78. package/dist/layout/hierarchy.js +464 -0
  79. package/dist/layout/hierarchy.js.map +1 -0
  80. package/dist/layout/index.d.ts +20 -0
  81. package/dist/layout/index.js +21 -0
  82. package/dist/layout/index.js.map +1 -0
  83. package/dist/layout/layoutInput.d.ts +29 -0
  84. package/dist/layout/layoutInput.js +45 -0
  85. package/dist/layout/layoutInput.js.map +1 -0
  86. package/dist/layout/molecule.d.ts +64 -0
  87. package/dist/layout/molecule.js +155 -0
  88. package/dist/layout/molecule.js.map +1 -0
  89. package/dist/layout/pull.d.ts +94 -0
  90. package/dist/layout/pull.js +137 -0
  91. package/dist/layout/pull.js.map +1 -0
  92. package/dist/layout/ranked.d.ts +75 -0
  93. package/dist/layout/ranked.js +148 -0
  94. package/dist/layout/ranked.js.map +1 -0
  95. package/dist/layout/registry.d.ts +23 -0
  96. package/dist/layout/registry.js +165 -0
  97. package/dist/layout/registry.js.map +1 -0
  98. package/dist/layout/seeded.d.ts +32 -0
  99. package/dist/layout/seeded.js +48 -0
  100. package/dist/layout/seeded.js.map +1 -0
  101. package/dist/layout/types.d.ts +81 -0
  102. package/dist/layout/types.js +14 -0
  103. package/dist/layout/types.js.map +1 -0
  104. package/dist/scene/index.d.ts +6 -0
  105. package/dist/scene/index.js +7 -0
  106. package/dist/scene/index.js.map +1 -0
  107. package/dist/scene/orrery.d.ts +592 -0
  108. package/dist/scene/orrery.js +1555 -0
  109. package/dist/scene/orrery.js.map +1 -0
  110. package/dist/types/encoding.d.ts +43 -0
  111. package/dist/types/encoding.js +7 -0
  112. package/dist/types/encoding.js.map +1 -0
  113. package/dist/types/space.d.ts +12 -0
  114. package/dist/types/space.js +10 -0
  115. package/dist/types/space.js.map +1 -0
  116. package/package.json +57 -0
@@ -0,0 +1,1555 @@
1
+ /**
2
+ * @file The orrery itself: one three.js space, composed of the layers.
3
+ *
4
+ * A surface hands in a graph — nodes with identity, parents and joins, a
5
+ * scalar metric, a multiplicity, and a LOOK it has read from its own domain
6
+ * (a state, a named paint, an ember, whether the finish wave passes) — and
7
+ * the orrery lays it out (`layout/`), draws it (`draw/`), and lets it be
8
+ * steered and picked (`controls/`). It knows nothing of what the nodes
9
+ * stand for: a surface says what a node looks like, and gets its own nodes
10
+ * back from every pick.
11
+ *
12
+ * Layout is a strategy seam:
13
+ * - `ranked`: deterministic tiers by graph depth; the same graph always
14
+ * draws the same picture, the third dimension is presentation.
15
+ * - `molecule`: a d3-force-3d settle, radii scaled by the metric; a surface
16
+ * that groups its nodes (a hand-off key) settles in a worker it provides.
17
+ *
18
+ * Colours come from the palette the surface hands in, read on every
19
+ * rebuild, so a theme change repaints the space like everything else.
20
+ *
21
+ * @module
22
+ */
23
+ import * as THREE from 'three';
24
+ import { NODE_RADIUS, PHYSICS_DEFAULT, molecule_prepare, moleculeRadii_of as orreryRadii_of, ranked_layout, layoutEngine_get, layoutInput_build, } from '../layout/index.js';
25
+ import { STAR_GLOW, WAVE_STEP_MS, haloRadius_of, CensusField, HandoffField, SphereField, StarField, TubeField, LabelField, paint_resolve, } from '../draw/index.js';
26
+ import { CameraRig, PointerGestures, ReplayClock, Picker, DRAG_THRESHOLD_PX, } from '../controls/index.js';
27
+ import { PullSimulation } from '../layout/pull.js';
28
+ export { PHYSICS_DEFAULT };
29
+ /** How long one node's wave flare lasts (rise and fall). */
30
+ const WAVE_FLARE_MS = 700;
31
+ /** Rest between wave loops in the ambient miniature. */
32
+ const WAVE_LOOP_GAP_MS = 2_500;
33
+ /** How long a tap's name stays up after the finger lifts. */
34
+ const TAP_TIP_MS = 1500;
35
+ /**
36
+ * The palette used when a surface hands none in.
37
+ *
38
+ * @returns Warm status colours, a cool root and a cool pulse.
39
+ */
40
+ export function palette_default() {
41
+ return {
42
+ running: new THREE.Color('#ff7700'),
43
+ done: new THREE.Color('#ffeecc'),
44
+ error: new THREE.Color('#ff2222'),
45
+ template: new THREE.Color('#ffaa44'),
46
+ unknown: new THREE.Color('#555555'),
47
+ edge: new THREE.Color('#cc5500'),
48
+ join: new THREE.Color('#ffcc99'),
49
+ root: new THREE.Color('#6fbfae'),
50
+ pulse: new THREE.Color('#48d8f0'),
51
+ };
52
+ }
53
+ /**
54
+ * Ranked placement is declared once in `./rankedLayout.js` and drawn twice
55
+ * — here, and as SVG in a preview card, which cannot hold a WebGL context.
56
+ * What this file adds is the third dimension: the tree is a sheet, every
57
+ * node at z = 0, so an orbit reads as turning the sheet and never scrambles
58
+ * the hierarchy (the earlier per-node depth jitter made a turned tree look
59
+ * like a different feed).
60
+ */
61
+ /**
62
+ * A scene node as orrery's molecule reads it: its parents with its joins
63
+ * after them, and its metric.
64
+ *
65
+ * @param node - The scene node.
66
+ * @returns The molecule node.
67
+ */
68
+ function moleculeNode_of(node) {
69
+ const out = { id: node.id, parents: [...node.parentIds, ...node.joinParentIds] };
70
+ if (node.metric !== undefined)
71
+ out.metric = node.metric;
72
+ return out;
73
+ }
74
+ /**
75
+ * The ranked layout, placed in the scene: orrery places the tiers, the scene
76
+ * holds them as vectors beside their nodes.
77
+ *
78
+ * @param nodes - The graph's nodes.
79
+ * @returns Every node placed.
80
+ */
81
+ function layout_ranked(nodes) {
82
+ const places = ranked_layout(nodes.map((node) => {
83
+ const out = { id: node.id, parentIds: node.parentIds };
84
+ if (node.metric !== undefined)
85
+ out.metric = node.metric;
86
+ return out;
87
+ }));
88
+ return nodes.map((node, index) => {
89
+ const at = places[index];
90
+ return { node, position: new THREE.Vector3(...(at?.position ?? [0, 0, 0])), radius: at?.radius ?? NODE_RADIUS };
91
+ });
92
+ }
93
+ /**
94
+ * Each node's radius, as the molecule settle sizes it.
95
+ *
96
+ * @param nodes - The graph's nodes.
97
+ * @returns Radius by id.
98
+ */
99
+ function moleculeRadii_of(nodes) {
100
+ return orreryRadii_of(nodes.map(moleculeNode_of));
101
+ }
102
+ /**
103
+ * Builds orrery's molecule settle over the scene's nodes and seeds.
104
+ *
105
+ * @param nodes - The graph's nodes.
106
+ * @param dimensions - 2 or 3.
107
+ * @param seed - Where nodes stood last.
108
+ * @param physics - The terms of this settle.
109
+ * @param frozen - Nodes that stand where their seed put them.
110
+ * @returns The settle, placing into the scene's vectors.
111
+ */
112
+ function moleculeScene_prepare(nodes, dimensions = 3, seed = new Map(), physics = PHYSICS_DEFAULT, frozen = new Set()) {
113
+ const seeds = new Map([...seed].map(([id, at]) => [id, [at.x, at.y, at.z]]));
114
+ const settle = molecule_prepare(nodes.map(moleculeNode_of), dimensions, seeds, physics, frozen);
115
+ return {
116
+ total: settle.total,
117
+ moving: settle.moving,
118
+ step: settle.step,
119
+ place: () => settle.place().map((place, index) => ({
120
+ node: nodes[index],
121
+ position: new THREE.Vector3(...place.position),
122
+ radius: place.radius,
123
+ })),
124
+ };
125
+ }
126
+ /**
127
+ * Lays out a molecule in one go.
128
+ *
129
+ * @returns Every node placed.
130
+ */
131
+ function layout_molecule(nodes, dimensions = 3, seed = new Map(), physics = PHYSICS_DEFAULT, frozen = new Set()) {
132
+ const settle = moleculeScene_prepare(nodes, dimensions, seed, physics, frozen);
133
+ settle.step(settle.total);
134
+ return settle.place();
135
+ }
136
+ /** Settles smaller than this many ticks × nodes run in one go. */
137
+ const SLICE_MIN_WORK = 90 * 400;
138
+ /** Time a settle may take from one frame before it yields to the next. */
139
+ const SLICE_BUDGET_MS = 12;
140
+ /**
141
+ * One live DAG rendering bound to a container element.
142
+ */
143
+ export class Orrery {
144
+ container;
145
+ handlers;
146
+ paletteOf;
147
+ /** The replay under way, or null: its clock, the arrivals, and what is shown. */
148
+ replay = null;
149
+ layoutWorkerOf;
150
+ ambient;
151
+ renderer;
152
+ scene;
153
+ camera;
154
+ group = new THREE.Group();
155
+ /** What lies under a pointer, among everything drawn. */
156
+ picker;
157
+ /** The solid spheres, halos and plain edges. */
158
+ spheres = new SphereField(this.group);
159
+ /** Every drawn sphere and halo, by its node's id: the sphere field's own map. */
160
+ get meshes() {
161
+ return this.spheres.map;
162
+ }
163
+ /** The field's stars, threads and nebulae. */
164
+ starField = new StarField(this.group);
165
+ /** Captions pinned above nodes. */
166
+ labels = new LabelField(this.group);
167
+ /** Every solid molecule's tubes, and the lamps its stages wear. */
168
+ tubes = new TubeField({
169
+ parent: this.group,
170
+ node: (id) => this.tubeNode_of(id),
171
+ sphere: (id) => this.meshes.get(id),
172
+ palette: () => this.palette_read(),
173
+ });
174
+ /** Every molecule's place between stars and spheres. */
175
+ handoffField = new HandoffField({
176
+ spheres: this.spheres,
177
+ stars: this.starField,
178
+ tubes: this.tubes,
179
+ look: (id) => this.handoffLook_of(id),
180
+ });
181
+ /** Every job of every stage, when the scene draws a census. */
182
+ censusField = new CensusField({
183
+ parent: this.group,
184
+ stars: this.starField,
185
+ tubes: this.tubes,
186
+ starsView: () => ({ pixelRatio: window.devicePixelRatio, heightPx: this.renderer.domElement.height, fovDeg: this.camera.fov }),
187
+ });
188
+ /** Where the last projection left every node — the next settle's seed. */
189
+ lastPositions = new Map();
190
+ /** Nodes held still through the current settle. */
191
+ frozen = new Set();
192
+ /** Physics for the current settle alone, over the standing terms. */
193
+ physicsOnce = undefined;
194
+ /** CENSUS: render every member of every ×N group as an instanced point. */
195
+ census = false;
196
+ /** The molecule's physics terms (see PhysicsTerms). */
197
+ physics = { ...PHYSICS_DEFAULT };
198
+ /** Sets physics terms and re-settles (warm-started, so it morphs). */
199
+ physics_set(terms, resettle = true) {
200
+ this.physics = { ...this.physics, ...terms };
201
+ // A caller about to set a new graph anyway changes the terms without
202
+ // settling the old one first: the first paint of a space used to
203
+ // settle three thousand spheres twice.
204
+ if (resettle)
205
+ this.rebuild();
206
+ }
207
+ physics_get() {
208
+ return { ...this.physics };
209
+ }
210
+ /**
211
+ * Toggles the census projection: the full multiplicity of the graph as
212
+ * instanced members on analytic shells (spectacle), versus the semantic
213
+ * shape (work). Selection belongs to shape; census draws, never picks.
214
+ *
215
+ * @param on - Whether census is active.
216
+ */
217
+ census_set(on) {
218
+ if (this.census === on)
219
+ return;
220
+ this.census = on;
221
+ // The census shells what stands; it moves nothing.
222
+ this.rebuild(true, 'hold');
223
+ }
224
+ census_get() {
225
+ return this.census;
226
+ }
227
+ graph = { nodes: [] };
228
+ /** The grab in progress: which node, its drag plane, and travel so far. */
229
+ drag = null;
230
+ /** The live reaction simulation while (and shortly after) a grab. */
231
+ pull = null;
232
+ /** The pulse wave's schedule: node id to flare time (ms into the wave). */
233
+ waveTimes = new Map();
234
+ /** Wall-clock start of the running wave, or null when no wave runs. */
235
+ waveStartAt = null;
236
+ /**
237
+ * The camera and the world it turns: the focus, the flights, the hold
238
+ * inside a node, the idle spin, and whether the operator has placed the
239
+ * camera since the scene last framed itself.
240
+ */
241
+ rig;
242
+ /** How nodes are drawn: lit spheres (every pane), or stars (the universe's choice). */
243
+ drawMode = 'spheres';
244
+ /** Placed nodes by id, for the spheres a solid feed draws. */
245
+ placedById = new Map();
246
+ /**
247
+ * The pointer's intents on the canvas — the view drag, the fingers, the
248
+ * taps — or null for a miniature, which nobody steers.
249
+ */
250
+ gestures = null;
251
+ /** The pending hide of a tap's name. */
252
+ tapTipTimer = null;
253
+ /** The node the hover tip names, and where the pointer was. */
254
+ hovered = null;
255
+ /** Times the scene has framed the graph itself (`camera_fit`). */
256
+ fits = 0;
257
+ strategy = 'ranked';
258
+ selectedId = null;
259
+ frameHandle = null;
260
+ disposed = false;
261
+ /** The hover tip naming the node under the pointer (pane mode only). */
262
+ tip = null;
263
+ /** The wave's flare color, re-read from the palette on every rebuild. */
264
+ pulseColor = new THREE.Color('#48d8f0');
265
+ /**
266
+ * @param container - The element the canvas fills.
267
+ * @param handlers - Picking callbacks (ignored in ambient mode).
268
+ * @param options - Pane vs miniature behavior.
269
+ */
270
+ constructor(container, handlers = {}, options = {}) {
271
+ this.container = container;
272
+ this.handlers = handlers;
273
+ this.paletteOf = options.palette ?? palette_default;
274
+ this.layoutWorkerOf = options.layoutWorker;
275
+ this.ambient = options.ambient === true;
276
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
277
+ this.renderer.setPixelRatio(window.devicePixelRatio);
278
+ // The renderer sizes its canvas in DEVICE pixels and never writes the
279
+ // CSS size (`setSize(w, h, false)` below); the class pins the canvas to
280
+ // its mount so a HiDPI display cannot spill it sideways — one rule for
281
+ // every mount (a pane, a /bin diagram, the header's cycler), not a
282
+ // per-host stylesheet that a new host forgets.
283
+ this.renderer.domElement.classList.add('dag-scene-canvas');
284
+ this.scene = new THREE.Scene();
285
+ this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200);
286
+ this.camera.position.set(0, 0, 14);
287
+ this.rig = new CameraRig(this.camera, this.group);
288
+ this.picker = new Picker(this.camera, this.group, this.renderer.domElement, {
289
+ meshes: () => this.meshes.values(),
290
+ stars: () => this.starField.list(),
291
+ nebulae: () => this.starField.nebulae(),
292
+ // A census is picked by its members only while it stands.
293
+ census: () => (this.census && this.censusField.drawn() ? this.censusField : null),
294
+ }, STAR_GLOW);
295
+ this.scene.add(new THREE.AmbientLight(0xffffff, 0.9));
296
+ const key = new THREE.DirectionalLight(0xffffff, 1.2);
297
+ key.position.set(4, 6, 8);
298
+ this.scene.add(key);
299
+ this.scene.add(this.group);
300
+ container.appendChild(this.renderer.domElement);
301
+ this.size_fit();
302
+ if (!this.ambient) {
303
+ // Hovering names the node: a small tip follows the pointer over a
304
+ // sphere, so identity does not cost a click.
305
+ this.tip = document.createElement('div');
306
+ this.tip.className = 'dag-node-tip';
307
+ this.tip.hidden = true;
308
+ container.appendChild(this.tip);
309
+ this.gestures = new PointerGestures(this.renderer.domElement, this.rig, {
310
+ grab_begin: (event) => this.grab_begin(event),
311
+ grab_move: (event) => this.drag_move(event),
312
+ grab_end: () => this.grab_end(),
313
+ hover: (event) => this.hover_handle(event),
314
+ leave: (event) => this.leave_handle(event),
315
+ tap: (event, kind) => this.pick_handle(event, kind),
316
+ tip_hide: () => { if (this.tip)
317
+ this.tip.hidden = true; },
318
+ gesture_end: () => this.handlers.gesture_end?.(),
319
+ }, {
320
+ flat: () => this.projection === '2d',
321
+ // Census is a place: the camera parks off the focus.
322
+ untethered: () => this.census,
323
+ });
324
+ }
325
+ // A pane resize (a collapsed console, a divider drag) reshapes the box
326
+ // without a window resize; an unfitted canvas would stretch the graph.
327
+ new ResizeObserver(() => this.size_fit()).observe(container);
328
+ const animate = () => {
329
+ if (this.disposed)
330
+ return;
331
+ if (this.ambient) {
332
+ this.rig.tumble_step();
333
+ this.wave_animate();
334
+ }
335
+ else {
336
+ // A touched graph holds still; the idle spin resumes after the wait.
337
+ // A flight or a stay inside a node holds it unconditionally.
338
+ this.rig.spin_step(!(this.gestures?.pointerOver() ?? false) && this.projection === '3d');
339
+ // The reaction simulation runs while hot: during a grab, and cooling
340
+ // after release until it settles.
341
+ if (this.pull !== null) {
342
+ if (this.pull.step(this.drag !== null)) {
343
+ this.positions_sync();
344
+ }
345
+ else {
346
+ this.pull = null;
347
+ }
348
+ }
349
+ if (this.dragReturns.length > 0) {
350
+ const now = Date.now();
351
+ this.dragReturns = this.dragReturns.filter((entry) => {
352
+ const t = Math.min(1, (now - entry.startedAt) / 300);
353
+ const eased = t * t * (3 - 2 * t);
354
+ entry.mesh.position.lerpVectors(entry.from, entry.to, eased);
355
+ return t < 1;
356
+ });
357
+ this.positions_sync();
358
+ }
359
+ this.wave_animate();
360
+ this.rig.flight_step();
361
+ }
362
+ this.starField.scale_update(this.renderer.domElement.height, this.camera.fov);
363
+ this.handoff_step();
364
+ this.replay_step();
365
+ this.tubes.frame(performance.now());
366
+ this.labels.declutter(this.camera);
367
+ this.renderer.render(this.scene, this.camera);
368
+ this.frameHandle = window.requestAnimationFrame(animate);
369
+ };
370
+ animate();
371
+ }
372
+ /**
373
+ * Replaces the rendered graph. Layout runs under the current strategy.
374
+ *
375
+ * @param graph - The normalized graph.
376
+ */
377
+ graph_set(graph, options = {}) {
378
+ this.graph = graph;
379
+ // Nodes to hold still through this settle, and physics for it alone:
380
+ // a descent settles one feed while the field around it stands, with
381
+ // no gravity — gravity pulls to the origin, and a feed unfolding far
382
+ // from it would stream there instead of opening where it stood.
383
+ this.frozen = new Set(options.frozen ?? []);
384
+ this.physicsOnce = options.physics;
385
+ // A graph arriving under a flight keeps the camera where the flight
386
+ // put it (`fit: false`); every other arrival is framed whole.
387
+ this.rebuild(options.fit !== false, options.settle ?? 'full');
388
+ // An ARRIVING graph gets one wave; a local re-projection (a scale or
389
+ // layout flip) must not fire one — an unasked pulse reads as a glitch.
390
+ if (options.wave !== false)
391
+ this.wave_start();
392
+ }
393
+ /** Whether the pane's wave loops continuously (the PULSE ON state). */
394
+ waveLooping = false;
395
+ /** Sets continuous wave looping; enabling fires a wave immediately. */
396
+ waveLoop_set(on) {
397
+ this.waveLooping = on;
398
+ if (on)
399
+ this.wave_start();
400
+ // Turning it off lets the current wave finish and simply not renew.
401
+ }
402
+ /** @returns Whether the wave is looping. */
403
+ waveLoop_get() {
404
+ return this.waveLooping;
405
+ }
406
+ /**
407
+ * Where every node settled last, by id, for a caller that remembers.
408
+ *
409
+ * @returns Rounded positions, node id to [x, y, z].
410
+ */
411
+ positions_get() {
412
+ const out = {};
413
+ for (const [id, position] of this.lastPositions) {
414
+ out[id] = [Math.round(position.x * 100) / 100, Math.round(position.y * 100) / 100, Math.round(position.z * 100) / 100];
415
+ }
416
+ return out;
417
+ }
418
+ /**
419
+ * Seeds the next settle from remembered positions: a node that was here
420
+ * before starts where it stood, a new one starts fresh. Takes effect on
421
+ * the next `graph_set`.
422
+ *
423
+ * @param positions - Node id to [x, y, z].
424
+ */
425
+ positions_seed(positions) {
426
+ this.lastPositions = new Map(Object.entries(positions).map(([id, [x, y, z]]) => [id, new THREE.Vector3(x, y, z)]));
427
+ }
428
+ /**
429
+ * Starts the pulse wave: nodes flare in dependency order, a join waiting
430
+ * for its last parent. History-honest — only nodes that actually executed
431
+ * (terminal success or error, or an authored template node) fire, so on a
432
+ * running feed the wave halts at the execution frontier.
433
+ */
434
+ wave_start() {
435
+ this.waveTimes = this.waveSchedule_compute();
436
+ this.waveStartAt = this.waveTimes.size > 0 ? Date.now() : null;
437
+ }
438
+ /**
439
+ * Flies the camera into a node: a dolly toward the sphere until it fills
440
+ * the frame. The idle spin holds for the whole stay; `flight_back` reverses.
441
+ *
442
+ * @param nodeId - The node to fly into.
443
+ * @param onArrived - Called once the camera is inside the node.
444
+ */
445
+ flight_into(nodeId, onArrived) {
446
+ const mesh = this.meshes.get(nodeId);
447
+ // In census there is no mesh per node: fly to the group's first member.
448
+ const censusIndex = mesh === undefined ? this.censusField.ids().indexOf(nodeId) : -1;
449
+ if ((mesh === undefined && censusIndex < 0) || this.rig.flying() || this.rig.holding_get()) {
450
+ return;
451
+ }
452
+ // The dive leaves the graph exactly as the operator has it — no reset
453
+ // snap. World position: the camera flies in world space, and the group
454
+ // may be rotated.
455
+ this.group.updateMatrixWorld(true);
456
+ const target = mesh !== undefined
457
+ ? mesh.getWorldPosition(new THREE.Vector3())
458
+ : this.group.localToWorld((this.censusField.positions()[censusIndex] ?? new THREE.Vector3()).clone());
459
+ this.rig.flyInto(target, onArrived);
460
+ }
461
+ /**
462
+ * Flies the camera to frame a set of nodes: the fit `camera_fit` would
463
+ * give those nodes alone, reached by a flight from where the camera
464
+ * stands rather than a cut. A descent into one feed frames that feed;
465
+ * the climb back frames everything. Never holds: picking goes on.
466
+ *
467
+ * @param ids - The nodes to frame; every drawn node when empty.
468
+ * @param durationMs - The flight's length.
469
+ * @param onDone - Called when the camera arrives.
470
+ * @param bulk - The share of the nodes the frame must hold, 0..1: at 1
471
+ * every outlier is inside the frame; at 0.85 the farthest few spill,
472
+ * and the bulk is close enough to touch. A large feed's settle throws
473
+ * a few nodes far out, and a frame that held them all put the feed at
474
+ * the centre as a starburst of two-pixel spheres nobody could click.
475
+ */
476
+ camera_flyToFit(ids, durationMs, onDone, bulk = 1, margin = 1.15) {
477
+ const wanted = new Set(ids);
478
+ const reaches = [];
479
+ this.group.updateMatrixWorld(true);
480
+ for (const id of this.drawnIds()) {
481
+ if (wanted.size > 0 && !wanted.has(id))
482
+ continue;
483
+ const drawn = this.nodeWorld_of(id);
484
+ if (drawn !== null)
485
+ reaches.push(drawn);
486
+ }
487
+ this.rig.flyToFit(reaches, durationMs, onDone, bulk, margin);
488
+ }
489
+ /**
490
+ * How far the camera stands from what it looks at.
491
+ *
492
+ * @returns The distance from the eye to the focus, in scene units.
493
+ */
494
+ camera_distance() {
495
+ return this.rig.eyeDistance();
496
+ }
497
+ /**
498
+ * Whether the operator has placed the camera since the scene last framed
499
+ * the graph: a wheel or a drag. A repaint of the same space keeps a
500
+ * placed camera; a fresh space is framed whole.
501
+ *
502
+ * @returns True once the operator has wheeled or dragged since the last fit.
503
+ */
504
+ camera_touched() {
505
+ return this.rig.touched_get();
506
+ }
507
+ /**
508
+ * Draws the nodes as lit spheres or as stars. Moves nothing: the scene
509
+ * is redrawn where it stands, the camera where the operator put it.
510
+ *
511
+ * @param mode - The draw mode.
512
+ */
513
+ draw_set(mode) {
514
+ if (mode === this.drawMode)
515
+ return;
516
+ this.drawMode = mode;
517
+ this.rebuild(false, 'hold');
518
+ }
519
+ /**
520
+ * Shows or hides the nodes' captions; nothing is redrawn.
521
+ *
522
+ * @param on - Shown.
523
+ */
524
+ captions_set(on) {
525
+ this.labels.shown_set(on);
526
+ }
527
+ /** @returns How nodes are drawn. */
528
+ draw_get() {
529
+ return this.drawMode;
530
+ }
531
+ /**
532
+ * What the scene holds, for a surface asked to say so: how it draws,
533
+ * how many nodes are solid spheres and how many stars, which of the
534
+ * graph's solid-marked nodes are drawn solid, and the hand-off's state.
535
+ *
536
+ * @returns A plain summary.
537
+ */
538
+ state_get() {
539
+ const solidMarked = this.graph.nodes.filter((node) => node.solid === true).map((node) => node.id);
540
+ const solidDrawn = solidMarked.filter((id) => this.meshes.has(id)).length;
541
+ return {
542
+ draw: this.drawMode,
543
+ arrangement: this.arrangement,
544
+ captions: this.labels.shown_get() ? this.labels.count() : 'off',
545
+ // How many nodes the scene knows a place for: what a hold can hold.
546
+ known: this.lastPositions.size,
547
+ census: this.census,
548
+ nodes: this.graph.nodes.length,
549
+ meshes: this.meshes.size,
550
+ stars: this.starField.count(),
551
+ solidMarked: solidMarked.length,
552
+ solidDrawn,
553
+ tubes: this.tubes.materialCount(),
554
+ censusTubes: this.tubes.censusCount(),
555
+ handoffGroups: this.handoffField.size(),
556
+ handoffSolid: this.handoffField.solidCount(),
557
+ camera: this.rig.eyeDistance().toFixed(1),
558
+ settling: this.slicing,
559
+ };
560
+ }
561
+ /**
562
+ * Flies the camera toward one node, along the line it already looks
563
+ * down, to a distance where its neighbourhood fills the view: the zoom
564
+ * into the star the operator clicked, not a framing of all it belongs to.
565
+ *
566
+ * @param id - The node.
567
+ * @param distance - How far from it to stop, in scene units.
568
+ * @param durationMs - The flight's length.
569
+ * @param onDone - Called on arrival (at once when the node is not drawn).
570
+ */
571
+ camera_flyToward(id, distance, durationMs, onDone) {
572
+ const drawn = this.nodeWorld_of(id);
573
+ if (drawn === null) {
574
+ onDone();
575
+ return;
576
+ }
577
+ this.rig.flyToward(drawn, distance, durationMs, onDone);
578
+ }
579
+ /**
580
+ * Whether the camera is parked inside a node.
581
+ *
582
+ * The fly-in dollies to just shy of a node's surface, so while this is
583
+ * true the pane is filled by the inside of one sphere. Anything that can
584
+ * leave an operator there without an overlay on top has to be able to ask
585
+ * this and fly them back.
586
+ *
587
+ * @returns True while the camera is held inside a node.
588
+ */
589
+ holding_get() {
590
+ return this.rig.holding_get();
591
+ }
592
+ /**
593
+ * Flies the camera back out to its pre-dive stance and releases the hold.
594
+ *
595
+ * @param onDone - Called once the camera is home.
596
+ */
597
+ flight_back(onDone) {
598
+ this.rig.flyBack(onDone);
599
+ }
600
+ /** Computes each fireable node's flare time, in ms into the wave. */
601
+ waveSchedule_compute() {
602
+ const times = new Map();
603
+ const present = new Set(this.graph.nodes.map((n) => n.id));
604
+ const fired = (node) => node.look.waved;
605
+ // Relaxation to a fixpoint: cheap at feed scale, and immune to input order.
606
+ let settled = false;
607
+ while (!settled) {
608
+ settled = true;
609
+ for (const node of this.graph.nodes) {
610
+ if (times.has(node.id) || !fired(node))
611
+ continue;
612
+ const parents = [...node.parentIds, ...node.joinParentIds].filter((id) => present.has(id));
613
+ if (!parents.every((id) => times.has(id)))
614
+ continue;
615
+ const latest = parents.reduce((max, id) => Math.max(max, times.get(id) ?? 0), -WAVE_STEP_MS);
616
+ times.set(node.id, latest + WAVE_STEP_MS);
617
+ settled = false;
618
+ }
619
+ }
620
+ return times;
621
+ }
622
+ /** Applies the wave's flares for this frame; loops in ambient mode. */
623
+ wave_animate() {
624
+ if (this.waveStartAt === null)
625
+ return;
626
+ const elapsed = Date.now() - this.waveStartAt;
627
+ let peak = 0;
628
+ if (this.census && this.censusField.drawn()) {
629
+ // The wave rides the cloud: every job flares with its stage's fire time.
630
+ peak = this.censusField.flare((id) => this.waveTimes.get(id), elapsed, this.pulseColor, WAVE_FLARE_MS);
631
+ if (elapsed > peak + WAVE_FLARE_MS) {
632
+ this.waveStartAt = this.ambient || this.waveLooping ? Date.now() + WAVE_LOOP_GAP_MS : null;
633
+ }
634
+ return;
635
+ }
636
+ for (const [id, fireAt] of this.waveTimes) {
637
+ peak = Math.max(peak, fireAt);
638
+ const mesh = this.meshes.get(id);
639
+ if (mesh === undefined || !(mesh.material instanceof THREE.MeshStandardMaterial))
640
+ continue;
641
+ const dt = elapsed - fireAt;
642
+ const flare = dt >= 0 && dt <= WAVE_FLARE_MS ? Math.sin((dt / WAVE_FLARE_MS) * Math.PI) : 0;
643
+ // The flare pops in two channels at once: a cool color (white died on
644
+ // the butter of finished nodes) and a size swell.
645
+ mesh.scale.setScalar(1 + flare * 0.45);
646
+ if (flare > 0) {
647
+ mesh.material.emissive.copy(this.pulseColor);
648
+ mesh.material.emissiveIntensity = flare * 1.2;
649
+ }
650
+ else {
651
+ mesh.material.emissive.setScalar(id === this.selectedId ? 1 : 0);
652
+ mesh.material.emissiveIntensity = id === this.selectedId ? 0.35 : 0;
653
+ }
654
+ }
655
+ if (elapsed > peak + WAVE_FLARE_MS) {
656
+ // A future start leaves the graph quiet through the gap, then loops.
657
+ this.waveStartAt = this.ambient || this.waveLooping ? Date.now() + WAVE_LOOP_GAP_MS : null;
658
+ }
659
+ }
660
+ /**
661
+ * Switches the layout strategy and re-lays the current graph.
662
+ *
663
+ * @param strategy - Ranked tiers or the force-settled molecule.
664
+ */
665
+ strategy_set(strategy) {
666
+ this.strategy = strategy;
667
+ this.rebuild();
668
+ }
669
+ /** Clears the node selection (a new graph owes nothing to the old one). */
670
+ selection_clear() {
671
+ this.selectedId = null;
672
+ this.selection_paint();
673
+ }
674
+ /**
675
+ * Lights the selected sphere and dims the rest, touching materials only.
676
+ * Selection used to rebuild the scene: every click re-ran the settle of
677
+ * every node (seconds, on three thousand) and refitted the camera to
678
+ * the whole graph, so a click on a sphere the operator had dollied up
679
+ * to threw them back out to the whole space before anything else
680
+ * happened, and a double click could never land. A selection is paint.
681
+ */
682
+ selection_paint() {
683
+ this.spheres.select(this.selectedId);
684
+ }
685
+ /** @returns The active layout strategy. */
686
+ strategy_get() {
687
+ return this.strategy;
688
+ }
689
+ /** The active projection: the sculpted 3D stage, or the flat schematic. */
690
+ projection = '3d';
691
+ /**
692
+ * Switches projection. 2D is the schematic reading: the layout flattens
693
+ * to the plane, the view squares up face-on, the idle spin rests, and an
694
+ * empty-space drag pans (there is no depth to orbit). 3D restores the
695
+ * sculpted stage.
696
+ *
697
+ * @param projection - The projection to show.
698
+ */
699
+ projection_set(projection) {
700
+ this.projection = projection;
701
+ if (projection === '2d') {
702
+ this.group.quaternion.identity();
703
+ }
704
+ this.rebuild();
705
+ }
706
+ /** @returns The active projection. */
707
+ projection_get() {
708
+ return this.projection;
709
+ }
710
+ /**
711
+ * Updates one node's look in place (its state changed on the progress
712
+ * channel) without re-laying the graph.
713
+ *
714
+ * @param nodeId - The node.
715
+ * @param look - How it looks now.
716
+ */
717
+ look_update(nodeId, look) {
718
+ const node = this.graph.nodes.find((n) => n.id === nodeId);
719
+ if (!node)
720
+ return;
721
+ node.look = look;
722
+ this.spheres.recolor(nodeId, paint_resolve(look.paint, this.palette_read()));
723
+ // A stage that started or finished changes what its tubes carry: a
724
+ // stream, a replay, or nothing.
725
+ if (this.tubes.holds(nodeId))
726
+ this.tubes.stale_mark();
727
+ }
728
+ /** @returns The colours to draw with now. */
729
+ palette_read() {
730
+ return this.paletteOf();
731
+ }
732
+ /** Re-reads the palette (the THEME pill changed) and repaints. */
733
+ palette_refresh() {
734
+ this.rebuild(true, 'hold');
735
+ }
736
+ /** Fits the renderer to the container's current box. */
737
+ size_fit() {
738
+ const width = Math.max(1, this.container.clientWidth);
739
+ const height = Math.max(1, this.container.clientHeight);
740
+ this.renderer.setSize(width, height, false);
741
+ this.camera.aspect = width / height;
742
+ this.camera.updateProjectionMatrix();
743
+ }
744
+ /** Tears the scene down and releases the GL context. */
745
+ dispose() {
746
+ this.disposed = true;
747
+ this.layoutWorker?.terminate();
748
+ this.layoutWorker = null;
749
+ if (this.frameHandle !== null)
750
+ window.cancelAnimationFrame(this.frameHandle);
751
+ this.gestures?.detach();
752
+ this.renderer.dispose();
753
+ this.renderer.domElement.remove();
754
+ this.spheres.dispose();
755
+ }
756
+ /**
757
+ * Fits the camera to the placed graph: distance from the bounding sphere
758
+ * so a sprawling molecule (or a wide 2D settle) sits inside the frustum
759
+ * instead of clipping through the near plane as black voids — or leaving
760
+ * the view entirely.
761
+ */
762
+ /**
763
+ * Builds the census: one instanced member per collapsed count, on a
764
+ * fibonacci shell around its group's anchor; equal-count parent/child
765
+ * groups pair members by index, so chains of ×N groups render as
766
+ * branched filaments over the shell — the cell-surface reading.
767
+ */
768
+ censusBuild(placed, palette, fit = true) {
769
+ // A ghost (a cluster's anchor) is nobody's job; it still counts in the
770
+ // cloud's centre and in how deep a stage lies.
771
+ const nodes = placed.map((item) => {
772
+ const node = item.node;
773
+ return {
774
+ id: node.id,
775
+ position: item.position,
776
+ radius: item.radius,
777
+ count: Math.max(1, node.count ?? 1),
778
+ color: paint_resolve(node.look.paint, palette),
779
+ dim: node.dim === true,
780
+ ember: node.look.ember,
781
+ state: node.look.state,
782
+ parents: node.parentIds,
783
+ ghost: node.ghost === true,
784
+ };
785
+ });
786
+ const cloud = this.censusField.build(nodes, this.drawMode === 'stars', palette);
787
+ const center = cloud.center;
788
+ const cloudRadius = cloud.radius;
789
+ // The census parks the camera only when a framing was asked for: a
790
+ // descent redraws under a camera the flight has placed, and re-parking
791
+ // it there was the view resetting under every click.
792
+ if (!this.ambient && fit) {
793
+ // A census cloud grown from near-planar anchors reads edge-on from
794
+ // the axis; open on a three-quarter orbit so the shells read as
795
+ // volume from the first frame.
796
+ this.rig.frame(center, cloudRadius, { stance: 'threeQuarter', farScale: 2.5 });
797
+ }
798
+ }
799
+ camera_fit(placed) {
800
+ if (placed.length === 0)
801
+ return;
802
+ this.rig.touch_clear();
803
+ // How many times the scene has framed the graph itself, on the canvas:
804
+ // a smoke reads it to prove a click did not refit a placed camera.
805
+ this.fits += 1;
806
+ this.renderer.domElement.dataset['fits'] = String(this.fits);
807
+ const center = new THREE.Vector3();
808
+ for (const item of placed)
809
+ center.add(item.position);
810
+ center.divideScalar(placed.length);
811
+ // A space of molecules (a host that names them) is framed by its bulk:
812
+ // a few bodies flung far out would otherwise shrink the galaxies to a
813
+ // speck. A single graph is framed whole.
814
+ const reaches = placed.map((item) => center.distanceTo(item.position) + item.radius).sort((a, b) => a - b);
815
+ const share = this.handlers.handoffKey !== undefined ? 0.95 : 1;
816
+ const radius = Math.max(1, reaches[Math.max(0, Math.ceil(reaches.length * share) - 1)] ?? 1);
817
+ // The far plane always clears the framed graph: a fixed 200 clipped
818
+ // sprawling molecules into black voids (and swallowed 2D whole).
819
+ this.rig.frame(center, radius);
820
+ }
821
+ /** Counts rebuilds, so a sliced settle overtaken by a newer one stops. */
822
+ rebuildGen = 0;
823
+ /** Whether a settle is running in slices, its readout up. */
824
+ slicing = false;
825
+ /**
826
+ * Rebuilds meshes and edges from the current graph and strategy.
827
+ *
828
+ * @param fit - Frame the result.
829
+ * @param mode - How the graph settles; see {@link SettleMode}.
830
+ */
831
+ rebuild(fit = true, mode = 'full') {
832
+ const generation = ++this.rebuildGen;
833
+ // A settle in slices overtaken by this rebuild ends here: its readout
834
+ // closes, or a quick rebuild after it would leave the bar standing at
835
+ // whatever it last said.
836
+ if (this.slicing) {
837
+ this.slicing = false;
838
+ this.handlers.progress?.(1, 1, 0);
839
+ }
840
+ if (this.strategy !== 'molecule') {
841
+ this.draw(layout_ranked(this.graph.nodes), fit);
842
+ return;
843
+ }
844
+ let frozen = this.frozen;
845
+ if (mode !== 'full' && frozen.size === 0) {
846
+ const standing = this.graph.nodes.map((node) => node.id).filter((id) => this.lastPositions.has(id));
847
+ if (standing.length > 0)
848
+ frozen = new Set(standing);
849
+ }
850
+ const settle = moleculeScene_prepare(this.graph.nodes, this.projection === '2d' ? 2 : 3, this.lastPositions, this.physicsOnce !== undefined ? { ...this.physics, ...this.physicsOnce } : this.physics, frozen);
851
+ // A settle too big for one frame runs in slices when someone is told
852
+ // how far it has come: the page keeps drawing the scene that stands,
853
+ // the operator sees a bar move, and the new scene replaces it at the
854
+ // end. Without a listener, and for a small settle, it runs in one go.
855
+ // Nothing to move (a repaint under `hold` or `new` with every node
856
+ // standing: a plugin lit, a palette changed): draw where they stand.
857
+ // Any engine would only spend its ticks on bodies that cannot move.
858
+ if (settle.moving === 0) {
859
+ this.draw(settle.place(), fit);
860
+ return;
861
+ }
862
+ const nodes = this.graph.nodes.length;
863
+ const progress = this.handlers.progress;
864
+ // The work is the ticks times the nodes that move: a descent settles a
865
+ // handful among thousands and must land in the same call, since the
866
+ // flight that follows reads the meshes it draws.
867
+ const small = settle.total * settle.moving < SLICE_MIN_WORK;
868
+ // A host that names its molecules (the universe) settles a big space
869
+ // as a hierarchy in a worker: molecules alone, then molecules as bodies.
870
+ // SPOKES and CLUMPS exist only there, at any size; a small galaxy
871
+ // settles on the page as it always has.
872
+ if ((!small || this.arrangement !== 'galaxy') && progress !== undefined && this.handlers.handoffKey !== undefined && this.frozen.size === 0) {
873
+ this.hierarchy_run(generation, fit, frozen, progress);
874
+ return;
875
+ }
876
+ if (progress === undefined || small) {
877
+ settle.step(settle.total);
878
+ this.draw(settle.place(), fit);
879
+ return;
880
+ }
881
+ let done = 0;
882
+ this.slicing = true;
883
+ progress(0, settle.total, nodes);
884
+ const slice = () => {
885
+ if (generation !== this.rebuildGen || this.disposed)
886
+ return;
887
+ const started = performance.now();
888
+ while (done < settle.total && performance.now() - started < SLICE_BUDGET_MS) {
889
+ settle.step(1);
890
+ done += 1;
891
+ }
892
+ if (done < settle.total) {
893
+ progress(done, settle.total, nodes);
894
+ window.requestAnimationFrame(slice);
895
+ return;
896
+ }
897
+ this.slicing = false;
898
+ this.draw(settle.place(), fit);
899
+ progress(settle.total, settle.total, nodes);
900
+ };
901
+ window.requestAnimationFrame(slice);
902
+ }
903
+ /** The layout worker, made on the first big settle and kept. */
904
+ layoutWorker = null;
905
+ /** How a hierarchy sits molecules around their anchor. */
906
+ arrangement = 'galaxy';
907
+ /** Where each arrangement's spheres stood, for a return to it. */
908
+ arrangementMemory = new Map();
909
+ /**
910
+ * Arranges molecules as clumps or spokes. The space is laid out afresh:
911
+ * a remembered shape would keep the old arrangement.
912
+ *
913
+ * @param arrangement - The arrangement: a registered engine's name.
914
+ * @param remembered - Where its spheres stood, from a host's memory.
915
+ * @param redraw - Redraw at once; false when the host is about to hand
916
+ * in the graph the arrangement needs (constellations bring their stars).
917
+ */
918
+ arrangement_set(arrangement, remembered, redraw = true) {
919
+ if (arrangement === this.arrangement)
920
+ return;
921
+ // Each arrangement keeps where its spheres stood: going back to one
922
+ // already seen redraws it, and only a first visit pays its settle.
923
+ this.arrangementMemory.set(this.arrangement, new Map(this.lastPositions));
924
+ this.arrangement = arrangement;
925
+ let kept = this.arrangementMemory.get(arrangement);
926
+ if (kept === undefined && remembered !== undefined && Object.keys(remembered).length > 0) {
927
+ kept = new Map(Object.entries(remembered).map(([id, [x, y, z]]) => [id, new THREE.Vector3(x, y, z)]));
928
+ }
929
+ this.lastPositions = kept ?? new Map();
930
+ if (redraw)
931
+ this.rebuild(true, kept === undefined ? 'full' : 'hold');
932
+ }
933
+ /** @returns How a hierarchy sits molecules round their hubs. */
934
+ arrangement_get() {
935
+ return this.arrangement;
936
+ }
937
+ /**
938
+ * Settles the graph as a hierarchy in the worker, reporting progress, and
939
+ * draws the answer if no newer rebuild has overtaken it.
940
+ */
941
+ hierarchy_run(generation, fit, frozen, progress) {
942
+ const keyOf = this.handlers.handoffKey;
943
+ if (keyOf === undefined)
944
+ return;
945
+ const radii = moleculeRadii_of(this.graph.nodes);
946
+ // Built as a session builds it, so a place the session keeps is a place this settle would find.
947
+ const seeds = new Map([...this.lastPositions].map(([id, at]) => [id, [at.x, at.y, at.z]]));
948
+ const nodes = layoutInput_build(this.graph.nodes, keyOf, seeds, frozen);
949
+ const total = 1000;
950
+ const count = nodes.length;
951
+ this.slicing = true;
952
+ progress(0, total, count);
953
+ const physics = this.physicsOnce !== undefined ? { ...this.physics, ...this.physicsOnce } : this.physics;
954
+ const placeAll = (positions) => {
955
+ const placed = this.graph.nodes.map((node) => {
956
+ const at = positions[node.id] ?? [0, 0, 0];
957
+ return { node, position: new THREE.Vector3(at[0], at[1], at[2]), radius: radii.get(node.id) ?? NODE_RADIUS };
958
+ });
959
+ this.slicing = false;
960
+ this.draw(placed, fit);
961
+ progress(total, total, count);
962
+ };
963
+ if (this.layoutWorker === null) {
964
+ const make = this.layoutWorkerOf;
965
+ if (make === undefined) {
966
+ // No worker from the surface: the engine settles on the page.
967
+ const engine = layoutEngine_get(this.arrangement);
968
+ if (engine === undefined) {
969
+ console.error(`layout: no engine named ${this.arrangement}`);
970
+ this.slicing = false;
971
+ progress(total, total, count);
972
+ return;
973
+ }
974
+ placeAll(engine.run({ nodes, physics }, (fraction) => progress(Math.min(total - 1, Math.floor(fraction * total)), total, count)).positions);
975
+ return;
976
+ }
977
+ this.layoutWorker = make();
978
+ }
979
+ const worker = this.layoutWorker;
980
+ worker.onmessage = (event) => {
981
+ const answer = event.data;
982
+ if (answer.generation !== this.rebuildGen || this.disposed)
983
+ return;
984
+ if (answer.type === 'progress') {
985
+ progress(Math.min(total - 1, Math.floor((answer.fraction ?? 0) * total)), total, count);
986
+ return;
987
+ }
988
+ if (answer.type === 'failed') {
989
+ // An engine the host does not know: the space stays as it stood,
990
+ // and the reason is said, never drawn as every node at the origin.
991
+ console.error(`layout: ${answer.reason ?? 'the engine failed'}`);
992
+ this.slicing = false;
993
+ progress(total, total, count);
994
+ return;
995
+ }
996
+ placeAll(answer.positions ?? {});
997
+ };
998
+ worker.postMessage({ generation, nodes, physics, arrangement: this.arrangement });
999
+ }
1000
+ /** Draws placed nodes as the scene: meshes, edges, census, the frame. */
1001
+ draw(placed, fit) {
1002
+ this.group.clear();
1003
+ this.spheres.clear();
1004
+ this.censusField.clear();
1005
+ this.pull = null;
1006
+ this.drag = null;
1007
+ this.starField.clear();
1008
+ this.labels.clear();
1009
+ this.handoffField.clear();
1010
+ this.placedById = new Map();
1011
+ this.tubes.clear();
1012
+ const palette = this.palette_read();
1013
+ this.pulseColor = palette.pulse;
1014
+ this.lastPositions = new Map(placed.map((p) => [p.node.id, p.position.clone()]));
1015
+ if (this.projection === '2d') {
1016
+ // The molecule already settled in-plane; ranked drops only its
1017
+ // parallax hash — its layout was two-dimensional by construction.
1018
+ for (const item of placed)
1019
+ item.position.z = 0;
1020
+ }
1021
+ if (this.census) {
1022
+ // The census shells every job but the feed the operator is at: an
1023
+ // entered feed stays solid spheres in its tubes, whatever the density.
1024
+ const solidOnes = placed.filter((item) => item.node.solid === true && item.node.ghost !== true);
1025
+ this.censusBuild(placed.filter((item) => item.node.solid !== true), palette, fit);
1026
+ if (solidOnes.length > 0)
1027
+ this.solid_draw(solidOnes, palette);
1028
+ return;
1029
+ }
1030
+ if (!this.ambient && fit)
1031
+ this.camera_fit(placed);
1032
+ const byId = new Map(placed.map((p) => [p.node.id, p]));
1033
+ const childrenOf = new Map();
1034
+ for (const item of placed) {
1035
+ for (const parentId of item.node.parentIds)
1036
+ childrenOf.set(parentId, [...(childrenOf.get(parentId) ?? []), item]);
1037
+ }
1038
+ const heldCentre_of = (id) => {
1039
+ const children = childrenOf.get(id) ?? [];
1040
+ if (children.length === 0)
1041
+ return null;
1042
+ const centre = new THREE.Vector3();
1043
+ for (const child of children)
1044
+ centre.add(child.position);
1045
+ return centre.divideScalar(children.length);
1046
+ };
1047
+ // Stars: every node but a solid one is a point of light, a halo a
1048
+ // nebula; they are drawn in batches after this loop.
1049
+ const starring = this.drawMode === 'stars';
1050
+ const starred = [];
1051
+ // A replay shows every node as a star: nothing turns solid under it.
1052
+ const handoffKey = starring && this.replay === null ? this.handlers.handoffKey : undefined;
1053
+ for (const { node, position, radius } of placed) {
1054
+ if (node.caption !== undefined) {
1055
+ // A ghost's words stand over what it holds: the layout parks the
1056
+ // anchor itself wherever its pulls cancel, often between clouds.
1057
+ const held = node.ghost === true ? heldCentre_of(node.id) : null;
1058
+ const reach = held !== null ? 0 : node.halo === true ? haloRadius_of(node.count ?? 1) : radius;
1059
+ this.labels.caption_add(node.id, node.caption, held ?? position, reach, palette.done, node.dim === true, node.count ?? 1);
1060
+ }
1061
+ if (node.ghost === true && node.halo !== true)
1062
+ continue;
1063
+ if (starring && node.solid !== true) {
1064
+ if (node.halo === true) {
1065
+ this.nebula_add(node, position, palette);
1066
+ continue;
1067
+ }
1068
+ starred.push({
1069
+ id: node.id,
1070
+ position: position.clone(),
1071
+ radius,
1072
+ color: paint_resolve(node.look.paint, palette).clone(),
1073
+ dim: node.dim === true,
1074
+ ember: node.look.ember,
1075
+ ...(node.ring === true ? { ring: true } : {}),
1076
+ });
1077
+ continue;
1078
+ }
1079
+ if (node.halo === true) {
1080
+ this.spheres.halo_add(node.id, position, haloRadius_of(node.count ?? 1), palette.edge, node.dim === true);
1081
+ continue;
1082
+ }
1083
+ // 2D is drawn flat: discs, not lit spheres — the schematic reading
1084
+ // all the way down; status colour, selection and flare carry over.
1085
+ this.spheres.sphere_add(node.id, position, radius, {
1086
+ color: paint_resolve(node.look.paint, palette),
1087
+ dim: node.dim === true,
1088
+ selected: node.id === this.selectedId,
1089
+ flat: this.projection === '2d',
1090
+ });
1091
+ }
1092
+ if (starred.length > 0)
1093
+ this.starField.draw(starred, window.devicePixelRatio, this.renderer.domElement.height, this.camera.fov);
1094
+ for (const item of placed)
1095
+ this.placedById.set(item.node.id, item);
1096
+ if (handoffKey !== undefined) {
1097
+ const byNode = new Map(this.graph.nodes.map((node) => [node.id, node]));
1098
+ this.handoffField.gather(starred, (id) => {
1099
+ const node = byNode.get(id);
1100
+ return node === undefined ? null : handoffKey(node);
1101
+ });
1102
+ }
1103
+ // An edge between two solid nodes is a line as ever; an edge touching a
1104
+ // star is a thread, all of them one batch.
1105
+ const threads = [];
1106
+ const threadColors = [];
1107
+ const solid = (item) => !starring || item.node.solid === true;
1108
+ const threadEnds = [];
1109
+ const thread_add = (from, to, color, dim, owner, fromId) => {
1110
+ const k = dim ? 0.35 : 1;
1111
+ const segment = threads.length / 6;
1112
+ threads.push(from.x, from.y, from.z, to.x, to.y, to.z);
1113
+ threadEnds.push(fromId, owner.id);
1114
+ threadColors.push(color.r * k, color.g * k, color.b * k, color.r * k, color.g * k, color.b * k);
1115
+ // A placed node is one of the graph's own: the surface's node, typed wide.
1116
+ const key = dim || handoffKey === undefined ? null : handoffKey(owner);
1117
+ if (key !== null)
1118
+ this.handoffField.thread_note(key, segment);
1119
+ };
1120
+ for (const item of placed) {
1121
+ const { node, position } = item;
1122
+ if (node.ghost === true)
1123
+ continue;
1124
+ for (const parentId of node.parentIds) {
1125
+ const parent = byId.get(parentId);
1126
+ if (!parent || parent.node.ghost === true)
1127
+ continue;
1128
+ const dim = node.dim === true || parent.node.dim === true;
1129
+ if (solid(item) && solid(parent)) {
1130
+ if (!starring && dim)
1131
+ this.spheres.edge_add(parentId, node.id, parent.position, position, palette.edge, false, dim);
1132
+ }
1133
+ else
1134
+ thread_add(parent.position, position, palette.edge, dim, node, parentId);
1135
+ }
1136
+ for (const joinId of node.joinParentIds) {
1137
+ const parent = byId.get(joinId);
1138
+ if (!parent)
1139
+ continue;
1140
+ if (node.joinFaint === true) {
1141
+ thread_add(parent.position, position, palette.join, true, node, joinId);
1142
+ continue;
1143
+ }
1144
+ const dim = node.dim === true || parent.node.dim === true;
1145
+ if (solid(item) && solid(parent)) {
1146
+ if (!starring && dim)
1147
+ this.spheres.edge_add(joinId, node.id, parent.position, position, palette.join, true, dim);
1148
+ }
1149
+ else
1150
+ thread_add(parent.position, position, palette.join, dim, node, joinId);
1151
+ }
1152
+ }
1153
+ // Solid spheres are joined by tubes: under stars, the nodes marked solid
1154
+ // (a feed the operator entered); drawn as spheres, every node not dimmed
1155
+ // — the DAG pane's graph and the universe's SPHERES alike. Dimmed
1156
+ // scenery keeps its faint lines.
1157
+ {
1158
+ const solidIds = placed
1159
+ .filter((item) => item.node.ghost !== true && item.node.halo !== true && (starring ? item.node.solid === true : item.node.dim !== true))
1160
+ .map((item) => item.node.id);
1161
+ if (solidIds.length > 0) {
1162
+ const entered = { tubes: null, mix: 1 };
1163
+ this.tubes.build(entered, solidIds, new Set(solidIds));
1164
+ }
1165
+ }
1166
+ this.starField.threads_draw(threads, threadColors, threadEnds);
1167
+ // A redraw during a replay keeps what has not arrived hidden.
1168
+ if (this.replay !== null)
1169
+ this.replay_paintAll();
1170
+ }
1171
+ /**
1172
+ * Plays the space's history: every node with an arrival hidden until it
1173
+ * arrives, then shown with a flash, at its final place, the camera
1174
+ * untouched. Nodes without one stand throughout. Nothing turns solid
1175
+ * while it plays; stars only.
1176
+ *
1177
+ * @param arrivals - When each node arrived (epoch ms for dates).
1178
+ * @param speed - 1 crosses the whole history in {@link REPLAY_WALL_MS}.
1179
+ */
1180
+ replay_begin(arrivals, speed = 1) {
1181
+ const clock = new ReplayClock(arrivals);
1182
+ this.replay = { clock, arrivals, shown: new Set() };
1183
+ // Redrawn with the hand-off off: a solid feed would stand before it arrived.
1184
+ this.rebuild(false, 'hold');
1185
+ this.replay_paintAll();
1186
+ clock.play(speed);
1187
+ }
1188
+ /**
1189
+ * Plays on (or again from the start, at the end).
1190
+ *
1191
+ * @param speed - A new speed, or the one it had.
1192
+ */
1193
+ replay_play(speed) {
1194
+ this.replay?.clock.play(speed);
1195
+ }
1196
+ /** Holds the replay where it is. */
1197
+ replay_pause() {
1198
+ this.replay?.clock.pause();
1199
+ }
1200
+ /**
1201
+ * Moves the replay to a moment: what had arrived by then shown, the rest hidden.
1202
+ *
1203
+ * @param at - The moment, in the arrivals' units.
1204
+ */
1205
+ replay_seek(at) {
1206
+ if (this.replay === null)
1207
+ return;
1208
+ this.replay_apply(this.replay.clock.seek(at));
1209
+ }
1210
+ /** Ends the replay: the space redrawn whole, the hand-off back. */
1211
+ replay_stop() {
1212
+ if (this.replay === null)
1213
+ return;
1214
+ this.replay = null;
1215
+ this.rebuild(false, 'hold');
1216
+ }
1217
+ /**
1218
+ * Where the replay stands, or null when none runs.
1219
+ *
1220
+ * @returns Whether it plays, its moment, and its span.
1221
+ */
1222
+ replay_state() {
1223
+ if (this.replay === null)
1224
+ return null;
1225
+ return { playing: this.replay.clock.playing(), at: this.replay.clock.at(), span: this.replay.clock.span() };
1226
+ }
1227
+ /** One frame of the replay: arrivals shown, departures hidden, flashes faded. */
1228
+ replay_step() {
1229
+ const replay = this.replay;
1230
+ if (replay === null)
1231
+ return;
1232
+ const wasPlaying = replay.clock.playing();
1233
+ const frame = replay.clock.step();
1234
+ this.replay_apply(frame);
1235
+ for (const [id, strength] of replay.clock.flashing())
1236
+ this.starField.flash_set(id, strength);
1237
+ this.starField.flush();
1238
+ if (wasPlaying || frame.arrived.length > 0)
1239
+ this.handlers.replay?.(frame.at, replay.clock.playing());
1240
+ }
1241
+ /** Shows what arrived and hides what left, threads following. */
1242
+ replay_apply(frame) {
1243
+ const replay = this.replay;
1244
+ if (replay === null || (frame.arrived.length === 0 && frame.departed.length === 0))
1245
+ return;
1246
+ for (const id of frame.arrived)
1247
+ replay.shown.add(id);
1248
+ for (const id of frame.departed) {
1249
+ replay.shown.delete(id);
1250
+ this.starField.flash_set(id, 0);
1251
+ }
1252
+ this.starField.presence_set(frame.arrived, 1);
1253
+ this.starField.presence_set(frame.departed, 0);
1254
+ this.starField.threads_present((id) => replay.shown.has(id) || !replay.arrivals.has(id));
1255
+ this.starField.flush();
1256
+ }
1257
+ /** Paints every dated node as the replay has it: shown if arrived, else hidden. */
1258
+ replay_paintAll() {
1259
+ const replay = this.replay;
1260
+ if (replay === null)
1261
+ return;
1262
+ const hidden = [...replay.arrivals.keys()].filter((id) => !replay.shown.has(id));
1263
+ this.starField.presence_set(hidden, 0);
1264
+ this.starField.presence_set(replay.shown, 1);
1265
+ this.starField.threads_present((id) => replay.shown.has(id) || !replay.arrivals.has(id));
1266
+ this.starField.flush();
1267
+ }
1268
+ /**
1269
+ * Steps the hand-off once a frame: a feed whose largest sphere spans
1270
+ * more than a few pixels turns solid, crossfading, its threads rising
1271
+ * to edges; one that shrinks back goes to stars and its spheres are let go.
1272
+ */
1273
+ handoff_step() {
1274
+ if (this.handoffField.size() === 0)
1275
+ return;
1276
+ const height = this.renderer.domElement.clientHeight || 1;
1277
+ const perUnit = height / (2 * Math.tan((this.camera.fov * Math.PI) / 360));
1278
+ this.group.updateMatrixWorld(true);
1279
+ this.camera.updateMatrixWorld();
1280
+ const view = new THREE.Matrix4().multiplyMatrices(this.camera.matrixWorldInverse, this.group.matrixWorld);
1281
+ this.handoffField.step(view, perUnit, performance.now());
1282
+ }
1283
+ /**
1284
+ * How a placed node's sphere is drawn when its molecule turns solid.
1285
+ *
1286
+ * @param id - The node.
1287
+ * @returns Where it stands, how large, what hue.
1288
+ */
1289
+ handoffLook_of(id) {
1290
+ const placed = this.placedById.get(id);
1291
+ if (placed === undefined)
1292
+ return undefined;
1293
+ return { position: placed.position, radius: placed.radius, color: paint_resolve(placed.node.look.paint, this.palette_read()) };
1294
+ }
1295
+ /**
1296
+ * A placed node as the tubes read it.
1297
+ *
1298
+ * @param id - The node.
1299
+ * @returns Where it stands, how large, what it hangs from, its state.
1300
+ */
1301
+ tubeNode_of(id) {
1302
+ const placed = this.placedById.get(id);
1303
+ if (placed === undefined)
1304
+ return undefined;
1305
+ return {
1306
+ position: placed.position,
1307
+ radius: placed.radius,
1308
+ parents: placed.node.parentIds,
1309
+ // A faint join is a thread, never a tube.
1310
+ joins: placed.node.joinFaint === true ? [] : placed.node.joinParentIds,
1311
+ state: placed.node.look.state,
1312
+ };
1313
+ }
1314
+ /**
1315
+ * A cluster's handle while the scene draws stars: a soft glow at its
1316
+ * anchor, facing the camera, sized as its halo would be.
1317
+ */
1318
+ nebula_add(node, position, palette) {
1319
+ this.starField.nebula_add(node.id, position, haloRadius_of(node.count ?? 1), palette.edge, node.dim === true);
1320
+ }
1321
+ /**
1322
+ * Where a drawn node stands in the world and how big it is — a sphere,
1323
+ * a star or a census member alike — for flights and framing.
1324
+ *
1325
+ * @param id - The node.
1326
+ * @returns Its world position and radius, or null when it is not drawn.
1327
+ */
1328
+ nodeWorld_of(id) {
1329
+ const mesh = this.meshes.get(id);
1330
+ if (mesh !== undefined) {
1331
+ const sphere = mesh.geometry;
1332
+ if (sphere.boundingSphere === null)
1333
+ sphere.computeBoundingSphere();
1334
+ return { position: mesh.getWorldPosition(new THREE.Vector3()), radius: sphere.boundingSphere?.radius ?? NODE_RADIUS };
1335
+ }
1336
+ const star = this.starField.entry(id);
1337
+ if (star !== undefined)
1338
+ return { position: this.group.localToWorld(star.position.clone()), radius: star.radius };
1339
+ return null;
1340
+ }
1341
+ /** Every drawn node's id: spheres and stars. */
1342
+ drawnIds() {
1343
+ return [...this.meshes.keys(), ...this.starField.ids()];
1344
+ }
1345
+ /** Copies simulation positions onto meshes and re-anchors every edge. */
1346
+ positions_sync() {
1347
+ for (const { id, position } of this.pull?.positions() ?? []) {
1348
+ this.meshes.get(id)?.position.set(...position);
1349
+ // Where a pulled node comes to rest is where it stands: a later
1350
+ // redraw starts from here instead of snapping it back.
1351
+ this.lastPositions.set(id, new THREE.Vector3(...position));
1352
+ }
1353
+ this.tubes.follow();
1354
+ this.spheres.edges_follow();
1355
+ }
1356
+ /**
1357
+ * A press the gestures could not place: when it landed on a node it
1358
+ * begins a pull (the structure reacts through a live force simulation
1359
+ * anchored at the grabbed node) and the press is the scene's to follow.
1360
+ *
1361
+ * @param event - The press.
1362
+ * @returns True when a node was taken hold of.
1363
+ */
1364
+ grab_begin(event) {
1365
+ const hit = this.picker.mesh_under(event);
1366
+ const nodeId = hit?.userData['nodeId'];
1367
+ if (hit === null || typeof nodeId !== 'string')
1368
+ return false;
1369
+ // Drag in the plane through the node, facing the camera: intuitive
1370
+ // pull, no depth surprises.
1371
+ const normal = this.camera.getWorldDirection(new THREE.Vector3()).negate();
1372
+ // The plane runs through the node where it stands in the WORLD: the ray
1373
+ // it is cut with is in world space, and the group may be moved and
1374
+ // turned (the universe turns about its focus).
1375
+ this.group.updateMatrixWorld(true);
1376
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, hit.getWorldPosition(new THREE.Vector3()));
1377
+ this.drag = {
1378
+ nodeId, plane, startX: event.clientX, startY: event.clientY, moved: false,
1379
+ // Ranked is deterministic truth: a pull peeks at ONE node and the
1380
+ // release returns it home. The whole-graph elastic reaction belongs
1381
+ // to the molecule — heating it under ranked dissolved the tiers.
1382
+ solo: this.strategy === 'ranked',
1383
+ home: hit.position.clone(),
1384
+ };
1385
+ return true;
1386
+ }
1387
+ /**
1388
+ * Builds the reaction simulation, from the meshes' current positions with
1389
+ * the grabbed node fixed. Deferred to the first real pointer movement: a
1390
+ * heated simulation on a mere press would shift nodes out from under the
1391
+ * click and dblclick raycasts. Links and charge only — no centering
1392
+ * force, or the pull would fight a recentering spring.
1393
+ */
1394
+ dragSim_begin(nodeId) {
1395
+ const radii = moleculeRadii_of(this.graph.nodes);
1396
+ const nodes = [...this.meshes].map(([id, mesh]) => ({
1397
+ id,
1398
+ position: [mesh.position.x, mesh.position.y, mesh.position.z],
1399
+ radius: radii.get(id) ?? NODE_RADIUS,
1400
+ dim: mesh.userData['dim'] === true,
1401
+ halo: mesh.userData['halo'] === true,
1402
+ }));
1403
+ const edges = [];
1404
+ for (const node of this.graph.nodes) {
1405
+ for (const parentId of [...node.parentIds, ...node.joinParentIds])
1406
+ edges.push({ from: parentId, to: node.id });
1407
+ }
1408
+ this.pull = new PullSimulation({ grabbed: nodeId, nodes, edges, dimensions: this.projection === '2d' ? 2 : 3, baseRadius: NODE_RADIUS });
1409
+ }
1410
+ /** Follows the pointer during a pull: the grabbed node tracks the drag plane. */
1411
+ drag_move(event) {
1412
+ if (this.drag === null)
1413
+ return;
1414
+ this.rig.spin_pause();
1415
+ if (!this.drag.moved &&
1416
+ Math.abs(event.clientX - this.drag.startX) + Math.abs(event.clientY - this.drag.startY) >
1417
+ DRAG_THRESHOLD_PX) {
1418
+ this.drag.moved = true;
1419
+ if (!this.drag.solo)
1420
+ this.dragSim_begin(this.drag.nodeId);
1421
+ }
1422
+ if (!this.drag.moved)
1423
+ return;
1424
+ const point = new THREE.Vector3();
1425
+ if (this.picker.ray_aim(event).ray.intersectPlane(this.drag.plane, point) === null)
1426
+ return;
1427
+ // Back into the group's own space, where the node and the simulation
1428
+ // live: pinned to a world point, the node leapt away and dragged its
1429
+ // whole molecule after it.
1430
+ this.group.worldToLocal(point);
1431
+ if (this.drag.solo) {
1432
+ const mesh = this.meshes.get(this.drag.nodeId);
1433
+ if (mesh !== undefined) {
1434
+ mesh.position.copy(point);
1435
+ this.positions_sync();
1436
+ }
1437
+ return;
1438
+ }
1439
+ this.pull?.pin([point.x, point.y, point.z]);
1440
+ }
1441
+ /** Nodes easing home after a ranked peek: mesh, from, to, start time. */
1442
+ dragReturns = [];
1443
+ /**
1444
+ * Releases a pull: the grip opens and the simulation cools to rest.
1445
+ *
1446
+ * @returns Whether the pull had moved, so its click is swallowed.
1447
+ */
1448
+ grab_end() {
1449
+ if (this.drag === null)
1450
+ return false;
1451
+ const moved = this.drag.moved;
1452
+ if (this.drag.solo && this.drag.moved) {
1453
+ const mesh = this.meshes.get(this.drag.nodeId);
1454
+ if (mesh !== undefined) {
1455
+ this.dragReturns.push({
1456
+ mesh, from: mesh.position.clone(), to: this.drag.home.clone(), startedAt: Date.now(),
1457
+ });
1458
+ }
1459
+ }
1460
+ this.pull?.release();
1461
+ this.drag = null;
1462
+ return moved;
1463
+ }
1464
+ /**
1465
+ * Draws nodes as lit spheres joined by tubes, among a census: the feed the
1466
+ * operator entered, which the census's points would otherwise swallow.
1467
+ *
1468
+ * @param solidOnes - The nodes to draw solid.
1469
+ * @param palette - The palette.
1470
+ */
1471
+ solid_draw(solidOnes, palette) {
1472
+ for (const item of solidOnes) {
1473
+ this.placedById.set(item.node.id, item);
1474
+ this.spheres.sphere_add(item.node.id, item.position, item.radius, { color: paint_resolve(item.node.look.paint, palette) });
1475
+ }
1476
+ const ids = solidOnes.map((item) => item.node.id);
1477
+ const entered = { tubes: null, mix: 1 };
1478
+ this.tubes.build(entered, ids, new Set(ids));
1479
+ }
1480
+ /**
1481
+ * The pointer left the field. A finger lifting leaves at once, so its
1482
+ * name stays up a moment to be read; a cursor's goes with it.
1483
+ *
1484
+ * @param event - The leave.
1485
+ */
1486
+ leave_handle(event) {
1487
+ this.hovered = null;
1488
+ if (event.pointerType === 'touch' && this.tip !== null && !this.tip.hidden) {
1489
+ if (this.tapTipTimer !== null)
1490
+ window.clearTimeout(this.tapTipTimer);
1491
+ this.tapTipTimer = window.setTimeout(() => {
1492
+ this.tapTipTimer = null;
1493
+ if (this.tip !== null && (this.gestures?.fingersDown() ?? 0) === 0)
1494
+ this.tip.hidden = true;
1495
+ }, TAP_TIP_MS);
1496
+ return;
1497
+ }
1498
+ if (this.tip)
1499
+ this.tip.hidden = true;
1500
+ }
1501
+ /** Names the node under the pointer in the hover tip, or hides it. */
1502
+ hover_handle(event) {
1503
+ if (this.tip === null)
1504
+ return;
1505
+ // In census the spheres are members of one instanced mesh: the group
1506
+ // under the pointer is what the tip names, as a click would pick.
1507
+ const nodeId = this.picker.node_under(event, this.gestures?.pressKind() === 'touch');
1508
+ const node = typeof nodeId === 'string'
1509
+ ? this.graph.nodes.find((n) => n.id === nodeId)
1510
+ : undefined;
1511
+ if (node === undefined) {
1512
+ this.tip.hidden = true;
1513
+ this.renderer.domElement.style.cursor = '';
1514
+ this.hovered = null;
1515
+ return;
1516
+ }
1517
+ this.hovered = { id: node.id, x: event.clientX, y: event.clientY };
1518
+ const bounds = this.renderer.domElement.getBoundingClientRect();
1519
+ this.tip.textContent = this.handlers.tip?.(node) ?? node.label;
1520
+ this.tip.style.left = `${event.clientX - bounds.left + 14}px`;
1521
+ this.tip.style.top = `${event.clientY - bounds.top + 10}px`;
1522
+ this.tip.hidden = false;
1523
+ this.renderer.domElement.style.cursor = 'pointer';
1524
+ }
1525
+ /** Resolves a pointer event to a node and fires the matching handler. */
1526
+ pick_handle(event, kind) {
1527
+ // What the tip names is what a click takes, when the pointer has not
1528
+ // moved off it: the operator aimed at the node they read.
1529
+ const held = this.hovered;
1530
+ const nodeId = held !== null && Math.hypot(event.clientX - held.x, event.clientY - held.y) <= 6
1531
+ ? held.id
1532
+ : this.picker.node_under(event, this.gestures?.pressKind() === 'touch');
1533
+ if (nodeId === null) {
1534
+ // Empty space is the natural off switch for the node detail.
1535
+ if (kind === 'select' && this.selectedId !== null) {
1536
+ this.selection_clear();
1537
+ this.handlers.deselect?.();
1538
+ }
1539
+ return;
1540
+ }
1541
+ const node = this.graph.nodes.find((n) => n.id === nodeId);
1542
+ if (!node)
1543
+ return;
1544
+ if (kind === 'select') {
1545
+ this.selectedId = nodeId;
1546
+ if (!this.census)
1547
+ this.selection_paint();
1548
+ this.handlers.select?.(node);
1549
+ }
1550
+ else {
1551
+ this.handlers.activate?.(node);
1552
+ }
1553
+ }
1554
+ }
1555
+ //# sourceMappingURL=orrery.js.map