@fusion.dev/helix-graph 0.119.0 → 0.121.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 +171 -6
- package/dist/connect-graph/arrange.d.ts +18 -0
- package/dist/connect-graph/arrange.js +92 -0
- package/dist/connect-graph/connect-graph.d.ts +58 -17
- package/dist/connect-graph/connect-graph.js +155 -34
- package/dist/connect-graph/diff.d.ts +50 -0
- package/dist/connect-graph/diff.js +48 -0
- package/dist/connect-graph/looks.d.ts +39 -3
- package/dist/connect-graph/looks.js +29 -6
- package/dist/connect-graph/seed.js +11 -2
- package/dist/connect-graph/stencil.d.ts +34 -0
- package/dist/connect-graph/stencil.js +121 -0
- package/dist/cosmos/cosmos-driver.d.ts +67 -0
- package/dist/cosmos/cosmos-driver.js +467 -0
- package/dist/cosmos/index.d.ts +1 -0
- package/dist/cosmos/index.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/overlay/overlay.d.ts +59 -0
- package/dist/overlay/overlay.js +296 -0
- package/dist/sigma/node-program.d.ts +71 -0
- package/dist/sigma/node-program.js +170 -0
- package/dist/sigma/sigma-driver.d.ts +44 -3
- package/dist/sigma/sigma-driver.js +197 -40
- package/package.json +25 -6
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const RASTER = 1024;
|
|
2
|
+
const PROBE = 64;
|
|
3
|
+
const WAVE = 0.07;
|
|
4
|
+
const seeded = (seed) => {
|
|
5
|
+
let state = seed >>> 0 || 1;
|
|
6
|
+
return () => {
|
|
7
|
+
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
|
8
|
+
return state / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
const insideOf = (stencil) => {
|
|
12
|
+
if ('inside' in stencil) {
|
|
13
|
+
return stencil.inside;
|
|
14
|
+
}
|
|
15
|
+
const scale = RASTER / Math.max(stencil.width, stencil.height, 1);
|
|
16
|
+
const width = Math.max(1, Math.ceil(stencil.width * scale));
|
|
17
|
+
const height = Math.max(1, Math.ceil(stencil.height * scale));
|
|
18
|
+
const canvas = document.createElement('canvas');
|
|
19
|
+
canvas.width = width;
|
|
20
|
+
canvas.height = height;
|
|
21
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
22
|
+
if (!context) {
|
|
23
|
+
return () => true;
|
|
24
|
+
}
|
|
25
|
+
context.scale(scale, scale);
|
|
26
|
+
context.fill(new Path2D(stencil.path), 'evenodd');
|
|
27
|
+
const alpha = context.getImageData(0, 0, width, height).data;
|
|
28
|
+
return (x, y) => {
|
|
29
|
+
const column = Math.floor(x * scale);
|
|
30
|
+
const row = Math.floor(y * scale);
|
|
31
|
+
return (column >= 0 &&
|
|
32
|
+
row >= 0 &&
|
|
33
|
+
column < width &&
|
|
34
|
+
row < height &&
|
|
35
|
+
(alpha[(row * width + column) * 4 + 3] ?? 0) > 127);
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export const placesIn = (inside, width, height, count) => {
|
|
39
|
+
let filled = 0;
|
|
40
|
+
for (let row = 0; row < PROBE; row++) {
|
|
41
|
+
for (let column = 0; column < PROBE; column++) {
|
|
42
|
+
if (inside(((column + 0.5) / PROBE) * width, ((row + 0.5) / PROBE) * height)) {
|
|
43
|
+
filled += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (count === 0 || filled === 0) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const random = seeded(count);
|
|
51
|
+
let step = Math.sqrt(((filled / PROBE ** 2) * width * height) / count);
|
|
52
|
+
for (let tries = 0; tries < 8; tries++) {
|
|
53
|
+
const places = [];
|
|
54
|
+
for (let y = step / 2; y < height; y += step) {
|
|
55
|
+
for (let x = step / 2; x < width; x += step) {
|
|
56
|
+
const place = {
|
|
57
|
+
x: x + (random() - 0.5) * step * 0.8,
|
|
58
|
+
y: y + (random() - 0.5) * step * 0.8,
|
|
59
|
+
};
|
|
60
|
+
if (inside(place.x, place.y)) {
|
|
61
|
+
places.push(place);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (places.length >= count) {
|
|
66
|
+
for (let at = places.length - 1; at > 0; at--) {
|
|
67
|
+
const other = Math.floor(random() * (at + 1));
|
|
68
|
+
[places[at], places[other]] = [places[other], places[at]];
|
|
69
|
+
}
|
|
70
|
+
return places.slice(0, count);
|
|
71
|
+
}
|
|
72
|
+
step *= Math.sqrt(places.length / count) * 0.97 || 0.5;
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
};
|
|
76
|
+
export const fitToStencil = (positions, stencil) => {
|
|
77
|
+
const notes = [...positions].map(([id, at]) => ({ at, id }));
|
|
78
|
+
if (notes.length === 0) {
|
|
79
|
+
return new Map();
|
|
80
|
+
}
|
|
81
|
+
const places = placesIn(insideOf(stencil), stencil.width, stencil.height, notes.length);
|
|
82
|
+
if (places.length < notes.length) {
|
|
83
|
+
return new Map(positions);
|
|
84
|
+
}
|
|
85
|
+
let left = Number.POSITIVE_INFINITY;
|
|
86
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
87
|
+
let bottom = Number.POSITIVE_INFINITY;
|
|
88
|
+
let top = Number.NEGATIVE_INFINITY;
|
|
89
|
+
for (const { at } of notes) {
|
|
90
|
+
left = Math.min(left, at.x);
|
|
91
|
+
right = Math.max(right, at.x);
|
|
92
|
+
bottom = Math.min(bottom, at.y);
|
|
93
|
+
top = Math.max(top, at.y);
|
|
94
|
+
}
|
|
95
|
+
const scale = Math.max(right - left, top - bottom, 1) / Math.max(stencil.width, stencil.height);
|
|
96
|
+
const { height, width } = stencil;
|
|
97
|
+
const spots = places
|
|
98
|
+
.map((place) => ({
|
|
99
|
+
across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
|
|
100
|
+
at: {
|
|
101
|
+
x: (left + right) / 2 + (place.x - width / 2) * scale,
|
|
102
|
+
y: (bottom + top) / 2 - (place.y - height / 2) * scale,
|
|
103
|
+
},
|
|
104
|
+
down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
|
|
105
|
+
}))
|
|
106
|
+
.sort((one, other) => one.across - other.across);
|
|
107
|
+
notes.sort((one, other) => one.at.x - other.at.x || one.at.y - other.at.y);
|
|
108
|
+
const strip = Math.ceil(Math.sqrt(notes.length));
|
|
109
|
+
const fitted = new Map();
|
|
110
|
+
for (let from = 0; from < notes.length; from += strip) {
|
|
111
|
+
const ours = notes.slice(from, from + strip).sort((one, other) => one.at.y - other.at.y);
|
|
112
|
+
const theirs = spots
|
|
113
|
+
.slice(from, from + strip)
|
|
114
|
+
.sort((one, other) => one.down - other.down)
|
|
115
|
+
.map((spot) => spot.at);
|
|
116
|
+
ours.forEach((note, at) => {
|
|
117
|
+
fitted.set(note.id, theirs[at]);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return fitted;
|
|
121
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { GraphDriver } from '../connect-graph/connect-graph.js';
|
|
2
|
+
import type { NodeLook } from '../connect-graph/looks.js';
|
|
3
|
+
/** A cosmos.gl graph, as far as the driver calls it. */
|
|
4
|
+
export interface CosmosGraph {
|
|
5
|
+
fitView(duration?: number, padding?: number): void;
|
|
6
|
+
fitViewByPointIndices(indices: number[], duration?: number, padding?: number): void;
|
|
7
|
+
getPointPositions(): number[];
|
|
8
|
+
getTrackedPointPositionsMap(): ReadonlyMap<number, [number, number]>;
|
|
9
|
+
getZoomLevel(): number;
|
|
10
|
+
readonly isReady: boolean;
|
|
11
|
+
readonly ready: Promise<void>;
|
|
12
|
+
pause(): void;
|
|
13
|
+
render(simulationAlpha?: number, transitionDuration?: number): void;
|
|
14
|
+
screenToSpacePosition(screenPosition: [number, number]): [number, number];
|
|
15
|
+
setConfigPartial(config: Record<string, unknown>): void;
|
|
16
|
+
setLinkColors(linkColors: Float32Array): void;
|
|
17
|
+
setLinks(links: Float32Array): void;
|
|
18
|
+
setLinkWidths(linkWidths: Float32Array): void;
|
|
19
|
+
setClusterPositions(clusterPositions: (number | undefined)[]): void;
|
|
20
|
+
setPinnedPoints(pinnedIndices: number[] | null): void;
|
|
21
|
+
setPointClusters(pointClusters: (number | undefined)[]): void;
|
|
22
|
+
setPointColors(pointColors: Float32Array): void;
|
|
23
|
+
setPointPositions(pointPositions: Float32Array, dontRescale?: boolean): void;
|
|
24
|
+
setPointShapes(pointShapes: Float32Array): void;
|
|
25
|
+
setPointSizes(pointSizes: Float32Array): void;
|
|
26
|
+
setZoomLevel(value: number, duration?: number): void;
|
|
27
|
+
setZoomTransformByPointPositions(positions: Float32Array, duration?: number, scale?: number): void;
|
|
28
|
+
spaceToScreenPosition(spacePosition: [number, number]): [number, number];
|
|
29
|
+
spaceToScreenRadius(spaceRadius: number): number;
|
|
30
|
+
start(alpha?: number): void;
|
|
31
|
+
trackPointPositionsByIndices(indices: number[]): void;
|
|
32
|
+
zoomToPointByIndex(index: number, duration?: number, scale?: number, canZoomOut?: boolean): void;
|
|
33
|
+
}
|
|
34
|
+
/** How the cosmos.gl driver behaves where the page does not say. */
|
|
35
|
+
export interface CosmosDriverOptions {
|
|
36
|
+
/** Whether each group's name is drawn over its notes. On by default. */
|
|
37
|
+
groupLabels?: boolean | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The longest a layout runs, in milliseconds, settled or not. 15 seconds by
|
|
40
|
+
* default, past where twenty thousand notes settle on a laptop's GPU.
|
|
41
|
+
*/
|
|
42
|
+
layoutLimit?: number | undefined;
|
|
43
|
+
/** A note's shape where it does not say: `circle` by default, `square` or `diamond`. */
|
|
44
|
+
shape?: NodeLook['shape'];
|
|
45
|
+
/** The size of cosmos.gl's space, as its own `spaceSize` says. 4096 by default. */
|
|
46
|
+
spaceSize?: number | undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Drives cosmos.gl for a Graph frame: the layout and the drawing both run on
|
|
50
|
+
* the GPU, which is the renderer to reach for past fifty thousand notes. Make
|
|
51
|
+
* the graph in a box inside the frame and pass both here, and
|
|
52
|
+
* `connectGraph` fills it:
|
|
53
|
+
*
|
|
54
|
+
* ```js
|
|
55
|
+
* const graph = new Graph(box, { enableSimulation: true })
|
|
56
|
+
* connectGraph(frame, cosmosDriver(graph, box))
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* **cosmos.gl draws notes with its own shaders**, so a note's shape is
|
|
60
|
+
* honored and its `material` and `emphasis` are not, and it names nothing:
|
|
61
|
+
* the driver names the hovered note, the selected one and its neighbors, and
|
|
62
|
+
* each group, over the drawing, as the Sigma driver does.
|
|
63
|
+
*
|
|
64
|
+
* **It is `@cosmos.gl/graph`, which is MIT**, and not Cosmograph, which is
|
|
65
|
+
* licensed for noncommercial use only.
|
|
66
|
+
*/
|
|
67
|
+
export declare const cosmosDriver: (graph: CosmosGraph, container: HTMLElement, options?: CosmosDriverOptions) => GraphDriver;
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { gesturesHeld, groupsOver, signalsOver } from '../overlay/overlay.js';
|
|
2
|
+
const SHAPES = { circle: 0, diamond: 3, ring: 0, square: 1 };
|
|
3
|
+
const STEP = 250;
|
|
4
|
+
const FAINT = 0.16;
|
|
5
|
+
const MORPH = 1400;
|
|
6
|
+
const ZOOM = 1.5;
|
|
7
|
+
const SAMPLE = 24;
|
|
8
|
+
const pixels = (size) => 0.5 * (1.6 + 1.35 * Math.max(size, 0));
|
|
9
|
+
const CLUSTER = 0.1;
|
|
10
|
+
const FOLLOW = 1000;
|
|
11
|
+
const PADDING = 0.08;
|
|
12
|
+
const channels = (color, alpha = 1) => {
|
|
13
|
+
const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
|
|
14
|
+
return [red / 255, green / 255, blue / 255, alpha];
|
|
15
|
+
};
|
|
16
|
+
export const cosmosDriver = (graph, container, options = {}) => {
|
|
17
|
+
let ids = [];
|
|
18
|
+
let index = new Map();
|
|
19
|
+
let drawnNow = new Map();
|
|
20
|
+
let links = [];
|
|
21
|
+
let sizes = [];
|
|
22
|
+
let looks;
|
|
23
|
+
let hovered;
|
|
24
|
+
let selected;
|
|
25
|
+
let near = new Set();
|
|
26
|
+
let groupSample = [];
|
|
27
|
+
let following = new Set();
|
|
28
|
+
let moved = false;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let uploaded = 0;
|
|
31
|
+
const live = () => graph.isReady && uploaded > 0 && !stopped;
|
|
32
|
+
let clustered = [];
|
|
33
|
+
const space = options.spaceSize ?? 4096;
|
|
34
|
+
let scale = 1;
|
|
35
|
+
const toSpace = (point) => [
|
|
36
|
+
space / 2 + point.x * scale,
|
|
37
|
+
space / 2 + point.y * scale,
|
|
38
|
+
];
|
|
39
|
+
const fromSpace = (x, y) => ({
|
|
40
|
+
x: (x - space / 2) / scale,
|
|
41
|
+
y: (y - space / 2) / scale,
|
|
42
|
+
});
|
|
43
|
+
const follow = (extra = []) => {
|
|
44
|
+
const next = new Set(groupSample);
|
|
45
|
+
for (const id of [selected, ...near]) {
|
|
46
|
+
const at = id === undefined ? undefined : index.get(id);
|
|
47
|
+
if (at !== undefined) {
|
|
48
|
+
next.add(at);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (hovered !== undefined) {
|
|
52
|
+
next.add(hovered);
|
|
53
|
+
}
|
|
54
|
+
for (const at of extra) {
|
|
55
|
+
next.add(at);
|
|
56
|
+
}
|
|
57
|
+
if (next.size !== following.size || [...next].some((at) => !following.has(at))) {
|
|
58
|
+
following = next;
|
|
59
|
+
graph.trackPointPositionsByIndices([...next]);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
let tracked = new Map();
|
|
63
|
+
let trackedAt = Number.NEGATIVE_INFINITY;
|
|
64
|
+
const positionsNow = () => {
|
|
65
|
+
const now = performance.now();
|
|
66
|
+
if (now - trackedAt > 8 && live()) {
|
|
67
|
+
tracked = graph.getTrackedPointPositionsMap();
|
|
68
|
+
trackedAt = now;
|
|
69
|
+
}
|
|
70
|
+
return tracked;
|
|
71
|
+
};
|
|
72
|
+
const onScreen = (id) => {
|
|
73
|
+
const at = index.get(id);
|
|
74
|
+
const point = at === undefined ? undefined : positionsNow().get(at);
|
|
75
|
+
if (!point || at === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const [x, y] = graph.spaceToScreenPosition(point);
|
|
79
|
+
return { radius: (sizes[at] ?? 8) / 2, x, y };
|
|
80
|
+
};
|
|
81
|
+
const held = gesturesHeld(container);
|
|
82
|
+
const signals = signalsOver(container, onScreen);
|
|
83
|
+
const groups = groupsOver(container, onScreen);
|
|
84
|
+
const names = document.createElement('canvas');
|
|
85
|
+
names.setAttribute('aria-hidden', 'true');
|
|
86
|
+
names.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
|
|
87
|
+
container.append(names);
|
|
88
|
+
const drawNames = () => {
|
|
89
|
+
const context = names.getContext('2d');
|
|
90
|
+
if (!context || !looks) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const ratio = window.devicePixelRatio || 1;
|
|
94
|
+
const width = Math.round(container.clientWidth * ratio);
|
|
95
|
+
const height = Math.round(container.clientHeight * ratio);
|
|
96
|
+
if (names.width !== width || names.height !== height) {
|
|
97
|
+
names.width = width;
|
|
98
|
+
names.height = height;
|
|
99
|
+
}
|
|
100
|
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
101
|
+
context.clearRect(0, 0, width, height);
|
|
102
|
+
const taken = [];
|
|
103
|
+
const shown = [selected, hovered === undefined ? undefined : ids[hovered], ...near];
|
|
104
|
+
for (const id of shown) {
|
|
105
|
+
if (id === undefined) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const at = onScreen(id);
|
|
109
|
+
const label = looks.node(id);
|
|
110
|
+
if (!at || label.hidden) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const name = drawnNow.get(id)?.label ?? id;
|
|
114
|
+
const strong = id === selected || id === ids[hovered ?? -1];
|
|
115
|
+
context.font = `${strong ? 600 : 500} ${strong ? 13 : 12}px ${looks.font}`;
|
|
116
|
+
const x = at.x + at.radius + 4;
|
|
117
|
+
const y = at.y + 4;
|
|
118
|
+
const box = {
|
|
119
|
+
bottom: y + 4,
|
|
120
|
+
left: x - 2,
|
|
121
|
+
right: x + context.measureText(name).width + 2,
|
|
122
|
+
top: y - 13,
|
|
123
|
+
};
|
|
124
|
+
const free = taken.every((other) => box.right < other.left ||
|
|
125
|
+
box.left > other.right ||
|
|
126
|
+
box.bottom < other.top ||
|
|
127
|
+
box.top > other.bottom);
|
|
128
|
+
if (!free && !strong) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
taken.push(box);
|
|
132
|
+
context.lineJoin = 'round';
|
|
133
|
+
context.lineWidth = 3;
|
|
134
|
+
context.strokeStyle = looks.ground;
|
|
135
|
+
context.strokeText(name, x, y);
|
|
136
|
+
context.fillStyle = strong ? looks.ink : looks.quiet;
|
|
137
|
+
context.fillText(name, x, y);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const redraw = () => {
|
|
141
|
+
drawNames();
|
|
142
|
+
groups.draw();
|
|
143
|
+
};
|
|
144
|
+
const flatNow = () => live()
|
|
145
|
+
? graph.getPointPositions()
|
|
146
|
+
: ids.flatMap((id) => toSpace(drawnNow.get(id) ?? { x: 0, y: 0 }));
|
|
147
|
+
const fitAll = (duration) => {
|
|
148
|
+
if (stopped) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!graph.isReady) {
|
|
152
|
+
void graph.ready.then(() => requestAnimationFrame(() => fitAll(duration)));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!moved && live()) {
|
|
156
|
+
graph.fitView(duration, PADDING);
|
|
157
|
+
clearTimeout(overviewing);
|
|
158
|
+
overviewing = setTimeout(() => {
|
|
159
|
+
overview = graph.getZoomLevel();
|
|
160
|
+
grow();
|
|
161
|
+
}, duration + 20);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
let overview = 0;
|
|
165
|
+
let overviewing;
|
|
166
|
+
let grown = 1;
|
|
167
|
+
const grow = () => {
|
|
168
|
+
const by = overview > 0 ? Math.min(Math.max(Math.sqrt(graph.getZoomLevel() / overview), 0.7), 2.4) : 1;
|
|
169
|
+
if (Math.abs(by - grown) > 0.01) {
|
|
170
|
+
grown = by;
|
|
171
|
+
graph.setConfigPartial({ pointSizeScale: by });
|
|
172
|
+
if (Math.abs(clarity() - painted) > 0.04) {
|
|
173
|
+
paintLinks();
|
|
174
|
+
graph.render();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const clarity = () => FAINT + (1 - FAINT) * Math.min(Math.max((grown - 1) / 0.8, 0), 1);
|
|
179
|
+
let painted = 1;
|
|
180
|
+
const paintLinks = () => {
|
|
181
|
+
if (!looks) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
painted = clarity();
|
|
185
|
+
const linkColors = new Float32Array(links.length * 4);
|
|
186
|
+
const widths = new Float32Array(links.length);
|
|
187
|
+
const lit = hovered === undefined ? undefined : ids[hovered];
|
|
188
|
+
links.forEach((link, at) => {
|
|
189
|
+
const look = looks.link(link.source, link.target);
|
|
190
|
+
const touches = lit !== undefined && (link.source === lit || link.target === lit);
|
|
191
|
+
const whole = look.raised || touches;
|
|
192
|
+
linkColors.set(channels(touches ? looks.node(lit).color : look.color, look.hidden ? 0 : whole ? 1 : painted), at * 4);
|
|
193
|
+
widths[at] = whole ? 1.6 : 0.8;
|
|
194
|
+
});
|
|
195
|
+
graph.setLinkColors(linkColors);
|
|
196
|
+
graph.setLinkWidths(widths);
|
|
197
|
+
};
|
|
198
|
+
const paint = (transition = 0) => {
|
|
199
|
+
if (!looks) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const colors = new Float32Array(ids.length * 4);
|
|
203
|
+
const drawn = new Float32Array(ids.length);
|
|
204
|
+
const shapes = new Float32Array(ids.length);
|
|
205
|
+
selected = undefined;
|
|
206
|
+
near = new Set();
|
|
207
|
+
ids.forEach((id, at) => {
|
|
208
|
+
const look = looks.node(id);
|
|
209
|
+
const weight = Math.min(Math.max(((drawnNow.get(id)?.size ?? 1) - 1) / 6, 0), 1);
|
|
210
|
+
const alpha = look.selected || look.raised ? 1 : 0.5 + 0.5 * weight;
|
|
211
|
+
colors.set(channels(look.color, look.hidden ? 0 : alpha), at * 4);
|
|
212
|
+
drawn[at] = (sizes[at] ?? 8) * (look.selected ? 1.5 : look.raised ? 1.25 : 1);
|
|
213
|
+
shapes[at] = SHAPES[look.shape ?? options.shape ?? 'circle'];
|
|
214
|
+
if (look.selected) {
|
|
215
|
+
selected = id;
|
|
216
|
+
}
|
|
217
|
+
else if (look.raised) {
|
|
218
|
+
near.add(id);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
graph.setPointColors(colors);
|
|
222
|
+
graph.setPointSizes(drawn);
|
|
223
|
+
graph.setPointShapes(shapes);
|
|
224
|
+
paintLinks();
|
|
225
|
+
const focused = selected === undefined ? undefined : index.get(selected);
|
|
226
|
+
graph.setConfigPartial({
|
|
227
|
+
focusedPointIndex: focused,
|
|
228
|
+
focusedPointRingColor: selected === undefined ? undefined : channels(looks.node(selected).color),
|
|
229
|
+
hoveredPointRingColor: channels(looks.ink, 0.6),
|
|
230
|
+
});
|
|
231
|
+
follow();
|
|
232
|
+
if (transition > 0) {
|
|
233
|
+
graph.render(undefined, transition);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
graph.render();
|
|
237
|
+
}
|
|
238
|
+
redraw();
|
|
239
|
+
};
|
|
240
|
+
const load = (nodes, next, positions, transition = 0) => {
|
|
241
|
+
ids = nodes.map((node) => node.id);
|
|
242
|
+
index = new Map(ids.map((id, at) => [id, at]));
|
|
243
|
+
drawnNow = new Map(nodes.map((node) => [node.id, node]));
|
|
244
|
+
sizes = nodes.map((node) => pixels(node.size));
|
|
245
|
+
links = next.filter((link) => index.has(link.source) && index.has(link.target));
|
|
246
|
+
const pairs = new Float32Array(links.length * 2);
|
|
247
|
+
links.forEach((link, at) => {
|
|
248
|
+
pairs[at * 2] = index.get(link.source);
|
|
249
|
+
pairs[at * 2 + 1] = index.get(link.target);
|
|
250
|
+
});
|
|
251
|
+
graph.setPointPositions(positions, true);
|
|
252
|
+
graph.setLinks(pairs);
|
|
253
|
+
paint(transition);
|
|
254
|
+
uploaded = 0;
|
|
255
|
+
if (graph.isReady) {
|
|
256
|
+
uploaded = ids.length;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
void graph.ready.then(() => {
|
|
260
|
+
uploaded = ids.length;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
graph.setConfigPartial({
|
|
265
|
+
backgroundColor: [0, 0, 0, 0],
|
|
266
|
+
onPointMouseOut: () => {
|
|
267
|
+
hovered = undefined;
|
|
268
|
+
paint();
|
|
269
|
+
},
|
|
270
|
+
onPointMouseOver: (at) => {
|
|
271
|
+
hovered = at;
|
|
272
|
+
paint();
|
|
273
|
+
},
|
|
274
|
+
onSimulationTick: redraw,
|
|
275
|
+
onTransition: redraw,
|
|
276
|
+
onZoom: () => {
|
|
277
|
+
grow();
|
|
278
|
+
redraw();
|
|
279
|
+
},
|
|
280
|
+
onZoomStart: (_, userDriven) => {
|
|
281
|
+
moved ||= userDriven;
|
|
282
|
+
},
|
|
283
|
+
simulationDecay: 600,
|
|
284
|
+
simulationGravity: 1.5,
|
|
285
|
+
simulationLinkDistance: 30,
|
|
286
|
+
simulationLinkSpring: 0.15,
|
|
287
|
+
simulationRepulsion: 30,
|
|
288
|
+
spaceSize: space,
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
center: (id, animate) => {
|
|
292
|
+
const at = index.get(id);
|
|
293
|
+
if (at !== undefined) {
|
|
294
|
+
graph.zoomToPointByIndex(at, animate ? STEP * 2 : 0, graph.getZoomLevel(), true);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
draw: ({ links: next, nodes }) => {
|
|
298
|
+
const reach = Math.max(1, ...nodes.map((node) => Math.max(Math.abs(node.x), Math.abs(node.y))));
|
|
299
|
+
scale = (space * 0.12) / reach;
|
|
300
|
+
moved = false;
|
|
301
|
+
const positions = new Float32Array(nodes.length * 2);
|
|
302
|
+
nodes.forEach((node, at) => {
|
|
303
|
+
positions.set(toSpace(node), at * 2);
|
|
304
|
+
});
|
|
305
|
+
load(nodes, next, positions);
|
|
306
|
+
graph.pause();
|
|
307
|
+
fitAll(0);
|
|
308
|
+
},
|
|
309
|
+
fit: (fitted, animate) => {
|
|
310
|
+
const indices = fitted.flatMap((id) => {
|
|
311
|
+
const at = index.get(id);
|
|
312
|
+
return at === undefined ? [] : [at];
|
|
313
|
+
});
|
|
314
|
+
graph.fitViewByPointIndices(indices, animate ? STEP * 2 : 0, 0.25);
|
|
315
|
+
},
|
|
316
|
+
gestures: (next) => {
|
|
317
|
+
held.set(next);
|
|
318
|
+
},
|
|
319
|
+
groups: (next) => {
|
|
320
|
+
if (options.groupLabels === false) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
groupSample = next.flatMap((group) => group.ids.slice(0, SAMPLE).flatMap((id) => {
|
|
324
|
+
const at = index.get(id);
|
|
325
|
+
return at === undefined ? [] : [at];
|
|
326
|
+
}));
|
|
327
|
+
follow();
|
|
328
|
+
groups.set(next.map((group) => ({ ...group, ids: group.ids.slice(0, SAMPLE) })));
|
|
329
|
+
const cluster = new Map();
|
|
330
|
+
next.forEach((group, at) => {
|
|
331
|
+
for (const id of group.ids) {
|
|
332
|
+
cluster.set(id, at);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
const clusters = ids.map((id) => cluster.get(id));
|
|
336
|
+
if (clusters.length === clustered.length &&
|
|
337
|
+
clusters.every((at, position) => at === clustered[position])) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
clustered = clusters;
|
|
341
|
+
graph.setPointClusters(clusters);
|
|
342
|
+
graph.setClusterPositions(next.flatMap((_, at) => {
|
|
343
|
+
const turn = (at / Math.max(next.length, 1)) * Math.PI * 2;
|
|
344
|
+
return [
|
|
345
|
+
space / 2 + Math.cos(turn) * space * 0.2,
|
|
346
|
+
space / 2 + Math.sin(turn) * space * 0.2,
|
|
347
|
+
];
|
|
348
|
+
}));
|
|
349
|
+
graph.setConfigPartial({ simulationCluster: next.length > 1 ? CLUSTER : 0 });
|
|
350
|
+
graph.render();
|
|
351
|
+
},
|
|
352
|
+
look: (next) => {
|
|
353
|
+
looks = next;
|
|
354
|
+
signals.look(next);
|
|
355
|
+
groups.look(next);
|
|
356
|
+
paint();
|
|
357
|
+
},
|
|
358
|
+
onPress: (press) => {
|
|
359
|
+
graph.setConfigPartial({
|
|
360
|
+
onPointClick: (at) => {
|
|
361
|
+
const id = ids[at];
|
|
362
|
+
if (id !== undefined) {
|
|
363
|
+
press(id);
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
return () => graph.setConfigPartial({ onPointClick: undefined });
|
|
368
|
+
},
|
|
369
|
+
panBy: (x, y, animate) => {
|
|
370
|
+
const { height, width } = container.getBoundingClientRect();
|
|
371
|
+
const to = graph.screenToSpacePosition([width / 2 + x, height / 2 + y]);
|
|
372
|
+
graph.setZoomTransformByPointPositions(new Float32Array(to), animate ? STEP : 0, graph.getZoomLevel());
|
|
373
|
+
},
|
|
374
|
+
settle: (unplaced, placed) => {
|
|
375
|
+
graph.setPinnedPoints(ids.flatMap((id, at) => (unplaced.has(id) ? [] : [at])));
|
|
376
|
+
let done = false;
|
|
377
|
+
const finish = () => {
|
|
378
|
+
if (done) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
done = true;
|
|
382
|
+
clearTimeout(limit);
|
|
383
|
+
clearInterval(fitting);
|
|
384
|
+
graph.setPinnedPoints(null);
|
|
385
|
+
graph.pause();
|
|
386
|
+
fitAll(STEP * 2);
|
|
387
|
+
const flat = flatNow();
|
|
388
|
+
placed(new Map(ids.map((id, at) => [id, fromSpace(flat[at * 2] ?? 0, flat[at * 2 + 1] ?? 0)])));
|
|
389
|
+
};
|
|
390
|
+
graph.setConfigPartial({ onSimulationEnd: finish });
|
|
391
|
+
graph.start(1);
|
|
392
|
+
const limit = setTimeout(finish, options.layoutLimit ?? 15000);
|
|
393
|
+
const fitting = setInterval(() => fitAll(FOLLOW), FOLLOW);
|
|
394
|
+
return () => {
|
|
395
|
+
done = true;
|
|
396
|
+
clearTimeout(limit);
|
|
397
|
+
clearInterval(fitting);
|
|
398
|
+
graph.setConfigPartial({ onSimulationEnd: undefined });
|
|
399
|
+
graph.setPinnedPoints(null);
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
signal: (from, to, colors, animate) => {
|
|
403
|
+
const ends = [index.get(from), index.get(to)].filter((at) => at !== undefined);
|
|
404
|
+
follow(ends);
|
|
405
|
+
signals.signal(from, to, colors, animate);
|
|
406
|
+
},
|
|
407
|
+
stop: () => {
|
|
408
|
+
stopped = true;
|
|
409
|
+
clearTimeout(overviewing);
|
|
410
|
+
graph.setConfigPartial({
|
|
411
|
+
onPointClick: undefined,
|
|
412
|
+
onPointMouseOut: undefined,
|
|
413
|
+
onPointMouseOver: undefined,
|
|
414
|
+
onSimulationEnd: undefined,
|
|
415
|
+
onSimulationTick: undefined,
|
|
416
|
+
onTransition: undefined,
|
|
417
|
+
onZoom: undefined,
|
|
418
|
+
});
|
|
419
|
+
signals.stop();
|
|
420
|
+
groups.stop();
|
|
421
|
+
names.remove();
|
|
422
|
+
held.stop();
|
|
423
|
+
},
|
|
424
|
+
update: (changes, animate) => {
|
|
425
|
+
const flat = flatNow();
|
|
426
|
+
const kept = new Map();
|
|
427
|
+
ids.forEach((id, at) => {
|
|
428
|
+
const node = drawnNow.get(id);
|
|
429
|
+
if (node) {
|
|
430
|
+
kept.set(id, { ...node, ...fromSpace(flat[at * 2] ?? 0, flat[at * 2 + 1] ?? 0) });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
for (const id of changes.removeNodes) {
|
|
434
|
+
kept.delete(id);
|
|
435
|
+
}
|
|
436
|
+
let shifted = false;
|
|
437
|
+
for (const node of changes.changeNodes) {
|
|
438
|
+
const was = drawnNow.get(node.id);
|
|
439
|
+
const moved = was !== undefined && (was.x !== node.x || was.y !== node.y);
|
|
440
|
+
shifted ||= moved;
|
|
441
|
+
const now = kept.get(node.id);
|
|
442
|
+
kept.set(node.id, moved || !now ? node : { ...node, x: now.x, y: now.y });
|
|
443
|
+
}
|
|
444
|
+
for (const node of changes.addNodes) {
|
|
445
|
+
kept.set(node.id, node);
|
|
446
|
+
}
|
|
447
|
+
const removed = new Set(changes.removeLinks.map((link) => `${link.source}\u0000${link.target}`));
|
|
448
|
+
const next = [
|
|
449
|
+
...links.filter((link) => !removed.has(`${link.source}\u0000${link.target}`)),
|
|
450
|
+
...changes.addLinks,
|
|
451
|
+
];
|
|
452
|
+
const nodes = [...kept.values()];
|
|
453
|
+
const positions = new Float32Array(nodes.length * 2);
|
|
454
|
+
nodes.forEach((node, at) => {
|
|
455
|
+
positions.set(toSpace(node), at * 2);
|
|
456
|
+
});
|
|
457
|
+
const glide = shifted && animate ? MORPH : 0;
|
|
458
|
+
load(nodes, next, positions, glide);
|
|
459
|
+
if (shifted) {
|
|
460
|
+
fitAll(glide);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
zoomBy: (direction, animate) => {
|
|
464
|
+
graph.setZoomLevel(graph.getZoomLevel() * (direction > 0 ? ZOOM : 1 / ZOOM), animate ? STEP : 0);
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type CosmosDriverOptions, type CosmosGraph, cosmosDriver } from './cosmos-driver.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cosmosDriver } from './cosmos-driver.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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, type GraphGroup, } 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';
|
|
5
|
+
export { fitToStencil, type GraphStencil } from './connect-graph/stencil.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
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';
|
|
5
|
+
export { fitToStencil } from './connect-graph/stencil.js';
|