@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.
- package/README.md +70 -38
- package/dist/connect-graph/arrange.d.ts +4 -2
- package/dist/connect-graph/arrange.js +5 -22
- package/dist/connect-graph/connect-graph.d.ts +18 -6
- package/dist/connect-graph/connect-graph.js +76 -32
- package/dist/connect-graph/diff.d.ts +7 -0
- package/dist/connect-graph/looks.d.ts +8 -0
- package/dist/connect-graph/looks.js +6 -3
- package/dist/connect-graph/seed.d.ts +9 -0
- package/dist/connect-graph/seed.js +21 -15
- package/dist/connect-graph/stencil.d.ts +3 -1
- package/dist/connect-graph/stencil.js +32 -10
- package/dist/cosmos/cosmos-driver.d.ts +5 -4
- package/dist/cosmos/cosmos-driver.js +122 -39
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/model/index.d.ts +1 -0
- package/dist/model/index.js +1 -0
- package/dist/{shapes → model}/model.d.ts +4 -3
- package/dist/{shapes → model}/model.js +10 -5
- package/dist/overlay/overlay.d.ts +14 -8
- package/dist/overlay/overlay.js +64 -62
- package/dist/shapes/flat.d.ts +1 -1
- package/dist/shapes/flat.js +9 -5
- package/dist/shapes/index.d.ts +3 -4
- package/dist/shapes/index.js +3 -4
- package/dist/{connect-graph → shapes}/shape-field.d.ts +7 -3
- package/dist/{connect-graph → shapes}/shape-field.js +4 -2
- package/dist/shapes/solids.d.ts +3 -3
- package/dist/shapes/solids.js +25 -9
- package/dist/{three → shapes}/surface.d.ts +1 -1
- package/dist/shapes/triangles.d.ts +2 -2
- package/dist/shapes/triangles.js +49 -30
- package/dist/sigma/sigma-driver.d.ts +33 -17
- package/dist/sigma/sigma-driver.js +69 -17
- package/dist/three/three-driver.d.ts +12 -3
- package/dist/three/three-driver.js +376 -201
- package/package.json +9 -5
- /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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
80
|
-
from the first note's color to the second's, or in the tone's color
|
|
81
|
-
has one. A small ring spreads from the note it leaves, and the note it
|
|
82
|
-
brightens and ripples.
|
|
83
|
-
|
|
84
|
-
|
|
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` |
|
|
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
|
|
135
|
-
and `labels: 'all'` every note where
|
|
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
|
|
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.
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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)` |
|
|
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`, `
|
|
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
|
-
|
|
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
|
|
257
|
-
shape does not have,
|
|
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
|
-
| `
|
|
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 `
|
|
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` | `
|
|
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
|
|
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
|
-
|
|
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
|
|
36
|
-
const
|
|
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 } =
|
|
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
|
-
/**
|
|
61
|
-
|
|
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.
|
|
120
|
-
*
|
|
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
|
-
/**
|
|
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`
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
53
|
-
const [
|
|
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'
|