@fusion.dev/helix-graph 0.120.0 → 0.122.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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode, type GraphChanges, 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
2
  export { diffGraph } from './connect-graph/diff.js';
3
3
  export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
4
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
@@ -2,3 +2,4 @@ export { connectGraph, } from './connect-graph/connect-graph.js';
2
2
  export { diffGraph } from './connect-graph/diff.js';
3
3
  export { graphColorsFrom, } from './connect-graph/looks.js';
4
4
  export { seedPositions } from './connect-graph/seed.js';
5
+ export { fitToStencil } from './connect-graph/stencil.js';
@@ -0,0 +1,77 @@
1
+ import type { ViewportGestureMode } from '@fusion.dev/helix-core';
2
+ import type { GraphGroup } from '../connect-graph/connect-graph.js';
3
+ import { type GraphLooks } from '../connect-graph/looks.js';
4
+ /** Where a note is on screen and how large, or nothing where it is not drawn. */
5
+ export type OnScreen = (id: string) => {
6
+ radius: number;
7
+ x: number;
8
+ y: number;
9
+ } | undefined;
10
+ /** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
11
+ export declare const isLight: (color: string) => boolean;
12
+ /** Eases in and out, so a signal leaves and arrives gently. */
13
+ export declare const ease: (at: number) => number;
14
+ /** A name's box on screen, in pixels. */
15
+ export interface Box {
16
+ bottom: number;
17
+ left: number;
18
+ right: number;
19
+ top: number;
20
+ }
21
+ /**
22
+ * **Signals are drawn on a canvas of the driver's own**, over the renderer's,
23
+ * so a signal never makes the renderer redraw the graph: ten signals a second
24
+ * over ten thousand notes draw ten dots. It draws only while something is in
25
+ * flight, and takes no pointer, so a press reaches the note under it.
26
+ */
27
+ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) => {
28
+ look: (looks: GraphLooks) => void;
29
+ signal: (from: string, to: string, colors: {
30
+ from: string;
31
+ to: string;
32
+ } | undefined, animate: boolean) => void;
33
+ stop: () => void;
34
+ };
35
+ /** A note to name over the drawing: its name, and whether it is the one the reader is on. */
36
+ export interface Named {
37
+ id: string;
38
+ name: string;
39
+ strong: boolean;
40
+ }
41
+ /**
42
+ * **Names on a canvas of their own**, for a renderer that draws none: each
43
+ * note given, beside it, ringed in the ground's color so it reads over the
44
+ * links. The strong ones always, and the rest where there is room, in the
45
+ * order given.
46
+ */
47
+ export declare const namesOver: (container: HTMLElement, onScreen: OnScreen) => {
48
+ draw: (looks: GraphLooks | undefined, named: readonly Named[]) => void;
49
+ stop: () => void;
50
+ };
51
+ /**
52
+ * **Group names are drawn on a canvas of their own**, under the signals', and
53
+ * drawn again whenever the driver says the view moved, so they follow it.
54
+ */
55
+ export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen) => {
56
+ draw: () => void;
57
+ look: (next: GraphLooks) => void;
58
+ set: (next: readonly GraphGroup[]) => void;
59
+ stop: () => void;
60
+ };
61
+ /**
62
+ * **Held back from the renderer rather than switched off in it.** A
63
+ * renderer's own switches turn its zoom and pan off for code as well, so the
64
+ * frame's buttons stopped working with them. A listener on the container in
65
+ * the capture phase runs before the renderer's, and keeps from it what the
66
+ * page has: the plain wheel and one finger while cooperative, and every
67
+ * gesture while a waiting surface is off.
68
+ */
69
+ export declare const gesturesHeld: (container: HTMLElement) => {
70
+ /**
71
+ * Which gestures the renderer takes. One finger scrolls the page past a
72
+ * graph that shares its gestures, and every finger does past one that is
73
+ * off.
74
+ */
75
+ set: (next: ViewportGestureMode) => void;
76
+ stop: () => void;
77
+ };
@@ -0,0 +1,358 @@
1
+ import { mixRgb } from '../connect-graph/looks.js';
2
+ const SPEED = 340;
3
+ const QUICKEST = 450;
4
+ const SLOWEST = 1400;
5
+ const ARRIVAL = 380;
6
+ const FLYING = 400;
7
+ const withAlpha = (color, alpha) => {
8
+ const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
9
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
10
+ };
11
+ const TAIL = 110;
12
+ const pulse = (context, leaves, arrives, from, to, along, through, glowing) => {
13
+ const strength = glowing ? 1.3 : 1;
14
+ const color = mixRgb(leaves, arrives, along);
15
+ const length = Math.hypot(to.x - from.x, to.y - from.y) || 1;
16
+ const head = { x: from.x + (to.x - from.x) * along, y: from.y + (to.y - from.y) * along };
17
+ const behind = Math.max(along - TAIL / length, 0);
18
+ const tail = { x: from.x + (to.x - from.x) * behind, y: from.y + (to.y - from.y) * behind };
19
+ context.lineCap = 'round';
20
+ context.globalAlpha = 0.14 * Math.sin(Math.PI * through);
21
+ const line = context.createLinearGradient(from.x, from.y, to.x, to.y);
22
+ line.addColorStop(0, leaves);
23
+ line.addColorStop(1, arrives);
24
+ context.strokeStyle = line;
25
+ context.lineWidth = 1;
26
+ context.beginPath();
27
+ context.moveTo(from.x, from.y);
28
+ context.lineTo(to.x, to.y);
29
+ context.stroke();
30
+ const fade = context.createLinearGradient(tail.x, tail.y, head.x, head.y);
31
+ const back = mixRgb(leaves, arrives, behind);
32
+ fade.addColorStop(0, withAlpha(back, 0));
33
+ fade.addColorStop(0.6, withAlpha(mixRgb(back, color, 0.6), 0.25));
34
+ fade.addColorStop(1, withAlpha(color, 0.95));
35
+ context.strokeStyle = fade;
36
+ context.shadowColor = color;
37
+ context.shadowBlur = 8;
38
+ context.globalAlpha = Math.min(0.35 * strength, 1);
39
+ context.lineWidth = 3.5;
40
+ context.beginPath();
41
+ context.moveTo(tail.x, tail.y);
42
+ context.lineTo(head.x, head.y);
43
+ context.stroke();
44
+ context.shadowBlur = 0;
45
+ context.globalAlpha = 1;
46
+ context.lineWidth = 1.4 * strength;
47
+ context.stroke();
48
+ const reach = 5 * strength;
49
+ const light = context.createRadialGradient(head.x, head.y, 0, head.x, head.y, reach);
50
+ light.addColorStop(0, 'rgba(255, 255, 255, 0.9)');
51
+ light.addColorStop(0.4, withAlpha(color, 0.75));
52
+ light.addColorStop(1, withAlpha(color, 0));
53
+ context.fillStyle = light;
54
+ context.beginPath();
55
+ context.arc(head.x, head.y, reach, 0, Math.PI * 2);
56
+ context.fill();
57
+ };
58
+ const arrive = (context, color, at, gone) => {
59
+ const reach = at.radius + 6;
60
+ const glow = context.createRadialGradient(at.x, at.y, at.radius * 0.4, at.x, at.y, reach);
61
+ glow.addColorStop(0, withAlpha(color, 0.5 * (1 - gone) ** 2));
62
+ glow.addColorStop(1, withAlpha(color, 0));
63
+ context.fillStyle = glow;
64
+ context.beginPath();
65
+ context.arc(at.x, at.y, reach, 0, Math.PI * 2);
66
+ context.fill();
67
+ };
68
+ export const isLight = (color) => {
69
+ const [red = 0, green = 0, blue = 0] = color.match(/[\d.]+/g)?.map(Number) ?? [];
70
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 > 0.5;
71
+ };
72
+ export const ease = (at) => (at < 0.5 ? 2 * at * at : 1 - (-2 * at + 2) ** 2 / 2);
73
+ const nameGroups = (context, groups, looks, at) => {
74
+ context.font = `650 11px ${looks.font}`;
75
+ context.letterSpacing = '0.14em';
76
+ context.textAlign = 'center';
77
+ context.textBaseline = 'middle';
78
+ context.shadowColor = looks.ground;
79
+ const taken = [];
80
+ for (const group of groups) {
81
+ let x = 0;
82
+ let y = 0;
83
+ let count = 0;
84
+ const points = [];
85
+ for (const id of group.ids) {
86
+ const point = at(id);
87
+ if (point) {
88
+ x += point.x;
89
+ y += point.y;
90
+ count += 1;
91
+ points.push(point);
92
+ }
93
+ }
94
+ if (count < 2) {
95
+ continue;
96
+ }
97
+ x /= count;
98
+ y /= count;
99
+ const spread = points.reduce((sum, point) => sum + Math.hypot(point.x - x, point.y - y), 0) / count;
100
+ if (spread < 12) {
101
+ continue;
102
+ }
103
+ const name = group.name.toLocaleUpperCase();
104
+ const half = context.measureText(name).width / 2 + 4;
105
+ const boxAt = (middle) => ({
106
+ bottom: middle + 9,
107
+ left: x - half,
108
+ right: x + half,
109
+ top: middle - 9,
110
+ });
111
+ const clear = (box) => taken.every((other) => box.right < other.left ||
112
+ box.left > other.right ||
113
+ box.bottom < other.top ||
114
+ box.top > other.bottom);
115
+ const line = [0, 18, -18].map((step) => y + step).find((middle) => clear(boxAt(middle)));
116
+ if (line === undefined) {
117
+ continue;
118
+ }
119
+ y = line;
120
+ taken.push(boxAt(y));
121
+ context.globalAlpha = group.faded ? 0.3 : 0.92;
122
+ context.fillStyle = mixRgb(group.color, looks.ink, 0.6);
123
+ context.shadowBlur = 10;
124
+ context.fillText(name, x, y);
125
+ context.shadowBlur = 3;
126
+ context.fillText(name, x, y);
127
+ }
128
+ context.shadowBlur = 0;
129
+ context.letterSpacing = '0px';
130
+ context.globalAlpha = 1;
131
+ };
132
+ const layer = (container, kind) => {
133
+ const canvas = document.createElement('canvas');
134
+ canvas.setAttribute('aria-hidden', 'true');
135
+ canvas.dataset.helixGraphLayer = kind;
136
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
137
+ const signals = container.querySelector('canvas[data-helix-graph-layer="signals"]');
138
+ if (kind === 'groups' && signals) {
139
+ container.insertBefore(canvas, signals);
140
+ }
141
+ else {
142
+ container.append(canvas);
143
+ }
144
+ return canvas;
145
+ };
146
+ const fresh = (canvas, container) => {
147
+ const context = canvas.getContext('2d');
148
+ if (!context) {
149
+ return null;
150
+ }
151
+ const ratio = window.devicePixelRatio || 1;
152
+ const width = Math.round(container.clientWidth * ratio);
153
+ const height = Math.round(container.clientHeight * ratio);
154
+ if (canvas.width !== width || canvas.height !== height) {
155
+ canvas.width = width;
156
+ canvas.height = height;
157
+ }
158
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
159
+ context.clearRect(0, 0, width, height);
160
+ return context;
161
+ };
162
+ export const signalsOver = (container, onScreen) => {
163
+ let overlay;
164
+ let flights = [];
165
+ let flying = 0;
166
+ let ink = 'rgb(128, 128, 128)';
167
+ let glowing = false;
168
+ const fly = (now) => {
169
+ const context = overlay ? fresh(overlay, container) : null;
170
+ if (!context) {
171
+ flights = [];
172
+ return;
173
+ }
174
+ context.globalCompositeOperation = glowing ? 'lighter' : 'source-over';
175
+ flights = flights.filter((flight) => {
176
+ const from = onScreen(flight.from);
177
+ const to = onScreen(flight.to);
178
+ if (!from || !to) {
179
+ return false;
180
+ }
181
+ flight.crossing ??= Math.min(Math.max((Math.hypot(to.x - from.x, to.y - from.y) / SPEED) * 1000, QUICKEST), SLOWEST);
182
+ const crossing = flight.crossing;
183
+ const gone = (now - flight.started) / crossing;
184
+ if (gone >= 1 + ARRIVAL / crossing) {
185
+ return false;
186
+ }
187
+ context.save();
188
+ if (gone < 1) {
189
+ if (flight.animate) {
190
+ pulse(context, flight.leaves, flight.arrives, from, to, ease(gone), gone, glowing);
191
+ }
192
+ else {
193
+ const line = context.createLinearGradient(from.x, from.y, to.x, to.y);
194
+ line.addColorStop(0, flight.leaves);
195
+ line.addColorStop(1, flight.arrives);
196
+ context.globalAlpha = 1 - gone;
197
+ context.strokeStyle = line;
198
+ context.lineWidth = 2;
199
+ context.beginPath();
200
+ context.moveTo(from.x, from.y);
201
+ context.lineTo(to.x, to.y);
202
+ context.stroke();
203
+ }
204
+ }
205
+ else if (flight.animate) {
206
+ arrive(context, flight.arrives, to, (gone - 1) / (ARRIVAL / crossing));
207
+ }
208
+ context.restore();
209
+ return true;
210
+ });
211
+ flying = flights.length > 0 ? requestAnimationFrame(fly) : 0;
212
+ };
213
+ return {
214
+ look: (looks) => {
215
+ ink = looks.ink;
216
+ glowing = isLight(looks.ink);
217
+ },
218
+ signal: (from, to, colors, animate) => {
219
+ overlay ??= layer(container, 'signals');
220
+ flights.push({
221
+ animate,
222
+ arrives: colors?.to ?? ink,
223
+ from,
224
+ leaves: colors?.from ?? ink,
225
+ started: performance.now(),
226
+ to,
227
+ });
228
+ if (flights.length > FLYING) {
229
+ flights = flights.slice(-FLYING);
230
+ }
231
+ if (!flying) {
232
+ flying = requestAnimationFrame(fly);
233
+ }
234
+ },
235
+ stop: () => {
236
+ cancelAnimationFrame(flying);
237
+ flying = 0;
238
+ flights = [];
239
+ overlay?.remove();
240
+ overlay = undefined;
241
+ },
242
+ };
243
+ };
244
+ export const namesOver = (container, onScreen) => {
245
+ const canvas = layer(container, 'names');
246
+ return {
247
+ draw: (looks, named) => {
248
+ const context = looks ? fresh(canvas, container) : null;
249
+ if (!context || !looks) {
250
+ return;
251
+ }
252
+ const taken = [];
253
+ for (const { id, name, strong } of named) {
254
+ const at = onScreen(id);
255
+ if (!at || looks.node(id).hidden) {
256
+ continue;
257
+ }
258
+ context.font = `${strong ? 600 : 500} ${strong ? 13 : 12}px ${looks.font}`;
259
+ const x = at.x + at.radius + 4;
260
+ const y = at.y + 4;
261
+ const box = {
262
+ bottom: y + 4,
263
+ left: x - 2,
264
+ right: x + context.measureText(name).width + 2,
265
+ top: y - 13,
266
+ };
267
+ const free = taken.every((other) => box.right < other.left ||
268
+ box.left > other.right ||
269
+ box.bottom < other.top ||
270
+ box.top > other.bottom);
271
+ if (!free && !strong) {
272
+ continue;
273
+ }
274
+ taken.push(box);
275
+ context.lineJoin = 'round';
276
+ context.lineWidth = 3;
277
+ context.strokeStyle = looks.ground;
278
+ context.strokeText(name, x, y);
279
+ context.fillStyle = strong ? looks.ink : looks.quiet;
280
+ context.fillText(name, x, y);
281
+ }
282
+ },
283
+ stop: () => {
284
+ canvas.remove();
285
+ },
286
+ };
287
+ };
288
+ export const groupsOver = (container, onScreen) => {
289
+ let canvas;
290
+ let groups = [];
291
+ let looks;
292
+ const draw = () => {
293
+ const context = canvas && looks ? fresh(canvas, container) : null;
294
+ if (context && looks) {
295
+ nameGroups(context, groups, looks, onScreen);
296
+ }
297
+ };
298
+ return {
299
+ draw,
300
+ look: (next) => {
301
+ looks = next;
302
+ draw();
303
+ },
304
+ set: (next) => {
305
+ canvas ??= layer(container, 'groups');
306
+ groups = next;
307
+ draw();
308
+ },
309
+ stop: () => {
310
+ canvas?.remove();
311
+ canvas = undefined;
312
+ },
313
+ };
314
+ };
315
+ export const gesturesHeld = (container) => {
316
+ let mode = 'free';
317
+ const touchAction = container.style.touchAction;
318
+ const hold = (event) => {
319
+ if (mode === 'free') {
320
+ return;
321
+ }
322
+ if (mode === 'off') {
323
+ event.stopImmediatePropagation();
324
+ return;
325
+ }
326
+ if (event instanceof WheelEvent) {
327
+ if (!(event.ctrlKey || event.metaKey)) {
328
+ event.stopImmediatePropagation();
329
+ }
330
+ return;
331
+ }
332
+ if (typeof TouchEvent !== 'undefined' && event instanceof TouchEvent) {
333
+ if (event.touches.length < 2) {
334
+ event.stopImmediatePropagation();
335
+ }
336
+ else if (event.type === 'touchstart' && event.cancelable) {
337
+ event.preventDefault();
338
+ }
339
+ }
340
+ };
341
+ const held = ['wheel', 'touchstart', 'touchmove', 'mousedown'];
342
+ for (const type of held) {
343
+ container.addEventListener(type, hold, { capture: true, passive: false });
344
+ }
345
+ return {
346
+ set: (next) => {
347
+ mode = next;
348
+ container.style.touchAction =
349
+ next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
350
+ },
351
+ stop: () => {
352
+ for (const type of held) {
353
+ container.removeEventListener(type, hold, { capture: true });
354
+ }
355
+ container.style.touchAction = touchAction;
356
+ },
357
+ };
358
+ };
@@ -0,0 +1 @@
1
+ export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, type ShapeField, } from '../connect-graph/shape-field.js';
@@ -0,0 +1 @@
1
+ export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, } from '../connect-graph/shape-field.js';
@@ -0,0 +1,71 @@
1
+ import { NodeProgram } from 'sigma/rendering';
2
+ import type { NodeDisplayData, RenderParams } from 'sigma/types';
3
+ /** The shapes a note is drawn in, numbered as the shader reads them. */
4
+ export declare const SHAPES: {
5
+ readonly circle: 0;
6
+ readonly diamond: 2;
7
+ readonly ring: 3;
8
+ readonly square: 1;
9
+ };
10
+ /** How heavily a note is drawn, numbered as the shader reads them. */
11
+ export declare const EMPHASES: {
12
+ readonly outlined: 1;
13
+ readonly solid: 0;
14
+ readonly subtle: 2;
15
+ };
16
+ /** What a note is made of, numbered as the shader reads them. */
17
+ export declare const MATERIALS: {
18
+ readonly flat: 0;
19
+ readonly frosted: 2;
20
+ readonly glass: 1;
21
+ };
22
+ /** What the node program reads off a note, beyond what Sigma gives every note. */
23
+ export interface HelixNodeData {
24
+ /** `EMPHASES`. */
25
+ emphasisCode?: number;
26
+ /** How strongly the note glows, from 0 to 1: the selected note's halo. */
27
+ glow?: number;
28
+ /** `MATERIALS`. */
29
+ materialCode?: number;
30
+ /** `SHAPES`. */
31
+ shapeCode?: number;
32
+ }
33
+ declare const UNIFORMS: readonly ['u_sizeRatio', 'u_correctionRatio', 'u_matrix'];
34
+ /**
35
+ * Draws a note in its shape, its emphasis and its material, with a glow where
36
+ * it is selected: one triangle a note, as Sigma's own circle is, cut to the
37
+ * shape where it is drawn. Pressing reads the shape and not the glow, so a
38
+ * press beside a note is not a press on it.
39
+ */
40
+ export declare class HelixNodeProgram extends NodeProgram<(typeof UNIFORMS)[number]> {
41
+ getDefinition(): {
42
+ ATTRIBUTES: ({
43
+ name: string;
44
+ size: number;
45
+ type: 5126;
46
+ normalized?: never;
47
+ } | {
48
+ name: string;
49
+ normalized: boolean;
50
+ size: number;
51
+ type: 5121;
52
+ })[];
53
+ CONSTANT_ATTRIBUTES: {
54
+ name: string;
55
+ size: number;
56
+ type: 5126;
57
+ }[];
58
+ CONSTANT_DATA: number[][];
59
+ FRAGMENT_SHADER_SOURCE: string;
60
+ METHOD: 4;
61
+ UNIFORMS: readonly ["u_sizeRatio", "u_correctionRatio", "u_matrix"];
62
+ VERTEX_SHADER_SOURCE: string;
63
+ VERTICES: number;
64
+ };
65
+ processVisibleItem(nodeIndex: number, startIndex: number, data: NodeDisplayData & HelixNodeData): void;
66
+ setUniforms(params: RenderParams, { gl, uniformLocations, }: {
67
+ gl: WebGLRenderingContext;
68
+ uniformLocations: Record<string, WebGLUniformLocation>;
69
+ }): void;
70
+ }
71
+ export {};
@@ -0,0 +1,170 @@
1
+ import { NodeProgram } from 'sigma/rendering';
2
+ import { floatColor } from 'sigma/utils';
3
+ export const SHAPES = { circle: 0, diamond: 2, ring: 3, square: 1 };
4
+ export const EMPHASES = { outlined: 1, solid: 0, subtle: 2 };
5
+ export const MATERIALS = { flat: 0, frosted: 2, glass: 1 };
6
+ const REACH = 2.6;
7
+ const VERTEX = `
8
+ attribute vec4 a_id;
9
+ attribute vec4 a_color;
10
+ attribute vec2 a_position;
11
+ attribute float a_size;
12
+ attribute float a_shape;
13
+ attribute float a_emphasis;
14
+ attribute float a_material;
15
+ attribute float a_glow;
16
+ attribute float a_angle;
17
+
18
+ uniform mat3 u_matrix;
19
+ uniform float u_sizeRatio;
20
+ uniform float u_correctionRatio;
21
+
22
+ varying vec4 v_color;
23
+ varying vec2 v_diffVector;
24
+ varying float v_radius;
25
+ varying float v_shape;
26
+ varying float v_emphasis;
27
+ varying float v_material;
28
+ varying float v_glow;
29
+
30
+ const float bias = 255.0 / 254.0;
31
+
32
+ void main() {
33
+ float size = a_size * u_correctionRatio / u_sizeRatio * 4.0;
34
+ vec2 diffVector = size * ${REACH.toFixed(2)} * vec2(cos(a_angle), sin(a_angle));
35
+ gl_Position = vec4((u_matrix * vec3(a_position + diffVector, 1)).xy, 0, 1);
36
+ v_diffVector = diffVector;
37
+ v_radius = size / 2.0;
38
+ v_shape = a_shape;
39
+ v_emphasis = a_emphasis;
40
+ v_material = a_material;
41
+ v_glow = a_glow;
42
+ #ifdef PICKING_MODE
43
+ v_color = a_id;
44
+ #else
45
+ v_color = a_color;
46
+ #endif
47
+ v_color.a *= bias;
48
+ }
49
+ `;
50
+ const FRAGMENT = `
51
+ precision highp float;
52
+
53
+ varying vec4 v_color;
54
+ varying vec2 v_diffVector;
55
+ varying float v_radius;
56
+ varying float v_shape;
57
+ varying float v_emphasis;
58
+ varying float v_material;
59
+ varying float v_glow;
60
+
61
+ uniform float u_correctionRatio;
62
+
63
+ /* How far a point is outside the note's shape, in radii: below zero inside. */
64
+ float outside(vec2 p, float shape) {
65
+ if (shape > 0.5 && shape < 1.5) {
66
+ vec2 q = abs(p) - vec2(0.7);
67
+ return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - 0.18;
68
+ }
69
+ if (shape > 1.5 && shape < 2.5) {
70
+ return (abs(p.x) + abs(p.y)) * 0.7071 - 0.86;
71
+ }
72
+ return length(p) - 1.0;
73
+ }
74
+
75
+ void main(void) {
76
+ vec2 p = v_diffVector / v_radius;
77
+ float d = outside(p, v_shape);
78
+
79
+ #ifdef PICKING_MODE
80
+ if (d > 0.0) discard;
81
+ gl_FragColor = v_color;
82
+ #else
83
+ float aa = u_correctionRatio * 2.0 / v_radius;
84
+ float inside = 1.0 - smoothstep(-aa, 0.0, d);
85
+
86
+ /* A ring is a circle drawn outlined, whatever its emphasis says. */
87
+ float emphasis = v_shape > 2.5 ? 1.0 : v_emphasis;
88
+ float edge = smoothstep(-0.32 - aa, -0.32, d);
89
+ float fill = emphasis > 1.5 ? mix(0.3, 1.0, edge) : emphasis > 0.5 ? edge : 1.0;
90
+
91
+ vec3 color = v_color.rgb;
92
+ float alpha = 1.0;
93
+ /* How large the note is drawn, in pixels: a highlight that suits a hub is
94
+ a speck of noise on a leaf, so the detail grows in with the size. */
95
+ float drawn = smoothstep(4.0, 14.0, v_radius / u_correctionRatio);
96
+ /* Lit from above on the left, as every surface here is. */
97
+ float lit = clamp(0.5 + 0.5 * dot(normalize(vec2(-0.45, 0.9)), p), 0.0, 1.0);
98
+ /* A fine rim a shade darker, so notes that touch stay two. */
99
+ float rim = smoothstep(-0.2 - aa, -0.2, d);
100
+ if (v_material > 0.5 && v_material < 1.5) {
101
+ /* Glass: shaded like a sphere, a highlight, and light at its rim. */
102
+ color *= mix(0.72, 1.12, lit);
103
+ float highlight = exp(-pow(length(p - vec2(-0.32, 0.38)) / 0.26, 2.0));
104
+ color = mix(color, vec3(1.0), highlight * mix(0.25, 0.6, drawn));
105
+ color = mix(color, vec3(1.0), rim * 0.22 * drawn);
106
+ alpha = 0.94;
107
+ } else if (v_material > 1.5) {
108
+ /* Frosted: a milky body, a soft fall of light, and no sharp highlight. */
109
+ color = mix(color * mix(0.86, 1.06, lit), vec3(1.0), 0.16);
110
+ color = mix(color, vec3(1.0), rim * 0.14);
111
+ alpha = 0.86;
112
+ } else {
113
+ color = mix(color, color * 0.72, rim * 0.6);
114
+ }
115
+
116
+ float body = inside * fill * alpha * v_color.a;
117
+ /* The glow, outside the shape: a crisp ring just clear of the edge and a
118
+ soft light falling away beyond it, both in the note's own color. */
119
+ float reach = ${(REACH - 1).toFixed(2)};
120
+ float ring = 1.0 - smoothstep(0.0, 0.07 + aa, abs(d - 0.32));
121
+ float falloff = exp(-d * 2.4) * (1.0 - smoothstep(reach * 0.8, reach, d));
122
+ float halo = v_glow * step(0.0, d) * clamp(ring * 0.95 + falloff * 0.55, 0.0, 1.0) * v_color.a;
123
+ /* Premultiplied, as Sigma blends: the body over its glow. */
124
+ gl_FragColor = vec4(color * body + v_color.rgb * halo * (1.0 - body), body + halo * (1.0 - body));
125
+ #endif
126
+ }
127
+ `;
128
+ const { FLOAT, UNSIGNED_BYTE } = WebGLRenderingContext;
129
+ const UNIFORMS = ['u_sizeRatio', 'u_correctionRatio', 'u_matrix'];
130
+ export class HelixNodeProgram extends NodeProgram {
131
+ getDefinition() {
132
+ return {
133
+ ATTRIBUTES: [
134
+ { name: 'a_position', size: 2, type: FLOAT },
135
+ { name: 'a_size', size: 1, type: FLOAT },
136
+ { name: 'a_color', normalized: true, size: 4, type: UNSIGNED_BYTE },
137
+ { name: 'a_id', normalized: true, size: 4, type: UNSIGNED_BYTE },
138
+ { name: 'a_shape', size: 1, type: FLOAT },
139
+ { name: 'a_emphasis', size: 1, type: FLOAT },
140
+ { name: 'a_material', size: 1, type: FLOAT },
141
+ { name: 'a_glow', size: 1, type: FLOAT },
142
+ ],
143
+ CONSTANT_ATTRIBUTES: [{ name: 'a_angle', size: 1, type: FLOAT }],
144
+ CONSTANT_DATA: [[0], [(2 * Math.PI) / 3], [(4 * Math.PI) / 3]],
145
+ FRAGMENT_SHADER_SOURCE: FRAGMENT,
146
+ METHOD: WebGLRenderingContext.TRIANGLES,
147
+ UNIFORMS,
148
+ VERTEX_SHADER_SOURCE: VERTEX,
149
+ VERTICES: 3,
150
+ };
151
+ }
152
+ processVisibleItem(nodeIndex, startIndex, data) {
153
+ const array = this.array;
154
+ let at = startIndex;
155
+ array[at++] = data.x;
156
+ array[at++] = data.y;
157
+ array[at++] = data.size;
158
+ array[at++] = floatColor(data.color);
159
+ array[at++] = nodeIndex;
160
+ array[at++] = data.shapeCode ?? SHAPES.circle;
161
+ array[at++] = data.emphasisCode ?? EMPHASES.solid;
162
+ array[at++] = data.materialCode ?? MATERIALS.flat;
163
+ array[at] = data.glow ?? 0;
164
+ }
165
+ setUniforms(params, { gl, uniformLocations, }) {
166
+ gl.uniform1f(uniformLocations.u_correctionRatio, params.correctionRatio);
167
+ gl.uniform1f(uniformLocations.u_sizeRatio, params.sizeRatio);
168
+ gl.uniformMatrix3fv(uniformLocations.u_matrix, false, params.matrix);
169
+ }
170
+ }