@fusion.dev/helix-graph 0.125.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 (41) hide show
  1. package/README.md +84 -35
  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 +11 -1
  12. package/dist/connect-graph/stencil.js +109 -11
  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/model/model.d.ts +16 -0
  20. package/dist/model/model.js +50 -0
  21. package/dist/overlay/overlay.d.ts +14 -8
  22. package/dist/overlay/overlay.js +64 -62
  23. package/dist/shapes/flat.d.ts +3 -2
  24. package/dist/shapes/flat.js +59 -6
  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 +12 -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 +12 -3
  33. package/dist/shapes/triangles.js +83 -2
  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/shapes/model.d.ts +0 -12
  40. package/dist/shapes/model.js +0 -31
  41. /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
@@ -23,6 +23,14 @@ export type GraphStencil = (ShapeField | {
23
23
  place?: 'fill' | 'surface' | undefined;
24
24
  /** How thick the surface's shell is, as a share of the shape's size. 0.04 by default. */
25
25
  shell?: number | undefined;
26
+ /**
27
+ * Which named part of the shape each group's notes go to, by group, such
28
+ * as `{ Vision: 'occipital' }`, where the shape has named parts: a model's
29
+ * named meshes, or an SVG's elements with an `id`. A group not named here,
30
+ * or naming a part the shape does not have, shares the parts no group
31
+ * claims.
32
+ */
33
+ regions?: Readonly<Record<string, string>> | undefined;
26
34
  };
27
35
  /** The field a stencil is, or is made into. */
28
36
  export declare const fieldOf: (stencil: GraphStencil) => ShapeField;
@@ -55,7 +63,9 @@ export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>,
55
63
  * `middle.y - (y - height / 2) * scale` and `middle.z + (z - depth / 2) * scale`.
56
64
  */
57
65
  export interface StencilFrame {
66
+ /** The stencil's shape, as a field, whatever form it was given in. */
58
67
  field: ShapeField;
68
+ /** Where the middle of the stencil's box was put, in the places' units. */
59
69
  middle: {
60
70
  x: number;
61
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,6 +78,72 @@ export const placesIn = (field, count, { place = 'fill', shell = SHELL } = {}) =
78
78
  }
79
79
  return [];
80
80
  };
81
+ const placesByRegion = (field, asked, options) => {
82
+ const regionOf = field.regionOf;
83
+ const count = [...asked.values()].reduce((sum, many) => sum + many, 0);
84
+ if (!regionOf) {
85
+ return undefined;
86
+ }
87
+ for (let times = 3; times <= 24; times *= 2) {
88
+ const pool = placesIn(field, count * times, options);
89
+ if (pool.length === 0) {
90
+ return undefined;
91
+ }
92
+ const byRegion = new Map();
93
+ const inRegion = pool.map((place) => regionOf(place.x, place.y, place.z));
94
+ pool.forEach((place, at) => {
95
+ const region = inRegion[at];
96
+ const list = byRegion.get(region);
97
+ if (list) {
98
+ list.push(place);
99
+ }
100
+ else {
101
+ byRegion.set(region, [place]);
102
+ }
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
+ }
114
+ const claimed = new Set([...wanted.keys()].filter((region) => region !== undefined));
115
+ const places = [];
116
+ const regions = [];
117
+ let short = false;
118
+ for (const [region, many] of wanted) {
119
+ if (region === undefined) {
120
+ continue;
121
+ }
122
+ const own = byRegion.get(region) ?? [];
123
+ if (own.length < many) {
124
+ short = true;
125
+ break;
126
+ }
127
+ for (const place of own.slice(0, many)) {
128
+ places.push(place);
129
+ regions.push(region);
130
+ }
131
+ }
132
+ const rest = wanted.get(undefined) ?? 0;
133
+ const taken = new Set(places);
134
+ const free = pool.filter((_, at) => !claimed.has(inRegion[at]));
135
+ const spare = free.length >= rest ? free : pool.filter((place) => !taken.has(place));
136
+ if (short || spare.length < rest) {
137
+ continue;
138
+ }
139
+ for (const place of spare.slice(0, rest)) {
140
+ places.push(place);
141
+ regions.push(undefined);
142
+ }
143
+ return { found, missing, places, regions };
144
+ }
145
+ return undefined;
146
+ };
81
147
  const nested = (items, keys) => {
82
148
  const [key, ...rest] = keys;
83
149
  if (!key) {
@@ -101,10 +167,38 @@ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
101
167
  return { positions: new Map() };
102
168
  }
103
169
  const field = fieldOf(stencil);
104
- const places = placesIn(field, notes.length, {
105
- place: stencil.place ?? 'fill',
106
- shell: stencil.shell ?? SHELL,
107
- });
170
+ const options = { place: stencil.place ?? 'fill', shell: stencil.shell ?? SHELL };
171
+ const groupOf = (id) => clustersOf?.(id)[0];
172
+ const noteRegion = new Map(notes.map((note) => {
173
+ const group = groupOf(note.id);
174
+ return [note.id, group === undefined ? undefined : stencil.regions?.[group]];
175
+ }));
176
+ const asked = new Map();
177
+ for (const region of noteRegion.values()) {
178
+ asked.set(region, (asked.get(region) ?? 0) + 1);
179
+ }
180
+ const regional = [...asked.keys()].some((region) => region !== undefined)
181
+ ? placesByRegion(field, asked, options)
182
+ : undefined;
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)) {
193
+ noteRegion.set(id, undefined);
194
+ }
195
+ }
196
+ const wanted = new Map();
197
+ for (const region of noteRegion.values()) {
198
+ wanted.set(region, (wanted.get(region) ?? 0) + 1);
199
+ }
200
+ const places = regional?.places ?? placesIn(field, notes.length, options);
201
+ const placeRegion = regional?.regions ?? places.map(() => undefined);
108
202
  if (places.length < notes.length) {
109
203
  return { positions: new Map(positions) };
110
204
  }
