@fusion.dev/helix-graph 0.118.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 +36 -1
- package/dist/connect-graph/connect-graph.d.ts +31 -17
- package/dist/connect-graph/connect-graph.js +91 -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 +25 -3
- package/dist/connect-graph/looks.js +9 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/sigma/sigma-driver.d.ts +15 -1
- package/dist/sigma/sigma-driver.js +212 -2
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -33,6 +33,9 @@ const connection = connectGraph(
|
|
|
33
33
|
// Keep where the layout put each note, and pass it back as `x` and `y` next time.
|
|
34
34
|
frame.addEventListener('helix-graph-layout', (event) => save(event.detail.positions))
|
|
35
35
|
|
|
36
|
+
// After replacing a stylesheet, which no attribute shows:
|
|
37
|
+
connection.restyle()
|
|
38
|
+
|
|
36
39
|
// Later, when the graph goes away:
|
|
37
40
|
connection.disconnect()
|
|
38
41
|
```
|
|
@@ -52,12 +55,44 @@ connection.disconnect()
|
|
|
52
55
|
changes.
|
|
53
56
|
- Answers the frame's pan and zoom buttons and keys, and tells the renderer which
|
|
54
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.
|
|
55
90
|
|
|
56
91
|
## What it exports
|
|
57
92
|
|
|
58
93
|
| Import | What |
|
|
59
94
|
|---|---|
|
|
60
|
-
| `@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 |
|
|
61
96
|
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
62
97
|
|
|
63
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.
|
|
@@ -63,6 +65,8 @@ export interface GraphDriver {
|
|
|
63
65
|
gestures?(mode: ViewportGestureMode): void;
|
|
64
66
|
/** The element the renderer made focusable, which the connector takes out of the tab order. */
|
|
65
67
|
focusTarget?(): HTMLElement | null;
|
|
68
|
+
/** Removes whatever the driver added to the renderer or the page, when the connection ends. */
|
|
69
|
+
stop?(): void;
|
|
66
70
|
}
|
|
67
71
|
/** How a connection behaves where the page does not say. */
|
|
68
72
|
export interface ConnectGraphOptions {
|
|
@@ -79,6 +83,12 @@ export interface GraphConnection {
|
|
|
79
83
|
disconnect(): void;
|
|
80
84
|
/** Lays the graph out again from where it starts, ignoring where the last layout put each note. */
|
|
81
85
|
relayout(): void;
|
|
86
|
+
/**
|
|
87
|
+
* Reads the page's colors again. The connection hears a theme that changes
|
|
88
|
+
* by a class, a mode or the reader's preference on its own; call this after
|
|
89
|
+
* replacing a stylesheet, which no attribute shows.
|
|
90
|
+
*/
|
|
91
|
+
restyle(): void;
|
|
82
92
|
}
|
|
83
93
|
/**
|
|
84
94
|
* Connects a Graph frame to whatever draws the graph.
|
|
@@ -88,7 +98,11 @@ export interface GraphConnection {
|
|
|
88
98
|
* connection draws the frame's `nodes` and `links`, lays out the notes that
|
|
89
99
|
* have no position and says where it put them in `helix-graph-layout`, answers
|
|
90
100
|
* the frame's pan and zoom, keeps the selection the same in the list and the
|
|
91
|
-
* drawing, colors the graph from the page's tokens,
|
|
92
|
-
*
|
|
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.
|
|
93
107
|
*/
|
|
94
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) {
|
|
@@ -158,11 +215,17 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
158
215
|
}
|
|
159
216
|
}
|
|
160
217
|
driver.gestures?.('free');
|
|
218
|
+
driver.stop?.();
|
|
161
219
|
graph.drawn?.(false);
|
|
162
220
|
},
|
|
163
221
|
relayout: () => {
|
|
164
222
|
known = new Map();
|
|
165
|
-
|
|
223
|
+
waiting = new Set();
|
|
224
|
+
draw(true);
|
|
225
|
+
},
|
|
226
|
+
restyle: () => {
|
|
227
|
+
colors = null;
|
|
228
|
+
restyle();
|
|
166
229
|
},
|
|
167
230
|
};
|
|
168
231
|
};
|
|
@@ -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`. */
|
|
@@ -11,10 +15,18 @@ export interface GraphColors {
|
|
|
11
15
|
ink: string;
|
|
12
16
|
/** A note in no group, and the links that touch the selected note: `foreground-muted`. */
|
|
13
17
|
muted: string;
|
|
18
|
+
/** What a note's name is drawn on while the pointer is over it: `surface-overlay`. */
|
|
19
|
+
overlay: string;
|
|
14
20
|
/** The selected note: `primary`. */
|
|
15
21
|
primary: string;
|
|
16
22
|
/** One color for each group, in the order of the groups' names: `series-1` and on. */
|
|
17
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;
|
|
18
30
|
}
|
|
19
31
|
/**
|
|
20
32
|
* The page's colors where the frame is, or `null` while they cannot be read.
|
|
@@ -37,8 +49,15 @@ export interface NodeLook {
|
|
|
37
49
|
color: string;
|
|
38
50
|
/** Outside the local graph that `depth` keeps. */
|
|
39
51
|
hidden: boolean;
|
|
40
|
-
/**
|
|
41
|
-
|
|
52
|
+
/**
|
|
53
|
+
* When its name is drawn: `always`, whatever the room, as the selected
|
|
54
|
+
* note's is; `room`, where it does not cover another name, as the
|
|
55
|
+
* renderer decides for every note at rest; `never`, as a faded note's is.
|
|
56
|
+
*
|
|
57
|
+
* **The neighbors get room and not always.** Forced, a hub's twenty
|
|
58
|
+
* neighbors drew their names over each other in a knot nobody could read.
|
|
59
|
+
*/
|
|
60
|
+
label: 'always' | 'never' | 'room';
|
|
42
61
|
/** Drawn over the rest, as the selected note and its neighbors are. */
|
|
43
62
|
raised: boolean;
|
|
44
63
|
/** The selected note. */
|
|
@@ -56,6 +75,8 @@ export interface GraphLooks {
|
|
|
56
75
|
font: string;
|
|
57
76
|
/** The color the labels are drawn in. */
|
|
58
77
|
ink: string;
|
|
78
|
+
/** What a name is drawn on while the pointer is over its note. */
|
|
79
|
+
overlay: string;
|
|
59
80
|
link(source: string, target: string): LinkLook;
|
|
60
81
|
node(id: string): NodeLook;
|
|
61
82
|
}
|
|
@@ -65,7 +86,8 @@ export interface GraphLooks {
|
|
|
65
86
|
* **The neighborhood stands out and the rest steps back**, which is what
|
|
66
87
|
* selecting a note in a graph of thousands is for: the selected note in the
|
|
67
88
|
* brand color, the notes it links to and from in their groups' colors with
|
|
68
|
-
* their names drawn, and every other note faded toward
|
|
89
|
+
* their names drawn where there is room, and every other note faded toward
|
|
90
|
+
* the ground, unnamed. With
|
|
69
91
|
* nothing selected, every note is in its group's color.
|
|
70
92
|
*
|
|
71
93
|
* Groups take the series in the order of their names, so a group keeps its
|
|
@@ -1,11 +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',
|
|
11
|
+
overlay: '--helix-color-surface-overlay',
|
|
8
12
|
primary: '--helix-color-primary',
|
|
13
|
+
success: '--helix-color-success',
|
|
14
|
+
warning: '--helix-color-warning',
|
|
9
15
|
};
|
|
10
16
|
export const graphColorsFrom = (element) => {
|
|
11
17
|
if (typeof document === 'undefined') {
|
|
@@ -81,7 +87,7 @@ export const looksOf = (options) => {
|
|
|
81
87
|
return {
|
|
82
88
|
color: colors.primary,
|
|
83
89
|
hidden: hidden(id),
|
|
84
|
-
label:
|
|
90
|
+
label: 'always',
|
|
85
91
|
raised: true,
|
|
86
92
|
selected: true,
|
|
87
93
|
};
|
|
@@ -90,10 +96,11 @@ export const looksOf = (options) => {
|
|
|
90
96
|
return {
|
|
91
97
|
color: focused && !neighbor ? mixRgb(own(id), colors.ground, FADED) : own(id),
|
|
92
98
|
hidden: hidden(id),
|
|
93
|
-
label: neighbor,
|
|
99
|
+
label: focused && !neighbor ? 'never' : 'room',
|
|
94
100
|
raised: neighbor,
|
|
95
101
|
selected: false,
|
|
96
102
|
};
|
|
97
103
|
},
|
|
104
|
+
overlay: colors.overlay,
|
|
98
105
|
};
|
|
99
106
|
};
|
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;
|
|
@@ -52,7 +64,9 @@ export interface SigmaRenderer {
|
|
|
52
64
|
node: string;
|
|
53
65
|
}) => void): unknown;
|
|
54
66
|
refresh(): unknown;
|
|
55
|
-
|
|
67
|
+
resize(force?: boolean): unknown;
|
|
68
|
+
scaleSize(size?: number): number;
|
|
69
|
+
setSetting(name: 'defaultDrawNodeHover' | 'edgeReducer' | 'labelColor' | 'labelFont' | 'labelRenderedSizeThreshold' | 'nodeReducer', value: unknown): unknown;
|
|
56
70
|
viewportToFramedGraph(point: {
|
|
57
71
|
x: number;
|
|
58
72
|
y: number;
|
|
@@ -5,6 +5,31 @@ const STILL = 0.004;
|
|
|
5
5
|
const HELD = 2;
|
|
6
6
|
const IDLE = 2000;
|
|
7
7
|
const pixels = (size) => 2 + 2 * Math.sqrt(Math.max(size, 0));
|
|
8
|
+
const hoverOf = (looks) => (context, data, settings) => {
|
|
9
|
+
if (!data.label) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const size = settings.labelSize;
|
|
13
|
+
context.font = `${settings.labelWeight} ${size}px ${settings.labelFont}`;
|
|
14
|
+
const width = context.measureText(data.label).width;
|
|
15
|
+
const x = data.x + data.size + 2;
|
|
16
|
+
const height = size + 8;
|
|
17
|
+
context.save();
|
|
18
|
+
context.shadowBlur = 8;
|
|
19
|
+
context.shadowColor = 'rgba(0, 0, 0, 0.25)';
|
|
20
|
+
context.fillStyle = looks.overlay;
|
|
21
|
+
context.beginPath();
|
|
22
|
+
context.roundRect(x - 4, data.y - height / 2, width + 8, height, 4);
|
|
23
|
+
context.fill();
|
|
24
|
+
context.restore();
|
|
25
|
+
context.fillStyle = looks.ink;
|
|
26
|
+
context.fillText(data.label, x, data.y + size / 3);
|
|
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);
|
|
8
33
|
const positionsOf = (graph) => {
|
|
9
34
|
const positions = new Map();
|
|
10
35
|
graph.forEachNode((node, attributes) => {
|
|
@@ -45,6 +70,110 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
45
70
|
for (const type of held) {
|
|
46
71
|
container.addEventListener(type, hold, { capture: true, passive: false });
|
|
47
72
|
}
|
|
73
|
+
const sized = typeof ResizeObserver === 'function'
|
|
74
|
+
? new ResizeObserver(() => {
|
|
75
|
+
sigma.resize(true);
|
|
76
|
+
sigma.refresh();
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
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
|
+
};
|
|
48
177
|
return {
|
|
49
178
|
center: (id, animate) => {
|
|
50
179
|
const at = sigma.getNodeDisplayData(id);
|
|
@@ -85,16 +214,21 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
85
214
|
next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
|
|
86
215
|
},
|
|
87
216
|
look: (looks) => {
|
|
217
|
+
ink = looks.ink;
|
|
88
218
|
sigma.setSetting('labelColor', { color: looks.ink });
|
|
89
219
|
sigma.setSetting('labelFont', looks.font);
|
|
220
|
+
sigma.setSetting('labelRenderedSizeThreshold', 0);
|
|
221
|
+
sigma.setSetting('defaultDrawNodeHover', hoverOf(looks));
|
|
90
222
|
sigma.setSetting('nodeReducer', (node, data) => {
|
|
91
223
|
const look = looks.node(node);
|
|
224
|
+
const grown = look.selected ? 1.5 : look.raised ? 1.25 : 1;
|
|
92
225
|
return {
|
|
93
226
|
...data,
|
|
94
227
|
color: look.color,
|
|
95
|
-
forceLabel: look.label,
|
|
228
|
+
forceLabel: look.label === 'always',
|
|
96
229
|
hidden: look.hidden,
|
|
97
|
-
|
|
230
|
+
label: look.label === 'never' ? null : data.label,
|
|
231
|
+
size: typeof data.size === 'number' ? data.size * grown : data.size,
|
|
98
232
|
zIndex: look.selected ? 2 : look.raised ? 1 : 0,
|
|
99
233
|
};
|
|
100
234
|
});
|
|
@@ -127,6 +261,82 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
127
261
|
camera.setState(to);
|
|
128
262
|
}
|
|
129
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
|
+
},
|
|
280
|
+
stop: () => {
|
|
281
|
+
cancelAnimationFrame(flying);
|
|
282
|
+
flying = 0;
|
|
283
|
+
flights = [];
|
|
284
|
+
overlay?.remove();
|
|
285
|
+
overlay = undefined;
|
|
286
|
+
sized?.disconnect();
|
|
287
|
+
for (const type of held) {
|
|
288
|
+
container.removeEventListener(type, hold, { capture: true });
|
|
289
|
+
}
|
|
290
|
+
container.style.touchAction = touchAction;
|
|
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
|
+
},
|
|
130
340
|
zoomBy: (direction, animate) => {
|
|
131
341
|
const ratio = camera.getState().ratio * (direction > 0 ? 1 / ZOOM : ZOOM);
|
|
132
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",
|