@fusion.dev/helix-graph 0.120.0 → 0.122.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 +194 -8
- package/dist/connect-graph/arrange.d.ts +20 -0
- package/dist/connect-graph/arrange.js +125 -0
- package/dist/connect-graph/connect-graph.d.ts +47 -5
- package/dist/connect-graph/connect-graph.js +86 -19
- package/dist/connect-graph/diff.d.ts +3 -1
- package/dist/connect-graph/diff.js +2 -1
- package/dist/connect-graph/looks.d.ts +29 -3
- package/dist/connect-graph/looks.js +24 -6
- package/dist/connect-graph/seed.d.ts +3 -0
- package/dist/connect-graph/seed.js +21 -4
- package/dist/connect-graph/shape-field.d.ts +47 -0
- package/dist/connect-graph/shape-field.js +172 -0
- package/dist/connect-graph/stencil.d.ts +50 -0
- package/dist/connect-graph/stencil.js +199 -0
- package/dist/cosmos/cosmos-driver.d.ts +67 -0
- package/dist/cosmos/cosmos-driver.js +419 -0
- package/dist/cosmos/index.d.ts +1 -0
- package/dist/cosmos/index.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/overlay/overlay.d.ts +77 -0
- package/dist/overlay/overlay.js +358 -0
- package/dist/shapes/index.d.ts +1 -0
- package/dist/shapes/index.js +1 -0
- package/dist/sigma/node-program.d.ts +71 -0
- package/dist/sigma/node-program.js +170 -0
- package/dist/sigma/sigma-driver.d.ts +31 -3
- package/dist/sigma/sigma-driver.js +125 -140
- package/dist/three/index.d.ts +1 -0
- package/dist/three/index.js +1 -0
- package/dist/three/three-driver.d.ts +32 -0
- package/dist/three/three-driver.js +714 -0
- package/package.json +47 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @fusion.dev/helix-graph
|
|
2
2
|
|
|
3
|
-
Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, or a
|
|
3
|
+
Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, cosmos.gl, or a
|
|
4
4
|
driver of your own, colored from your tokens.
|
|
5
5
|
|
|
6
6
|
```sh
|
|
@@ -75,8 +75,9 @@ socket.addEventListener('message', ({ data }) => {
|
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
**`frame.signal(from, to, { tone })` shows something passing between two
|
|
78
|
-
notes**: a message, a request, a payment. It is drawn
|
|
79
|
-
|
|
78
|
+
notes**: a message, a request, a payment. It is drawn as a pulse of light
|
|
79
|
+
traveling from one to the other, turning from the first note's color to the
|
|
80
|
+
second's, or in the tone's color where it has one, over the graph and without
|
|
80
81
|
redrawing it, so a busy feed costs a dot each. A reader who asks for less motion
|
|
81
82
|
sees the link lit for a moment instead.
|
|
82
83
|
|
|
@@ -88,27 +89,212 @@ frame.signal('Payments', 'Fraud review', { tone: 'danger' })
|
|
|
88
89
|
A signal is drawn and never said: a screen reader does not hear it. Where what
|
|
89
90
|
passed matters to a reader, say it in the page's own words as well.
|
|
90
91
|
|
|
92
|
+
## How notes look
|
|
93
|
+
|
|
94
|
+
Each note can say how it is drawn, in the words Helix's other components use:
|
|
95
|
+
|
|
96
|
+
| Field | Values | What it does |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `shape` | `circle`, `square`, `diamond`, `ring` | What kind of note it is at a glance |
|
|
99
|
+
| `emphasis` | `solid`, `outlined`, `subtle` | Filled, a ring with the ground showing through, or a tinted fill with a ring |
|
|
100
|
+
| `material` | `glass`, `frosted` | A shaded sphere with a highlight and a lit rim, or a milky body with a soft fall of light |
|
|
101
|
+
| `tone` | `primary`, `accent`, `info`, `success`, `warning`, `danger`, `neutral` | What it means, which colors it over its group |
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
frame.nodes = [
|
|
105
|
+
{ id: 'Roadmap', group: 'Product', shape: 'square' },
|
|
106
|
+
{ id: 'Outage 14', group: 'Support', tone: 'danger', emphasis: 'outlined' },
|
|
107
|
+
]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`sigmaDriver(sigma, { material, shape, emphasis })` sets what a note is drawn as
|
|
111
|
+
where it does not say. The selected note keeps its own color and is told apart by
|
|
112
|
+
a ring and a glow in that color.
|
|
113
|
+
|
|
114
|
+
**A note without a `size` is as large as it is connected**, so hubs read at a
|
|
115
|
+
glance. Links are tinted by the note they leave, the selected note's and the
|
|
116
|
+
hovered note's in their color, and choosing a note in the list frames it with
|
|
117
|
+
its neighbors.
|
|
118
|
+
|
|
119
|
+
**Notes are named on hover**, and the selected note and its neighbors where
|
|
120
|
+
there is room, each name ringed in the ground's color so it reads over the
|
|
121
|
+
links. Each group's name is drawn over its notes, which is how a reader finds
|
|
122
|
+
their way. `labels: 'hubs'` names the busiest notes at rest as well, quietly,
|
|
123
|
+
and `labels: 'all'` every note where its name does not cover another.
|
|
124
|
+
|
|
125
|
+
## Large graphs
|
|
126
|
+
|
|
127
|
+
**Past fifty thousand notes, draw with cosmos.gl**, which lays the graph out
|
|
128
|
+
and draws it on the GPU. The frame, the list, selection and signals are the
|
|
129
|
+
same; only the driver changes:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bun add @cosmos.gl/graph
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { Graph } from '@cosmos.gl/graph'
|
|
137
|
+
import { connectGraph } from '@fusion.dev/helix-graph'
|
|
138
|
+
import { cosmosDriver } from '@fusion.dev/helix-graph/cosmos'
|
|
139
|
+
|
|
140
|
+
const box = document.getElementById('drawing')
|
|
141
|
+
const graph = new Graph(box, { attribution: '', enableSimulation: true, scalePointsOnZoom: false })
|
|
142
|
+
connectGraph(frame, cosmosDriver(graph, box))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
cosmos.gl draws notes with its own shaders, so a note's `shape` is honored and
|
|
146
|
+
its `material` and `emphasis` are not. Each group is given a place of its own
|
|
147
|
+
and its notes are drawn toward it, so twenty thousand notes in twelve groups
|
|
148
|
+
come apart into twelve regions. The view follows the layout as it opens out,
|
|
149
|
+
until the reader moves it. Zoomed out, a note is a fine point and a leaf is
|
|
150
|
+
drawn partly clear, so a group reads as a field of points with its hubs
|
|
151
|
+
standing out; notes grow as the reader zooms in.
|
|
152
|
+
|
|
153
|
+
**cosmos.gl's own start reports a `ResizeObserver loop` error**, which is
|
|
154
|
+
harmless and shows in the console: it observes its canvas from inside
|
|
155
|
+
luma.gl's resize callback. Make the GPU device yourself and hand it over a
|
|
156
|
+
frame after its canvas is measured, and cosmos.gl starts outside any callback:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { luma } from '@luma.gl/core'
|
|
160
|
+
import { webgl2Adapter } from '@luma.gl/webgl'
|
|
161
|
+
|
|
162
|
+
const canvas = Object.assign(document.createElement('canvas'), {
|
|
163
|
+
style: 'width:100%;height:100%',
|
|
164
|
+
})
|
|
165
|
+
box.append(canvas)
|
|
166
|
+
const device = luma
|
|
167
|
+
.createDevice({
|
|
168
|
+
adapters: [webgl2Adapter],
|
|
169
|
+
createCanvasContext: { autoResize: true, canvas, useDevicePixels: true },
|
|
170
|
+
type: 'webgl',
|
|
171
|
+
webgl: { antialias: false },
|
|
172
|
+
})
|
|
173
|
+
.then(async (made) => {
|
|
174
|
+
await made.canvasContext?.initialized
|
|
175
|
+
await new Promise((done) => requestAnimationFrame(done))
|
|
176
|
+
return made
|
|
177
|
+
})
|
|
178
|
+
const graph = new Graph(box, { attribution: '', enableSimulation: true }, device)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Use the `@luma.gl` version `@cosmos.gl/graph` depends on, since two versions
|
|
182
|
+
on one page throw, and destroy the device after the graph.
|
|
183
|
+
|
|
184
|
+
**The driver is for `@cosmos.gl/graph`, which is MIT.** Cosmograph, the
|
|
185
|
+
product built on it, is licensed for noncommercial use only.
|
|
186
|
+
|
|
187
|
+
## Shapes
|
|
188
|
+
|
|
189
|
+
**`stencil` draws the notes in a shape**, such as a brain or a country's
|
|
190
|
+
outline. Pass an SVG path and the box it is drawn in, as the SVG's `viewBox`
|
|
191
|
+
gives it:
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
connectGraph(frame, driver, {
|
|
195
|
+
stencil: { path: brain.getAttribute('d'), width: 220, height: 170 },
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
No layout runs, so the shape is there from the first frame. Each group takes
|
|
200
|
+
a region of the shape, each note sits by the hub it links to, and the notes
|
|
201
|
+
fill the shape evenly, with borders between regions that curve. Holes are cut
|
|
202
|
+
by the even-odd rule, which is how the grooves of a brain show. Only a graph
|
|
203
|
+
with no places of its own is shaped; a note added later is placed by the
|
|
204
|
+
layout beside its neighbors. `stencil` also takes `inside(x, y)`, a test of
|
|
205
|
+
whether a point in the box is in the shape, in place of `path`, and
|
|
206
|
+
`fitToStencil` fits places a page has into one.
|
|
207
|
+
|
|
208
|
+
**Every shape becomes a distance field**: for each point in its box, how far
|
|
209
|
+
it is from the shape's edge, below zero inside. `@fusion.dev/helix-graph/shapes`
|
|
210
|
+
makes one from what a page has, and a field is a stencil itself:
|
|
211
|
+
|
|
212
|
+
| Function | Makes |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `shapeFromPath(path, width, height)` | A flat field from an SVG path |
|
|
215
|
+
| `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
|
|
216
|
+
| `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 |
|
|
217
|
+
|
|
218
|
+
## In three dimensions
|
|
219
|
+
|
|
220
|
+
**`threeDriver(box)` draws the graph in three dimensions** with three.js, to
|
|
221
|
+
be turned and looked into. The frame, the list, selection, names and signals
|
|
222
|
+
are the same as in two:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
bun add three d3-force-3d
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
import { connectGraph } from '@fusion.dev/helix-graph'
|
|
230
|
+
import { inflate, shapeFromPath } from '@fusion.dev/helix-graph/shapes'
|
|
231
|
+
import { threeDriver } from '@fusion.dev/helix-graph/three'
|
|
232
|
+
|
|
233
|
+
connectGraph(frame, threeDriver(box), {
|
|
234
|
+
stencil: { ...inflate(shapeFromPath(path, 220, 170), { depth: 140 }), place: 'surface' },
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- **A solid stencil draws the notes in the solid**: all through it, or, with
|
|
239
|
+
`place: 'surface'`, in a thin shell just inside its edge, which is how a
|
|
240
|
+
solid reads as the thing it is shaped like. Each group takes one side of
|
|
241
|
+
the shape, and each hub's notes a patch around it.
|
|
242
|
+
- **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
|
|
243
|
+
- **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
|
|
244
|
+
turns slowly by itself while nobody is handling it, and never for a reader
|
|
245
|
+
who asks for less motion.
|
|
246
|
+
- **The far side recedes.** On a surface, the far side of the shape falls to
|
|
247
|
+
a tenth, so the near side reads; through a graph, its far notes are a
|
|
248
|
+
little quieter than its near ones.
|
|
249
|
+
- **Each group is named over its near side**, and a group's name moves a line
|
|
250
|
+
where it would cover another's.
|
|
251
|
+
|
|
252
|
+
| `threeDriver` option | Default | What else it takes |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `autoRotate` | `true` | `false` leaves the graph still until the reader turns it |
|
|
255
|
+
| `shape` | `circle` | `square` or `diamond`, for every note that does not say |
|
|
256
|
+
| `groupLabels` | `true` | `false` leaves each group unnamed |
|
|
257
|
+
| `layoutLimit` | `10000` | The longest a layout runs, in milliseconds |
|
|
258
|
+
|
|
91
259
|
## What it exports
|
|
92
260
|
|
|
93
261
|
| Import | What |
|
|
94
262
|
|---|---|
|
|
95
|
-
| `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver` and `
|
|
263
|
+
| `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`, `GraphChanges` and `GraphStencil` types |
|
|
96
264
|
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
265
|
+
| `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
|
|
266
|
+
| `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
|
|
267
|
+
| `@fusion.dev/helix-graph/shapes` | `shapeFromPath`, `shapeFromInside`, `inflate`, and the `ShapeField` type |
|
|
97
268
|
|
|
98
|
-
**
|
|
99
|
-
by the calls it makes on it, so a page brings
|
|
100
|
-
holds those shapes to the real classes,
|
|
101
|
-
here
|
|
269
|
+
**Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
|
|
270
|
+
driver types its renderer by the calls it makes on it, so a page brings
|
|
271
|
+
whichever version it has, and a spec holds those shapes to the real classes,
|
|
272
|
+
so a renamed method fails a typecheck here before it reaches a page. `@fusion.dev/helix-graph/sigma` imports
|
|
273
|
+
Sigma's rendering module for the program that draws the notes, which is why
|
|
274
|
+
Sigma 3 is a peer dependency.
|
|
102
275
|
|
|
103
276
|
| `connectGraph` option | Default | What else it takes |
|
|
104
277
|
|---|---|---|
|
|
105
278
|
| `center` | `true` | `false` leaves the view where it is when a note is chosen in the list |
|
|
279
|
+
| `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` |
|
|
106
280
|
|
|
107
281
|
| `sigmaDriver` option | Default | What else it takes |
|
|
108
282
|
|---|---|---|
|
|
109
283
|
| `layout` | none | A function that makes a layout for the graph, such as ForceAtlas2's worker |
|
|
284
|
+
| `material` | flat | `glass` or `frosted`, for every note that does not say |
|
|
285
|
+
| `shape` | `circle` | `square`, `diamond` or `ring`, for every note that does not say |
|
|
286
|
+
| `emphasis` | `solid` | `outlined` or `subtle`, for every note that does not say |
|
|
287
|
+
| `labels` | `'hover'` | `'hubs'` or `'all'`: which notes are named at rest |
|
|
288
|
+
| `groupLabels` | `true` | `false` leaves each group unnamed |
|
|
110
289
|
| `layoutLimit` | `10000` | The longest a layout runs, in milliseconds, about where 10,000 notes settle |
|
|
111
290
|
|
|
291
|
+
| `cosmosDriver` option | Default | What else it takes |
|
|
292
|
+
|---|---|---|
|
|
293
|
+
| `shape` | `circle` | `square` or `diamond`, for every note that does not say |
|
|
294
|
+
| `groupLabels` | `true` | `false` leaves each group unnamed |
|
|
295
|
+
| `layoutLimit` | `15000` | The longest a layout runs, in milliseconds |
|
|
296
|
+
| `spaceSize` | `4096` | The size of cosmos.gl's space, as its own `spaceSize` says |
|
|
297
|
+
|
|
112
298
|
## Working on it
|
|
113
299
|
|
|
114
300
|
```sh
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GraphPoint, SeedLink } from './seed.js';
|
|
2
|
+
/** A note as the arrangement reads it: its group, if it has one. */
|
|
3
|
+
export interface ArrangedNode {
|
|
4
|
+
group?: string | undefined;
|
|
5
|
+
id: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Places for every note from the graph's structure alone, with no layout to
|
|
9
|
+
* wait for: **each group around a circle, or a sphere in a solid, each hub in its group, and each
|
|
10
|
+
* note beside the hub it hangs from**, the most connected note it links to
|
|
11
|
+
* in its group.
|
|
12
|
+
*
|
|
13
|
+
* Not a layout to look at, since fans overlap and nothing is pushed apart:
|
|
14
|
+
* it is where a stencil starts, which reads only which notes are near which,
|
|
15
|
+
* so the shape is drawn from the first frame with each group a region and
|
|
16
|
+
* each hub's notes around it.
|
|
17
|
+
*/
|
|
18
|
+
export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[], { solid }?: {
|
|
19
|
+
solid?: boolean;
|
|
20
|
+
}) => Map<string, GraphPoint>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const TURN = Math.PI * (3 - Math.sqrt(5));
|
|
2
|
+
const STEP = (Math.sqrt(5) - 1) / 2;
|
|
3
|
+
const spiral = (at, spacing, solid) => {
|
|
4
|
+
if (!solid) {
|
|
5
|
+
return {
|
|
6
|
+
x: Math.cos(at * TURN) * Math.sqrt(at + 0.5) * spacing,
|
|
7
|
+
y: Math.sin(at * TURN) * Math.sqrt(at + 0.5) * spacing,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
const height = 1 - 2 * ((at * STEP) % 1);
|
|
11
|
+
const around = Math.sqrt(1 - height * height);
|
|
12
|
+
const out = Math.cbrt(at + 0.5) * spacing;
|
|
13
|
+
return {
|
|
14
|
+
x: Math.cos(at * TURN) * around * out,
|
|
15
|
+
y: height * out,
|
|
16
|
+
z: Math.sin(at * TURN) * around * out,
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const around = (at, count, solid) => {
|
|
20
|
+
if (!solid) {
|
|
21
|
+
const turn = (at / count) * Math.PI * 2;
|
|
22
|
+
return { x: Math.cos(turn), y: Math.sin(turn) };
|
|
23
|
+
}
|
|
24
|
+
const height = 1 - (2 * (at + 0.5)) / count;
|
|
25
|
+
const ring = Math.sqrt(1 - height * height);
|
|
26
|
+
return { x: Math.cos(at * TURN) * ring, y: height, z: Math.sin(at * TURN) * ring };
|
|
27
|
+
};
|
|
28
|
+
const plus = (one, other) => ({
|
|
29
|
+
x: one.x + other.x,
|
|
30
|
+
y: one.y + other.y,
|
|
31
|
+
...(one.z === undefined && other.z === undefined ? {} : { z: (one.z ?? 0) + (other.z ?? 0) }),
|
|
32
|
+
});
|
|
33
|
+
export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
|
|
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;
|
|
54
|
+
const above = (one, other) => rank(one) > rank(other) || (rank(one) === rank(other) && one < other);
|
|
55
|
+
const parent = new Map();
|
|
56
|
+
for (const { id } of nodes) {
|
|
57
|
+
let best = id;
|
|
58
|
+
for (const near of neighbors.get(id) ?? []) {
|
|
59
|
+
if (groupOf.get(near) === groupOf.get(id) && above(near, best)) {
|
|
60
|
+
best = near;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
parent.set(id, best);
|
|
64
|
+
}
|
|
65
|
+
const hubOf = (id) => {
|
|
66
|
+
let at = id;
|
|
67
|
+
while (parent.get(at) !== at) {
|
|
68
|
+
at = parent.get(at);
|
|
69
|
+
}
|
|
70
|
+
let step = id;
|
|
71
|
+
while (step !== at) {
|
|
72
|
+
const next = parent.get(step);
|
|
73
|
+
parent.set(step, at);
|
|
74
|
+
step = next;
|
|
75
|
+
}
|
|
76
|
+
return at;
|
|
77
|
+
};
|
|
78
|
+
const groups = new Map();
|
|
79
|
+
for (const { id } of nodes) {
|
|
80
|
+
const group = groupOf.get(id);
|
|
81
|
+
const hubs = groups.get(group) ?? new Map();
|
|
82
|
+
groups.set(group, hubs);
|
|
83
|
+
const hub = hubOf(id);
|
|
84
|
+
const members = hubs.get(hub) ?? [];
|
|
85
|
+
hubs.set(hub, members);
|
|
86
|
+
if (hub !== id) {
|
|
87
|
+
members.push(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const radiusOf = (hubs) => {
|
|
91
|
+
let count = 0;
|
|
92
|
+
for (const members of hubs.values()) {
|
|
93
|
+
count += members.length + 1;
|
|
94
|
+
}
|
|
95
|
+
return (solid ? Math.cbrt(count) : Math.sqrt(count)) * 1.5;
|
|
96
|
+
};
|
|
97
|
+
const radii = [...groups.values()].map(radiusOf);
|
|
98
|
+
const widest = Math.max(0, ...radii);
|
|
99
|
+
const ring = groups.size > 1
|
|
100
|
+
? solid
|
|
101
|
+
? (widest * 2.4) / Math.sqrt((4 * Math.PI) / groups.size)
|
|
102
|
+
: (widest * 1.2) / Math.sin(Math.PI / groups.size)
|
|
103
|
+
: 0;
|
|
104
|
+
const positions = new Map();
|
|
105
|
+
[...groups.values()].forEach((hubs, at) => {
|
|
106
|
+
const way = around(at, groups.size, solid);
|
|
107
|
+
const middle = {
|
|
108
|
+
x: way.x * ring,
|
|
109
|
+
y: way.y * ring,
|
|
110
|
+
...(solid ? { z: (way.z ?? 0) * ring } : {}),
|
|
111
|
+
};
|
|
112
|
+
const ordered = [...hubs].sort(([, one], [, other]) => other.length - one.length);
|
|
113
|
+
const mean = ordered.reduce((sum, [, members]) => sum + members.length + 1, 0) / ordered.length;
|
|
114
|
+
ordered.forEach(([hub, members], place) => {
|
|
115
|
+
const center = plus(middle, spiral(place, (solid ? Math.cbrt(mean) : Math.sqrt(mean)) * 2, solid));
|
|
116
|
+
positions.set(hub, center);
|
|
117
|
+
members
|
|
118
|
+
.sort((one, other) => rank(other) - rank(one))
|
|
119
|
+
.forEach((member, step) => {
|
|
120
|
+
positions.set(member, plus(center, spiral(step + 1, 1, solid)));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
return positions;
|
|
125
|
+
};
|
|
@@ -2,6 +2,7 @@ import { type ViewportGestureMode } from '@fusion.dev/helix-core';
|
|
|
2
2
|
import { type DrawnLink, type DrawnNode, type GraphChanges } from './diff.js';
|
|
3
3
|
import { type GraphLooks } from './looks.js';
|
|
4
4
|
import { type GraphPoint } from './seed.js';
|
|
5
|
+
import { type GraphStencil } from './stencil.js';
|
|
5
6
|
export type { DrawnLink, DrawnNode, GraphChanges } from './diff.js';
|
|
6
7
|
/**
|
|
7
8
|
* What a renderer has to answer for the frame to drive it, and nothing that is
|
|
@@ -30,12 +31,16 @@ export interface GraphDriver {
|
|
|
30
31
|
*/
|
|
31
32
|
update?(changes: GraphChanges, animate: boolean): void;
|
|
32
33
|
/**
|
|
33
|
-
* Draws something passing from one note to another,
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Draws something passing from one note to another, traveling the way it
|
|
35
|
+
* went and arriving with a pulse, or, where `animate` is false, the link
|
|
36
|
+
* between them lit for a moment. It leaves in `colors.from` and arrives in
|
|
37
|
+
* `colors.to`, which are one color where the signal has a tone. `colors` is
|
|
38
|
+
* undefined while the page's colors cannot be read.
|
|
37
39
|
*/
|
|
38
|
-
signal?(from: string, to: string,
|
|
40
|
+
signal?(from: string, to: string, colors: {
|
|
41
|
+
from: string;
|
|
42
|
+
to: string;
|
|
43
|
+
} | undefined, animate: boolean): void;
|
|
39
44
|
/**
|
|
40
45
|
* How each note and link is drawn from now on, asked one at a time as the
|
|
41
46
|
* renderer draws them: a selection or a theme changed.
|
|
@@ -47,6 +52,11 @@ export interface GraphDriver {
|
|
|
47
52
|
zoomBy(direction: 1 | -1, animate: boolean): void;
|
|
48
53
|
/** Brings a note into the middle of the view, where the renderer can. */
|
|
49
54
|
center?(id: string, animate: boolean): void;
|
|
55
|
+
/**
|
|
56
|
+
* Frames a set of notes, the selected one and its neighbors, in the middle
|
|
57
|
+
* of the view at a zoom that holds them all, where the renderer can.
|
|
58
|
+
*/
|
|
59
|
+
fit?(ids: readonly string[], animate: boolean): void;
|
|
50
60
|
/** Calls `press` with a note's `id` when the reader presses it in the drawing. Returns what stops listening. */
|
|
51
61
|
onPress?(press: (id: string) => void): () => void;
|
|
52
62
|
/**
|
|
@@ -65,9 +75,33 @@ export interface GraphDriver {
|
|
|
65
75
|
gestures?(mode: ViewportGestureMode): void;
|
|
66
76
|
/** The element the renderer made focusable, which the connector takes out of the tab order. */
|
|
67
77
|
focusTarget?(): HTMLElement | null;
|
|
78
|
+
/**
|
|
79
|
+
* The graph's groups, each named over its notes, which is how a reader
|
|
80
|
+
* finds their way while notes are named only on hover. `faded` while a note
|
|
81
|
+
* outside the group is selected.
|
|
82
|
+
*/
|
|
83
|
+
groups?(groups: readonly GraphGroup[]): void;
|
|
84
|
+
/**
|
|
85
|
+
* Where in a shape the notes are drawn, where a `stencil` gives one:
|
|
86
|
+
* `surface`, a shell, whose far side a renderer that draws in three
|
|
87
|
+
* dimensions sets back so its near side reads; `fill`, all through it; or
|
|
88
|
+
* undefined, no shape.
|
|
89
|
+
*/
|
|
90
|
+
shape?(place: 'fill' | 'surface' | undefined): void;
|
|
68
91
|
/** Removes whatever the driver added to the renderer or the page, when the connection ends. */
|
|
69
92
|
stop?(): void;
|
|
70
93
|
}
|
|
94
|
+
/** One group of notes, as the renderer names it over them. */
|
|
95
|
+
export interface GraphGroup {
|
|
96
|
+
/** The group's color, which its name is drawn in. */
|
|
97
|
+
color: string;
|
|
98
|
+
/** Whether a note outside the group is selected, which steps its name back. */
|
|
99
|
+
faded: boolean;
|
|
100
|
+
/** The notes in the group, by `id`. */
|
|
101
|
+
ids: readonly string[];
|
|
102
|
+
/** The group's name, as the notes give it. */
|
|
103
|
+
name: string;
|
|
104
|
+
}
|
|
71
105
|
/** How a connection behaves where the page does not say. */
|
|
72
106
|
export interface ConnectGraphOptions {
|
|
73
107
|
/**
|
|
@@ -76,6 +110,14 @@ export interface ConnectGraphOptions {
|
|
|
76
110
|
* reader is already looking at it.
|
|
77
111
|
*/
|
|
78
112
|
center?: boolean | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* A shape the notes are drawn in, such as a brain or a country's outline:
|
|
115
|
+
* an SVG path and the box it is drawn in. **No layout runs**: each group
|
|
116
|
+
* takes a region of the shape and each note sits by the hub it links to,
|
|
117
|
+
* from the first frame. Only a graph with no places of its own is shaped;
|
|
118
|
+
* a note added later is placed by the layout beside its neighbors.
|
|
119
|
+
*/
|
|
120
|
+
stencil?: GraphStencil | undefined;
|
|
79
121
|
}
|
|
80
122
|
/** A connection, which is ended rather than left to leak. */
|
|
81
123
|
export interface GraphConnection {
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphSignalEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
|
|
2
|
+
import { arrangeByGroup } from './arrange.js';
|
|
2
3
|
import { diffGraph, isEmpty, linkKey, } from './diff.js';
|
|
3
|
-
import { graphColorsFrom, looksOf } from './looks.js';
|
|
4
|
+
import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
|
|
4
5
|
import { seedPositions } from './seed.js';
|
|
6
|
+
import { fieldOf, fitToStencil } from './stencil.js';
|
|
5
7
|
const viewportOf = (frame) => {
|
|
6
8
|
const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
|
|
7
9
|
return { height: box.height, width: box.width };
|
|
8
10
|
};
|
|
9
|
-
const toneColor = (colors, tone) => tone === undefined || tone === 'primary'
|
|
10
|
-
? colors.primary
|
|
11
|
-
: tone === 'neutral'
|
|
12
|
-
? colors.muted
|
|
13
|
-
: colors[tone];
|
|
14
11
|
const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
15
12
|
export const connectGraph = (frame, driver, options = {}) => {
|
|
16
13
|
const graph = frame;
|
|
@@ -19,7 +16,9 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
19
16
|
let drawnLinks = new Map();
|
|
20
17
|
let first = true;
|
|
21
18
|
let waiting = new Set();
|
|
19
|
+
let owed = false;
|
|
22
20
|
let colors = null;
|
|
21
|
+
let current;
|
|
23
22
|
let stopSettling;
|
|
24
23
|
let pressed;
|
|
25
24
|
let pending = 0;
|
|
@@ -43,19 +42,42 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
43
42
|
}
|
|
44
43
|
}
|
|
45
44
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
45
|
+
const notes = new Map(nodes.map((node) => [node.id, node]));
|
|
46
|
+
current = looksOf({
|
|
48
47
|
colors,
|
|
49
48
|
font: getComputedStyle(frame)
|
|
50
49
|
.fontFamily.split(',')[0]
|
|
51
50
|
?.trim()
|
|
52
51
|
.replace(/^["']|["']$/g, ''),
|
|
53
|
-
groupOf: (id) => groups.get(id),
|
|
54
52
|
groups: nodes.flatMap((node) => (node.group === undefined ? [] : [node.group])),
|
|
55
53
|
near,
|
|
54
|
+
noteOf: (id) => notes.get(id),
|
|
56
55
|
selected,
|
|
57
56
|
shown: graph.shown,
|
|
58
|
-
})
|
|
57
|
+
});
|
|
58
|
+
driver.look(current);
|
|
59
|
+
if (driver.groups) {
|
|
60
|
+
const members = new Map();
|
|
61
|
+
for (const node of nodes) {
|
|
62
|
+
if (node.group !== undefined) {
|
|
63
|
+
const list = members.get(node.group);
|
|
64
|
+
if (list) {
|
|
65
|
+
list.push(node.id);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
members.set(node.group, [node.id]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const own = selected === undefined ? undefined : notes.get(selected)?.group;
|
|
73
|
+
const looks = current;
|
|
74
|
+
driver.groups([...members].map(([name, ids]) => ({
|
|
75
|
+
color: looks.colorOf(ids.find((id) => notes.get(id)?.tone === undefined) ?? ids[0] ?? ''),
|
|
76
|
+
faded: selected !== undefined && own !== name,
|
|
77
|
+
ids,
|
|
78
|
+
name,
|
|
79
|
+
})));
|
|
80
|
+
}
|
|
59
81
|
};
|
|
60
82
|
const draw = (whole = false) => {
|
|
61
83
|
if (stopSettling) {
|
|
@@ -71,16 +93,35 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
71
93
|
nextLinks.set(linkKey({ source, target }), { label, source, target });
|
|
72
94
|
}
|
|
73
95
|
}
|
|
74
|
-
|
|
96
|
+
let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
|
|
97
|
+
const shaped = options.stencil && ids.size > 0 && unplaced.size === ids.size
|
|
98
|
+
? fitToStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
|
|
99
|
+
solid: fieldOf(options.stencil).depth > 0,
|
|
100
|
+
}), options.stencil)
|
|
101
|
+
: undefined;
|
|
102
|
+
driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined);
|
|
103
|
+
if (shaped) {
|
|
104
|
+
positions = shaped;
|
|
105
|
+
unplaced = new Set();
|
|
106
|
+
known = new Map(shaped);
|
|
107
|
+
}
|
|
108
|
+
const degree = new Map();
|
|
109
|
+
for (const { source, target } of nextLinks.values()) {
|
|
110
|
+
degree.set(source, (degree.get(source) ?? 0) + 1);
|
|
111
|
+
degree.set(target, (degree.get(target) ?? 0) + 1);
|
|
112
|
+
}
|
|
75
113
|
const nextNodes = new Map();
|
|
76
114
|
for (const node of nodes) {
|
|
77
115
|
const at = positions.get(node.id);
|
|
78
116
|
nextNodes.set(node.id, {
|
|
79
117
|
id: node.id,
|
|
80
118
|
label: node.label || node.id,
|
|
81
|
-
size: Number.isFinite(node.size)
|
|
119
|
+
size: Number.isFinite(node.size)
|
|
120
|
+
? node.size
|
|
121
|
+
: 1 + Math.min(Math.sqrt(degree.get(node.id) ?? 0), 6),
|
|
82
122
|
x: at.x,
|
|
83
123
|
y: at.y,
|
|
124
|
+
...(at.z === undefined ? {} : { z: at.z }),
|
|
84
125
|
});
|
|
85
126
|
}
|
|
86
127
|
for (const id of waiting) {
|
|
@@ -103,7 +144,9 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
103
144
|
drawnLinks = nextLinks;
|
|
104
145
|
known = positions;
|
|
105
146
|
restyle();
|
|
106
|
-
|
|
147
|
+
if (first || whole) {
|
|
148
|
+
owed = true;
|
|
149
|
+
}
|
|
107
150
|
first = false;
|
|
108
151
|
if (unplaced.size > 0 && driver.settle) {
|
|
109
152
|
waiting = unplaced;
|
|
@@ -114,20 +157,25 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
114
157
|
for (const [id, at] of settled) {
|
|
115
158
|
const node = drawnNodes.get(id);
|
|
116
159
|
if (node) {
|
|
117
|
-
drawnNodes.set(id, { ...node, x: at.x, y: at.y });
|
|
160
|
+
drawnNodes.set(id, { ...node, x: at.x, y: at.y, z: at.z });
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
|
|
121
|
-
if (
|
|
164
|
+
if (owed) {
|
|
165
|
+
owed = false;
|
|
122
166
|
graph.drawn?.(true);
|
|
123
167
|
}
|
|
124
168
|
});
|
|
125
169
|
return;
|
|
126
170
|
}
|
|
127
171
|
waiting = new Set();
|
|
128
|
-
if (
|
|
172
|
+
if (shaped) {
|
|
173
|
+
graph.laidOut?.(Object.fromEntries([...shaped].map(([id, at]) => [id, { ...at }])));
|
|
174
|
+
}
|
|
175
|
+
if (owed) {
|
|
129
176
|
pending = requestAnimationFrame(() => {
|
|
130
|
-
if (connected) {
|
|
177
|
+
if (connected && owed) {
|
|
178
|
+
owed = false;
|
|
131
179
|
graph.drawn?.(true);
|
|
132
180
|
}
|
|
133
181
|
});
|
|
@@ -139,7 +187,12 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
139
187
|
return;
|
|
140
188
|
}
|
|
141
189
|
colors ??= graphColorsFrom(frame);
|
|
142
|
-
|
|
190
|
+
const toned = tone !== undefined && colors ? toneColor(colors, tone) : undefined;
|
|
191
|
+
driver.signal(from, to, toned
|
|
192
|
+
? { from: toned, to: toned }
|
|
193
|
+
: current
|
|
194
|
+
? { from: current.colorOf(from), to: current.colorOf(to) }
|
|
195
|
+
: undefined, !still());
|
|
143
196
|
};
|
|
144
197
|
const handlePartChange = (event) => {
|
|
145
198
|
const { kind } = event.detail;
|
|
@@ -150,7 +203,21 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
150
203
|
restyle();
|
|
151
204
|
const selected = graph.active;
|
|
152
205
|
if (selected && selected !== pressed && options.center !== false) {
|
|
153
|
-
|
|
206
|
+
const near = [selected];
|
|
207
|
+
for (const { source, target } of drawnLinks.values()) {
|
|
208
|
+
if (source === selected) {
|
|
209
|
+
near.push(target);
|
|
210
|
+
}
|
|
211
|
+
else if (target === selected) {
|
|
212
|
+
near.push(source);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (driver.fit && near.length > 1) {
|
|
216
|
+
driver.fit(near, !still());
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
driver.center?.(selected, !still());
|
|
220
|
+
}
|
|
154
221
|
}
|
|
155
222
|
pressed = undefined;
|
|
156
223
|
}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
export interface DrawnNode {
|
|
3
3
|
id: string;
|
|
4
4
|
label: string;
|
|
5
|
-
/** How large it is drawn, relative to the others: the note's `size`, or
|
|
5
|
+
/** How large it is drawn, relative to the others: the note's `size`, or from how many links it has. */
|
|
6
6
|
size: number;
|
|
7
7
|
x: number;
|
|
8
8
|
y: number;
|
|
9
|
+
/** How near the viewer, for a renderer that draws in three dimensions. */
|
|
10
|
+
z?: number | undefined;
|
|
9
11
|
}
|
|
10
12
|
/** A link as the renderer draws it, between two notes the graph holds. */
|
|
11
13
|
export interface DrawnLink {
|