@fusion.dev/helix-graph 0.134.0 → 0.136.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 +3 -0
- package/dist/connect-graph/connect-graph.js +33 -5
- package/dist/connect-graph/sizes.d.ts +14 -0
- package/dist/connect-graph/sizes.js +2 -0
- package/dist/sigma/sigma-driver.js +4 -4
- package/dist/snapshot/index.d.ts +1 -0
- package/dist/snapshot/index.js +1 -0
- package/dist/snapshot/snapshot.d.ts +60 -0
- package/dist/snapshot/snapshot.js +204 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -69,6 +69,8 @@ first.
|
|
|
69
69
|
gestures the frame's `gestures` lets it take.
|
|
70
70
|
- Draws only what changed when the page hands the frame new notes or links, and
|
|
71
71
|
draws the frame's `signal()`s.
|
|
72
|
+
- Fades out a snapshot drawn on the server by `graphSnapshot` once the renderer
|
|
73
|
+
has drawn the graph whole.
|
|
72
74
|
|
|
73
75
|
## Live graphs
|
|
74
76
|
|
|
@@ -357,6 +359,7 @@ connectGraph(frame, threeDriver(box), {
|
|
|
357
359
|
| `@fusion.dev/helix-graph/three` | `threeDriver(box, options)`, and the type `ThreeDriverOptions` |
|
|
358
360
|
| `@fusion.dev/helix-graph/shapes` | `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `cuboid`, `onSphere`, `surfaceOf`, and the types `ImageOptions`, `ShapeField` and `TriangleOptions` |
|
|
359
361
|
| `@fusion.dev/helix-graph/model` | `shapeFromModel(source, options)`, which needs three.js |
|
|
362
|
+
| `@fusion.dev/helix-graph/snapshot` | `graphSnapshot(nodes, links, options)`, an SVG of the graph to paint before the renderer loads, and the types `GraphSnapshotOptions`, `SnapshotLink` and `SnapshotNode` |
|
|
360
363
|
|
|
361
364
|
**Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
|
|
362
365
|
driver types its renderer by the calls it makes on it, so a page brings
|
|
@@ -4,6 +4,7 @@ import { diffGraph, isEmpty, linkKey, } from './diff.js';
|
|
|
4
4
|
import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
|
|
5
5
|
import { reachOf } from './reach.js';
|
|
6
6
|
import { seedPositions } from './seed.js';
|
|
7
|
+
import { sizeOf } from './sizes.js';
|
|
7
8
|
import { fieldOf, fitInStencil } from './stencil.js';
|
|
8
9
|
const clustersOf = (nodes, hubOf) => {
|
|
9
10
|
const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
|
|
@@ -33,6 +34,35 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
33
34
|
let pressed;
|
|
34
35
|
let pending = 0;
|
|
35
36
|
let connected = true;
|
|
37
|
+
const snapshots = [...frame.querySelectorAll(':scope > [data-helix-graph-snapshot]')];
|
|
38
|
+
const away = (snapshot) => {
|
|
39
|
+
snapshot.hidden = true;
|
|
40
|
+
snapshot.style.display = 'none';
|
|
41
|
+
};
|
|
42
|
+
const ready = () => {
|
|
43
|
+
graph.drawn?.(true);
|
|
44
|
+
if (snapshots.length === 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const style = getComputedStyle(frame);
|
|
48
|
+
const time = /^\s*(\d*\.?\d+)(ms|s)\s*$/i.exec(style.getPropertyValue('--helix-duration-normal'));
|
|
49
|
+
const duration = time ? Number(time[1]) * (time[2]?.toLowerCase() === 's' ? 1000 : 1) : 200;
|
|
50
|
+
const easing = style.getPropertyValue('--helix-easing-standard').trim() || 'ease';
|
|
51
|
+
for (const snapshot of snapshots.splice(0)) {
|
|
52
|
+
if (still() || duration <= 0 || typeof snapshot.animate !== 'function') {
|
|
53
|
+
away(snapshot);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
let fade;
|
|
57
|
+
try {
|
|
58
|
+
fade = snapshot.animate([{ opacity: 1 }, { opacity: 0 }], { duration, easing });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
fade = snapshot.animate([{ opacity: 1 }, { opacity: 0 }], { duration });
|
|
62
|
+
}
|
|
63
|
+
fade.finished.then(() => away(snapshot), () => away(snapshot));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
36
66
|
const selectedNote = () => {
|
|
37
67
|
const active = graph.active;
|
|
38
68
|
return active && (graph.nodes ?? []).some((node) => node.id === active) ? active : undefined;
|
|
@@ -145,9 +175,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
145
175
|
nextNodes.set(node.id, {
|
|
146
176
|
id: node.id,
|
|
147
177
|
label: node.label || node.id,
|
|
148
|
-
size:
|
|
149
|
-
? node.size
|
|
150
|
-
: 1 + Math.min(Math.sqrt(degree.get(node.id) ?? 0), 6),
|
|
178
|
+
size: sizeOf(node.size, degree.get(node.id) ?? 0),
|
|
151
179
|
x: at.x,
|
|
152
180
|
y: at.y,
|
|
153
181
|
...(at.z === undefined ? {} : { z: at.z }),
|
|
@@ -195,7 +223,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
195
223
|
graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
|
|
196
224
|
if (owed) {
|
|
197
225
|
owed = false;
|
|
198
|
-
|
|
226
|
+
ready();
|
|
199
227
|
}
|
|
200
228
|
});
|
|
201
229
|
return;
|
|
@@ -208,7 +236,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
208
236
|
pending = requestAnimationFrame(() => {
|
|
209
237
|
if (connected && owed) {
|
|
210
238
|
owed = false;
|
|
211
|
-
|
|
239
|
+
ready();
|
|
212
240
|
}
|
|
213
241
|
});
|
|
214
242
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How large a note is drawn, in one place, so the drawing and a snapshot
|
|
3
|
+
* drawn on the server agree.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* **A note without a size is as large as it is connected**, so a hub reads as
|
|
7
|
+
* one at a glance and the graph's shape shows before a word of it is read.
|
|
8
|
+
* The square root, so a note with a hundred links is not ten times the size
|
|
9
|
+
* of one with ten, and no larger than a note with thirty-six, so a hub with
|
|
10
|
+
* thousands does not cover its own region.
|
|
11
|
+
*/
|
|
12
|
+
export declare const sizeOf: (size: number | undefined, links: number) => number;
|
|
13
|
+
/** A note's drawn radius in pixels, from its relative size: a leaf about four, a busy hub about nine. */
|
|
14
|
+
export declare const pixelsOf: (size: number) => number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pixelsOf } from '../connect-graph/sizes.js';
|
|
1
2
|
import { chromeOver, clearOf, ease, gesturesHeld, groupsOver, nameStart, signalsOver, } from '../overlay/overlay.js';
|
|
2
3
|
import { EMPHASES, HelixNodeProgram, MATERIALS, SHAPES } from './node-program.js';
|
|
3
4
|
const SETTINGS = [
|
|
@@ -21,7 +22,6 @@ const SAMPLE = 250;
|
|
|
21
22
|
const STILL = 0.004;
|
|
22
23
|
const HELD = 2;
|
|
23
24
|
const IDLE = 2000;
|
|
24
|
-
const pixels = (size) => 1.6 + 1.35 * Math.max(size, 0);
|
|
25
25
|
const hoverOf = (looks) => (context, data, settings) => {
|
|
26
26
|
if (!data.label) {
|
|
27
27
|
return;
|
|
@@ -180,7 +180,7 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
180
180
|
for (const node of nodes) {
|
|
181
181
|
graph.addNode(node.id, {
|
|
182
182
|
label: node.label,
|
|
183
|
-
size:
|
|
183
|
+
size: pixelsOf(node.size),
|
|
184
184
|
x: node.x,
|
|
185
185
|
y: node.y,
|
|
186
186
|
});
|
|
@@ -330,7 +330,7 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
330
330
|
}
|
|
331
331
|
const growing = new Map();
|
|
332
332
|
for (const node of changes.addNodes) {
|
|
333
|
-
const size =
|
|
333
|
+
const size = pixelsOf(node.size);
|
|
334
334
|
graph.addNode(node.id, {
|
|
335
335
|
label: node.label,
|
|
336
336
|
size: animate ? 0 : size,
|
|
@@ -351,7 +351,7 @@ export const sigmaDriver = (sigma, options = {}) => {
|
|
|
351
351
|
}
|
|
352
352
|
graph.mergeNodeAttributes(node.id, {
|
|
353
353
|
label: node.label,
|
|
354
|
-
size:
|
|
354
|
+
size: pixelsOf(node.size),
|
|
355
355
|
...(moves.has(node.id) ? {} : { x: node.x, y: node.y }),
|
|
356
356
|
});
|
|
357
357
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type GraphSnapshotOptions, graphSnapshot, type SnapshotLink, type SnapshotNode, } from './snapshot.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { graphSnapshot, } from './snapshot.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { NodeLook, NoteFacts } from '../connect-graph/looks.js';
|
|
2
|
+
import { type GraphReach } from '../connect-graph/reach.js';
|
|
3
|
+
/** A note, as a snapshot draws it: the frame's own, with its place given. */
|
|
4
|
+
export interface SnapshotNode extends NoteFacts {
|
|
5
|
+
id: string;
|
|
6
|
+
label?: string | undefined;
|
|
7
|
+
size?: number | undefined;
|
|
8
|
+
x?: number | undefined;
|
|
9
|
+
y?: number | undefined;
|
|
10
|
+
}
|
|
11
|
+
/** A link, as a snapshot draws it. */
|
|
12
|
+
export interface SnapshotLink {
|
|
13
|
+
source: string;
|
|
14
|
+
target: string;
|
|
15
|
+
}
|
|
16
|
+
/** What a snapshot draws, and the box it is drawn for. */
|
|
17
|
+
export interface GraphSnapshotOptions {
|
|
18
|
+
/** The selected note, lit as the frame lights it. None by default. */
|
|
19
|
+
active?: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The part of the graph the drawing fits, as Sigma's `setCustomBBox` takes
|
|
22
|
+
* it. The box around the notes by default. Give both the same box.
|
|
23
|
+
*/
|
|
24
|
+
bounds?: {
|
|
25
|
+
x: readonly [number, number];
|
|
26
|
+
y: readonly [number, number];
|
|
27
|
+
} | null | undefined;
|
|
28
|
+
/** How heavily a note is drawn where it does not say, as the Sigma driver's `emphasis`. */
|
|
29
|
+
emphasis?: NodeLook['emphasis'];
|
|
30
|
+
/** Whether each group's name is drawn over its notes, as the Sigma driver's `groupLabels`. On by default. */
|
|
31
|
+
groupLabels?: boolean | undefined;
|
|
32
|
+
/** The box's height, in pixels. 480 by default. */
|
|
33
|
+
height?: number | undefined;
|
|
34
|
+
/** Which notes are named, as the Sigma driver's `labels`. `hover` by default. */
|
|
35
|
+
labels?: 'all' | 'hover' | 'hubs' | undefined;
|
|
36
|
+
/** The room kept at each edge, in pixels, as Sigma's `stagePadding`. 30 by default. */
|
|
37
|
+
padding?: number | undefined;
|
|
38
|
+
/** How far `active` reaches, as `connectGraph`'s `reach`. `links` by default. */
|
|
39
|
+
reach?: GraphReach | undefined;
|
|
40
|
+
/** A note's shape where it does not say, as the Sigma driver's `shape`. */
|
|
41
|
+
shape?: NodeLook['shape'];
|
|
42
|
+
/** The box's width, in pixels. 1200 by default. */
|
|
43
|
+
width?: number | undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* **The graph drawn on the server, as an SVG in the theme's colors**, for a
|
|
47
|
+
* page to paint before the renderer has loaded. Put it in the frame after
|
|
48
|
+
* the element the renderer draws in, and `connectGraph` fades it out once
|
|
49
|
+
* the renderer has drawn the graph whole.
|
|
50
|
+
*
|
|
51
|
+
* **Where Sigma.js draws it**: fitted to the box as Sigma fits a graph, each
|
|
52
|
+
* note its size and its group's or tone's color, each link tinted by the
|
|
53
|
+
* note it leaves, and the names placed after their notes and left out where
|
|
54
|
+
* one would cover another. The colors are the theme's tokens, so it is
|
|
55
|
+
* light or dark with the page.
|
|
56
|
+
*
|
|
57
|
+
* **Whole or absent.** An empty string where any note has no `x` and `y`,
|
|
58
|
+
* since only a layout run in the browser could place it.
|
|
59
|
+
*/
|
|
60
|
+
export declare const graphSnapshot: (nodes: readonly SnapshotNode[], links: readonly SnapshotLink[], options?: GraphSnapshotOptions) => string;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { reachOf } from '../connect-graph/reach.js';
|
|
2
|
+
import { pixelsOf, sizeOf } from '../connect-graph/sizes.js';
|
|
3
|
+
import { clearOf } from '../overlay/overlay.js';
|
|
4
|
+
const SERIES = 24;
|
|
5
|
+
const FADED = 75;
|
|
6
|
+
const HUB = 6.5;
|
|
7
|
+
const LABEL = 13;
|
|
8
|
+
const widthOf = (text, size) => text.length * size * 0.56;
|
|
9
|
+
const escaped = (text) => text.replace(/[&<>"']/g, (character) => ({ "'": ''', '"': '"', '&': '&', '<': '<', '>': '>' })[character]);
|
|
10
|
+
const short = (value) => String(Math.round(value * 10) / 10);
|
|
11
|
+
const token = (name) => `var(--helix-color-${name})`;
|
|
12
|
+
const mix = (from, to, amount) => amount <= 0 ? from : `color-mix(in srgb, ${to} ${short(amount)}%, ${from})`;
|
|
13
|
+
const toneOf = (tone) => tone === 'primary'
|
|
14
|
+
? token('primary')
|
|
15
|
+
: tone === 'neutral'
|
|
16
|
+
? token('foreground-muted')
|
|
17
|
+
: token(tone);
|
|
18
|
+
const correctionOf = (width, height, across, down) => {
|
|
19
|
+
const box = height / width;
|
|
20
|
+
const graph = down / across;
|
|
21
|
+
if ((box < 1 && graph > 1) || (box > 1 && graph < 1)) {
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
const ratio = Math.min(Math.max(graph, 1 / graph), Math.max(1 / box, box));
|
|
25
|
+
return Number.isFinite(ratio) ? ratio : 1;
|
|
26
|
+
};
|
|
27
|
+
const shapeOf = (shape, x, y, radius, paint) => {
|
|
28
|
+
if (shape === 'square') {
|
|
29
|
+
const half = radius * 0.88;
|
|
30
|
+
return `<rect height="${short(half * 2)}" rx="${short(radius * 0.18)}" width="${short(half * 2)}" x="${short(x - half)}" y="${short(y - half)}" ${paint}/>`;
|
|
31
|
+
}
|
|
32
|
+
if (shape === 'diamond') {
|
|
33
|
+
const reach = radius * 1.216;
|
|
34
|
+
return `<path d="M${short(x)} ${short(y - reach)}L${short(x + reach)} ${short(y)}L${short(x)} ${short(y + reach)}L${short(x - reach)} ${short(y)}Z" ${paint}/>`;
|
|
35
|
+
}
|
|
36
|
+
return `<circle cx="${short(x)}" cy="${short(y)}" r="${short(radius)}" ${paint}/>`;
|
|
37
|
+
};
|
|
38
|
+
export const graphSnapshot = (nodes, links, options = {}) => {
|
|
39
|
+
const width = options.width ?? 1200;
|
|
40
|
+
const height = options.height ?? 480;
|
|
41
|
+
const padding = options.padding ?? 30;
|
|
42
|
+
if (nodes.length === 0 ||
|
|
43
|
+
nodes.some((node) => !(Number.isFinite(node.x) && Number.isFinite(node.y)))) {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
const notes = new Map(nodes.map((node) => [node.id, node]));
|
|
47
|
+
const drawn = links.filter((link) => link.source !== link.target && notes.has(link.source) && notes.has(link.target));
|
|
48
|
+
const xs = nodes.map((node) => node.x);
|
|
49
|
+
const ys = nodes.map((node) => node.y);
|
|
50
|
+
const [left, right] = options.bounds?.x ?? [Math.min(...xs), Math.max(...xs)];
|
|
51
|
+
const [bottom, top] = options.bounds?.y ?? [Math.min(...ys), Math.max(...ys)];
|
|
52
|
+
const extent = Math.max(right - left, top - bottom) || 1;
|
|
53
|
+
const scale = ((Math.min(width, height) - 2 * padding) *
|
|
54
|
+
correctionOf(width, height, right - left, top - bottom)) /
|
|
55
|
+
extent;
|
|
56
|
+
const at = (node) => ({
|
|
57
|
+
x: width / 2 + (node.x - (left + right) / 2) * scale,
|
|
58
|
+
y: height / 2 - (node.y - (bottom + top) / 2) * scale,
|
|
59
|
+
});
|
|
60
|
+
const selected = options.active && notes.has(options.active) ? options.active : undefined;
|
|
61
|
+
const reached = selected === undefined ? undefined : reachOf(selected, drawn, options.reach ?? 'links');
|
|
62
|
+
const near = reached?.near ?? new Set();
|
|
63
|
+
const groups = [...new Set(nodes.flatMap((node) => (node.group ? [node.group] : [])))].sort();
|
|
64
|
+
const series = new Map(groups.map((group, index) => [group, token(`series-${(index % SERIES) + 1}`)]));
|
|
65
|
+
const own = (node) => node.tone
|
|
66
|
+
? toneOf(node.tone)
|
|
67
|
+
: ((node.group && series.get(node.group)) ?? token('foreground-muted'));
|
|
68
|
+
const ground = token('surface-sunken');
|
|
69
|
+
const faded = (node) => selected !== undefined && node.id !== selected && !near.has(node.id);
|
|
70
|
+
const degree = new Map();
|
|
71
|
+
for (const { source, target } of drawn) {
|
|
72
|
+
degree.set(source, (degree.get(source) ?? 0) + 1);
|
|
73
|
+
degree.set(target, (degree.get(target) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
const radiusOf = (node) => pixelsOf(sizeOf(node.size, degree.get(node.id) ?? 0)) *
|
|
76
|
+
(node.id === selected ? 1.5 : near.has(node.id) ? 1.25 : 1);
|
|
77
|
+
const along = Math.min(Math.max(Math.log(drawn.length / 50) / Math.log(2000 / 50), 0), 1);
|
|
78
|
+
const rest = (0.35 + (0.72 - 0.35) * along) * 100;
|
|
79
|
+
const lines = [];
|
|
80
|
+
const raisedLines = [];
|
|
81
|
+
for (const link of drawn) {
|
|
82
|
+
const from = at(notes.get(link.source));
|
|
83
|
+
const to = at(notes.get(link.target));
|
|
84
|
+
const lit = reached?.along(link.source, link.target) ?? false;
|
|
85
|
+
const color = lit
|
|
86
|
+
? own(notes.get(selected))
|
|
87
|
+
: selected !== undefined
|
|
88
|
+
? mix(token('border'), ground, 60)
|
|
89
|
+
: mix(own(notes.get(link.source)), ground, rest);
|
|
90
|
+
(lit ? raisedLines : lines).push(`<line style="stroke:${color}" stroke-width="${lit ? 1.2 : 0.6}" x1="${short(from.x)}" x2="${short(to.x)}" y1="${short(from.y)}" y2="${short(to.y)}"/>`);
|
|
91
|
+
}
|
|
92
|
+
const order = (node) => node.id === selected ? 2 : near.has(node.id) ? 1 : 0;
|
|
93
|
+
const shapes = [...nodes]
|
|
94
|
+
.sort((one, other) => order(one) - order(other))
|
|
95
|
+
.map((node) => {
|
|
96
|
+
const { x, y } = at(node);
|
|
97
|
+
const radius = radiusOf(node);
|
|
98
|
+
const color = faded(node) ? mix(own(node), ground, FADED) : own(node);
|
|
99
|
+
const shape = node.shape ?? options.shape ?? 'circle';
|
|
100
|
+
const emphasis = shape === 'ring' ? 'outlined' : (node.emphasis ?? options.emphasis ?? 'solid');
|
|
101
|
+
const body = emphasis === 'solid'
|
|
102
|
+
? shapeOf(shape === 'ring' ? 'circle' : shape, x, y, radius, `style="fill:${color}"`)
|
|
103
|
+
:
|
|
104
|
+
(emphasis === 'subtle'
|
|
105
|
+
? shapeOf(shape, x, y, radius, `fill-opacity="0.3" style="fill:${color}"`)
|
|
106
|
+
: '') +
|
|
107
|
+
shapeOf(shape === 'ring' ? 'circle' : shape, x, y, radius * 0.84, `fill="none" stroke-width="${short(radius * 0.32)}" style="stroke:${color}"`);
|
|
108
|
+
const glow = node.id === selected
|
|
109
|
+
? `<circle cx="${short(x)}" cy="${short(y)}" fill-opacity="0.18" r="${short(radius * 2.2)}" style="fill:${color}"/>` +
|
|
110
|
+
`<circle cx="${short(x)}" cy="${short(y)}" fill="none" r="${short(radius * 1.32)}" stroke-width="${short(Math.max(radius * 0.08, 1))}" style="stroke:${color}"/>`
|
|
111
|
+
: '';
|
|
112
|
+
return glow + body;
|
|
113
|
+
});
|
|
114
|
+
const naming = options.labels ?? 'hover';
|
|
115
|
+
const taken = [];
|
|
116
|
+
const names = [];
|
|
117
|
+
const candidates = nodes
|
|
118
|
+
.filter((node) => {
|
|
119
|
+
if (!node.label && !node.id) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (node.id === selected) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
if (faded(node)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return (near.has(node.id) ||
|
|
129
|
+
naming === 'all' ||
|
|
130
|
+
(naming === 'hubs' && pixelsOf(sizeOf(node.size, degree.get(node.id) ?? 0)) >= HUB));
|
|
131
|
+
})
|
|
132
|
+
.sort((one, other) => (other.id === selected ? 1 : 0) - (one.id === selected ? 1 : 0) ||
|
|
133
|
+
radiusOf(other) - radiusOf(one));
|
|
134
|
+
for (const node of candidates) {
|
|
135
|
+
const text = node.label || node.id;
|
|
136
|
+
const strong = node.id === selected || near.has(node.id);
|
|
137
|
+
const size = strong ? LABEL : LABEL - 1;
|
|
138
|
+
const { x, y } = at(node);
|
|
139
|
+
const radius = radiusOf(node);
|
|
140
|
+
const wide = widthOf(text, size);
|
|
141
|
+
const line = y + size / 3;
|
|
142
|
+
const boxAt = (from) => ({
|
|
143
|
+
bottom: line + 4,
|
|
144
|
+
left: from - 2,
|
|
145
|
+
right: from + wide + 2,
|
|
146
|
+
top: line - size,
|
|
147
|
+
});
|
|
148
|
+
const after = x + radius + 4;
|
|
149
|
+
const fits = (from) => {
|
|
150
|
+
const box = boxAt(from);
|
|
151
|
+
return box.left >= 0 && box.right <= width && clearOf(box, taken);
|
|
152
|
+
};
|
|
153
|
+
const before = x - radius - 4 - wide;
|
|
154
|
+
const start = fits(after) || !fits(before) ? after : before;
|
|
155
|
+
const box = boxAt(start);
|
|
156
|
+
if (!clearOf(box, taken) && node.id !== selected) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
taken.push(box);
|
|
160
|
+
names.push(`<text font-size="${size}" font-weight="${strong ? 600 : 500}" style="fill:${token(strong ? 'foreground' : 'foreground-muted')};stroke:${ground}" x="${short(start)}" y="${short(line)}">${escaped(text)}</text>`);
|
|
161
|
+
}
|
|
162
|
+
const regions = [];
|
|
163
|
+
if (options.groupLabels !== false) {
|
|
164
|
+
const ownGroup = selected === undefined ? undefined : notes.get(selected)?.group;
|
|
165
|
+
for (const group of groups) {
|
|
166
|
+
const members = nodes.filter((node) => node.group === group);
|
|
167
|
+
const points = members.map(at);
|
|
168
|
+
const middle = {
|
|
169
|
+
x: points.reduce((sum, point) => sum + point.x, 0) / points.length,
|
|
170
|
+
y: points.reduce((sum, point) => sum + point.y, 0) / points.length,
|
|
171
|
+
};
|
|
172
|
+
const spread = points.reduce((sum, point) => sum + Math.hypot(point.x - middle.x, point.y - middle.y), 0) /
|
|
173
|
+
points.length;
|
|
174
|
+
if (points.length < 2 || spread < 12) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const name = group.toLocaleUpperCase();
|
|
178
|
+
const half = (widthOf(name, 11) * 1.14) / 2 + 4;
|
|
179
|
+
const boxAt = (y) => ({
|
|
180
|
+
bottom: y + 9,
|
|
181
|
+
left: middle.x - half,
|
|
182
|
+
right: middle.x + half,
|
|
183
|
+
top: y - 9,
|
|
184
|
+
});
|
|
185
|
+
const y = [0, 18, -18]
|
|
186
|
+
.map((step) => middle.y + step)
|
|
187
|
+
.find((one) => clearOf(boxAt(one), taken));
|
|
188
|
+
if (y === undefined) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
taken.push(boxAt(y));
|
|
192
|
+
const tinted = members.find((node) => node.tone === undefined) ?? members[0];
|
|
193
|
+
const color = mix(tinted ? own(tinted) : token('foreground-muted'), token('foreground'), 60);
|
|
194
|
+
const fade = selected !== undefined && ownGroup !== group ? 0.3 : 0.92;
|
|
195
|
+
regions.push(`<text dominant-baseline="middle" fill-opacity="${fade}" font-size="11" font-weight="650" letter-spacing="0.14em" style="fill:${color};stroke:${ground}" text-anchor="middle" x="${short(middle.x)}" y="${short(y)}">${escaped(name)}</text>`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return (`<div aria-hidden="true" data-helix-graph-snapshot style="inset:0;overflow:hidden;pointer-events:none;position:absolute">` +
|
|
199
|
+
`<svg height="${height}" viewBox="0 0 ${width} ${height}" width="${width}" xmlns="http://www.w3.org/2000/svg" style="left:50%;position:absolute;top:50%;translate:-50% -50%">` +
|
|
200
|
+
`<g stroke-linecap="round">${lines.join('')}${raisedLines.join('')}</g>` +
|
|
201
|
+
`<g>${shapes.join('')}</g>` +
|
|
202
|
+
`<g paint-order="stroke" stroke-linejoin="round" stroke-width="3">${regions.join('')}${names.join('')}</g>` +
|
|
203
|
+
`</svg></div>`);
|
|
204
|
+
};
|
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, cosmos.gl, three.js, or a driver of your own.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.136.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./dist/sigma/index.d.ts",
|
|
34
34
|
"default": "./dist/sigma/index.js"
|
|
35
35
|
},
|
|
36
|
+
"./snapshot": {
|
|
37
|
+
"types": "./dist/snapshot/index.d.ts",
|
|
38
|
+
"default": "./dist/snapshot/index.js"
|
|
39
|
+
},
|
|
36
40
|
"./three": {
|
|
37
41
|
"types": "./dist/three/index.d.ts",
|
|
38
42
|
"default": "./dist/three/index.js"
|
|
@@ -51,12 +55,12 @@
|
|
|
51
55
|
},
|
|
52
56
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
53
57
|
"dependencies": {
|
|
54
|
-
"@fusion.dev/helix-core": "0.
|
|
58
|
+
"@fusion.dev/helix-core": "0.136.0"
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|
|
57
61
|
"@cosmos.gl/graph": "^3.4.2",
|
|
58
|
-
"@fusion.dev/helix-headless": "0.
|
|
59
|
-
"@fusion.dev/helix-testing": "0.
|
|
62
|
+
"@fusion.dev/helix-headless": "0.136.0",
|
|
63
|
+
"@fusion.dev/helix-testing": "0.136.0",
|
|
60
64
|
"@types/bun": "^1.3.0",
|
|
61
65
|
"@types/three": "^0.186.0",
|
|
62
66
|
"d3-force-3d": "^3.0.6",
|