@fusion.dev/helix-graph 0.126.0 → 0.127.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 (39) hide show
  1. package/README.md +70 -38
  2. package/dist/connect-graph/arrange.d.ts +4 -2
  3. package/dist/connect-graph/arrange.js +5 -22
  4. package/dist/connect-graph/connect-graph.d.ts +18 -6
  5. package/dist/connect-graph/connect-graph.js +76 -32
  6. package/dist/connect-graph/diff.d.ts +7 -0
  7. package/dist/connect-graph/looks.d.ts +8 -0
  8. package/dist/connect-graph/looks.js +6 -3
  9. package/dist/connect-graph/seed.d.ts +9 -0
  10. package/dist/connect-graph/seed.js +21 -15
  11. package/dist/connect-graph/stencil.d.ts +3 -1
  12. package/dist/connect-graph/stencil.js +32 -10
  13. package/dist/cosmos/cosmos-driver.d.ts +5 -4
  14. package/dist/cosmos/cosmos-driver.js +122 -39
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +2 -2
  17. package/dist/model/index.d.ts +1 -0
  18. package/dist/model/index.js +1 -0
  19. package/dist/{shapes → model}/model.d.ts +4 -3
  20. package/dist/{shapes → model}/model.js +10 -5
  21. package/dist/overlay/overlay.d.ts +14 -8
  22. package/dist/overlay/overlay.js +64 -62
  23. package/dist/shapes/flat.d.ts +1 -1
  24. package/dist/shapes/flat.js +9 -5
  25. package/dist/shapes/index.d.ts +3 -4
  26. package/dist/shapes/index.js +3 -4
  27. package/dist/{connect-graph → shapes}/shape-field.d.ts +7 -3
  28. package/dist/{connect-graph → shapes}/shape-field.js +4 -2
  29. package/dist/shapes/solids.d.ts +3 -3
  30. package/dist/shapes/solids.js +25 -9
  31. package/dist/{three → shapes}/surface.d.ts +1 -1
  32. package/dist/shapes/triangles.d.ts +2 -2
  33. package/dist/shapes/triangles.js +49 -30
  34. package/dist/sigma/sigma-driver.d.ts +33 -17
  35. package/dist/sigma/sigma-driver.js +69 -17
  36. package/dist/three/three-driver.d.ts +12 -3
  37. package/dist/three/three-driver.js +376 -201
  38. package/package.json +9 -5
  39. /package/dist/{three → shapes}/surface.js +0 -0
@@ -1,6 +1,8 @@
1
1
  /** A point in the renderer's own units. */
