@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
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @fusion.dev/helix-graph
2
2
 
3
- Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, cosmos.gl, or a
4
- driver of your own, colored from your tokens.
3
+ Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, cosmos.gl,
4
+ three.js, or a driver of your own, colored from your tokens.
5
5
 
6
6
  ```sh
7
7
  bun add @fusion.dev/helix @fusion.dev/helix-graph sigma graphology graphology-layout-forceatlas2
@@ -22,7 +22,9 @@ const frame = document.querySelector('helix-graph')
22
22
  frame.nodes = notes // [{ id, label, group, size, x, y }]
23
23
  frame.links = links // [{ source, target, label }]
24
24
 
25
- const sigma = new Sigma(new Graph(), document.getElementById('drawing'))
25
+ // The box the drawing goes in: a <div> inside the frame, sized by it.
26
+ const box = frame.querySelector('div')
27
+ const sigma = new Sigma(new Graph(), box)
26
28
  const connection = connectGraph(
27
29
  frame,
28
30
  sigmaDriver(sigma, {
@@ -36,10 +38,20 @@ frame.addEventListener('helix-graph-layout', (event) => save(event.detail.positi
36
38
  // After replacing a stylesheet, which no attribute shows:
37
39
  connection.restyle()
38
40
 
39
- // Later, when the graph goes away:
41
+ // Lay the graph out again from the start, ignoring every place it has.
42
+ connection.relayout()
43
+
44
+ // Later, when the graph goes away: the connection first, then the renderer.
40
45
  connection.disconnect()
46
+ sigma.kill()
41
47
  ```
42
48
 
49
+ **The renderer is the page's to free.** After `disconnect()`, call
50
+ `sigma.kill()` for Sigma and `graph.destroy()` for cosmos.gl; `threeDriver`
51
+ makes its own renderer, and `disconnect()` frees it. A frame taken off the
52
+ page without `disconnect()` leaves its renderer running, so disconnect it
53
+ first.
54
+
43
55
  ## What it does
44
56
 
45
57
  - Draws the frame's `nodes` and `links`, and draws them again when either changes.
