@fusion.dev/helix-graph 0.120.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.
@@ -1,10 +1,12 @@
1
+ import { ease, gesturesHeld, groupsOver, signalsOver } from '../overlay/overlay.js';
2
+ import { EMPHASES, HelixNodeProgram, MATERIALS, SHAPES } from './node-program.js';
1
3
  const STEP = 250;
2
4
  const ZOOM = 1.5;
3
5
  const SAMPLE = 250;
4
6
  const STILL = 0.004;
5
7
  const HELD = 2;
6
8
  const IDLE = 2000;
7
- const pixels = (size) => 2 + 2 * Math.sqrt(Math.max(size, 0));
9
+ const pixels = (size) => 1.6 + 1.35 * Math.max(size, 0);
8
10
  const hoverOf = (looks) => (context, data, settings) => {
9
11
  if (!data.label) {
10
12
  return;
@@ -25,11 +27,33 @@ const hoverOf = (looks) => (context, data, settings) => {
25
27
  context.fillStyle = looks.ink;
26
28
  context.fillText(data.label, x, data.y + size / 3);
27
29
  };
28
- const TRAVEL = 700;
29
- const PULSE = 450;
30
30
  const GROW = 300;
31
- const FLYING = 400;
32
- const ease = (at) => (at < 0.5 ? 2 * at * at : 1 - (-2 * at + 2) ** 2 / 2);
31
+ const labelOf = (looks, claim) => (context, data, settings) => {
32
+ if (!data.label) {
33
+ return;
34
+ }
35
+ const strong = data.strong === true;
36
+ const size = strong ? settings.labelSize : settings.labelSize - 1;
37
+ context.font = `${strong ? 600 : 500} ${size}px ${settings.labelFont}`;
38
+ const x = data.x + data.size + 4;
39
+ const y = data.y + size / 3;
40
+ const box = {
41
+ bottom: y + 4,
42
+ left: x - 2,
43
+ right: x + context.measureText(data.label).width + 2,
44
+ top: y - size,
45
+ };
46
+ if (!claim(box, data.forceLabel === true)) {
47
+ return;
48
+ }
49
+ context.lineJoin = 'round';
50
+ context.lineWidth = 3;
51
+ context.strokeStyle = looks.ground;
52
+ context.strokeText(data.label, x, y);
53
+ context.fillStyle = strong ? looks.ink : looks.quiet;
54
+ context.fillText(data.label, x, y);
55
+ };
56
+ const HUB = 6.5;
33
57
  const positionsOf = (graph) => {
34
58
  const positions = new Map();
35
59
  graph.forEachNode((node, attributes) => {
@@ -41,35 +65,8 @@ export const sigmaDriver = (sigma, options = {}) => {
41
65
  const graph = sigma.getGraph();
42
66
  const container = sigma.getContainer();
43
67
  const camera = sigma.getCamera();
44
- let mode = 'free';
45
- const touchAction = container.style.touchAction;
46
- const hold = (event) => {
47
- if (mode === 'free') {
48
- return;
49
- }
50
- if (mode === 'off') {
51
- event.stopImmediatePropagation();
52
- return;
53
- }
54
- if (event instanceof WheelEvent) {
55
- if (!(event.ctrlKey || event.metaKey)) {
56
- event.stopImmediatePropagation();
57
- }
58
- return;
59
- }
60
- if (typeof TouchEvent !== 'undefined' && event instanceof TouchEvent) {
61
- if (event.touches.length < 2) {
62
- event.stopImmediatePropagation();
63
- }
64
- else if (event.type === 'touchstart' && event.cancelable) {
65
- event.preventDefault();
66
- }
67
- }
68
- };
69
- const held = ['wheel', 'touchstart', 'touchmove', 'mousedown'];
70
- for (const type of held) {
71
- container.addEventListener(type, hold, { capture: true, passive: false });
72
- }
68
+ const programs = sigma.getSetting('nodeProgramClasses');
69
+ sigma.setSetting('nodeProgramClasses', { ...programs, helix: HelixNodeProgram });
73
70
  const sized = typeof ResizeObserver === 'function'
74
71
  ? new ResizeObserver(() => {
75
72
  sigma.resize(true);
@@ -77,10 +74,34 @@ export const sigmaDriver = (sigma, options = {}) => {
77
74
  })
78
75
  : undefined;
79
76
  sized?.observe(container);
80
- let overlay;
81
- let flights = [];
82
- let flying = 0;
83
- let ink = 'rgb(128, 128, 128)';
77
+ let hovered;
78
+ const enter = ({ node }) => {
79
+ hovered = node;
80
+ sigma.refresh();
81
+ };
82
+ const leave = () => {
83
+ hovered = undefined;
84
+ sigma.refresh();
85
+ };
86
+ sigma.on('enterNode', enter);
87
+ sigma.on('leaveNode', leave);
88
+ const naming = options.labels ?? 'hover';
89
+ let taken = [];
90
+ const claim = (box, must) => {
91
+ const free = taken.every((other) => box.right < other.left ||
92
+ box.left > other.right ||
93
+ box.bottom < other.top ||
94
+ box.top > other.bottom);
95
+ if (free || must) {
96
+ taken.push(box);
97
+ }
98
+ return free || must;
99
+ };
100
+ const clear = () => {
101
+ taken = [];
102
+ };
103
+ sigma.on('beforeRender', clear);
104
+ const held = gesturesHeld(container);
84
105
  const onScreen = (id) => {
85
106
  const data = sigma.getNodeDisplayData(id);
86
107
  if (!data || data.hidden) {
@@ -89,76 +110,9 @@ export const sigmaDriver = (sigma, options = {}) => {
89
110
  const at = sigma.framedGraphToViewport(data);
90
111
  return { radius: sigma.scaleSize(data.size), x: at.x, y: at.y };
91
112
  };
92
- const fly = (now) => {
93
- const context = overlay?.getContext('2d');
94
- if (!overlay || !context) {
95
- flights = [];
96
- return;
97
- }
98
- const ratio = window.devicePixelRatio || 1;
99
- const width = Math.round(container.clientWidth * ratio);
100
- const height = Math.round(container.clientHeight * ratio);
101
- if (overlay.width !== width || overlay.height !== height) {
102
- overlay.width = width;
103
- overlay.height = height;
104
- }
105
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
106
- context.clearRect(0, 0, width, height);
107
- flights = flights.filter((flight) => {
108
- const from = onScreen(flight.from);
109
- const to = onScreen(flight.to);
110
- const gone = (now - flight.started) / TRAVEL;
111
- if (!from || !to || gone >= 1 + PULSE / TRAVEL) {
112
- return false;
113
- }
114
- context.save();
115
- context.strokeStyle = flight.color;
116
- context.fillStyle = flight.color;
117
- if (gone < 1) {
118
- if (flight.animate) {
119
- const along = ease(gone);
120
- const x = from.x + (to.x - from.x) * along;
121
- const y = from.y + (to.y - from.y) * along;
122
- context.globalAlpha = 0.6;
123
- context.lineWidth = 2;
124
- context.beginPath();
125
- context.moveTo(from.x, from.y);
126
- context.lineTo(x, y);
127
- context.stroke();
128
- context.globalAlpha = 1;
129
- context.shadowBlur = 14;
130
- context.shadowColor = flight.color;
131
- context.beginPath();
132
- context.arc(x, y, 5, 0, Math.PI * 2);
133
- context.fill();
134
- context.shadowBlur = 0;
135
- context.fillStyle = 'rgba(255, 255, 255, 0.85)';
136
- context.beginPath();
137
- context.arc(x, y, 2, 0, Math.PI * 2);
138
- context.fill();
139
- }
140
- else {
141
- context.globalAlpha = 1 - gone;
142
- context.lineWidth = 2;
143
- context.beginPath();
144
- context.moveTo(from.x, from.y);
145
- context.lineTo(to.x, to.y);
146
- context.stroke();
147
- }
148
- }
149
- else if (flight.animate) {
150
- const opened = (gone - 1) / (PULSE / TRAVEL);
151
- context.globalAlpha = 1 - opened;
152
- context.lineWidth = 2.5;
153
- context.beginPath();
154
- context.arc(to.x, to.y, to.radius + 2 + opened * 16, 0, Math.PI * 2);
155
- context.stroke();
156
- }
157
- context.restore();
158
- return true;
159
- });
160
- flying = flights.length > 0 ? requestAnimationFrame(fly) : 0;
161
- };
113
+ const signals = signalsOver(container, onScreen);
114
+ const groups = groupsOver(container, onScreen);
115
+ sigma.on('afterRender', groups.draw);
162
116
  const grow = (sizes) => {
163
117
  const started = performance.now();
164
118
  const step = (now) => {
@@ -208,38 +162,83 @@ export const sigmaDriver = (sigma, options = {}) => {
208
162
  }
209
163
  }
210
164
  },
165
+ fit: (ids, animate) => {
166
+ let low = { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY };
167
+ let high = { x: Number.NEGATIVE_INFINITY, y: Number.NEGATIVE_INFINITY };
168
+ for (const id of ids) {
169
+ const at = sigma.getNodeDisplayData(id);
170
+ if (at) {
171
+ low = { x: Math.min(low.x, at.x), y: Math.min(low.y, at.y) };
172
+ high = { x: Math.max(high.x, at.x), y: Math.max(high.y, at.y) };
173
+ }
174
+ }
175
+ if (!Number.isFinite(low.x)) {
176
+ return;
177
+ }
178
+ const state = {
179
+ ratio: Math.min(Math.max(Math.max(high.x - low.x, high.y - low.y) * 2.4, 0.12), 1),
180
+ x: (low.x + high.x) / 2,
181
+ y: (low.y + high.y) / 2,
182
+ };
183
+ if (animate) {
184
+ camera.animate(state, { duration: STEP * 2 });
185
+ }
186
+ else {
187
+ camera.setState(state);
188
+ }
189
+ },
211
190
  gestures: (next) => {
212
- mode = next;
213
- container.style.touchAction =
214
- next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
191
+ held.set(next);
192
+ },
193
+ groups: (next) => {
194
+ if (options.groupLabels !== false) {
195
+ groups.set(next);
196
+ }
215
197
  },
216
198
  look: (looks) => {
217
- ink = looks.ink;
199
+ signals.look(looks);
200
+ groups.look(looks);
218
201
  sigma.setSetting('labelColor', { color: looks.ink });
202
+ sigma.setSetting('labelSize', 13);
203
+ sigma.setSetting('minEdgeThickness', 0.8);
219
204
  sigma.setSetting('labelFont', looks.font);
220
205
  sigma.setSetting('labelRenderedSizeThreshold', 0);
206
+ sigma.setSetting('labelGridCellSize', 130);
207
+ sigma.setSetting('defaultDrawNodeLabel', labelOf(looks, claim));
221
208
  sigma.setSetting('defaultDrawNodeHover', hoverOf(looks));
222
209
  sigma.setSetting('nodeReducer', (node, data) => {
223
210
  const look = looks.node(node);
224
211
  const grown = look.selected ? 1.5 : look.raised ? 1.25 : 1;
212
+ const named = look.label === 'always' ||
213
+ (look.label === 'room' &&
214
+ (look.raised ||
215
+ naming === 'all' ||
216
+ (naming === 'hubs' && typeof data.size === 'number' && data.size >= HUB)));
225
217
  return {
226
218
  ...data,
227
219
  color: look.color,
220
+ emphasisCode: EMPHASES[look.emphasis ?? options.emphasis ?? 'solid'],
228
221
  forceLabel: look.label === 'always',
222
+ glow: look.glow,
229
223
  hidden: look.hidden,
230
- label: look.label === 'never' ? null : data.label,
224
+ label: named ? data.label : null,
225
+ materialCode: MATERIALS[look.material ?? options.material ?? 'flat'],
226
+ shapeCode: SHAPES[look.shape ?? options.shape ?? 'circle'],
231
227
  size: typeof data.size === 'number' ? data.size * grown : data.size,
228
+ strong: look.selected || look.raised,
229
+ type: 'helix',
232
230
  zIndex: look.selected ? 2 : look.raised ? 1 : 0,
233
231
  };
234
232
  });
235
233
  sigma.setSetting('edgeReducer', (_edge, data) => {
236
234
  const look = looks.link(String(data.from), String(data.to));
235
+ const lit = hovered !== undefined && (data.from === hovered || data.to === hovered);
237
236
  return {
238
237
  ...data,
239
- color: look.color,
238
+ color: lit ? looks.node(hovered).color : look.color,
240
239
  hidden: look.hidden,
241
- size: look.raised ? 1.5 : 1,
242
- zIndex: look.raised ? 1 : 0,
240
+ size: look.raised || lit ? 1.6 : 0.8,
241
+ zIndex: look.raised || lit ? 1 : 0,
243
242
  };
244
243
  });
245
244
  sigma.refresh();
@@ -261,33 +260,19 @@ export const sigmaDriver = (sigma, options = {}) => {
261
260
  camera.setState(to);
262
261
  }
263
262
  },
264
- signal: (from, to, color, animate) => {
265
- if (!overlay) {
266
- overlay = document.createElement('canvas');
267
- overlay.setAttribute('aria-hidden', 'true');
268
- overlay.style.cssText =
269
- 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
270
- container.append(overlay);
271
- }
272
- flights.push({ animate, color: color ?? ink, from, started: performance.now(), to });
273
- if (flights.length > FLYING) {
274
- flights = flights.slice(-FLYING);
275
- }
276
- if (!flying) {
277
- flying = requestAnimationFrame(fly);
278
- }
263
+ signal: (from, to, colors, animate) => {
264
+ signals.signal(from, to, colors, animate);
279
265
  },
280
266
  stop: () => {
281
- cancelAnimationFrame(flying);
282
- flying = 0;
283
- flights = [];
284
- overlay?.remove();
285
- overlay = undefined;
267
+ sigma.off('afterRender', groups.draw);
268
+ sigma.off('beforeRender', clear);
269
+ groups.stop();
270
+ signals.stop();
271
+ sigma.off('enterNode', enter);
272
+ sigma.off('leaveNode', leave);
273
+ sigma.setSetting('nodeProgramClasses', programs);
286
274
  sized?.disconnect();
287
- for (const type of held) {
288
- container.removeEventListener(type, hold, { capture: true });
289
- }
290
- container.style.touchAction = touchAction;
275
+ held.stop();
291
276
  },
292
277
  update: (changes, animate) => {
293
278
  for (const id of changes.removeNodes) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-graph",
3
- "description": "Connects the Helix Graph frame to a graph renderer: Sigma.js, or a driver of your own.",
4
- "version": "0.120.0",
3
+ "description": "Connects the Helix Graph frame to a graph renderer: Sigma.js, cosmos.gl, or a driver of your own.",
4
+ "version": "0.121.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -17,6 +17,10 @@
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
19
  },
20
+ "./cosmos": {
21
+ "types": "./dist/cosmos/index.d.ts",
22
+ "default": "./dist/cosmos/index.js"
23
+ },
20
24
  "./sigma": {
21
25
  "types": "./dist/sigma/index.d.ts",
22
26
  "default": "./dist/sigma/index.js"
@@ -35,11 +39,12 @@
35
39
  },
36
40
  "bugs": "https://github.com/project-fusion/fusion/issues",
37
41
  "dependencies": {
38
- "@fusion.dev/helix-core": "0.120.0"
42
+ "@fusion.dev/helix-core": "0.121.0"
39
43
  },
40
44
  "devDependencies": {
41
- "@fusion.dev/helix-headless": "0.120.0",
42
- "@fusion.dev/helix-testing": "0.120.0",
45
+ "@cosmos.gl/graph": "^3.4.2",
46
+ "@fusion.dev/helix-headless": "0.121.0",
47
+ "@fusion.dev/helix-testing": "0.121.0",
43
48
  "@types/bun": "^1.3.0",
44
49
  "graphology": "^0.26.0",
45
50
  "graphology-layout-forceatlas2": "^0.10.1",
@@ -48,11 +53,25 @@
48
53
  },
49
54
  "homepage": "https://fusion.dev",
50
55
  "keywords": [
56
+ "accessibility",
57
+ "cosmos.gl",
51
58
  "graph",
52
59
  "network",
53
60
  "sigma",
54
- "accessibility"
61
+ "webgl"
55
62
  ],
63
+ "peerDependencies": {
64
+ "@cosmos.gl/graph": "^3.4.0",
65
+ "sigma": "^3.0.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@cosmos.gl/graph": {
69
+ "optional": true
70
+ },
71
+ "sigma": {
72
+ "optional": true
73
+ }
74
+ },
56
75
  "publishConfig": {
57
76
  "access": "restricted"
58
77
  }