2
2
  export interface GraphPoint {
3
+ /** Across. */
3
4
  x: number;
5
+ /** Up. */
4
6
  y: number;
5
7
  /** How near the viewer, for a renderer that draws in three dimensions. */
6
8
  z?: number | undefined;
@@ -17,6 +19,13 @@ export interface SeedLink {
17
19
  source: string;
18
20
  target: string;
19
21
  }
22
+ /** The angle between one place and the next on a spiral: the golden angle, which never lines two up. */
23
+ export declare const TURN: number;
24
+ /**
25
+ * Each note's neighbors across the links, both ways, kept to the links
26
+ * whose two ends `keep` allows where it is given.
27
+ */
28
+ export declare const neighborsOf: (links: readonly SeedLink[], keep?: (id: string) => boolean) => Map<string, string[]>;
20
29
  /**
21
30
  * Where each note starts, and which of them a layout still has to place.
22
31
  *
@@ -1,4 +1,23 @@
1
- const TURN = Math.PI * (3 - Math.sqrt(5));
1
+ export const TURN = Math.PI * (3 - Math.sqrt(5));
2
+ export const neighborsOf = (links, keep) => {
3
+ const neighbors = new Map();
4
+ const add = (from, to) => {
5
+ const list = neighbors.get(from);
6
+ if (list) {
7
+ list.push(to);
8
+ }
9
+ else {
10
+ neighbors.set(from, [to]);
11
+ }
12
+ };
13
+ for (const { source, target } of links) {
14
+ if (!keep || (keep(source) && keep(target))) {
15
+ add(source, target);
16
+ add(target, source);
17
+ }
18
+ }
19
+ return neighbors;
20
+ };
2
21
  const NEAR = 0.5;
3
22
  const nudge = (id) => {
4
23
  let hash = 2166136261;
@@ -26,20 +45,7 @@ export const seedPositions = (nodes, links, known = new Map()) => {
26
45
  }
27
46
  }
28
47
  }
29
- const neighbors = new Map();
30
- const add = (from, to) => {
31
- const list = neighbors.get(from);
32
- if (list) {
33
- list.push(to);
34
- }
35
- else {
36
- neighbors.set(from, [to]);
37
- }
38
- };
39
- for (const { source, target } of links) {
40
- add(source, target);
41
- add(target, source);
42
- }
48
+ const neighbors = neighborsOf(links);
43
49
  let spiral = positions.size;
44
50
  for (const node of nodes) {
45
51
  if (positions.has(node.id)) {
@@ -1,5 +1,5 @@
1
+ import { type ShapeField } from '../shapes/shape-field.js';
1
2
  import type { GraphPoint } from './seed.js';
2
- import { type ShapeField } from './shape-field.js';
3
3
  /**
4
4
  * A shape the notes are drawn in. **Any shape field**, flat or solid, such as
5
5
  * `inflate` makes; or, flat, an SVG path in a box `width` by `height`, as an
@@ -63,7 +63,9 @@ export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>,
63
63
  * `middle.y - (y - height / 2) * scale` and `middle.z + (z - depth / 2) * scale`.
64
64
  */
65
65
  export interface StencilFrame {
66
+ /** The stencil's shape, as a field, whatever form it was given in. */
66
67
  field: ShapeField;
68
+ /** Where the middle of the stencil's box was put, in the places' units. */
67
69
  middle: {
68
70
  x: number;
69
71
  y: number;
@@ -1,4 +1,4 @@
1
- import { fieldFromInside, fieldFromPath } from './shape-field.js';
1
+ import { fieldFromInside, fieldFromPath } from '../shapes/shape-field.js';
2
2
  import { surfaceWeb } from './surface-web.js';
3
3
  const PROBE = { flat: 64, solid: 32 };
4
4
  const SHELL = 0.04;
@@ -78,9 +78,9 @@ export const placesIn = (field, count, { place = 'fill', shell = SHELL } = {}) =
78
78
  }
79
79
  return [];
80
80
  };
81
- const placesByRegion = (field, wanted, options) => {
81
+ const placesByRegion = (field, asked, options) => {
82
82
  const regionOf = field.regionOf;
83
- const count = [...wanted.values()].reduce((sum, many) => sum + many, 0);
83
+ const count = [...asked.values()].reduce((sum, many) => sum + many, 0);
84
84
  if (!regionOf) {
85
85
  return undefined;
86
86
  }
@@ -101,6 +101,16 @@ const placesByRegion = (field, wanted, options) => {
101
101
  byRegion.set(region, [place]);
102
102
  }
103
103
  });
104
+ const found = new Set([...byRegion.keys()].filter((region) => region !== undefined));
105
+ const missing = [...asked.keys()].filter((region) => region !== undefined && !found.has(region));
106
+ if (missing.length > 0 && times * 2 <= 24) {
107
+ continue;
108
+ }
109
+ const wanted = new Map();
110
+ for (const [region, many] of asked) {
111
+ const part = region !== undefined && found.has(region) ? region : undefined;
112
+ wanted.set(part, (wanted.get(part) ?? 0) + many);
113
+ }
104
114
  const claimed = new Set([...wanted.keys()].filter((region) => region !== undefined));
105
115
  const places = [];
106
116
  const regions = [];
@@ -130,7 +140,7 @@ const placesByRegion = (field, wanted, options) => {
130
140
  places.push(place);
131
141
  regions.push(undefined);
132
142
  }
133
- return { places, regions };
143
+ return { found, missing, places, regions };
134
144
  }
135
145
  return undefined;
136
146
  };
@@ -163,18 +173,30 @@ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
163
173
  const group = groupOf(note.id);
164
174
  return [note.id, group === undefined ? undefined : stencil.regions?.[group]];
165
175
  }));
166
- const wanted = new Map();
176
+ const asked = new Map();
167
177
  for (const region of noteRegion.values()) {
168
- wanted.set(region, (wanted.get(region) ?? 0) + 1);
178
+ asked.set(region, (asked.get(region) ?? 0) + 1);
169
179
  }
