@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 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` type |
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
- /** A note as the renderer draws it, placed. */
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, and tells the renderer
92
- * which gestures it may take.
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
- stopSettling = undefined;
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 links = (graph.links ?? []).filter(({ source, target }) => source !== target && ids.has(source) && ids.has(target));
57
- const { positions, unplaced } = seedPositions(nodes, links, known);
58
- graph.drawn?.(false);
59
- driver.draw({
60
- links: links.map(({ label, source, target }) => ({ label, source, target })),
61
- nodes: nodes.map((node) => {
62
- const at = positions.get(node.id);
63
- return {
64
- id: node.id,
65
- label: node.label || node.id,
66
- size: Number.isFinite(node.size) ? node.size : 1,
67
- x: at.x,
68
- y: at.y,
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
- graph.drawn?.(true);
121
+ if (opening) {
122
+ graph.drawn?.(true);
123
+ }
79
124
  });
80
125
  return;
81
126
  }
82
- known = positions;
83
- pending = requestAnimationFrame(() => {
84
- if (connected) {
85
- graph.drawn?.(true);
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
- draw();
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
- /** Its name is drawn whatever the room, as the selected note's and its neighbors' are. */
41
- label: boolean;
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 the ground. With
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: true,
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
@@ -1,3 +1,4 @@
1
1
  export { connectGraph, } from './connect-graph/connect-graph.js';
2
+ export { diffGraph } from './connect-graph/diff.js';
2
3
  export { graphColorsFrom, } from './connect-graph/looks.js';
3
4
  export { seedPositions } from './connect-graph/seed.js';
@@ -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
- setSetting(name: 'edgeReducer' | 'labelColor' | 'labelFont' | 'nodeReducer', value: unknown): unknown;
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
- size: look.selected && typeof data.size === 'number' ? data.size * 1.5 : data.size,
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.118.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.118.0"
38
+ "@fusion.dev/helix-core": "0.120.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@fusion.dev/helix-headless": "0.118.0",
42
- "@fusion.dev/helix-testing": "0.118.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",