@@ -76,14 +88,12 @@ socket.addEventListener('message', ({ data }) => {
76
88
 
77
89
  **`frame.signal(from, to, { tone })` shows something passing between two
78
90
  notes**: a message, a request, a payment. It is drawn as a comet traveling
79
- from one to the other along a gently bowed path, its tail tapering and turning
80
- from the first note's color to the second's, or in the tone's color where it
81
- has one. A small ring spreads from the note it leaves, and the note it reaches
82
- brightens and ripples. A reply on the same link bows the other way, so the two
83
- do not run over each other, and in three dimensions the path arcs over the
84
- shape. It is drawn over the graph without redrawing it, so a busy feed costs a
85
- few strokes each. A reader who asks for less motion sees the path lit for a
86
- moment instead.
91
+ from one to the other along the link between them, its tail tapering and
92
+ turning from the first note's color to the second's, or in the tone's color
93
+ where it has one. A small ring spreads from the note it leaves, and the note it
94
+ reaches brightens and ripples. It is drawn over the graph without redrawing it,
95
+ so a busy feed costs a few strokes each. A reader who asks for less motion sees
96
+ the link lit for a moment instead.
87
97
 
88
98
  ```js
89
99
  frame.signal('Checkout', 'Payments', { tone: 'success' })
@@ -101,7 +111,7 @@ Each note can say how it is drawn, in the words Helix's other components use:
101
111
  |---|---|---|
102
112
  | `shape` | `circle`, `square`, `diamond`, `ring` | What kind of note it is at a glance |
103
113
  | `emphasis` | `solid`, `outlined`, `subtle` | Filled, a ring with the ground showing through, or a tinted fill with a ring |
104
- | `material` | `glass`, `frosted` | A shaded sphere with a highlight and a lit rim, or a milky body with a soft fall of light |
114
+ | `material` | `flat`, `glass`, `frosted` | Matte, a shaded sphere with a highlight and a lit rim, or a milky body with a soft fall of light |
105
115
  | `tone` | `primary`, `accent`, `info`, `success`, `warning`, `danger`, `neutral` | What it means, which colors it over its group |
106
116
 
107
117
  ```js
@@ -128,11 +138,15 @@ glance. Links are tinted by the note they leave, the selected note's and the
128
138
  hovered note's in their color, and choosing a note in the list frames it with
129
139
  its neighbors.
130
140
 
141
+ **A press on a note selects it, and a press where no note is lets it go**,
142
+ so the graph's colors come back, in every driver.
143
+
131
144
  **Notes are named on hover**, and the selected note and its neighbors where
132
145
  there is room, each name ringed in the ground's color so it reads over the
133
146
  links. Each group's name is drawn over its notes, which is how a reader finds
134
- their way. `labels: 'hubs'` names the busiest notes at rest as well, quietly,
135
- and `labels: 'all'` every note where its name does not cover another.
147
+ their way. With Sigma, `sigmaDriver(sigma, { labels: 'hubs' })` names the
148
+ busiest notes at rest as well, quietly, and `labels: 'all'` every note where
149
+ its name does not cover another.
136
150
 
137
151
  ## Large graphs
138
152
 
@@ -203,49 +217,60 @@ outline. Pass an SVG path and the box it is drawn in, as the SVG's `viewBox`
203
217
  gives it:
204
218
 
205
219
  ```js
220
+ const brain = document.querySelector('#brain path')
206
221
  connectGraph(frame, driver, {
207
- stencil: { path: brain.getAttribute('d'), width: 220, height: 170 },
222
+ stencil: { path: brain?.getAttribute('d') ?? '', width: 220, height: 170 },
208
223
  })
209
224
  ```
210
225
 
211
226
  No layout runs, so the shape is there from the first frame. Each group takes
212
227
  a region of the shape, each note sits by the hub it links to, and the notes
213
228
  fill the shape evenly, with borders between regions that curve. Holes are cut
214
- by the even-odd rule, which is how the grooves of a brain show. Only a graph
215
- with no places of its own is shaped; a note added later is placed by the
216
- layout beside its neighbors. `stencil` also takes `inside(x, y)`, a test of
217
- whether a point in the box is in the shape, in place of `path`, and
218
- `fitToStencil` fits places a page has into one.
229
+ by the even-odd rule, which is how the grooves of a brain show. Every whole
230
+ drawing is shaped, whatever places its notes carry, so places a page kept from
231
+ an earlier drawing come back in the same shape; a note added later is placed
232
+ by the layout beside its neighbors. A shape with no inside to hold the notes
233
+ is warned of in the console, and the graph is laid out instead. `stencil` also
234
+ takes `inside(x, y)`, a test of whether a point in the box is in the shape, in
235
+ place of `path`, and `fitToStencil` fits places a page has into one.
219
236
 
220
237
  **Every shape becomes a distance field**: for each point in its box, how far
221
238
  it is from the shape's edge, below zero inside. `@fusion.dev/helix-graph/shapes`
222
- makes one from what a page has, and a field is a stencil itself:
239
+ makes one from what a page has, and a field is a stencil itself.
240
+ `shapeFromModel` is an entry of its own, `@fusion.dev/helix-graph/model`, since
241
+ it loads three.js, so a page that draws a flat shape never bundles it:
223
242
 
224
243
  | Function | Makes |
225
244
  |---|---|
226
- | `shapeFromModel(url)` | A solid from a glTF model, `.glb` or `.gltf`, every mesh where the model places it. Async; three.js reads the file |
245
+ | `shapeFromModel(url)` | From `/model`: a solid from a glTF model, `.glb` or `.gltf`, every mesh where the model places it. Async; three.js reads the file, and an error names the model |
227
246
  | `shapeFromTriangles(positions, index)` | A solid from a closed surface of triangles a page already holds |
228
247
  | `shapeFromSvg(svg)` | A flat field from an `<svg>` or its markup: every path, rectangle, circle, ellipse and polygon in its `viewBox` |
229
248
  | `shapeFromImage(image, { threshold })` | A flat field from an image's opaque pixels |
230
249
  | `shapeFromPath(path, width, height)` | A flat field from an SVG path |
231
250
  | `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
232
251
  | `inflate(field, { depth, roundness })` | A solid puffed out of a flat field, as a cushion is, cut through where the flat shape has a hole |
233
- | `sphere`, `torus`, `box` | Solids measured exactly from their formulas |
252
+ | `sphere({ radius })`, `torus({ radius, tube })`, `cuboid({ width, height, depth, round })` | Solids measured exactly from their formulas |
234
253
  | `onSphere(field, { radius, thickness })` | A flat map wrapped round a ball, as a world map is round a globe |
235
254
  | `surfaceOf(field, { at, cells })` | The surface of a solid as triangles, to draw or to save as a model |
236
255
 
237
256
  ```js
238
- const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' }
257
+ import { shapeFromModel } from '@fusion.dev/helix-graph/model'
258
+
259
+ const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' as const }
239
260
  connectGraph(frame, threeDriver(box), { stencil })
240
261
  ```
241
262
 
263
+ In a module script, as `await` at the top level needs. In TypeScript,
264
+ `as const` keeps `place` the word the stencil takes, where a plain object
265
+ widens it to any string.
266
+
242
267
  **`regions` sends each group to a named part of the shape**, such as a lobe
243
268
  of a brain or a state on a map:
244
269
 
245
270
  ```js
246
271
  const stencil = {
247
272
  ...(await shapeFromModel('/models/brain.glb')),
248
- place: 'surface',
273
+ place: 'surface' as const,
249
274
  regions: { Vision: 'occipital', Hearing: 'temporal', Balance: 'cerebellum' },
250
275
  }
251
276
  ```
@@ -253,8 +278,10 @@ const stencil = {
253
278
  A model's parts are its meshes, named as it names them, where it has two or
254
279
  more; an SVG's are its shapes with an `id`, or in a group with one; and a
255
280
  field a page makes says its own with `regionOf(x, y, z)`. Each part fills
256
- evenly with the groups sent to it, and a group sent nowhere, or to a part the
257
- shape does not have, shares the parts no group claims.
281
+ evenly with the groups sent to it, and a group sent nowhere shares the parts
282
+ no group claims. A part the shape does not have, as one misspelled, is warned
283
+ of in the console with the parts it has, and its groups are placed with the
284
+ rest.
258
285
 
259
286
  **A model is read a column of cells at a time**, so one of a hundred thousand
260
287
  triangles becomes a field in a moment. Any closed mesh works; a mesh with
@@ -278,7 +305,7 @@ import { inflate, shapeFromPath } from '@fusion.dev/helix-graph/shapes'
278
305
  import { threeDriver } from '@fusion.dev/helix-graph/three'
279
306
 
280
307
  connectGraph(frame, threeDriver(box), {
281
- stencil: { ...inflate(shapeFromPath(path, 220, 170), { depth: 140 }), place: 'surface' },
308
+ stencil: { ...inflate(shapeFromPath(path, 220, 170), { depth: 140 }), place: 'surface' as const },
282
309
  })
283
310
  ```
284
311
 
@@ -300,13 +327,17 @@ connectGraph(frame, threeDriver(box), {
300
327
  who asks for less motion.
301
328
  - **The far side recedes.** Through a graph, its far notes are a little
302
329
  quieter than its near ones.
330
+ - **Edges are smooth at any density.** The drawing has half as many pixels again as the screen, at least two for each of the page's, and links are lines of a set width with smooth edges, where WebGL draws its own a device pixel wide.
303
331
  - **Each group is named over its near side**, and a group's name moves a line
304
332
  where it would cover another's.
333
+ - **It draws only while something moves**: the reader's hands, the turning, a
334
+ layout, or a glide. It draws nothing while its box is out of view.
305
335
 
306
336
  | `threeDriver` option | Default | What else it takes |
307
337
  |---|---|---|
308
338
  | `autoRotate` | `true` | `false` leaves the graph still until the reader turns it |
309
- | `shape` | `circle` | `square` or `diamond`, for every note that does not say |
339
+ | `density` | screen × 1.5, 2 to 3 | How many pixels the drawing has for each of the page's; fewer is faster on a slow GPU |
340
+ | `shape` | `circle` | `square` or `diamond`, for every note that does not say; a `ring` is drawn as a circle |
310
341
  | `groupLabels` | `true` | `false` leaves each group unnamed |
311
342
  | `layoutLimit` | `10000` | The longest a layout runs, in milliseconds |
312
343
 
@@ -314,11 +345,12 @@ connectGraph(frame, threeDriver(box), {
314
345
 
315
346
  | Import | What |
316
347
  |---|---|
317
- | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`, `GraphChanges` and `GraphStencil` types |
318
- | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
319
- | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
320
- | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
321
- | `@fusion.dev/helix-graph/shapes` | `shapeFromModel`, `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `box`, `onSphere`, `surfaceOf`, and the `ShapeField` type |
348
+ | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `linkKey`, `seedPositions`, and the types `ConnectGraphOptions`, `DrawnLink`, `DrawnNode`, `GraphChanges`, `GraphColors`, `GraphConnection`, `GraphDriver`, `GraphGroup`, `GraphLooks`, `GraphPoint`, `GraphStencil`, `GraphTone`, `LinkLook`, `NodeLook`, `SeedLink`, `SeedNode`, `ShapeField` and `StencilFrame` |
349
+ | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)`, and the types `SigmaDriverOptions`, `SigmaGraph`, `SigmaLayout` and `SigmaRenderer` |
350
+ | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)`, and the types `CosmosDriverOptions` and `CosmosGraph` |
351
+ | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)`, and the type `ThreeDriverOptions` |
352
+ | `@fusion.dev/helix-graph/shapes` | `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `cuboid`, `onSphere`, `surfaceOf`, and the types `ImageOptions`, `ShapeField` and `TriangleOptions` |
353
+ | `@fusion.dev/helix-graph/model` | `shapeFromModel(source, options)`, which needs three.js |
322
354
 
323
355
  **Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
324
356
  driver types its renderer by the calls it makes on it, so a page brings
@@ -330,12 +362,12 @@ Sigma 3 is a peer dependency.
330
362
  | `connectGraph` option | Default | What else it takes |
331
363
  |---|---|---|
332
364
  | `center` | `true` | `false` leaves the view where it is when a note is chosen in the list |
333
- | `stencil` | none | A shape to draw the notes in: a field from `shapes`, `{ path, width, height }` or `{ inside, width, height }`, with `place`, `fill` or `surface` |
365
+ | `stencil` | none | A shape to draw the notes in: a field from `shapes`, `{ path, width, height }` or `{ inside, width, height }`, with `place`, `fill` or `surface`; `shell`, how thick a surface's shell is as a share of the shape's size, `0.04` by default; and `regions`, the part each group goes to |
334
366
 
335
367
  | `sigmaDriver` option | Default | What else it takes |
336
368
  |---|---|---|
337
369
  | `layout` | none | A function that makes a layout for the graph, such as ForceAtlas2's worker |
338
- | `material` | flat | `glass` or `frosted`, for every note that does not say |
370
+ | `material` | `flat` | `glass` or `frosted`, for every note that does not say |
339
371
  | `shape` | `circle` | `square`, `diamond` or `ring`, for every note that does not say |
340
372
  | `emphasis` | `solid` | `outlined` or `subtle`, for every note that does not say |
341
373
  | `labels` | `'hover'` | `'hubs'` or `'all'`: which notes are named at rest |
@@ -344,7 +376,7 @@ Sigma 3 is a peer dependency.
344
376
 
345
377
  | `cosmosDriver` option | Default | What else it takes |
346
378
  |---|---|---|
347
- | `shape` | `circle` | `square` or `diamond`, for every note that does not say |
379
+ | `shape` | `circle` | `square` or `diamond`, for every note that does not say; a `ring` is drawn as a circle |
348
380
  | `groupLabels` | `true` | `false` leaves each group unnamed |
349
381
  | `layoutLimit` | `15000` | The longest a layout runs, in milliseconds |
350
382
  | `spaceSize` | `4096` | The size of cosmos.gl's space, as its own `spaceSize` says |
@@ -1,4 +1,4 @@
1
- import type { GraphPoint, SeedLink } from './seed.js';
1
+ import { type GraphPoint, type SeedLink } from './seed.js';
2
2
  /** A note as the arrangement reads it: its group, if it has one. */
3
3
  export interface ArrangedNode {
4
4
  group?: string | undefined;
@@ -25,6 +25,8 @@ export declare const hubsOf: (nodes: readonly ArrangedNode[], links: readonly Se
25
25
  * so the shape is drawn from the first frame with each group a region and
26
26
  * each hub's notes around it.
27
27
  */
28
- export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[], { solid }?: {
28
+ export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[], { hubs, solid, }?: {
29
+ /** Each note's hub and the notes' ranks, where the caller worked them out already. */
30
+ hubs?: ReturnType<typeof hubsOf>;
29
31
  solid?: boolean;
30
32
  }) => Map<string, GraphPoint>;
@@ -1,4 +1,4 @@
1
- const TURN = Math.PI * (3 - Math.sqrt(5));
1
+ import { neighborsOf, TURN } from './seed.js';
2
2
  const STEP = (Math.sqrt(5) - 1) / 2;
3
3
  const spiral = (at, spacing, solid) => {
4
4
  if (!solid) {
@@ -32,25 +32,8 @@ const plus = (one, other) => ({
32
32
  });
33
33
  export const hubsOf = (nodes, links) => {
34
34
  const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
35
- const degree = new Map();
36
- const neighbors = new Map();
37
- const add = (from, to) => {
38
- degree.set(from, (degree.get(from) ?? 0) + 1);
39
- const list = neighbors.get(from);
40
- if (list) {
41
- list.push(to);
42
- }
43
- else {
44
- neighbors.set(from, [to]);
45
- }
46
- };
47
- for (const { source, target } of links) {
48
- if (groupOf.has(source) && groupOf.has(target)) {
49
- add(source, target);
50
- add(target, source);
51
- }
52
- }
53
- const rank = (id) => degree.get(id) ?? 0;
35
+ const neighbors = neighborsOf(links, (id) => groupOf.has(id));
36
+ const rank = (id) => neighbors.get(id)?.length ?? 0;
54
37
  const above = (one, other) => rank(one) > rank(other) || (rank(one) === rank(other) && one < other);
55
38
  const parent = new Map();
56
39
  for (const { id } of nodes) {
@@ -77,9 +60,9 @@ export const hubsOf = (nodes, links) => {
77
60
  };
78
61
  return { hubOf, rank };
79
62
  };
80
- export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
63
+ export const arrangeByGroup = (nodes, links, { hubs = hubsOf(nodes, links), solid = false, } = {}) => {
81
64
  const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
82
- const { hubOf, rank } = hubsOf(nodes, links);
65
+ const { hubOf, rank } = hubs;
83
66
  const groups = new Map();
84
67
  for (const { id } of nodes) {
85
68
  const group = groupOf.get(id);
@@ -57,8 +57,12 @@ export interface GraphDriver {
57
57
  * of the view at a zoom that holds them all, where the renderer can.
58
58
  */
59
59
  fit?(ids: readonly string[], animate: boolean): void;
60
- /** Calls `press` with a note's `id` when the reader presses it in the drawing. Returns what stops listening. */
61
- onPress?(press: (id: string) => void): () => void;
60
+ /**
61
+ * Calls `press` with a note's `id` when the reader presses it in the
62
+ * drawing, and with undefined when they press where no note is. Returns
63
+ * what stops listening.
64
+ */
65
+ onPress?(press: (id: string | undefined) => void): () => void;
62
66
  /**
63
67
  * Runs a layout over the drawn graph, moving the notes in `unplaced` and
64
68
  * holding the rest, and calls `placed` once it has settled with where every
@@ -116,8 +120,11 @@ export interface ConnectGraphOptions {
116
120
  * A shape the notes are drawn in, such as a brain or a country's outline:
117
121
  * an SVG path and the box it is drawn in. **No layout runs**: each group
118
122
  * takes a region of the shape and each note sits by the hub it links to,
119
- * from the first frame. Only a graph with no places of its own is shaped;
120
- * a note added later is placed by the layout beside its neighbors.
123
+ * from the first frame. Every whole drawing is shaped, whatever places its
124
+ * notes carry, so places a page kept from an earlier drawing are shaped
125
+ * again the same way; a note added later is placed by the layout beside
126
+ * its neighbors. A shape that holds none of the notes is warned of, and
127
+ * the graph is laid out instead.
121
128
  */
122
129
  stencil?: GraphStencil | undefined;
123
130
  }
@@ -125,7 +132,10 @@ export interface ConnectGraphOptions {
125
132
  export interface GraphConnection {
126
133
  /** Stops answering the frame and puts back everything connecting changed. */
127
134
  disconnect(): void;
128
- /** Lays the graph out again from where it starts, ignoring where the last layout put each note. */
135
+ /**
136
+ * Lays the graph out again from where it starts, ignoring where the last
137
+ * layout put each note and the places the page gave its notes.
138
+ */
129
139
  relayout(): void;
130
140
  /**
131
141
  * Reads the page's colors again. The connection hears a theme that changes
@@ -138,7 +148,9 @@ export interface GraphConnection {
138
148
  * Connects a Graph frame to whatever draws the graph.
139
149
  *
140
150
  * The frame is `helix-graph` or `helix-headless-graph`, and the driver is
141
- * `sigmaDriver` from `@fusion.dev/helix-graph/sigma` or one of your own. The
151
+ * `sigmaDriver` from `@fusion.dev/helix-graph/sigma`, `cosmosDriver` from
152
+ * `./cosmos`, `threeDriver` from `./three`, or one of your own. The page
153
+ * keeps the renderer it made: after `disconnect()`, kill or destroy it. The
142
154
  * connection draws the frame's `nodes` and `links`, lays out the notes that
143
155
  * have no position and says where it put them in `helix-graph-layout`, answers
144
156
  * the frame's pan and zoom, keeps the selection the same in the list and the
@@ -4,9 +4,8 @@ import { diffGraph, isEmpty, linkKey, } from './diff.js';
4
4
  import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
5
5
  import { seedPositions } from './seed.js';
6
6
  import { fieldOf, fitInStencil } from './stencil.js';
7
- const clustersOf = (nodes, links) => {
7
+ const clustersOf = (nodes, hubOf) => {
8
8
  const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
9
- const { hubOf } = hubsOf(nodes, links);
10
9
  return (id) => [groupOf.get(id) ?? '', hubOf(id)];
11
10
  };
12
11
  const viewportOf = (frame) => {
@@ -16,7 +15,11 @@ const viewportOf = (frame) => {
16
15
  const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
17
16
  export const connectGraph = (frame, driver, options = {}) => {
18
17
  const graph = frame;
18
+ if (!('nodes' in frame) || typeof graph.requestActive !== 'function') {
19
+ console.warn('connectGraph: the element given is not a Graph frame (helix-graph or helix-headless-graph), so nothing will be drawn.', frame);
20
+ }
19
21
  let known = new Map();
22
+ let framed;
20
23
  let shapeFrame;
21
24
  let drawnNodes = new Map();
22
25
  let drawnLinks = new Map();
@@ -29,14 +32,21 @@ export const connectGraph = (frame, driver, options = {}) => {
29
32
  let pressed;
30
33
  let pending = 0;
31
34
  let connected = true;
35
+ const selectedNote = () => {
36
+ const active = graph.active;
37
+ return active && (graph.nodes ?? []).some((node) => node.id === active) ? active : undefined;
38
+ };
32
39
  const restyle = () => {
40
+ if (!connected) {
41
+ return;
42
+ }
33
43
  colors ??= graphColorsFrom(frame);
34
44
  if (!colors) {
35
45
  return;
36
46
  }
37
47
  const nodes = graph.nodes ?? [];
38
48
  const links = graph.links ?? [];
39
- const selected = graph.active || undefined;
49
+ const selected = selectedNote();
40
50
  const near = new Set();
41
51
  if (selected !== undefined) {
42
52
  for (const { source, target } of links) {
@@ -85,7 +95,7 @@ export const connectGraph = (frame, driver, options = {}) => {
85
95
  })));
86
96
  }
87
97
  };
88
- const draw = (whole = false) => {
98
+ const draw = (whole = false, fresh = false) => {
89
99
  if (stopSettling) {
90
100
  stopSettling();
91
101
  stopSettling = undefined;
@@ -99,16 +109,25 @@ export const connectGraph = (frame, driver, options = {}) => {
99
109
  nextLinks.set(linkKey({ source, target }), { label, source, target });
100
110
  }
101
111
  }
102
- let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
103
- const fit = options.stencil && ids.size > 0 && unplaced.size === ids.size
104
- ? fitInStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
105
- solid: fieldOf(options.stencil).depth > 0,
106
- }), options.stencil, { clustersOf: clustersOf(nodes, [...nextLinks.values()]) })
107
- : undefined;
108
- const shaped = fit?.positions;
112
+ const linkList = [...nextLinks.values()];
113
+ let { positions, unplaced } = seedPositions(fresh ? nodes.map(({ id }) => ({ id })) : nodes, linkList, fresh ? new Map() : known);
114
+ const shaping = options.stencil !== undefined &&
115
+ ids.size > 0 &&
116
+ (first || whole || unplaced.size === ids.size);
117
+ let fit;
118
+ if (shaping && options.stencil) {
119
+ const hubs = hubsOf(nodes, linkList);
120
+ fit = fitInStencil(arrangeByGroup(nodes, linkList, { hubs, solid: fieldOf(options.stencil).depth > 0 }), options.stencil, { clustersOf: clustersOf(nodes, hubs.hubOf) });
121
+ }
122
+ const shaped = fit?.frame ? fit.positions : undefined;
109
123
  if (fit?.frame) {
110
124
  shapeFrame = fit.frame;
111
125
  }
126
+ else if (shaping) {
127
+ console.warn('connectGraph: the stencil has no inside to place the notes in, so the graph is laid out instead.', options.stencil);
128
+ positions = seedPositions(nodes.map(({ id }) => ({ id })), linkList, new Map()).positions;
129
+ unplaced = new Set(ids);
130
+ }
112
131
  driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined, shapeFrame);
113
132
  if (shaped) {
114
133
  positions = shaped;
@@ -148,7 +167,7 @@ export const connectGraph = (frame, driver, options = {}) => {
148
167
  }
149
168
  else {
150
169
  graph.drawn?.(false);
151
- driver.draw({ links: [...nextLinks.values()], nodes: [...nextNodes.values()] });
170
+ driver.draw({ links: linkList, nodes: [...nextNodes.values()] });
152
171
  }
153
172
  drawnNodes = nextNodes;
154
173
  drawnLinks = nextLinks;
@@ -162,6 +181,9 @@ export const connectGraph = (frame, driver, options = {}) => {
162
181
  waiting = unplaced;
163
182
  stopSettling = driver.settle(unplaced, (settled) => {
164
183
  stopSettling = undefined;
184
+ if (!connected) {
185
+ return;
186
+ }
165
187
  waiting = new Set();
166
188
  known = new Map(settled);
167
189
  for (const [id, at] of settled) {
@@ -204,32 +226,44 @@ export const connectGraph = (frame, driver, options = {}) => {
204
226
  ? { from: current.colorOf(from), to: current.colorOf(to) }
205
227
  : undefined, !still());
206
228
  };
229
+ const frameSelection = () => {
230
+ const selected = selectedNote();
231
+ if (selected === framed) {
232
+ pressed = undefined;
233
+ return;
234
+ }
235
+ framed = selected;
236
+ if (selected !== undefined &&
237
+ selected !== pressed &&
238
+ options.center !== false &&
239
+ drawnNodes.has(selected)) {
240
+ const near = [selected];
241
+ for (const { source, target } of drawnLinks.values()) {
242
+ if (source === selected) {
243
+ near.push(target);
244
+ }
245
+ else if (target === selected) {
246
+ near.push(source);
247
+ }
248
+ }
249
+ if (driver.fit && near.length > 1) {
250
+ driver.fit(near, !still());
251
+ }
252
+ else {
253
+ driver.center?.(selected, !still());
254
+ }
255
+ }
256
+ pressed = undefined;
257
+ };
207
258
  const handlePartChange = (event) => {
208
259
  const { kind } = event.detail;
209
260
  if (kind === 'data') {
210
261
  draw();
262
+ frameSelection();
211
263
  }
212
264
  else if (kind === 'focus') {
213
265
  restyle();
214
- const selected = graph.active;
215
- if (selected && selected !== pressed && options.center !== false) {
216
- const near = [selected];
217
- for (const { source, target } of drawnLinks.values()) {
218
- if (source === selected) {
219
- near.push(target);
220
- }
221
- else if (target === selected) {
222
- near.push(source);
223
- }
224
- }
225
- if (driver.fit && near.length > 1) {
226
- driver.fit(near, !still());
227
- }
228
- else {
229
- driver.center?.(selected, !still());
230
- }
231
- }
232
- pressed = undefined;
266
+ frameSelection();
233
267
  }
234
268
  else {
235
269
  driver.gestures?.(graph.gestureMode ?? 'free');
@@ -253,6 +287,13 @@ export const connectGraph = (frame, driver, options = {}) => {
253
287
  frame.addEventListener(HelixGraphPanEvent.eventName, handlePan);
254
288
  frame.addEventListener(HelixGraphZoomEvent.eventName, handleZoom);
255
289
  const stopPress = driver.onPress?.((id) => {
290
+ if (id === undefined) {
291
+ pressed = undefined;
292
+ if (graph.active) {
293
+ graph.requestActive?.('');
294
+ }
295
+ return;
296
+ }
256
297
  pressed = id;
257
298
  if (graph.requestActive?.(id) === false || graph.active !== id) {
258
299
  pressed = undefined;
@@ -296,9 +337,12 @@ export const connectGraph = (frame, driver, options = {}) => {
296
337
  graph.drawn?.(false);
297
338
  },
298
339
  relayout: () => {
340
+ if (!connected) {
341
+ return;
342
+ }
299
343
  known = new Map();
300
344
  waiting = new Set();
301
- draw(true);
345
+ draw(true, true);
302
346
  },
303
347
  restyle: () => {
304
348
  colors = null;
@@ -1,18 +1,25 @@
1
1
  /** A note as the renderer draws it, placed. */
2
2
  export interface DrawnNode {
3
+ /** The note's `id`, as the frame holds it. */
3
4
  id: string;
5
+ /** The name drawn beside it: its `label`, or its `id` where it has none. */
4
6
  label: string;
5
7
  /** How large it is drawn, relative to the others: the note's `size`, or from how many links it has. */
6
8
  size: number;
9
+ /** Across, in the renderer's own units. */
7
10
  x: number;
11
+ /** Up, in the renderer's own units. */
8
12
  y: number;
9
13
  /** How near the viewer, for a renderer that draws in three dimensions. */
10
14
  z?: number | undefined;
11
15
  }
12
16
  /** A link as the renderer draws it, between two notes the graph holds. */
13
17
  export interface DrawnLink {
18
+ /** What the link says, where it says anything. */
14
19
  label?: string | undefined;
20
+ /** The `id` of the note the link runs from. */
15
21
  source: string;
22
+ /** The `id` of the note the link runs to. */
16
23
  target: string;
17
24
  }
18
25
  /** What changed between two drawings of a graph, which a renderer applies in place. */
@@ -42,10 +42,13 @@ export interface GraphColors {
42
42
  * next change.
43
43
  */
44
44
  export declare const graphColorsFrom: (element: Element) => GraphColors | null;
45
+ /** An `rgb()` color's red, green and blue, from 0 to 255, each `missing` where the color does not say. */
46
+ export declare const rgbOf: (color: string, missing?: number) => [number, number, number];
45
47
  /** One `rgb()` moved toward another by `amount`, from 0 to 1. */
46
48
  export declare const mixRgb: (from: string, to: string, amount: number) => string;
47
49
  /** How one note is drawn. */
48
50
  export interface NodeLook {
51
+ /** The color it is drawn in: its own, or faded toward the ground where the selection sets it back. */
49
52
  color: string;
50
53
  /** Outside the local graph that `depth` keeps. */
51
54
  hidden: boolean;
@@ -85,8 +88,11 @@ export interface NoteFacts {
85
88
  export declare const toneColor: (colors: GraphColors, tone: GraphTone | undefined) => string;
86
89
  /** How one link is drawn. */
87
90
  export interface LinkLook {
91
+ /** The color it is drawn in. */
88
92
  color: string;
93
+ /** Outside the local graph that `depth` keeps. */
89
94
  hidden: boolean;
95
+ /** Drawn over the rest, as a link that touches the selected note is. */
90
96
  raised: boolean;
91
97
  }
92
98
  /** How every note and link is drawn, asked one at a time, so ten thousand cost nothing until drawn. */
@@ -103,7 +109,9 @@ export interface GraphLooks {
103
109
  overlay: string;
104
110
  /** The quieter ink a name at rest is drawn in, beside the selected note's and its neighbors'. */
105
111
  quiet: string;
112
+ /** How the link from `source` to `target` is drawn. */
106
113
  link(source: string, target: string): LinkLook;
114
+ /** How the note `id` is drawn. */
107
115
  node(id: string): NodeLook;
108
116
  }
109
117
  /**
@@ -48,10 +48,13 @@ export const graphColorsFrom = (element) => {
48
48
  probe.remove();
49
49
  }
50
50
  };
51
+ export const rgbOf = (color, missing = 128) => {
52
+ const [red = missing, green = missing, blue = missing] = color.match(/[\d.]+/g)?.map(Number) ?? [];
53
+ return [red, green, blue];
54
+ };
51
55
  export const mixRgb = (from, to, amount) => {
52
- const parts = (value) => value.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
53
- const [one, other] = [parts(from), parts(to)];
54
- const channel = (at) => Math.round((one[at] ?? 0) + ((other[at] ?? 0) - (one[at] ?? 0)) * amount);
56
+ const [one, other] = [rgbOf(from, 0), rgbOf(to, 0)];
57
+ const channel = (at) => Math.round(one[at] + (other[at] - one[at]) * amount);
55
58
  return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
56
59
  };
57
60
  export const toneColor = (colors, tone) => tone === undefined || tone === 'primary'