@@ -237,13 +331,17 @@ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
237
331
  halve(ours.slice(0, half), near, level);
238
332
  halve(ours.slice(half), rest, level);
239
333
  };
240
- halve([...notes], targets.map((_, at) => at));
334
+ for (const region of wanted.keys()) {
335
+ halve(notes.filter((note) => noteRegion.get(note.id) === region), targets.flatMap((_, at) => (placeRegion[at] === region ? [at] : [])));
336
+ }
241
337
  return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
242
338
  }
243
- const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
244
- const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
245
- ours.forEach((note, at) => {
246
- fitted.set(note.id, theirs[at].at);
247
- });
339
+ for (const region of wanted.keys()) {
340
+ const ours = nested(notes.filter((note) => noteRegion.get(note.id) === region), [(note) => note.at.x, (note) => note.at.y]);
341
+ const theirs = nested(spots.filter((_, at) => placeRegion[at] === region), [(spot) => spot.across, (spot) => spot.down]);
342
+ ours.forEach((note, at) => {
343
+ fitted.set(note.id, theirs[at].at);
344
+ });
345
+ }
248
346
  return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
249
347
  };
@@ -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';
@@ -0,0 +1,16 @@
1
+ import type { ShapeField } from '../shapes/shape-field.js';
2
+ import { type TriangleOptions } from '../shapes/triangles.js';
3
+ /**
4
+ * A solid field from a glTF model, the web's standard format for one: a
5
+ * URL to a `.glb` or `.gltf` file, or the file's bytes. Every mesh in it is
6
+ * read, placed as the model places it, and where it has two meshes or more,
7
+ * each is a part of the field, named as the model names it, which a
8
+ * stencil's `regions` sends groups to. A mesh the model leaves unnamed is
9
+ * named as three.js names it, `mesh_0` and on.
10
+ *
11
+ * **three.js reads the file**, imported when this is first called, so a page
12
+ * that draws no model never downloads a loader. It is an optional peer of
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`.
15
+ */
16
+ export declare const shapeFromModel: (source: string | ArrayBuffer, options?: TriangleOptions) => Promise<ShapeField>;