@fusion.dev/helix-graph 0.119.0 → 0.120.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 +33 -1
- package/dist/connect-graph/connect-graph.d.ts +23 -17
- package/dist/connect-graph/connect-graph.js +86 -28
- package/dist/connect-graph/diff.d.ts +50 -0
- package/dist/connect-graph/diff.js +48 -0
- package/dist/connect-graph/looks.d.ts +10 -0
- package/dist/connect-graph/looks.js +5 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/sigma/sigma-driver.d.ts +13 -0
- package/dist/sigma/sigma-driver.js +172 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -55,12 +55,44 @@ connection.disconnect()
|
|
|
55
55
|
changes.
|
|
56
56
|
- Answers the frame's pan and zoom buttons and keys, and tells the renderer which
|
|
57
57
|
gestures the frame's `gestures` lets it take.
|
|
58
|
+
- Draws only what changed when the page hands the frame new notes or links, and
|
|
59
|
+
draws the frame's `signal()`s.
|
|
60
|
+
|
|
61
|
+
## Live graphs
|
|
62
|
+
|
|
63
|
+
**Hand the frame new arrays as often as the graph changes.** The connection
|
|
64
|
+
compares them with what is drawn, by each note's `id` and each link's two ends,
|
|
65
|
+
and the renderer is told only what was added, taken away, renamed or moved. New
|
|
66
|
+
notes grow in and are laid out with the rest held still, and the frame stays
|
|
67
|
+
`:state(ready)` throughout.
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
socket.addEventListener('message', ({ data }) => {
|
|
71
|
+
const note = JSON.parse(data)
|
|
72
|
+
frame.nodes = [...frame.nodes, note]
|
|
73
|
+
frame.links = [...frame.links, { source: note.id, target: note.parent }]
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**`frame.signal(from, to, { tone })` shows something passing between two
|
|
78
|
+
notes**: a message, a request, a payment. It is drawn traveling from one to the
|
|
79
|
+
other and arriving with a pulse, in the tone's color, over the graph and without
|
|
80
|
+
redrawing it, so a busy feed costs a dot each. A reader who asks for less motion
|
|
81
|
+
sees the link lit for a moment instead.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
frame.signal('Checkout', 'Payments', { tone: 'success' })
|
|
85
|
+
frame.signal('Payments', 'Fraud review', { tone: 'danger' })
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A signal is drawn and never said: a screen reader does not hear it. Where what
|
|
89
|
+
passed matters to a reader, say it in the page's own words as well.
|
|
58
90
|
|
|
59
91
|
## What it exports
|
|
60
92
|
|
|
61
93
|
| Import | What |
|
|
62
94
|
|---|---|
|
|
63
|
-
| `@fusion.dev/helix-graph` | `connectGraph`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`
|
|
95
|
+
| `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver` and `GraphChanges` types |
|
|
64
96
|
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
65
97
|
|
|
66
98
|
**Neither Sigma nor graphology is a dependency.** The driver types the renderer
|
|
@@ -1,21 +1,8 @@
|
|
|
1
1
|
import { type ViewportGestureMode } from '@fusion.dev/helix-core';
|
|
2
|
+
import { type DrawnLink, type DrawnNode, type GraphChanges } from './diff.js';
|
|
2
3
|
import { type GraphLooks } from './looks.js';
|
|
3
4
|
import { type GraphPoint } from './seed.js';
|
|
4
|
-
|
|
5
|
-
export interface DrawnNode {
|
|
6
|
-
id: string;
|
|
7
|
-
label: string;
|
|
8
|
-
/** How large it is drawn, relative to the others: the note's `size`, or 1. */
|
|
9
|
-
size: number;
|
|
10
|
-
x: number;
|
|
11
|
-
y: number;
|
|
12
|
-
}
|
|
13
|
-
/** A link as the renderer draws it, between two notes the graph holds. */
|
|
14
|
-
export interface DrawnLink {
|
|
15
|
-
label?: string | undefined;
|
|
16
|
-
source: string;
|
|
17
|
-
target: string;
|
|
18
|
-
}
|
|
5
|
+
export type { DrawnLink, DrawnNode, GraphChanges } from './diff.js';
|
|
19
6
|
/**
|
|
20
7
|
* What a renderer has to answer for the frame to drive it, and nothing that is
|
|
21
8
|
* the same for every renderer: that is the connector's.
|
|
@@ -34,6 +21,21 @@ export interface GraphDriver {
|
|
|
34
21
|
links: DrawnLink[];
|
|
35
22
|
nodes: DrawnNode[];
|
|
36
23
|
}): void;
|
|
24
|
+
/**
|
|
25
|
+
* Applies what changed since the last drawing, in place: what to add, what
|
|
26
|
+
* to take away and which notes moved or were renamed. `animate` says
|
|
27
|
+
* whether the reader may watch it happen. Without one, every change is a
|
|
28
|
+
* whole `draw`, which is right for a graph that changes now and then and
|
|
29
|
+
* wasteful for one that changes on every message.
|
|
30
|
+
*/
|
|
31
|
+
update?(changes: GraphChanges, animate: boolean): void;
|
|
32
|
+
/**
|
|
33
|
+
* Draws something passing from one note to another, in `color`, traveling
|
|
34
|
+
* the way it went and arriving with a pulse, or, where `animate` is false,
|
|
35
|
+
* the link between them lit for a moment. `color` is undefined while the
|
|
36
|
+
* page's colors cannot be read.
|
|
37
|
+
*/
|
|
38
|
+
signal?(from: string, to: string, color: string | undefined, animate: boolean): void;
|
|
37
39
|
/**
|
|
38
40
|
* How each note and link is drawn from now on, asked one at a time as the
|
|
39
41
|
* renderer draws them: a selection or a theme changed.
|
|
@@ -96,7 +98,11 @@ export interface GraphConnection {
|
|
|
96
98
|
* connection draws the frame's `nodes` and `links`, lays out the notes that
|
|
97
99
|
* have no position and says where it put them in `helix-graph-layout`, answers
|
|
98
100
|
* the frame's pan and zoom, keeps the selection the same in the list and the
|
|
99
|
-
* drawing, colors the graph from the page's tokens,
|
|
100
|
-
*
|
|
101
|
+
* drawing, colors the graph from the page's tokens, tells the renderer which
|
|
102
|
+
* gestures it may take, and draws the frame's `signal()`s.
|
|
103
|
+
*
|
|
104
|
+
* **A page changes the graph by handing the frame new arrays**, as often as
|
|
105
|
+
* it likes: the connection compares them with what is drawn and the renderer
|
|
106
|
+
* is told only what changed.
|
|
101
107
|
*/
|
|
102
108
|
export declare const connectGraph: (frame: HTMLElement, driver: GraphDriver, options?: ConnectGraphOptions) => GraphConnection;
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
|
|
1
|
+
import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphSignalEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
|
|
2
|
+
import { diffGraph, isEmpty, linkKey, } from './diff.js';
|
|
2
3
|
import { graphColorsFrom, looksOf } from './looks.js';
|
|
3
4
|
import { seedPositions } from './seed.js';
|
|
4
5
|
const viewportOf = (frame) => {
|
|
5
6
|
const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
|
|
6
7
|
return { height: box.height, width: box.width };
|
|
7
8
|
};
|
|
9
|
+
const toneColor = (colors, tone) => tone === undefined || tone === 'primary'
|
|
10
|
+
? colors.primary
|
|
11
|
+
: tone === 'neutral'
|
|
12
|
+
? colors.muted
|
|
13
|
+
: colors[tone];
|
|
8
14
|
const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
9
15
|
export const connectGraph = (frame, driver, options = {}) => {
|
|
10
16
|
const graph = frame;
|
|
11
17
|
let known = new Map();
|
|
18
|
+
let drawnNodes = new Map();
|
|
19
|
+
let drawnLinks = new Map();
|
|
20
|
+
let first = true;
|
|
21
|
+
let waiting = new Set();
|
|
12
22
|
let colors = null;
|
|
13
23
|
let stopSettling;
|
|
14
24
|
let pressed;
|
|
@@ -47,44 +57,89 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
47
57
|
shown: graph.shown,
|
|
48
58
|
}));
|
|
49
59
|
};
|
|
50
|
-
const draw = () => {
|
|
51
|
-
stopSettling
|
|
52
|
-
|
|
60
|
+
const draw = (whole = false) => {
|
|
61
|
+
if (stopSettling) {
|
|
62
|
+
stopSettling();
|
|
63
|
+
stopSettling = undefined;
|
|
64
|
+
}
|
|
53
65
|
cancelAnimationFrame(pending);
|
|
54
66
|
const nodes = graph.nodes ?? [];
|
|
55
67
|
const ids = new Set(nodes.map((node) => node.id));
|
|
56
|
-
const
|
|
57
|
-
const {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const nextLinks = new Map();
|
|
69
|
+
for (const { label, source, target } of graph.links ?? []) {
|
|
70
|
+
if (source !== target && ids.has(source) && ids.has(target)) {
|
|
71
|
+
nextLinks.set(linkKey({ source, target }), { label, source, target });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
|
|
75
|
+
const nextNodes = new Map();
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
const at = positions.get(node.id);
|
|
78
|
+
nextNodes.set(node.id, {
|
|
79
|
+
id: node.id,
|
|
80
|
+
label: node.label || node.id,
|
|
81
|
+
size: Number.isFinite(node.size) ? node.size : 1,
|
|
82
|
+
x: at.x,
|
|
83
|
+
y: at.y,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
for (const id of waiting) {
|
|
87
|
+
if (ids.has(id)) {
|
|
88
|
+
unplaced.add(id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const incremental = !first && !whole && driver.update !== undefined;
|
|
92
|
+
if (incremental) {
|
|
93
|
+
const changes = diffGraph({ links: drawnLinks, nodes: drawnNodes }, { links: nextLinks, nodes: nextNodes });
|
|
94
|
+
if (!isEmpty(changes)) {
|
|
95
|
+
driver.update?.(changes, !still());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
graph.drawn?.(false);
|
|
100
|
+
driver.draw({ links: [...nextLinks.values()], nodes: [...nextNodes.values()] });
|
|
101
|
+
}
|
|
102
|
+
drawnNodes = nextNodes;
|
|
103
|
+
drawnLinks = nextLinks;
|
|
104
|
+
known = positions;
|
|
72
105
|
restyle();
|
|
106
|
+
const opening = first || whole;
|
|
107
|
+
first = false;
|
|
73
108
|
if (unplaced.size > 0 && driver.settle) {
|
|
109
|
+
waiting = unplaced;
|
|
74
110
|
stopSettling = driver.settle(unplaced, (settled) => {
|
|
75
111
|
stopSettling = undefined;
|
|
112
|
+
waiting = new Set();
|
|
76
113
|
known = new Map(settled);
|
|
114
|
+
for (const [id, at] of settled) {
|
|
115
|
+
const node = drawnNodes.get(id);
|
|
116
|
+
if (node) {
|
|
117
|
+
drawnNodes.set(id, { ...node, x: at.x, y: at.y });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
77
120
|
graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
|
|
78
|
-
|
|
121
|
+
if (opening) {
|
|
122
|
+
graph.drawn?.(true);
|
|
123
|
+
}
|
|
79
124
|
});
|
|
80
125
|
return;
|
|
81
126
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
127
|
+
waiting = new Set();
|
|
128
|
+
if (opening) {
|
|
129
|
+
pending = requestAnimationFrame(() => {
|
|
130
|
+
if (connected) {
|
|
131
|
+
graph.drawn?.(true);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const handleSignal = (event) => {
|
|
137
|
+
const { from, to, tone } = event.detail;
|
|
138
|
+
if (!driver.signal || !drawnNodes.has(from) || !drawnNodes.has(to)) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
colors ??= graphColorsFrom(frame);
|
|
142
|
+
driver.signal(from, to, colors ? toneColor(colors, tone) : undefined, !still());
|
|
88
143
|
};
|
|
89
144
|
const handlePartChange = (event) => {
|
|
90
145
|
const { kind } = event.detail;
|
|
@@ -117,6 +172,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
117
172
|
}
|
|
118
173
|
};
|
|
119
174
|
frame.addEventListener(HelixGraphPartChangeEvent.eventName, handlePartChange);
|
|
175
|
+
frame.addEventListener(HelixGraphSignalEvent.eventName, handleSignal);
|
|
120
176
|
frame.addEventListener(HelixGraphPanEvent.eventName, handlePan);
|
|
121
177
|
frame.addEventListener(HelixGraphZoomEvent.eventName, handleZoom);
|
|
122
178
|
const stopPress = driver.onPress?.((id) => {
|
|
@@ -147,6 +203,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
147
203
|
stopPress?.();
|
|
148
204
|
stopTheme();
|
|
149
205
|
frame.removeEventListener(HelixGraphPartChangeEvent.eventName, handlePartChange);
|
|
206
|
+
frame.removeEventListener(HelixGraphSignalEvent.eventName, handleSignal);
|
|
150
207
|
frame.removeEventListener(HelixGraphPanEvent.eventName, handlePan);
|
|
151
208
|
frame.removeEventListener(HelixGraphZoomEvent.eventName, handleZoom);
|
|
152
209
|
if (focusable) {
|
|
@@ -163,7 +220,8 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
163
220
|
},
|
|
164
221
|
relayout: () => {
|
|
165
222
|
known = new Map();
|
|
166
|
-
|
|
223
|
+
waiting = new Set();
|
|
224
|
+
draw(true);
|
|
167
225
|
},
|
|
168
226
|
restyle: () => {
|
|
169
227
|
colors = null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** A note as the renderer draws it, placed. */
|
|
2
|
+
export interface DrawnNode {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** How large it is drawn, relative to the others: the note's `size`, or 1. */
|
|
6
|
+
size: number;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}
|
|
10
|
+
/** A link as the renderer draws it, between two notes the graph holds. */
|
|
11
|
+
export interface DrawnLink {
|
|
12
|
+
label?: string | undefined;
|
|
13
|
+
source: string;
|
|
14
|
+
target: string;
|
|
15
|
+
}
|
|
16
|
+
/** What changed between two drawings of a graph, which a renderer applies in place. */
|
|
17
|
+
export interface GraphChanges {
|
|
18
|
+
/** Links to draw that were not drawn. */
|
|
19
|
+
addLinks: DrawnLink[];
|
|
20
|
+
/** Notes to draw that were not drawn, placed. */
|
|
21
|
+
addNodes: DrawnNode[];
|
|
22
|
+
/** Notes still drawn whose name, size or place changed. */
|
|
23
|
+
changeNodes: DrawnNode[];
|
|
24
|
+
/** Links no longer drawn. */
|
|
25
|
+
removeLinks: DrawnLink[];
|
|
26
|
+
/** Notes no longer drawn, by `id`. */
|
|
27
|
+
removeNodes: string[];
|
|
28
|
+
}
|
|
29
|
+
/** A link's key: its two ends, which no `id` can contain both of by accident. */
|
|
30
|
+
export declare const linkKey: (link: {
|
|
31
|
+
source: string;
|
|
32
|
+
target: string;
|
|
33
|
+
}) => string;
|
|
34
|
+
/** Whether a diff changed nothing. */
|
|
35
|
+
export declare const isEmpty: (changes: GraphChanges) => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* What changed from one drawing to the next, matched by `id` and by a link's
|
|
38
|
+
* two ends.
|
|
39
|
+
*
|
|
40
|
+
* **Linear in the size of the graph and nothing more**, so a page can hand the
|
|
41
|
+
* frame a new array for every message on a busy feed and the renderer is told
|
|
42
|
+
* only what moved: ten thousand notes with one added is one note drawn.
|
|
43
|
+
*/
|
|
44
|
+
export declare const diffGraph: (before: {
|
|
45
|
+
links: ReadonlyMap<string, DrawnLink>;
|
|
46
|
+
nodes: ReadonlyMap<string, DrawnNode>;
|
|
47
|
+
}, after: {
|
|
48
|
+
links: ReadonlyMap<string, DrawnLink>;
|
|
49
|
+
nodes: ReadonlyMap<string, DrawnNode>;
|
|
50
|
+
}) => GraphChanges;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const linkKey = (link) => `${link.source}\u0000${link.target}`;
|
|
2
|
+
export const isEmpty = (changes) => changes.addLinks.length === 0 &&
|
|
3
|
+
changes.addNodes.length === 0 &&
|
|
4
|
+
changes.changeNodes.length === 0 &&
|
|
5
|
+
changes.removeLinks.length === 0 &&
|
|
6
|
+
changes.removeNodes.length === 0;
|
|
7
|
+
export const diffGraph = (before, after) => {
|
|
8
|
+
const changes = {
|
|
9
|
+
addLinks: [],
|
|
10
|
+
addNodes: [],
|
|
11
|
+
changeNodes: [],
|
|
12
|
+
removeLinks: [],
|
|
13
|
+
removeNodes: [],
|
|
14
|
+
};
|
|
15
|
+
for (const [id, node] of after.nodes) {
|
|
16
|
+
const was = before.nodes.get(id);
|
|
17
|
+
if (!was) {
|
|
18
|
+
changes.addNodes.push(node);
|
|
19
|
+
}
|
|
20
|
+
else if (was.label !== node.label ||
|
|
21
|
+
was.size !== node.size ||
|
|
22
|
+
was.x !== node.x ||
|
|
23
|
+
was.y !== node.y) {
|
|
24
|
+
changes.changeNodes.push(node);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const id of before.nodes.keys()) {
|
|
28
|
+
if (!after.nodes.has(id)) {
|
|
29
|
+
changes.removeNodes.push(id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const [key, link] of after.links) {
|
|
33
|
+
const was = before.links.get(key);
|
|
34
|
+
if (!was) {
|
|
35
|
+
changes.addLinks.push(link);
|
|
36
|
+
}
|
|
37
|
+
else if (was.label !== link.label) {
|
|
38
|
+
changes.removeLinks.push(was);
|
|
39
|
+
changes.addLinks.push(link);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const [key, link] of before.links) {
|
|
43
|
+
if (!after.links.has(key)) {
|
|
44
|
+
changes.removeLinks.push(link);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return changes;
|
|
48
|
+
};
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* where the frame is.
|
|
4
4
|
*/
|
|
5
5
|
export interface GraphColors {
|
|
6
|
+
/** A signal meaning something to look at: `accent`. */
|
|
7
|
+
accent: string;
|
|
8
|
+
/** A signal meaning something went wrong: `danger`. */
|
|
9
|
+
danger: string;
|
|
6
10
|
/** What the drawing is laid on, which faded notes are mixed toward: `surface-sunken`. */
|
|
7
11
|
ground: string;
|
|
8
12
|
/** The links: `border`. */
|
|
@@ -17,6 +21,12 @@ export interface GraphColors {
|
|
|
17
21
|
primary: string;
|
|
18
22
|
/** One color for each group, in the order of the groups' names: `series-1` and on. */
|
|
19
23
|
series: string[];
|
|
24
|
+
/** A signal meaning something done: `success`. */
|
|
25
|
+
success: string;
|
|
26
|
+
/** A signal meaning something to know: `info`. */
|
|
27
|
+
info: string;
|
|
28
|
+
/** A signal meaning something to watch: `warning`. */
|
|
29
|
+
warning: string;
|
|
20
30
|
}
|
|
21
31
|
/**
|
|
22
32
|
* The page's colors where the frame is, or `null` while they cannot be read.
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { readColor } from '@fusion.dev/helix-core';
|
|
2
2
|
const SERIES = 24;
|
|
3
3
|
const TOKENS = {
|
|
4
|
+
accent: '--helix-color-accent',
|
|
5
|
+
danger: '--helix-color-danger',
|
|
4
6
|
ground: '--helix-color-surface-sunken',
|
|
7
|
+
info: '--helix-color-info',
|
|
5
8
|
ink: '--helix-color-foreground',
|
|
6
9
|
line: '--helix-color-border',
|
|
7
10
|
muted: '--helix-color-foreground-muted',
|
|
8
11
|
overlay: '--helix-color-surface-overlay',
|
|
9
12
|
primary: '--helix-color-primary',
|
|
13
|
+
success: '--helix-color-success',
|
|
14
|
+
warning: '--helix-color-warning',
|
|
10
15
|
};
|
|
11
16
|
export const graphColorsFrom = (element) => {
|
|
12
17
|
if (typeof document === 'undefined') {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphConnection, type GraphDriver, } from './connect-graph/connect-graph.js';
|
|
1
|
+
export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphChanges, type GraphConnection, type GraphDriver, } from './connect-graph/connect-graph.js';
|
|
2
|
+
export { diffGraph } from './connect-graph/diff.js';
|
|
2
3
|
export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
|
|
3
4
|
export { type GraphPoint, seedPositions } from './connect-graph/seed.js';
|
package/dist/index.js
CHANGED
|
@@ -4,10 +4,13 @@ export interface SigmaGraph {
|
|
|
4
4
|
addEdge(source: string, target: string, attributes?: Record<string, unknown>): unknown;
|
|
5
5
|
addNode(node: string, attributes?: Record<string, unknown>): unknown;
|
|
6
6
|
clear(): void;
|
|
7
|
+
dropEdge(source: string, target: string): void;
|
|
8
|
+
dropNode(node: string): void;
|
|
7
9
|
forEachNode(callback: (node: string, attributes: Record<string, unknown>) => void): void;
|
|
8
10
|
getNodeAttributes(node: string): Record<string, unknown>;
|
|
9
11
|
hasEdge(source: string, target: string): boolean;
|
|
10
12
|
hasNode(node: string): boolean;
|
|
13
|
+
mergeNodeAttributes(node: string, attributes: Record<string, unknown>): unknown;
|
|
11
14
|
readonly order: number;
|
|
12
15
|
setNodeAttribute(node: string, name: string, value: unknown): unknown;
|
|
13
16
|
}
|
|
@@ -41,7 +44,16 @@ export interface SigmaRenderer {
|
|
|
41
44
|
width: number;
|
|
42
45
|
};
|
|
43
46
|
getGraph(): SigmaGraph;
|
|
47
|
+
framedGraphToViewport(point: {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
}): {
|
|
51
|
+
x: number;
|
|
52
|
+
y: number;
|
|
53
|
+
};
|
|
44
54
|
getNodeDisplayData(node: string): {
|
|
55
|
+
hidden?: boolean;
|
|
56
|
+
size: number;
|
|
45
57
|
x: number;
|
|
46
58
|
y: number;
|
|
47
59
|
} | undefined;
|
|
@@ -53,6 +65,7 @@ export interface SigmaRenderer {
|
|
|
53
65
|
}) => void): unknown;
|
|
54
66
|
refresh(): unknown;
|
|
55
67
|
resize(force?: boolean): unknown;
|
|
68
|
+
scaleSize(size?: number): number;
|
|
56
69
|
setSetting(name: 'defaultDrawNodeHover' | 'edgeReducer' | 'labelColor' | 'labelFont' | 'labelRenderedSizeThreshold' | 'nodeReducer', value: unknown): unknown;
|
|
57
70
|
viewportToFramedGraph(point: {
|
|
58
71
|
x: number;
|
|
@@ -25,6 +25,11 @@ const hoverOf = (looks) => (context, data, settings) => {
|
|
|
25
25
|
context.fillStyle = looks.ink;
|
|
26
26
|
context.fillText(data.label, x, data.y + size / 3);
|
|
27
27
|
};
|
|
28
|
+
const TRAVEL = 700;
|
|
29
|
+
const PULSE = 450;
|
|
30
|
+
const GROW = 300;
|
|
31
|
+
const FLYING = 400;
|
|
32
|
+
const ease = (at) => (at < 0.5 ? 2 * at * at : 1 - (-2 * at + 2) ** 2 / 2);
|
|
28
33
|
const positionsOf = (graph) => {
|
|
29
34
|
const positions = new Map();
|
|
30
35
|
graph.forEachNode((node, attributes) => {
|
|
@@ -72,6 +77,103 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
72
77
|
})
|
|
73
78
|
: undefined;
|
|
74
79
|
sized?.observe(container);
|
|
80
|
+
let overlay;
|
|
81
|
+
let flights = [];
|
|
82
|
+
let flying = 0;
|
|
83
|
+
let ink = 'rgb(128, 128, 128)';
|
|
84
|
+
const onScreen = (id) => {
|
|
85
|
+
const data = sigma.getNodeDisplayData(id);
|
|
86
|
+
if (!data || data.hidden) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const at = sigma.framedGraphToViewport(data);
|
|
90
|
+
return { radius: sigma.scaleSize(data.size), x: at.x, y: at.y };
|
|
91
|
+
};
|
|
92
|
+
const fly = (now) => {
|
|
93
|
+
const context = overlay?.getContext('2d');
|
|
94
|
+
if (!overlay || !context) {
|
|
95
|
+
flights = [];
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const ratio = window.devicePixelRatio || 1;
|
|
99
|
+
const width = Math.round(container.clientWidth * ratio);
|
|
100
|
+
const height = Math.round(container.clientHeight * ratio);
|
|
101
|
+
if (overlay.width !== width || overlay.height !== height) {
|
|
102
|
+
overlay.width = width;
|
|
103
|
+
overlay.height = height;
|
|
104
|
+
}
|
|
105
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
106
|
+
context.clearRect(0, 0, width, height);
|
|
107
|
+
flights = flights.filter((flight) => {
|
|
108
|
+
const from = onScreen(flight.from);
|
|
109
|
+
const to = onScreen(flight.to);
|
|
110
|
+
const gone = (now - flight.started) / TRAVEL;
|
|
111
|
+
if (!from || !to || gone >= 1 + PULSE / TRAVEL) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
context.save();
|
|
115
|
+
context.strokeStyle = flight.color;
|
|
116
|
+
context.fillStyle = flight.color;
|
|
117
|
+
if (gone < 1) {
|
|
118
|
+
if (flight.animate) {
|
|
119
|
+
const along = ease(gone);
|
|
120
|
+
const x = from.x + (to.x - from.x) * along;
|
|
121
|
+
const y = from.y + (to.y - from.y) * along;
|
|
122
|
+
context.globalAlpha = 0.6;
|
|
123
|
+
context.lineWidth = 2;
|
|
124
|
+
context.beginPath();
|
|
125
|
+
context.moveTo(from.x, from.y);
|
|
126
|
+
context.lineTo(x, y);
|
|
127
|
+
context.stroke();
|
|
128
|
+
context.globalAlpha = 1;
|
|
129
|
+
context.shadowBlur = 14;
|
|
130
|
+
context.shadowColor = flight.color;
|
|
131
|
+
context.beginPath();
|
|
132
|
+
context.arc(x, y, 5, 0, Math.PI * 2);
|
|
133
|
+
context.fill();
|
|
134
|
+
context.shadowBlur = 0;
|
|
135
|
+
context.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
|
136
|
+
context.beginPath();
|
|
137
|
+
context.arc(x, y, 2, 0, Math.PI * 2);
|
|
138
|
+
context.fill();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
context.globalAlpha = 1 - gone;
|
|
142
|
+
context.lineWidth = 2;
|
|
143
|
+
context.beginPath();
|
|
144
|
+
context.moveTo(from.x, from.y);
|
|
145
|
+
context.lineTo(to.x, to.y);
|
|
146
|
+
context.stroke();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (flight.animate) {
|
|
150
|
+
const opened = (gone - 1) / (PULSE / TRAVEL);
|
|
151
|
+
context.globalAlpha = 1 - opened;
|
|
152
|
+
context.lineWidth = 2.5;
|
|
153
|
+
context.beginPath();
|
|
154
|
+
context.arc(to.x, to.y, to.radius + 2 + opened * 16, 0, Math.PI * 2);
|
|
155
|
+
context.stroke();
|
|
156
|
+
}
|
|
157
|
+
context.restore();
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
flying = flights.length > 0 ? requestAnimationFrame(fly) : 0;
|
|
161
|
+
};
|
|
162
|
+
const grow = (sizes) => {
|
|
163
|
+
const started = performance.now();
|
|
164
|
+
const step = (now) => {
|
|
165
|
+
const along = Math.min((now - started) / GROW, 1);
|
|
166
|
+
for (const [id, size] of sizes) {
|
|
167
|
+
if (graph.hasNode(id)) {
|
|
168
|
+
graph.setNodeAttribute(id, 'size', size * ease(along));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (along < 1) {
|
|
172
|
+
requestAnimationFrame(step);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
requestAnimationFrame(step);
|
|
176
|
+
};
|
|
75
177
|
return {
|
|
76
178
|
center: (id, animate) => {
|
|
77
179
|
const at = sigma.getNodeDisplayData(id);
|
|
@@ -112,6 +214,7 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
112
214
|
next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
|
|
113
215
|
},
|
|
114
216
|
look: (looks) => {
|
|
217
|
+
ink = looks.ink;
|
|
115
218
|
sigma.setSetting('labelColor', { color: looks.ink });
|
|
116
219
|
sigma.setSetting('labelFont', looks.font);
|
|
117
220
|
sigma.setSetting('labelRenderedSizeThreshold', 0);
|
|
@@ -158,13 +261,82 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
158
261
|
camera.setState(to);
|
|
159
262
|
}
|
|
160
263
|
},
|
|
264
|
+
signal: (from, to, color, animate) => {
|
|
265
|
+
if (!overlay) {
|
|
266
|
+
overlay = document.createElement('canvas');
|
|
267
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
268
|
+
overlay.style.cssText =
|
|
269
|
+
'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
|
|
270
|
+
container.append(overlay);
|
|
271
|
+
}
|
|
272
|
+
flights.push({ animate, color: color ?? ink, from, started: performance.now(), to });
|
|
273
|
+
if (flights.length > FLYING) {
|
|
274
|
+
flights = flights.slice(-FLYING);
|
|
275
|
+
}
|
|
276
|
+
if (!flying) {
|
|
277
|
+
flying = requestAnimationFrame(fly);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
161
280
|
stop: () => {
|
|
281
|
+
cancelAnimationFrame(flying);
|
|
282
|
+
flying = 0;
|
|
283
|
+
flights = [];
|
|
284
|
+
overlay?.remove();
|
|
285
|
+
overlay = undefined;
|
|
162
286
|
sized?.disconnect();
|
|
163
287
|
for (const type of held) {
|
|
164
288
|
container.removeEventListener(type, hold, { capture: true });
|
|
165
289
|
}
|
|
166
290
|
container.style.touchAction = touchAction;
|
|
167
291
|
},
|
|
292
|
+
update: (changes, animate) => {
|
|
293
|
+
for (const id of changes.removeNodes) {
|
|
294
|
+
if (graph.hasNode(id)) {
|
|
295
|
+
graph.dropNode(id);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const link of changes.removeLinks) {
|
|
299
|
+
if (graph.hasEdge(link.source, link.target)) {
|
|
300
|
+
graph.dropEdge(link.source, link.target);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const growing = new Map();
|
|
304
|
+
for (const node of changes.addNodes) {
|
|
305
|
+
const size = pixels(node.size);
|
|
306
|
+
graph.addNode(node.id, {
|
|
307
|
+
label: node.label,
|
|
308
|
+
size: animate ? 0 : size,
|
|
309
|
+
x: node.x,
|
|
310
|
+
y: node.y,
|
|
311
|
+
});
|
|
312
|
+
if (animate) {
|
|
313
|
+
growing.set(node.id, size);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const node of changes.changeNodes) {
|
|
317
|
+
graph.mergeNodeAttributes(node.id, {
|
|
318
|
+
label: node.label,
|
|
319
|
+
size: pixels(node.size),
|
|
320
|
+
x: node.x,
|
|
321
|
+
y: node.y,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
for (const link of changes.addLinks) {
|
|
325
|
+
if (graph.hasNode(link.source) &&
|
|
326
|
+
graph.hasNode(link.target) &&
|
|
327
|
+
!graph.hasEdge(link.source, link.target)) {
|
|
328
|
+
graph.addEdge(link.source, link.target, {
|
|
329
|
+
from: link.source,
|
|
330
|
+
label: link.label,
|
|
331
|
+
size: 1,
|
|
332
|
+
to: link.target,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (growing.size > 0) {
|
|
337
|
+
grow(growing);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
168
340
|
zoomBy: (direction, animate) => {
|
|
169
341
|
const ratio = camera.getState().ratio * (direction > 0 ? 1 / ZOOM : ZOOM);
|
|
170
342
|
if (animate) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-graph",
|
|
3
3
|
"description": "Connects the Helix Graph frame to a graph renderer: Sigma.js, or a driver of your own.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.120.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
},
|
|
36
36
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@fusion.dev/helix-core": "0.
|
|
38
|
+
"@fusion.dev/helix-core": "0.120.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@fusion.dev/helix-headless": "0.
|
|
42
|
-
"@fusion.dev/helix-testing": "0.
|
|
41
|
+
"@fusion.dev/helix-headless": "0.120.0",
|
|
42
|
+
"@fusion.dev/helix-testing": "0.120.0",
|
|
43
43
|
"@types/bun": "^1.3.0",
|
|
44
44
|
"graphology": "^0.26.0",
|
|
45
45
|
"graphology-layout-forceatlas2": "^0.10.1",
|