@fusion.dev/helix-graph 0.0.0-dev → 0.118.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 +82 -0
- package/dist/connect-graph/connect-graph.d.ts +94 -0
- package/dist/connect-graph/connect-graph.js +168 -0
- package/dist/connect-graph/looks.d.ts +82 -0
- package/dist/connect-graph/looks.js +99 -0
- package/dist/connect-graph/seed.d.ts +30 -0
- package/dist/connect-graph/seed.js +49 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/sigma/index.d.ts +1 -2
- package/dist/sigma/index.js +1 -2
- package/dist/sigma/sigma-driver.d.ts +105 -0
- package/dist/sigma/sigma-driver.js +207 -0
- package/package.json +4 -4
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sigma/index.d.ts.map +0 -1
- package/dist/sigma/index.js.map +0 -1
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @fusion.dev/helix-graph
|
|
2
|
+
|
|
3
|
+
Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, or a
|
|
4
|
+
driver of your own, colored from your tokens.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bun add @fusion.dev/helix @fusion.dev/helix-graph sigma graphology graphology-layout-forceatlas2
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The elements themselves are on the
|
|
11
|
+
[Graph reference](https://fusion.dev/helix/components/graph).
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { connectGraph } from '@fusion.dev/helix-graph'
|
|
15
|
+
import { sigmaDriver } from '@fusion.dev/helix-graph/sigma'
|
|
16
|
+
import Graph from 'graphology'
|
|
17
|
+
import forceAtlas2 from 'graphology-layout-forceatlas2'
|
|
18
|
+
import FA2Layout from 'graphology-layout-forceatlas2/worker'
|
|
19
|
+
import Sigma from 'sigma'
|
|
20
|
+
|
|
21
|
+
const frame = document.querySelector('helix-graph')
|
|
22
|
+
frame.nodes = notes // [{ id, label, group, size, x, y }]
|
|
23
|
+
frame.links = links // [{ source, target, label }]
|
|
24
|
+
|
|
25
|
+
const sigma = new Sigma(new Graph(), document.getElementById('drawing'))
|
|
26
|
+
const connection = connectGraph(
|
|
27
|
+
frame,
|
|
28
|
+
sigmaDriver(sigma, {
|
|
29
|
+
layout: (graph) => new FA2Layout(graph, { settings: forceAtlas2.inferSettings(graph) }),
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// Keep where the layout put each note, and pass it back as `x` and `y` next time.
|
|
34
|
+
frame.addEventListener('helix-graph-layout', (event) => save(event.detail.positions))
|
|
35
|
+
|
|
36
|
+
// Later, when the graph goes away:
|
|
37
|
+
connection.disconnect()
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it does
|
|
41
|
+
|
|
42
|
+
- Draws the frame's `nodes` and `links`, and draws them again when either changes.
|
|
43
|
+
- Lays out only the notes without a position, in a worker, and stops once they
|
|
44
|
+
are still. Notes keep where they were when a page adds one.
|
|
45
|
+
- Says where every note ended in `helix-graph-layout`, and makes the frame
|
|
46
|
+
`:state(ready)`.
|
|
47
|
+
- Keeps the selection the same in the list and the drawing. A note chosen in
|
|
48
|
+
the list is brought into view. The selected note's links and backlinks stand
|
|
49
|
+
out, and the rest of the graph is faded.
|
|
50
|
+
- Colors each `group` from the series tokens, the selected note from `primary`,
|
|
51
|
+
and the labels in the theme's face, and draws them again when the theme
|
|
52
|
+
changes.
|
|
53
|
+
- Answers the frame's pan and zoom buttons and keys, and tells the renderer which
|
|
54
|
+
gestures the frame's `gestures` lets it take.
|
|
55
|
+
|
|
56
|
+
## What it exports
|
|
57
|
+
|
|
58
|
+
| Import | What |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `@fusion.dev/helix-graph` | `connectGraph`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver` type |
|
|
61
|
+
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
62
|
+
|
|
63
|
+
**Neither Sigma nor graphology is a dependency.** The driver types the renderer
|
|
64
|
+
by the calls it makes on it, so a page brings whichever version it has. A spec
|
|
65
|
+
holds those shapes to the real classes, so a renamed method fails a typecheck
|
|
66
|
+
here rather than a page.
|
|
67
|
+
|
|
68
|
+
| `connectGraph` option | Default | What else it takes |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `center` | `true` | `false` leaves the view where it is when a note is chosen in the list |
|
|
71
|
+
|
|
72
|
+
| `sigmaDriver` option | Default | What else it takes |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `layout` | none | A function that makes a layout for the graph, such as ForceAtlas2's worker |
|
|
75
|
+
| `layoutLimit` | `10000` | The longest a layout runs, in milliseconds, about where 10,000 notes settle |
|
|
76
|
+
|
|
77
|
+
## Working on it
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
bun run --filter @fusion.dev/helix-graph build
|
|
81
|
+
bun run --filter @fusion.dev/helix-graph test
|
|
82
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { type ViewportGestureMode } from '@fusion.dev/helix-core';
|
|
2
|
+
import { type GraphLooks } from './looks.js';
|
|
3
|
+
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
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What a renderer has to answer for the frame to drive it, and nothing that is
|
|
21
|
+
* the same for every renderer: that is the connector's.
|
|
22
|
+
*
|
|
23
|
+
* **Pixels in, whatever the renderer calls them out.** The frame asks in
|
|
24
|
+
* reading order and in fractions of the viewport, and the connector turns that
|
|
25
|
+
* into pixels on screen before a driver sees it, so which way `start` is on a
|
|
26
|
+
* right-to-left page is answered once.
|
|
27
|
+
*/
|
|
28
|
+
export interface GraphDriver {
|
|
29
|
+
/**
|
|
30
|
+
* Draws the graph, replacing whatever was drawn, where each note is placed.
|
|
31
|
+
* Keep the view where it is: a note added to a graph is no reason to move.
|
|
32
|
+
*/
|
|
33
|
+
draw(graph: {
|
|
34
|
+
links: DrawnLink[];
|
|
35
|
+
nodes: DrawnNode[];
|
|
36
|
+
}): void;
|
|
37
|
+
/**
|
|
38
|
+
* How each note and link is drawn from now on, asked one at a time as the
|
|
39
|
+
* renderer draws them: a selection or a theme changed.
|
|
40
|
+
*/
|
|
41
|
+
look(looks: GraphLooks): void;
|
|
42
|
+
/** Moves the view by pixels on screen, over a short animation or at once. */
|
|
43
|
+
panBy(x: number, y: number, animate: boolean): void;
|
|
44
|
+
/** Zooms the view one step about its middle: `1` in and `-1` out. */
|
|
45
|
+
zoomBy(direction: 1 | -1, animate: boolean): void;
|
|
46
|
+
/** Brings a note into the middle of the view, where the renderer can. */
|
|
47
|
+
center?(id: string, animate: boolean): void;
|
|
48
|
+
/** Calls `press` with a note's `id` when the reader presses it in the drawing. Returns what stops listening. */
|
|
49
|
+
onPress?(press: (id: string) => void): () => void;
|
|
50
|
+
/**
|
|
51
|
+
* Runs a layout over the drawn graph, moving the notes in `unplaced` and
|
|
52
|
+
* holding the rest, and calls `placed` once it has settled with where every
|
|
53
|
+
* note ended. Returns what stops it early. Without one, notes stay where the
|
|
54
|
+
* connector started them.
|
|
55
|
+
*/
|
|
56
|
+
settle?(unplaced: ReadonlySet<string>, placed: (positions: ReadonlyMap<string, GraphPoint>) => void): () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Which gestures the renderer takes from the page, as the frame's
|
|
59
|
+
* `gestures` decides. `free` is what the renderer did before it was told
|
|
60
|
+
* otherwise. `cooperative` leaves the wheel and one finger to the page and
|
|
61
|
+
* takes the wheel with Ctrl or ⌘, and two fingers. `off` takes none.
|
|
62
|
+
*/
|
|
63
|
+
gestures?(mode: ViewportGestureMode): void;
|
|
64
|
+
/** The element the renderer made focusable, which the connector takes out of the tab order. */
|
|
65
|
+
focusTarget?(): HTMLElement | null;
|
|
66
|
+
}
|
|
67
|
+
/** How a connection behaves where the page does not say. */
|
|
68
|
+
export interface ConnectGraphOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Whether selecting a note in the list brings it into the middle of the
|
|
71
|
+
* view. On by default. A note pressed in the drawing is never moved: the
|
|
72
|
+
* reader is already looking at it.
|
|
73
|
+
*/
|
|
74
|
+
center?: boolean | undefined;
|
|
75
|
+
}
|
|
76
|
+
/** A connection, which is ended rather than left to leak. */
|
|
77
|
+
export interface GraphConnection {
|
|
78
|
+
/** Stops answering the frame and puts back everything connecting changed. */
|
|
79
|
+
disconnect(): void;
|
|
80
|
+
/** Lays the graph out again from where it starts, ignoring where the last layout put each note. */
|
|
81
|
+
relayout(): void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Connects a Graph frame to whatever draws the graph.
|
|
85
|
+
*
|
|
86
|
+
* The frame is `helix-graph` or `helix-headless-graph`, and the driver is
|
|
87
|
+
* `sigmaDriver` from `@fusion.dev/helix-graph/sigma` or one of your own. The
|
|
88
|
+
* connection draws the frame's `nodes` and `links`, lays out the notes that
|
|
89
|
+
* have no position and says where it put them in `helix-graph-layout`, answers
|
|
90
|
+
* 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.
|
|
93
|
+
*/
|
|
94
|
+
export declare const connectGraph: (frame: HTMLElement, driver: GraphDriver, options?: ConnectGraphOptions) => GraphConnection;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
|
|
2
|
+
import { graphColorsFrom, looksOf } from './looks.js';
|
|
3
|
+
import { seedPositions } from './seed.js';
|
|
4
|
+
const viewportOf = (frame) => {
|
|
5
|
+
const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
|
|
6
|
+
return { height: box.height, width: box.width };
|
|
7
|
+
};
|
|
8
|
+
const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
9
|
+
export const connectGraph = (frame, driver, options = {}) => {
|
|
10
|
+
const graph = frame;
|
|
11
|
+
let known = new Map();
|
|
12
|
+
let colors = null;
|
|
13
|
+
let stopSettling;
|
|
14
|
+
let pressed;
|
|
15
|
+
let pending = 0;
|
|
16
|
+
let connected = true;
|
|
17
|
+
const restyle = () => {
|
|
18
|
+
colors ??= graphColorsFrom(frame);
|
|
19
|
+
if (!colors) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const nodes = graph.nodes ?? [];
|
|
23
|
+
const links = graph.links ?? [];
|
|
24
|
+
const selected = graph.active || undefined;
|
|
25
|
+
const near = new Set();
|
|
26
|
+
if (selected !== undefined) {
|
|
27
|
+
for (const { source, target } of links) {
|
|
28
|
+
if (source === selected) {
|
|
29
|
+
near.add(target);
|
|
30
|
+
}
|
|
31
|
+
else if (target === selected) {
|
|
32
|
+
near.add(source);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const groups = new Map(nodes.map((node) => [node.id, node.group]));
|
|
37
|
+
driver.look(looksOf({
|
|
38
|
+
colors,
|
|
39
|
+
font: getComputedStyle(frame)
|
|
40
|
+
.fontFamily.split(',')[0]
|
|
41
|
+
?.trim()
|
|
42
|
+
.replace(/^["']|["']$/g, ''),
|
|
43
|
+
groupOf: (id) => groups.get(id),
|
|
44
|
+
groups: nodes.flatMap((node) => (node.group === undefined ? [] : [node.group])),
|
|
45
|
+
near,
|
|
46
|
+
selected,
|
|
47
|
+
shown: graph.shown,
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
const draw = () => {
|
|
51
|
+
stopSettling?.();
|
|
52
|
+
stopSettling = undefined;
|
|
53
|
+
cancelAnimationFrame(pending);
|
|
54
|
+
const nodes = graph.nodes ?? [];
|
|
55
|
+
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
|
+
});
|
|
72
|
+
restyle();
|
|
73
|
+
if (unplaced.size > 0 && driver.settle) {
|
|
74
|
+
stopSettling = driver.settle(unplaced, (settled) => {
|
|
75
|
+
stopSettling = undefined;
|
|
76
|
+
known = new Map(settled);
|
|
77
|
+
graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
|
|
78
|
+
graph.drawn?.(true);
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
known = positions;
|
|
83
|
+
pending = requestAnimationFrame(() => {
|
|
84
|
+
if (connected) {
|
|
85
|
+
graph.drawn?.(true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const handlePartChange = (event) => {
|
|
90
|
+
const { kind } = event.detail;
|
|
91
|
+
if (kind === 'data') {
|
|
92
|
+
draw();
|
|
93
|
+
}
|
|
94
|
+
else if (kind === 'focus') {
|
|
95
|
+
restyle();
|
|
96
|
+
const selected = graph.active;
|
|
97
|
+
if (selected && selected !== pressed && options.center !== false) {
|
|
98
|
+
driver.center?.(selected, !still());
|
|
99
|
+
}
|
|
100
|
+
pressed = undefined;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
driver.gestures?.(graph.gestureMode ?? 'free');
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const handlePan = (event) => {
|
|
107
|
+
const { by, to } = event.detail;
|
|
108
|
+
const { height, width } = viewportOf(frame);
|
|
109
|
+
const inline = (to === 'end' ? 1 : to === 'start' ? -1 : 0) * (isRtl(frame) ? -1 : 1);
|
|
110
|
+
const block = to === 'down' ? 1 : to === 'up' ? -1 : 0;
|
|
111
|
+
driver.panBy(inline * width * by, block * height * by, !still());
|
|
112
|
+
};
|
|
113
|
+
const handleZoom = (event) => {
|
|
114
|
+
const { by } = event.detail;
|
|
115
|
+
if (by !== 0) {
|
|
116
|
+
driver.zoomBy(by > 0 ? 1 : -1, !still());
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
frame.addEventListener(HelixGraphPartChangeEvent.eventName, handlePartChange);
|
|
120
|
+
frame.addEventListener(HelixGraphPanEvent.eventName, handlePan);
|
|
121
|
+
frame.addEventListener(HelixGraphZoomEvent.eventName, handleZoom);
|
|
122
|
+
const stopPress = driver.onPress?.((id) => {
|
|
123
|
+
pressed = id;
|
|
124
|
+
if (graph.requestActive?.(id) === false || graph.active !== id) {
|
|
125
|
+
pressed = undefined;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const focusable = driver.focusTarget?.() ?? null;
|
|
129
|
+
const tabIndex = focusable?.getAttribute('tabindex') ?? null;
|
|
130
|
+
focusable?.setAttribute('tabindex', '-1');
|
|
131
|
+
const stopTheme = watchTheme(frame, () => {
|
|
132
|
+
colors = null;
|
|
133
|
+
restyle();
|
|
134
|
+
});
|
|
135
|
+
void Promise.resolve(graph.updateComplete).then(() => {
|
|
136
|
+
if (!connected) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
driver.gestures?.(graph.gestureMode ?? 'free');
|
|
140
|
+
draw();
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
disconnect: () => {
|
|
144
|
+
connected = false;
|
|
145
|
+
cancelAnimationFrame(pending);
|
|
146
|
+
stopSettling?.();
|
|
147
|
+
stopPress?.();
|
|
148
|
+
stopTheme();
|
|
149
|
+
frame.removeEventListener(HelixGraphPartChangeEvent.eventName, handlePartChange);
|
|
150
|
+
frame.removeEventListener(HelixGraphPanEvent.eventName, handlePan);
|
|
151
|
+
frame.removeEventListener(HelixGraphZoomEvent.eventName, handleZoom);
|
|
152
|
+
if (focusable) {
|
|
153
|
+
if (tabIndex === null) {
|
|
154
|
+
focusable.removeAttribute('tabindex');
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
focusable.setAttribute('tabindex', tabIndex);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
driver.gestures?.('free');
|
|
161
|
+
graph.drawn?.(false);
|
|
162
|
+
},
|
|
163
|
+
relayout: () => {
|
|
164
|
+
known = new Map();
|
|
165
|
+
draw();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The colors a graph is drawn in, each `rgb()`, read from the page's tokens
|
|
3
|
+
* where the frame is.
|
|
4
|
+
*/
|
|
5
|
+
export interface GraphColors {
|
|
6
|
+
/** What the drawing is laid on, which faded notes are mixed toward: `surface-sunken`. */
|
|
7
|
+
ground: string;
|
|
8
|
+
/** The links: `border`. */
|
|
9
|
+
line: string;
|
|
10
|
+
/** The labels: `foreground`. */
|
|
11
|
+
ink: string;
|
|
12
|
+
/** A note in no group, and the links that touch the selected note: `foreground-muted`. */
|
|
13
|
+
muted: string;
|
|
14
|
+
/** The selected note: `primary`. */
|
|
15
|
+
primary: string;
|
|
16
|
+
/** One color for each group, in the order of the groups' names: `series-1` and on. */
|
|
17
|
+
series: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The page's colors where the frame is, or `null` while they cannot be read.
|
|
21
|
+
*
|
|
22
|
+
* **Settled by the browser and read back as `rgb()`**, because a token is not
|
|
23
|
+
* a color yet: it is `light-dark()` over a ramp, which only the browser can
|
|
24
|
+
* settle for the frame's scheme, and what it settles to may be `oklch()`,
|
|
25
|
+
* which a WebGL renderer cannot parse. Read inside the frame, so a graph in a
|
|
26
|
+
* dark region of a light page is drawn dark.
|
|
27
|
+
*
|
|
28
|
+
* **Not yet rather than never.** A stylesheet being swapped takes the tokens
|
|
29
|
+
* away for a moment, which is `null` here, and the connector asks again on the
|
|
30
|
+
* next change.
|
|
31
|
+
*/
|
|
32
|
+
export declare const graphColorsFrom: (element: Element) => GraphColors | null;
|
|
33
|
+
/** One `rgb()` moved toward another by `amount`, from 0 to 1. */
|
|
34
|
+
export declare const mixRgb: (from: string, to: string, amount: number) => string;
|
|
35
|
+
/** How one note is drawn. */
|
|
36
|
+
export interface NodeLook {
|
|
37
|
+
color: string;
|
|
38
|
+
/** Outside the local graph that `depth` keeps. */
|
|
39
|
+
hidden: boolean;
|
|
40
|
+
/** Its name is drawn whatever the room, as the selected note's and its neighbors' are. */
|
|
41
|
+
label: boolean;
|
|
42
|
+
/** Drawn over the rest, as the selected note and its neighbors are. */
|
|
43
|
+
raised: boolean;
|
|
44
|
+
/** The selected note. */
|
|
45
|
+
selected: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** How one link is drawn. */
|
|
48
|
+
export interface LinkLook {
|
|
49
|
+
color: string;
|
|
50
|
+
hidden: boolean;
|
|
51
|
+
raised: boolean;
|
|
52
|
+
}
|
|
53
|
+
/** How every note and link is drawn, asked one at a time, so ten thousand cost nothing until drawn. */
|
|
54
|
+
export interface GraphLooks {
|
|
55
|
+
/** The typeface the labels are drawn in: the first of the frame's own. */
|
|
56
|
+
font: string;
|
|
57
|
+
/** The color the labels are drawn in. */
|
|
58
|
+
ink: string;
|
|
59
|
+
link(source: string, target: string): LinkLook;
|
|
60
|
+
node(id: string): NodeLook;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* How the graph is drawn for one selection.
|
|
64
|
+
*
|
|
65
|
+
* **The neighborhood stands out and the rest steps back**, which is what
|
|
66
|
+
* selecting a note in a graph of thousands is for: the selected note in the
|
|
67
|
+
* 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
|
|
69
|
+
* nothing selected, every note is in its group's color.
|
|
70
|
+
*
|
|
71
|
+
* Groups take the series in the order of their names, so a group keeps its
|
|
72
|
+
* color whichever notes a page loads first.
|
|
73
|
+
*/
|
|
74
|
+
export declare const looksOf: (options: {
|
|
75
|
+
colors: GraphColors;
|
|
76
|
+
font?: string | undefined;
|
|
77
|
+
groupOf: (id: string) => string | undefined;
|
|
78
|
+
groups: readonly string[];
|
|
79
|
+
near: ReadonlySet<string>;
|
|
80
|
+
selected: string | undefined;
|
|
81
|
+
shown: ReadonlySet<string> | undefined;
|
|
82
|
+
}) => GraphLooks;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readColor } from '@fusion.dev/helix-core';
|
|
2
|
+
const SERIES = 24;
|
|
3
|
+
const TOKENS = {
|
|
4
|
+
ground: '--helix-color-surface-sunken',
|
|
5
|
+
ink: '--helix-color-foreground',
|
|
6
|
+
line: '--helix-color-border',
|
|
7
|
+
muted: '--helix-color-foreground-muted',
|
|
8
|
+
primary: '--helix-color-primary',
|
|
9
|
+
};
|
|
10
|
+
export const graphColorsFrom = (element) => {
|
|
11
|
+
if (typeof document === 'undefined') {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const probe = document.createElement('span');
|
|
15
|
+
probe.style.display = 'none';
|
|
16
|
+
probe.setAttribute('aria-hidden', 'true');
|
|
17
|
+
element.append(probe);
|
|
18
|
+
try {
|
|
19
|
+
const read = (token) => {
|
|
20
|
+
if (getComputedStyle(probe).getPropertyValue(token).trim() === '') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
probe.style.color = `var(${token})`;
|
|
24
|
+
const bytes = readColor(getComputedStyle(probe).color);
|
|
25
|
+
return bytes ? `rgb(${bytes[0]}, ${bytes[1]}, ${bytes[2]})` : null;
|
|
26
|
+
};
|
|
27
|
+
const named = Object.fromEntries(Object.entries(TOKENS).map(([name, token]) => [name, read(token)]));
|
|
28
|
+
if (Object.values(named).some((one) => one === null)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const series = [];
|
|
32
|
+
for (let at = 1; at <= SERIES; at++) {
|
|
33
|
+
const one = read(`--helix-color-series-${at}`);
|
|
34
|
+
if (one === null) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
series.push(one);
|
|
38
|
+
}
|
|
39
|
+
return { ...named, series };
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
probe.remove();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
export const mixRgb = (from, to, amount) => {
|
|
46
|
+
const parts = (value) => value.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
|
|
47
|
+
const [one, other] = [parts(from), parts(to)];
|
|
48
|
+
const channel = (at) => Math.round((one[at] ?? 0) + ((other[at] ?? 0) - (one[at] ?? 0)) * amount);
|
|
49
|
+
return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
|
|
50
|
+
};
|
|
51
|
+
const FADED = 0.75;
|
|
52
|
+
export const looksOf = (options) => {
|
|
53
|
+
const { colors, groupOf, near, selected, shown } = options;
|
|
54
|
+
const order = new Map([...new Set(options.groups)].sort().map((group, at) => [group, at]));
|
|
55
|
+
const own = (id) => {
|
|
56
|
+
const group = groupOf(id);
|
|
57
|
+
const at = group === undefined ? undefined : order.get(group);
|
|
58
|
+
return at === undefined || colors.series.length === 0
|
|
59
|
+
? colors.muted
|
|
60
|
+
: colors.series[at % colors.series.length];
|
|
61
|
+
};
|
|
62
|
+
const focused = selected !== undefined;
|
|
63
|
+
const hidden = (id) => shown !== undefined && !shown.has(id);
|
|
64
|
+
return {
|
|
65
|
+
font: options.font || 'sans-serif',
|
|
66
|
+
ink: colors.ink,
|
|
67
|
+
link: (source, target) => {
|
|
68
|
+
const touches = focused && (source === selected || target === selected);
|
|
69
|
+
return {
|
|
70
|
+
color: touches
|
|
71
|
+
? colors.muted
|
|
72
|
+
: focused
|
|
73
|
+
? mixRgb(colors.line, colors.ground, 0.5)
|
|
74
|
+
: colors.line,
|
|
75
|
+
hidden: hidden(source) || hidden(target),
|
|
76
|
+
raised: touches,
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
node: (id) => {
|
|
80
|
+
if (id === selected) {
|
|
81
|
+
return {
|
|
82
|
+
color: colors.primary,
|
|
83
|
+
hidden: hidden(id),
|
|
84
|
+
label: true,
|
|
85
|
+
raised: true,
|
|
86
|
+
selected: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const neighbor = focused && near.has(id);
|
|
90
|
+
return {
|
|
91
|
+
color: focused && !neighbor ? mixRgb(own(id), colors.ground, FADED) : own(id),
|
|
92
|
+
hidden: hidden(id),
|
|
93
|
+
label: neighbor,
|
|
94
|
+
raised: neighbor,
|
|
95
|
+
selected: false,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** A point in the renderer's own units. */
|
|
2
|
+
export interface GraphPoint {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
/** A note as the connector reads it off the frame. */
|
|
7
|
+
export interface SeedNode {
|
|
8
|
+
id: string;
|
|
9
|
+
x?: number | undefined;
|
|
10
|
+
y?: number | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** A link as the connector reads it off the frame. */
|
|
13
|
+
export interface SeedLink {
|
|
14
|
+
source: string;
|
|
15
|
+
target: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Where each note starts, and which of them a layout still has to place.
|
|
19
|
+
*
|
|
20
|
+
* **Kept where it is known.** A note's own `x` and `y` win, then where the
|
|
21
|
+
* last layout put it, so a page that adds one note does not watch the rest
|
|
22
|
+
* of the graph jump. A new note starts beside a placed neighbor, which is
|
|
23
|
+
* close to where a layout will want it, and a note with none starts on a
|
|
24
|
+
* spiral around the middle, in the order it was given. Nothing is random, so
|
|
25
|
+
* the same graph starts the same way on every load.
|
|
26
|
+
*/
|
|
27
|
+
export declare const seedPositions: (nodes: readonly SeedNode[], links: readonly SeedLink[], known?: ReadonlyMap<string, GraphPoint>) => {
|
|
28
|
+
positions: Map<string, GraphPoint>;
|
|
29
|
+
unplaced: Set<string>;
|
|
30
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const TURN = Math.PI * (3 - Math.sqrt(5));
|
|
2
|
+
const NEAR = 0.5;
|
|
3
|
+
const nudge = (id) => {
|
|
4
|
+
let hash = 2166136261;
|
|
5
|
+
for (let at = 0; at < id.length; at++) {
|
|
6
|
+
hash = Math.imul(hash ^ id.charCodeAt(at), 16777619);
|
|
7
|
+
}
|
|
8
|
+
const angle = ((hash >>> 0) / 4294967296) * Math.PI * 2;
|
|
9
|
+
return { x: Math.cos(angle) * NEAR, y: Math.sin(angle) * NEAR };
|
|
10
|
+
};
|
|
11
|
+
export const seedPositions = (nodes, links, known = new Map()) => {
|
|
12
|
+
const positions = new Map();
|
|
13
|
+
const unplaced = new Set();
|
|
14
|
+
for (const node of nodes) {
|
|
15
|
+
if (Number.isFinite(node.x) && Number.isFinite(node.y)) {
|
|
16
|
+
positions.set(node.id, { x: node.x, y: node.y });
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const kept = known.get(node.id);
|
|
20
|
+
if (kept) {
|
|
21
|
+
positions.set(node.id, { ...kept });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const neighbors = new Map();
|
|
26
|
+
for (const { source, target } of links) {
|
|
27
|
+
neighbors.set(source, [...(neighbors.get(source) ?? []), target]);
|
|
28
|
+
neighbors.set(target, [...(neighbors.get(target) ?? []), source]);
|
|
29
|
+
}
|
|
30
|
+
let spiral = positions.size;
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
if (positions.has(node.id)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
unplaced.add(node.id);
|
|
36
|
+
const placed = (neighbors.get(node.id) ?? []).find((one) => positions.has(one));
|
|
37
|
+
const beside = placed === undefined ? undefined : positions.get(placed);
|
|
38
|
+
if (beside) {
|
|
39
|
+
const offset = nudge(node.id);
|
|
40
|
+
positions.set(node.id, { x: beside.x + offset.x, y: beside.y + offset.y });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const radius = Math.sqrt(spiral + 0.5);
|
|
44
|
+
const angle = spiral * TURN;
|
|
45
|
+
positions.set(node.id, { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius });
|
|
46
|
+
spiral += 1;
|
|
47
|
+
}
|
|
48
|
+
return { positions, unplaced };
|
|
49
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {};
|
|
2
|
-
|
|
1
|
+
export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphConnection, type GraphDriver, } from './connect-graph/connect-graph.js';
|
|
2
|
+
export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
|
|
3
|
+
export { type GraphPoint, seedPositions } from './connect-graph/seed.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {};
|
|
2
|
-
|
|
1
|
+
export { connectGraph, } from './connect-graph/connect-graph.js';
|
|
2
|
+
export { graphColorsFrom, } from './connect-graph/looks.js';
|
|
3
|
+
export { seedPositions } from './connect-graph/seed.js';
|
package/dist/sigma/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export {};
|
|
2
|
-
//# sourceMappingURL=index.d.ts.map
|
|
1
|
+
export { type SigmaCamera, type SigmaDriverOptions, type SigmaGraph, type SigmaLayout, type SigmaRenderer, sigmaDriver, } from './sigma-driver.js';
|
package/dist/sigma/index.js
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export {};
|
|
2
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
export { sigmaDriver, } from './sigma-driver.js';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { GraphDriver } from '../connect-graph/connect-graph.js';
|
|
2
|
+
/** A graphology graph, as far as the driver calls it. */
|
|
3
|
+
export interface SigmaGraph {
|
|
4
|
+
addEdge(source: string, target: string, attributes?: Record<string, unknown>): unknown;
|
|
5
|
+
addNode(node: string, attributes?: Record<string, unknown>): unknown;
|
|
6
|
+
clear(): void;
|
|
7
|
+
forEachNode(callback: (node: string, attributes: Record<string, unknown>) => void): void;
|
|
8
|
+
getNodeAttributes(node: string): Record<string, unknown>;
|
|
9
|
+
hasEdge(source: string, target: string): boolean;
|
|
10
|
+
hasNode(node: string): boolean;
|
|
11
|
+
readonly order: number;
|
|
12
|
+
setNodeAttribute(node: string, name: string, value: unknown): unknown;
|
|
13
|
+
}
|
|
14
|
+
/** Sigma's camera, as far as the driver calls it. */
|
|
15
|
+
export interface SigmaCamera {
|
|
16
|
+
animate(state: {
|
|
17
|
+
ratio?: number;
|
|
18
|
+
x?: number;
|
|
19
|
+
y?: number;
|
|
20
|
+
}, options?: {
|
|
21
|
+
duration?: number;
|
|
22
|
+
}): unknown;
|
|
23
|
+
getState(): {
|
|
24
|
+
angle: number;
|
|
25
|
+
ratio: number;
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
};
|
|
29
|
+
setState(state: {
|
|
30
|
+
ratio?: number;
|
|
31
|
+
x?: number;
|
|
32
|
+
y?: number;
|
|
33
|
+
}): unknown;
|
|
34
|
+
}
|
|
35
|
+
/** A Sigma renderer, as far as the driver calls it. */
|
|
36
|
+
export interface SigmaRenderer {
|
|
37
|
+
getCamera(): SigmaCamera;
|
|
38
|
+
getContainer(): HTMLElement;
|
|
39
|
+
getDimensions(): {
|
|
40
|
+
height: number;
|
|
41
|
+
width: number;
|
|
42
|
+
};
|
|
43
|
+
getGraph(): SigmaGraph;
|
|
44
|
+
getNodeDisplayData(node: string): {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
} | undefined;
|
|
48
|
+
off(type: 'clickNode', listener: (payload: {
|
|
49
|
+
node: string;
|
|
50
|
+
}) => void): unknown;
|
|
51
|
+
on(type: 'clickNode', listener: (payload: {
|
|
52
|
+
node: string;
|
|
53
|
+
}) => void): unknown;
|
|
54
|
+
refresh(): unknown;
|
|
55
|
+
setSetting(name: 'edgeReducer' | 'labelColor' | 'labelFont' | 'nodeReducer', value: unknown): unknown;
|
|
56
|
+
viewportToFramedGraph(point: {
|
|
57
|
+
x: number;
|
|
58
|
+
y: number;
|
|
59
|
+
}): {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** A layout that runs over a graphology graph until it is stopped, as ForceAtlas2's worker does. */
|
|
65
|
+
export interface SigmaLayout {
|
|
66
|
+
kill(): void;
|
|
67
|
+
start(): void;
|
|
68
|
+
stop(): void;
|
|
69
|
+
}
|
|
70
|
+
/** How the Sigma driver behaves where the page does not say. */
|
|
71
|
+
export interface SigmaDriverOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Makes a layout for the graph, which the driver runs where notes have no
|
|
74
|
+
* position and stops once they have settled. ForceAtlas2's worker is the
|
|
75
|
+
* one to use:
|
|
76
|
+
*
|
|
77
|
+
* ```js
|
|
78
|
+
* import FA2Layout from 'graphology-layout-forceatlas2/worker'
|
|
79
|
+
* import forceAtlas2 from 'graphology-layout-forceatlas2'
|
|
80
|
+
*
|
|
81
|
+
* sigmaDriver(sigma, {
|
|
82
|
+
* layout: (graph) => new FA2Layout(graph, { settings: forceAtlas2.inferSettings(graph) }),
|
|
83
|
+
* })
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* Without one, notes stay where the connector started them.
|
|
87
|
+
*/
|
|
88
|
+
layout?: ((graph: SigmaGraph) => SigmaLayout) | undefined;
|
|
89
|
+
/** The longest a layout runs, in milliseconds, settled or not. 10 seconds by default, about where 10,000 notes settle. */
|
|
90
|
+
layoutLimit?: number | undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Drives Sigma.js for a Graph frame. Make the renderer with an empty
|
|
94
|
+
* graphology graph and pass it here, and `connectGraph` fills it:
|
|
95
|
+
*
|
|
96
|
+
* ```js
|
|
97
|
+
* const sigma = new Sigma(new Graph(), container)
|
|
98
|
+
* connectGraph(frame, sigmaDriver(sigma, { layout }))
|
|
99
|
+
* ```
|
|
100
|
+
*
|
|
101
|
+
* **Neither Sigma nor graphology is a dependency.** The driver types the
|
|
102
|
+
* renderer by the calls it makes on it, so a page brings whichever version it
|
|
103
|
+
* has, and a spec holds those shapes to the real classes.
|
|
104
|
+
*/
|
|
105
|
+
export declare const sigmaDriver: (sigma: SigmaRenderer, options?: SigmaDriverOptions) => GraphDriver;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const STEP = 250;
|
|
2
|
+
const ZOOM = 1.5;
|
|
3
|
+
const SAMPLE = 250;
|
|
4
|
+
const STILL = 0.004;
|
|
5
|
+
const HELD = 2;
|
|
6
|
+
const IDLE = 2000;
|
|
7
|
+
const pixels = (size) => 2 + 2 * Math.sqrt(Math.max(size, 0));
|
|
8
|
+
const positionsOf = (graph) => {
|
|
9
|
+
const positions = new Map();
|
|
10
|
+
graph.forEachNode((node, attributes) => {
|
|
11
|
+
positions.set(node, { x: Number(attributes.x), y: Number(attributes.y) });
|
|
12
|
+
});
|
|
13
|
+
return positions;
|
|
14
|
+
};
|
|
15
|
+
export const sigmaDriver = (sigma, options = {}) => {
|
|
16
|
+
const graph = sigma.getGraph();
|
|
17
|
+
const container = sigma.getContainer();
|
|
18
|
+
const camera = sigma.getCamera();
|
|
19
|
+
let mode = 'free';
|
|
20
|
+
const touchAction = container.style.touchAction;
|
|
21
|
+
const hold = (event) => {
|
|
22
|
+
if (mode === 'free') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (mode === 'off') {
|
|
26
|
+
event.stopImmediatePropagation();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (event instanceof WheelEvent) {
|
|
30
|
+
if (!(event.ctrlKey || event.metaKey)) {
|
|
31
|
+
event.stopImmediatePropagation();
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (typeof TouchEvent !== 'undefined' && event instanceof TouchEvent) {
|
|
36
|
+
if (event.touches.length < 2) {
|
|
37
|
+
event.stopImmediatePropagation();
|
|
38
|
+
}
|
|
39
|
+
else if (event.type === 'touchstart' && event.cancelable) {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const held = ['wheel', 'touchstart', 'touchmove', 'mousedown'];
|
|
45
|
+
for (const type of held) {
|
|
46
|
+
container.addEventListener(type, hold, { capture: true, passive: false });
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
center: (id, animate) => {
|
|
50
|
+
const at = sigma.getNodeDisplayData(id);
|
|
51
|
+
if (!at) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (animate) {
|
|
55
|
+
camera.animate({ x: at.x, y: at.y }, { duration: STEP * 2 });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
camera.setState({ x: at.x, y: at.y });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
draw: ({ links, nodes }) => {
|
|
62
|
+
graph.clear();
|
|
63
|
+
for (const node of nodes) {
|
|
64
|
+
graph.addNode(node.id, {
|
|
65
|
+
label: node.label,
|
|
66
|
+
size: pixels(node.size),
|
|
67
|
+
x: node.x,
|
|
68
|
+
y: node.y,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
for (const link of links) {
|
|
72
|
+
if (!graph.hasEdge(link.source, link.target)) {
|
|
73
|
+
graph.addEdge(link.source, link.target, {
|
|
74
|
+
from: link.source,
|
|
75
|
+
label: link.label,
|
|
76
|
+
size: 1,
|
|
77
|
+
to: link.target,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
gestures: (next) => {
|
|
83
|
+
mode = next;
|
|
84
|
+
container.style.touchAction =
|
|
85
|
+
next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
|
|
86
|
+
},
|
|
87
|
+
look: (looks) => {
|
|
88
|
+
sigma.setSetting('labelColor', { color: looks.ink });
|
|
89
|
+
sigma.setSetting('labelFont', looks.font);
|
|
90
|
+
sigma.setSetting('nodeReducer', (node, data) => {
|
|
91
|
+
const look = looks.node(node);
|
|
92
|
+
return {
|
|
93
|
+
...data,
|
|
94
|
+
color: look.color,
|
|
95
|
+
forceLabel: look.label,
|
|
96
|
+
hidden: look.hidden,
|
|
97
|
+
size: look.selected && typeof data.size === 'number' ? data.size * 1.5 : data.size,
|
|
98
|
+
zIndex: look.selected ? 2 : look.raised ? 1 : 0,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
sigma.setSetting('edgeReducer', (_edge, data) => {
|
|
102
|
+
const look = looks.link(String(data.from), String(data.to));
|
|
103
|
+
return {
|
|
104
|
+
...data,
|
|
105
|
+
color: look.color,
|
|
106
|
+
hidden: look.hidden,
|
|
107
|
+
size: look.raised ? 1.5 : 1,
|
|
108
|
+
zIndex: look.raised ? 1 : 0,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
sigma.refresh();
|
|
112
|
+
},
|
|
113
|
+
onPress: (press) => {
|
|
114
|
+
const listener = ({ node }) => press(node);
|
|
115
|
+
sigma.on('clickNode', listener);
|
|
116
|
+
return () => {
|
|
117
|
+
sigma.off('clickNode', listener);
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
panBy: (x, y, animate) => {
|
|
121
|
+
const { height, width } = sigma.getDimensions();
|
|
122
|
+
const to = sigma.viewportToFramedGraph({ x: width / 2 + x, y: height / 2 + y });
|
|
123
|
+
if (animate) {
|
|
124
|
+
camera.animate(to, { duration: STEP });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
camera.setState(to);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
zoomBy: (direction, animate) => {
|
|
131
|
+
const ratio = camera.getState().ratio * (direction > 0 ? 1 / ZOOM : ZOOM);
|
|
132
|
+
if (animate) {
|
|
133
|
+
camera.animate({ ratio }, { duration: STEP });
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
camera.setState({ ratio });
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
...(options.layout
|
|
140
|
+
? {
|
|
141
|
+
settle: (unplaced, placed) => {
|
|
142
|
+
const make = options.layout;
|
|
143
|
+
graph.forEachNode((node) => {
|
|
144
|
+
graph.setNodeAttribute(node, 'fixed', !unplaced.has(node));
|
|
145
|
+
});
|
|
146
|
+
const layout = make(graph);
|
|
147
|
+
layout.start();
|
|
148
|
+
const limit = options.layoutLimit ?? 10000;
|
|
149
|
+
const started = performance.now();
|
|
150
|
+
let last = positionsOf(graph);
|
|
151
|
+
let done = false;
|
|
152
|
+
let moving = false;
|
|
153
|
+
let held = 0;
|
|
154
|
+
const finish = () => {
|
|
155
|
+
if (done) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
done = true;
|
|
159
|
+
clearInterval(timer);
|
|
160
|
+
layout.stop();
|
|
161
|
+
layout.kill();
|
|
162
|
+
graph.forEachNode((node) => {
|
|
163
|
+
graph.setNodeAttribute(node, 'fixed', false);
|
|
164
|
+
});
|
|
165
|
+
placed(positionsOf(graph));
|
|
166
|
+
};
|
|
167
|
+
const timer = setInterval(() => {
|
|
168
|
+
const now = positionsOf(graph);
|
|
169
|
+
let moved = 0;
|
|
170
|
+
let low = Number.POSITIVE_INFINITY;
|
|
171
|
+
let high = Number.NEGATIVE_INFINITY;
|
|
172
|
+
for (const [id, at] of now) {
|
|
173
|
+
const was = last.get(id);
|
|
174
|
+
if (was) {
|
|
175
|
+
moved += Math.hypot(at.x - was.x, at.y - was.y);
|
|
176
|
+
}
|
|
177
|
+
low = Math.min(low, at.x, at.y);
|
|
178
|
+
high = Math.max(high, at.x, at.y);
|
|
179
|
+
}
|
|
180
|
+
last = now;
|
|
181
|
+
const extent = Math.max(high - low, 1);
|
|
182
|
+
if (moved / Math.max(now.size, 1) / extent >= STILL) {
|
|
183
|
+
moving = true;
|
|
184
|
+
held = 0;
|
|
185
|
+
}
|
|
186
|
+
else if (moving) {
|
|
187
|
+
held += 1;
|
|
188
|
+
}
|
|
189
|
+
const elapsed = performance.now() - started;
|
|
190
|
+
if ((moving && held >= HELD) || (!moving && elapsed > IDLE) || elapsed > limit) {
|
|
191
|
+
finish();
|
|
192
|
+
}
|
|
193
|
+
}, SAMPLE);
|
|
194
|
+
return () => {
|
|
195
|
+
if (done) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
done = true;
|
|
199
|
+
clearInterval(timer);
|
|
200
|
+
layout.stop();
|
|
201
|
+
layout.kill();
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
206
|
+
};
|
|
207
|
+
};
|
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.118.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.118.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@fusion.dev/helix-headless": "0.
|
|
42
|
-
"@fusion.dev/helix-testing": "0.
|
|
41
|
+
"@fusion.dev/helix-headless": "0.118.0",
|
|
42
|
+
"@fusion.dev/helix-testing": "0.118.0",
|
|
43
43
|
"@types/bun": "^1.3.0",
|
|
44
44
|
"graphology": "^0.26.0",
|
|
45
45
|
"graphology-layout-forceatlas2": "^0.10.1",
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAA"}
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sigma/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAA"}
|
package/dist/sigma/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sigma/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAA"}
|