170
- const regional = stencil.regions && [...wanted.keys()].some((region) => region !== undefined)
171
- ? placesByRegion(field, wanted, options)
180
+ const regional = [...asked.keys()].some((region) => region !== undefined)
181
+ ? placesByRegion(field, asked, options)
172
182
  : undefined;
173
- if (!regional) {
174
- for (const id of noteRegion.keys()) {
183
+ if (regional && regional.missing.length > 0) {
184
+ console.warn(`fitInStencil: the shape has no part named ${regional.missing.map((part) => `"${part}"`).join(', ')}, so the notes sent there are placed with the rest. Its parts: ${[...regional.found].join(', ') || 'none'}.`);
185
+ }
186
+ else if (!regional && [...asked.keys()].some((region) => region !== undefined)) {
187
+ console.warn(field.regionOf
188
+ ? 'fitInStencil: the parts named in regions are too small for the notes sent to them, so the notes are placed over the whole shape.'
189
+ : 'fitInStencil: the shape has no named parts, so regions is not used and the notes are placed over the whole shape.');
190
+ }
191
+ for (const [id, region] of noteRegion) {
192
+ if (region !== undefined && !regional?.found.has(region)) {
175
193
  noteRegion.set(id, undefined);
176
194
  }
177
195
  }
196
+ const wanted = new Map();
197
+ for (const region of noteRegion.values()) {
198
+ wanted.set(region, (wanted.get(region) ?? 0) + 1);
199
+ }
178
200
  const places = regional?.places ?? placesIn(field, notes.length, options);
179
201
  const placeRegion = regional?.regions ?? places.map(() => undefined);
180
202
  if (places.length < notes.length) {
@@ -1,5 +1,5 @@
1
1
  import type { GraphDriver } from '../connect-graph/connect-graph.js';
2
- import type { NodeLook } from '../connect-graph/looks.js';
2
+ import { type NodeLook } from '../connect-graph/looks.js';
3
3
  /** A cosmos.gl graph, as far as the driver calls it. */
4
4
  export interface CosmosGraph {
5
5
  fitView(duration?: number, padding?: number): void;
@@ -24,7 +24,7 @@ export interface CosmosGraph {
24
24
  setPointShapes(pointShapes: Float32Array): void;
25
25
  setPointSizes(pointSizes: Float32Array): void;
26
26
  setZoomLevel(value: number, duration?: number): void;
27
- setZoomTransformByPointPositions(positions: Float32Array, duration?: number, scale?: number): void;
27
+ setZoomTransformByPointPositions(positions: Float32Array, duration?: number, scale?: number, padding?: number): void;
28
28
  spaceToScreenPosition(spacePosition: [number, number]): [number, number];
29
29
  spaceToScreenRadius(spaceRadius: number): number;
30
30
  start(alpha?: number): void;
@@ -40,7 +40,7 @@ export interface CosmosDriverOptions {
40
40
  * default, past where twenty thousand notes settle on a laptop's GPU.
41
41
  */
42
42
  layoutLimit?: number | undefined;
43
- /** A note's shape where it does not say: `circle` by default, `square` or `diamond`. */
43
+ /** A note's shape where it does not say: `circle` by default, `square` or `diamond`. A `ring` is drawn as a circle. */
44
44
  shape?: NodeLook['shape'];
45
45
  /** The size of cosmos.gl's space, as its own `spaceSize` says. 4096 by default. */
46
46
  spaceSize?: number | undefined;
@@ -62,6 +62,7 @@ export interface CosmosDriverOptions {
62
62
  * each group, over the drawing, as the Sigma driver does.
63
63
  *
64
64
  * **It is `@cosmos.gl/graph`, which is MIT**, and not Cosmograph, which is
65
- * licensed for noncommercial use only.
65
+ * licensed for noncommercial use only. The graph is the page's: after
66
+ * `disconnect()`, `graph.destroy()` frees it.
66
67
  */
67
68
  export declare const cosmosDriver: (graph: CosmosGraph, container: HTMLElement, options?: CosmosDriverOptions) => GraphDriver;
@@ -1,3 +1,5 @@
1
+ import { linkKey } from '../connect-graph/diff.js';
2
+ import { rgbOf } from '../connect-graph/looks.js';
1
3
  import { gesturesHeld, groupsOver, namesOver, signalsOver, } from '../overlay/overlay.js';
2
4
  const SHAPES = { circle: 0, diamond: 3, ring: 0, square: 1 };
3
5
  const STEP = 250;
@@ -9,8 +11,10 @@ const pixels = (size) => 0.5 * (1.6 + 1.35 * Math.max(size, 0));
9
11
  const CLUSTER = 0.1;
10
12
  const FOLLOW = 1000;
11
13
  const PADDING = 0.08;
14
+ const SIGNALED = 2500;
15
+ const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
12
16
  const channels = (color, alpha = 1) => {
13
- const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
17
+ const [red, green, blue] = rgbOf(color);
14
18
  return [red / 255, green / 255, blue / 255, alpha];
15
19
  };
16
20
  export const cosmosDriver = (graph, container, options = {}) => {
@@ -25,6 +29,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
25
29
  let near = new Set();
26
30
  let groupSample = [];
27
31
  let following = new Set();
32
+ const signaled = new Map();
28
33
  let moved = false;
29
34
  let stopped = false;
30
35
  let uploaded = 0;
@@ -51,8 +56,17 @@ export const cosmosDriver = (graph, container, options = {}) => {
51
56
  if (hovered !== undefined) {
52
57
  next.add(hovered);
53
58
  }
59
+ const now = performance.now();
54
60
  for (const at of extra) {
55
- next.add(at);
61
+ signaled.set(at, now + SIGNALED);
62
+ }
63
+ for (const [at, until] of signaled) {
64
+ if (until < now) {
65
+ signaled.delete(at);
66
+ }
67
+ else {
68
+ next.add(at);
69
+ }
56
70
  }
57
71
  if (next.size !== following.size || [...next].some((at) => !following.has(at))) {
58
72
  following = next;
@@ -80,7 +94,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
80
94
  };
81
95
  const held = gesturesHeld(container);
82
96
  const signals = signalsOver(container, onScreen);
83
- const groups = groupsOver(container, onScreen);
97
+ const groups = groupsOver(container, onScreen, () => names.boxes());
84
98
  const names = namesOver(container, onScreen);
85
99
  const drawNames = () => {
86
100
  const lit = hovered === undefined ? undefined : ids[hovered];
@@ -96,10 +110,11 @@ export const cosmosDriver = (graph, container, options = {}) => {
96
110
  const flatNow = () => live()
97
111
  ? graph.getPointPositions()
98
112
  : ids.flatMap((id) => toSpace(drawnNow.get(id) ?? { x: 0, y: 0 }));
99
- const fitAll = (duration) => {
113
+ const fitAll = (asked) => {
100
114
  if (stopped) {
101
115
  return;
102
116
  }
117
+ const duration = still() ? 0 : asked;
103
118
  if (!graph.isReady) {
104
119
  void graph.ready.then(() => requestAnimationFrame(() => fitAll(duration)));
105
120
  return;
@@ -123,30 +138,59 @@ export const cosmosDriver = (graph, container, options = {}) => {
123
138
  graph.setConfigPartial({ pointSizeScale: by });
124
139
  if (Math.abs(clarity() - painted) > 0.04) {
125
140
  paintLinks();
126
- graph.render();
141
+ graph.render(undefined, 0);
127
142
  }
128
143
  }
129
144
  };
130
145
  const clarity = () => FAINT + (1 - FAINT) * Math.min(Math.max((grown - 1) / 0.8, 0), 1);
131
146
  let painted = 1;
147
+ let linkColors = new Float32Array(0);
148
+ let widths = new Float32Array(0);
149
+ let linksOf = new Map();
150
+ const paintLink = (at, lit) => {
151
+ const link = links[at];
152
+ const look = looks.link(link.source, link.target);
153
+ const touches = lit !== undefined && (link.source === lit || link.target === lit);
154
+ const whole = look.raised || touches;
155
+ linkColors.set(channels(touches ? looks.node(lit).color : look.color, look.hidden ? 0 : whole ? 1 : painted), at * 4);
156
+ widths[at] = whole ? 1.6 : 0.8;
157
+ };
132
158
  const paintLinks = () => {
133
159
  if (!looks) {
134
160
  return;
135
161
  }
136
162
  painted = clarity();
137
- const linkColors = new Float32Array(links.length * 4);
138
- const widths = new Float32Array(links.length);
163
+ if (linkColors.length !== links.length * 4) {
164
+ linkColors = new Float32Array(links.length * 4);
165
+ widths = new Float32Array(links.length);
166
+ }
139
167
  const lit = hovered === undefined ? undefined : ids[hovered];
140
- links.forEach((link, at) => {
141
- const look = looks.link(link.source, link.target);
142
- const touches = lit !== undefined && (link.source === lit || link.target === lit);
143
- const whole = look.raised || touches;
144
- linkColors.set(channels(touches ? looks.node(lit).color : look.color, look.hidden ? 0 : whole ? 1 : painted), at * 4);
145
- widths[at] = whole ? 1.6 : 0.8;
146
- });
168
+ for (let at = 0; at < links.length; at++) {
169
+ paintLink(at, lit);
170
+ }
171
+ graph.setLinkColors(linkColors);
172
+ graph.setLinkWidths(widths);
173
+ };
174
+ const hover = (next) => {
175
+ const was = hovered === undefined ? undefined : ids[hovered];
176
+ hovered = next;
177
+ container.style.cursor = next === undefined ? cursor : 'pointer';
178
+ if (!looks) {
179
+ return;
180
+ }
181
+ const lit = next === undefined ? undefined : ids[next];
182
+ for (const id of [was, lit]) {
183
+ for (const at of id === undefined ? [] : (linksOf.get(id) ?? [])) {
184
+ paintLink(at, lit);
185
+ }
186
+ }
147
187
  graph.setLinkColors(linkColors);
148
188
  graph.setLinkWidths(widths);
189
+ follow();
190
+ graph.render(undefined, 0);
191
+ redraw();
149
192
  };
193
+ const cursor = container.style.cursor;
150
194
  const paint = (transition = 0) => {
151
195
  if (!looks) {
152
196
  return;
@@ -181,12 +225,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
181
225
  hoveredPointRingColor: channels(looks.ink, 0.6),
182
226
  });
183
227
  follow();
184
- if (transition > 0) {
185
- graph.render(undefined, transition);
186
- }
187
- else {
188
- graph.render();
189
- }
228
+ graph.render(undefined, still() ? 0 : transition);
190
229
  redraw();
191
230
  };
192
231
  const load = (nodes, next, positions, transition = 0) => {
@@ -194,7 +233,20 @@ export const cosmosDriver = (graph, container, options = {}) => {
194
233
  index = new Map(ids.map((id, at) => [id, at]));
195
234
  drawnNow = new Map(nodes.map((node) => [node.id, node]));
196
235
  sizes = nodes.map((node) => pixels(node.size));
236
+ hovered = undefined;
197
237
  links = next.filter((link) => index.has(link.source) && index.has(link.target));
238
+ linksOf = new Map();
239
+ links.forEach((link, at) => {
240
+ for (const end of [link.source, link.target]) {
241
+ const list = linksOf.get(end);
242
+ if (list) {
243
+ list.push(at);
244
+ }
245
+ else {
246
+ linksOf.set(end, [at]);
247
+ }
248
+ }
249
+ });
198
250
  const pairs = new Float32Array(links.length * 2);
199
251
  links.forEach((link, at) => {
200
252
  pairs[at * 2] = index.get(link.source);
@@ -215,14 +267,8 @@ export const cosmosDriver = (graph, container, options = {}) => {
215
267
  };
216
268
  graph.setConfigPartial({
217
269
  backgroundColor: [0, 0, 0, 0],
218
- onPointMouseOut: () => {
219
- hovered = undefined;
220
- paint();
221
- },
222
- onPointMouseOver: (at) => {
223
- hovered = at;
224
- paint();
225
- },
270
+ onPointMouseOut: () => hover(undefined),
271
+ onPointMouseOver: (at) => hover(at),
226
272
  onSimulationTick: redraw,
227
273
  onTransition: redraw,
228
274
  onZoom: () => {
@@ -243,11 +289,15 @@ export const cosmosDriver = (graph, container, options = {}) => {
243
289
  center: (id, animate) => {
244
290
  const at = index.get(id);
245
291
  if (at !== undefined) {
292
+ moved = true;
246
293
  graph.zoomToPointByIndex(at, animate ? STEP * 2 : 0, graph.getZoomLevel(), true);
247
294
  }
248
295
  },
249
296
  draw: ({ links: next, nodes }) => {
250
- const reach = Math.max(1, ...nodes.map((node) => Math.max(Math.abs(node.x), Math.abs(node.y))));
297
+ let reach = 1;
298
+ for (const node of nodes) {
299
+ reach = Math.max(reach, Math.abs(node.x), Math.abs(node.y));
300
+ }
251
301
  scale = (space * 0.12) / reach;
252
302
  moved = false;
253
303
  const positions = new Float32Array(nodes.length * 2);
@@ -259,11 +309,41 @@ export const cosmosDriver = (graph, container, options = {}) => {
259
309
  fitAll(0);
260
310
  },
261
311
  fit: (fitted, animate) => {
262
- const indices = fitted.flatMap((id) => {
312
+ const flat = flatNow();
313
+ const box = { bottom: -Infinity, left: Infinity, right: -Infinity, top: Infinity };
314
+ for (const id of fitted) {
263
315
  const at = index.get(id);
264
- return at === undefined ? [] : [at];
265
- });
266
- graph.fitViewByPointIndices(indices, animate ? STEP * 2 : 0, 0.25);
316
+ if (at !== undefined) {
317
+ const x = flat[at * 2] ?? 0;
318
+ const y = flat[at * 2 + 1] ?? 0;
319
+ box.left = Math.min(box.left, x);
320
+ box.right = Math.max(box.right, x);
321
+ box.top = Math.min(box.top, y);
322
+ box.bottom = Math.max(box.bottom, y);
323
+ }
324
+ }
325
+ if (!Number.isFinite(box.left)) {
326
+ return;
327
+ }
328
+ let low = Infinity;
329
+ let high = -Infinity;
330
+ for (let at = 0; at < flat.length; at++) {
331
+ low = Math.min(low, flat[at]);
332
+ high = Math.max(high, flat[at]);
333
+ }
334
+ const least = Math.max((high - low) * 0.2, 1);
335
+ const middle = { x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 };
336
+ const half = {
337
+ x: Math.max(box.right - box.left, least) / 2,
338
+ y: Math.max(box.bottom - box.top, least) / 2,
339
+ };
340
+ moved = true;
341
+ graph.setZoomTransformByPointPositions(new Float32Array([
342
+ middle.x - half.x,
343
+ middle.y - half.y,
344
+ middle.x + half.x,
345
+ middle.y + half.y,
346
+ ]), animate && !still() ? STEP * 2 : 0, undefined, 0.25);
267
347
  },
268
348
  gestures: (next) => {
269
349
  held.set(next);
@@ -309,6 +389,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
309
389
  },
310
390
  onPress: (press) => {
311
391
  graph.setConfigPartial({
392
+ onBackgroundClick: () => press(undefined),
312
393
  onPointClick: (at) => {
313
394
  const id = ids[at];
314
395
  if (id !== undefined) {
@@ -316,11 +397,12 @@ export const cosmosDriver = (graph, container, options = {}) => {
316
397
  }
317
398
  },
318
399
  });
319
- return () => graph.setConfigPartial({ onPointClick: undefined });
400
+ return () => graph.setConfigPartial({ onBackgroundClick: undefined, onPointClick: undefined });
320
401
  },
321
402
  panBy: (x, y, animate) => {
322
403
  const { height, width } = container.getBoundingClientRect();
323
404
  const to = graph.screenToSpacePosition([width / 2 + x, height / 2 + y]);
405
+ moved = true;
324
406
  graph.setZoomTransformByPointPositions(new Float32Array(to), animate ? STEP : 0, graph.getZoomLevel());
325
407
  },
326
408
  settle: (unplaced, placed) => {
@@ -349,6 +431,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
349
431
  clearInterval(fitting);
350
432
  graph.setConfigPartial({ onSimulationEnd: undefined });
351
433
  graph.setPinnedPoints(null);
434
+ graph.pause();
352
435
  };
353
436
  },
354
437
  signal: (from, to, colors, animate) => {
@@ -359,7 +442,9 @@ export const cosmosDriver = (graph, container, options = {}) => {
359
442
  stop: () => {
360
443
  stopped = true;
361
444
  clearTimeout(overviewing);
445
+ container.style.cursor = cursor;
362
446
  graph.setConfigPartial({
447
+ onBackgroundClick: undefined,
363
448
  onPointClick: undefined,
364
449
  onPointMouseOut: undefined,
365
450
  onPointMouseOver: undefined,
@@ -396,11 +481,8 @@ export const cosmosDriver = (graph, container, options = {}) => {
396
481
  for (const node of changes.addNodes) {
397
482
  kept.set(node.id, node);
398
483
  }
399
- const removed = new Set(changes.removeLinks.map((link) => `${link.source}\u0000${link.target}`));
400
- const next = [
401
- ...links.filter((link) => !removed.has(`${link.source}\u0000${link.target}`)),
402
- ...changes.addLinks,
403
- ];
484
+ const removed = new Set(changes.removeLinks.map(linkKey));
485
+ const next = [...links.filter((link) => !removed.has(linkKey(link))), ...changes.addLinks];
404
486
  const nodes = [...kept.values()];
405
487
  const positions = new Float32Array(nodes.length * 2);
406
488
  nodes.forEach((node, at) => {
@@ -413,6 +495,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
413
495
  }
414
496
  },
415
497
  zoomBy: (direction, animate) => {
498
+ moved = true;
416
499
  graph.setZoomLevel(graph.getZoomLevel() * (direction > 0 ? ZOOM : 1 / ZOOM), animate ? STEP : 0);
417
500
  },
418
501
  };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphChanges, type GraphConnection, type GraphDriver, type GraphGroup, } from './connect-graph/connect-graph.js';
2
- export { diffGraph } from './connect-graph/diff.js';
3
- export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
4
- export { type GraphPoint, seedPositions } from './connect-graph/seed.js';
2
+ export { diffGraph, linkKey } from './connect-graph/diff.js';
3
+ export { type GraphColors, type GraphLooks, type GraphTone, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
4
+ export { type GraphPoint, type SeedLink, type SeedNode, seedPositions, } from './connect-graph/seed.js';
5
5
  export { fitToStencil, type GraphStencil, type StencilFrame, } from './connect-graph/stencil.js';
6
+ export type { ShapeField } from './shapes/shape-field.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { connectGraph, } from './connect-graph/connect-graph.js';
2
- export { diffGraph } from './connect-graph/diff.js';
2
+ export { diffGraph, linkKey } from './connect-graph/diff.js';
3
3
  export { graphColorsFrom, } from './connect-graph/looks.js';
4
- export { seedPositions } from './connect-graph/seed.js';
4
+ export { seedPositions, } from './connect-graph/seed.js';
5
5
  export { fitToStencil, } from './connect-graph/stencil.js';
@@ -0,0 +1 @@
1
+ export { shapeFromModel } from './model.js';
@@ -0,0 +1 @@
1
+ export { shapeFromModel } from './model.js';
@@ -1,5 +1,5 @@
1
- import type { ShapeField } from '../connect-graph/shape-field.js';
2
- import { type TriangleOptions } from './triangles.js';
1
+ import type { ShapeField } from '../shapes/shape-field.js';
2
+ import { type TriangleOptions } from '../shapes/triangles.js';
3
3
  /**
4
4
  * A solid field from a glTF model, the web's standard format for one: a
5
5
  * URL to a `.glb` or `.gltf` file, or the file's bytes. Every mesh in it is
@@ -10,6 +10,7 @@ import { type TriangleOptions } from './triangles.js';
10
10
  *
11
11
  * **three.js reads the file**, imported when this is first called, so a page
12
12
  * that draws no model never downloads a loader. It is an optional peer of
13
- * this package.
13
+ * this package. A model that cannot be fetched or read is an error that
14
+ * names it, the loader's own error as its `cause`.
14
15
  */
15
16
  export declare const shapeFromModel: (source: string | ArrayBuffer, options?: TriangleOptions) => Promise<ShapeField>;
@@ -1,13 +1,18 @@
1
- import { shapeFromTriangles } from './triangles.js';
1
+ import { shapeFromTriangles } from '../shapes/triangles.js';
2
2
  export const shapeFromModel = async (source, options = {}) => {
3
+ const which = typeof source === 'string' ? `The model ${source}` : 'The model given as bytes';
3
4
  const [{ GLTFLoader }, { Mesh, Vector3 }] = await Promise.all([
4
5
  import('three/addons/loaders/GLTFLoader.js'),
5
6
  import('three'),
6
- ]);
7
+ ]).catch((cause) => {
8
+ throw new Error(`${which} could not be read, because three.js could not be loaded. Install three alongside @fusion.dev/helix-graph.`, { cause });
9
+ });
7
10
  const loader = new GLTFLoader();
8
- const model = typeof source === 'string'
9
- ? await loader.loadAsync(source)
10
- : await loader.parseAsync(source, '');
11
+ const model = await (typeof source === 'string'
12
+ ? loader.loadAsync(source)
13
+ : loader.parseAsync(source, '')).catch((cause) => {
14
+ throw new Error(`${which} could not be read as a glTF model: ${String(cause)}`, { cause });
15
+ });
11
16
  model.scene.updateMatrixWorld(true);
12
17
  const corners = [];
13
18
  const names = [];
@@ -7,11 +7,6 @@ export type OnScreen = (id: string) => {
7
7
  x: number;
8
8
  y: number;
9
9
  } | undefined;
10
- /** A point and how to bend toward it: the control point of a curve on screen. */
11
- export type Bend = (from: string, to: string) => {
12
- x: number;
13
- y: number;
14
- } | undefined;
15
10
  /** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
16
11
  export declare const isLight: (color: string) => boolean;
17
12
  /** Eases in and out, so a signal leaves and arrives gently. */
@@ -23,13 +18,19 @@ export interface Box {
23
18
  right: number;
24
19
  top: number;
25
20
  }
21
+ /**
22
+ * How many pixels a layer has for each of the page's: the screen's own, and
23
+ * never fewer than two, so on a screen of one a name's edges and a signal's
24
+ * tail are drawn twice as finely and smoothed down. At most three.
25
+ */
26
+ export declare const overlayDensity: () => number;
26
27
  /**
27
28
  * **Signals are drawn on a canvas of the driver's own**, over the renderer's,
28
29
  * so a signal never makes the renderer redraw the graph: ten signals a second
29
30
  * over ten thousand notes draw ten dots. It draws only while something is in
30
31
  * flight, and takes no pointer, so a press reaches the note under it.
31
32
  */
32
- export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen, bend?: Bend) => {
33
+ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) => {
33
34
  look: (looks: GraphLooks) => void;
34
35
  signal: (from: string, to: string, colors: {
35
36
  from: string;
@@ -50,14 +51,18 @@ export interface Named {
50
51
  * order given.
51
52
  */
52
53
  export declare const namesOver: (container: HTMLElement, onScreen: OnScreen) => {
54
+ /** The boxes the names last drawn take. */
55
+ boxes: () => readonly Box[];
53
56
  draw: (looks: GraphLooks | undefined, named: readonly Named[]) => void;
54
57
  stop: () => void;
55
58
  };
56
59
  /**
57
60
  * **Group names are drawn on a canvas of their own**, under the signals', and
58
61
  * drawn again whenever the driver says the view moved, so they follow it.
62
+ * `avoid` says where notes' names are drawn, which a group's name keeps
63
+ * clear of: moved a line, or left out where no line is clear.
59
64
  */
60
- export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen) => {
65
+ export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen, avoid?: () => readonly Box[]) => {
61
66
  draw: () => void;
62
67
  look: (next: GraphLooks) => void;
63
68
  set: (next: readonly GraphGroup[]) => void;
@@ -69,7 +74,8 @@ export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen) =>
69
74
  * frame's buttons stopped working with them. A listener on the container in
70
75
  * the capture phase runs before the renderer's, and keeps from it what the
71
76
  * page has: the plain wheel and one finger while cooperative, and every
72
- * gesture while a waiting surface is off.
77
+ * gesture while a waiting surface is off, a double click among them, which
78
+ * zooms where a renderer zooms on one. A single click still selects a note.
73
79
  */
74
80
  export declare const gesturesHeld: (container: HTMLElement) => {
75
81
  /**