@fusion.dev/helix-graph 0.123.0 → 0.125.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 CHANGED
@@ -75,11 +75,15 @@ socket.addEventListener('message', ({ data }) => {
75
75
  ```
76
76
 
77
77
  **`frame.signal(from, to, { tone })` shows something passing between two
78
- notes**: a message, a request, a payment. It is drawn as a pulse of light
79
- traveling from one to the other, turning from the first note's color to the
80
- second's, or in the tone's color where it has one, over the graph and without
81
- redrawing it, so a busy feed costs a dot each. A reader who asks for less motion
82
- sees the link lit for a moment instead.
78
+ notes**: a message, a request, a payment. It is drawn as a comet traveling
79
+ from one to the other along a gently bowed path, its tail tapering and turning
80
+ from the first note's color to the second's, or in the tone's color where it
81
+ has one. A small ring spreads from the note it leaves, and the note it reaches
82
+ brightens and ripples. A reply on the same link bows the other way, so the two
83
+ do not run over each other, and in three dimensions the path arcs over the
84
+ shape. It is drawn over the graph without redrawing it, so a busy feed costs a
85
+ few strokes each. A reader who asks for less motion sees the path lit for a
86
+ moment instead.
83
87
 
84
88
  ```js
85
89
  frame.signal('Checkout', 'Payments', { tone: 'success' })
@@ -108,7 +112,15 @@ frame.nodes = [
108
112
  ```
109
113
 
110
114
  `sigmaDriver(sigma, { material, shape, emphasis })` sets what a note is drawn as
111
- where it does not say. The selected note keeps its own color and is told apart by
115
+ where it does not say. A note without a material is matte, lit across so it
116
+ reads as round. In three dimensions every note is a solid: a circle is a
117
+ sphere, worked out for each pixel and written at its depth, so notes meet the
118
+ shape and each other as solids do; a square is a cube and a diamond an
119
+ octahedron. On a dark ground a sphere is an orb of energy, a dark glassy body
120
+ with a glowing rim and a hot point at its middle, and on a light ground frosted
121
+ glass; a cube or an octahedron is a hologram, faint faces with fine edges of
122
+ light. Each catches a glint from a light fixed in the scene, so the glint moves
123
+ as the reader turns the graph. The selected note keeps its own color and is told apart by
112
124
  a ring and a glow in that color.
113
125
 
114
126
  **A note without a `size` is as large as it is connected**, so hubs read at a
@@ -220,6 +232,7 @@ makes one from what a page has, and a field is a stencil itself:
220
232
  | `inflate(field, { depth, roundness })` | A solid puffed out of a flat field, as a cushion is, cut through where the flat shape has a hole |
221
233
  | `sphere`, `torus`, `box` | Solids measured exactly from their formulas |
222
234
  | `onSphere(field, { radius, thickness })` | A flat map wrapped round a ball, as a world map is round a globe |
235
+ | `surfaceOf(field, { at, cells })` | The surface of a solid as triangles, to draw or to save as a model |
223
236
 
224
237
  ```js
225
238
  const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' }
@@ -254,12 +267,16 @@ connectGraph(frame, threeDriver(box), {
254
267
 
255
268
  - **A solid stencil draws the notes in the solid**: all through it, or, with
256
269
  `place: 'surface'`, in a thin shell just inside its edge, which is how a
257
- solid reads as the thing it is shaped like. Each group takes one piece of
258
- the surface, measured across it, so a group on a knot is a stretch of its
259
- tube, and each hub's notes a patch around it.
260
- - **On a surface, the shape itself is drawn** in the ground's color just
261
- inside the notes, so what is behind it is hidden, and the pointer finds
262
- only the notes in front.
270
+ solid reads as the thing it is shaped like. **Each group is given a piece
271
+ of the shape of its own**, as many places as it has notes, and each hub a
272
+ piece of its group's, so no group is cut in two: on a knot, a group is a
273
+ stretch of its tube, and on a brain, a region of it.
274
+ - **On a surface, the shape itself is drawn** just inside the notes, so what
275
+ is behind it is hidden and the pointer finds only the notes in front.
276
+ **Each region is washed in its notes' color**, a translucent stain that
277
+ fades into the next region's, and glows in that color at the edge turning
278
+ away. It is lit faintly from above, and a model's own surface is drawn,
279
+ with every fold the field's cells are too coarse to hold.
263
280
  - **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
264
281
  - **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
265
282
  turns slowly by itself while nobody is handling it, and never for a reader
@@ -284,7 +301,7 @@ connectGraph(frame, threeDriver(box), {
284
301
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
285
302
  | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
286
303
  | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
287
- | `@fusion.dev/helix-graph/shapes` | `shapeFromModel`, `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `box`, `onSphere`, and the `ShapeField` type |
304
+ | `@fusion.dev/helix-graph/shapes` | `shapeFromModel`, `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `box`, `onSphere`, `surfaceOf`, and the `ShapeField` type |
288
305
 
289
306
  **Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
290
307
  driver types its renderer by the calls it makes on it, so a page brings
@@ -4,6 +4,16 @@ export interface ArrangedNode {
4
4
  group?: string | undefined;
5
5
  id: string;
6
6
  }
7
+ /**
8
+ * The hub each note hangs from, and how connected each note is: a note
9
+ * hangs from its most connected neighbor in its group, where that neighbor
10
+ * is more connected than it is, and the note at the end of the chain is the
11
+ * hub. A note nothing is more connected than is its own hub.
12
+ */
13
+ export declare const hubsOf: (nodes: readonly ArrangedNode[], links: readonly SeedLink[]) => {
14
+ hubOf: (id: string) => string;
15
+ rank: (id: string) => number;
16
+ };
7
17
  /**
8
18
  * Places for every note from the graph's structure alone, with no layout to
9
19
  * wait for: **each group around a circle, or a sphere in a solid, each hub in its group, and each
@@ -30,7 +30,7 @@ const plus = (one, other) => ({
30
30
  y: one.y + other.y,
31
31
  ...(one.z === undefined && other.z === undefined ? {} : { z: (one.z ?? 0) + (other.z ?? 0) }),
32
32
  });
33
- export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
33
+ export const hubsOf = (nodes, links) => {
34
34
  const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
35
35
  const degree = new Map();
36
36
  const neighbors = new Map();
@@ -75,6 +75,11 @@ export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
75
75
  }
76
76
  return at;
77
77
  };
78
+ return { hubOf, rank };
79
+ };
80
+ export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
81
+ const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
82
+ const { hubOf, rank } = hubsOf(nodes, links);
78
83
  const groups = new Map();
79
84
  for (const { id } of nodes) {
80
85
  const group = groupOf.get(id);
@@ -1,9 +1,14 @@
1
1
  import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphSignalEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
2
- import { arrangeByGroup } from './arrange.js';
2
+ import { arrangeByGroup, hubsOf } from './arrange.js';
3
3
  import { diffGraph, isEmpty, linkKey, } from './diff.js';
4
4
  import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
5
5
  import { seedPositions } from './seed.js';
6
6
  import { fieldOf, fitInStencil } from './stencil.js';
7
+ const clustersOf = (nodes, links) => {
8
+ const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
9
+ const { hubOf } = hubsOf(nodes, links);
10
+ return (id) => [groupOf.get(id) ?? '', hubOf(id)];
11
+ };
7
12
  const viewportOf = (frame) => {
8
13
  const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
9
14
  return { height: box.height, width: box.width };
@@ -98,7 +103,7 @@ export const connectGraph = (frame, driver, options = {}) => {
98
103
  const fit = options.stencil && ids.size > 0 && unplaced.size === ids.size
99
104
  ? fitInStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
100
105
  solid: fieldOf(options.stencil).depth > 0,
101
- }), options.stencil)
106
+ }), options.stencil, { clustersOf: clustersOf(nodes, [...nextLinks.values()]) })
102
107
  : undefined;
103
108
  const shaped = fit?.positions;
104
109
  if (fit?.frame) {
@@ -14,6 +14,16 @@ export interface ShapeField {
14
14
  distance(x: number, y: number, z?: number): number;
15
15
  /** How tall the box is. Its y points down, as an SVG's does. */
16
16
  readonly height: number;
17
+ /**
18
+ * The surface the field was read from, where it was read from one, in the
19
+ * box's units: corners three numbers each, and triangles three corners
20
+ * each. A renderer draws it in place of a surface it would make from the
21
+ * field, since it holds every detail the field's cells are too coarse for.
22
+ */
23
+ readonly mesh?: {
24
+ corners: Float32Array;
25
+ triangles: Uint32Array;
26
+ } | undefined;
17
27
  /** How wide the box is. */
18
28
  readonly width: number;
19
29
  }
@@ -67,7 +67,14 @@ export interface StencilFrame {
67
67
  shell: number;
68
68
  }
69
69
  /** `fitToStencil`, saying where it put the stencil's box as well, where it fitted the places at all. */
70
- export declare const fitInStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => {
70
+ export declare const fitInStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil, { clustersOf, }?: {
71
+ /**
72
+ * The clusters a note is in, broadest first, such as its group and then
73
+ * its hub. Each cluster is given a piece of a solid of its own, as many
74
+ * places as it has notes, so no cluster is cut in two.
75
+ */
76
+ clustersOf?: ((id: string) => readonly string[]) | undefined;
77
+ }) => {
71
78
  frame?: StencilFrame | undefined;
72
79
  positions: Map<string, GraphPoint>;
73
80
  };
@@ -95,7 +95,7 @@ const nested = (items, keys) => {
95
95
  return out;
96
96
  };
97
97
  export const fitToStencil = (positions, stencil) => fitInStencil(positions, stencil).positions;
98
- export const fitInStencil = (positions, stencil) => {
98
+ export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
99
99
  const notes = [...positions].map(([id, at]) => ({ at, id }));
100
100
  if (notes.length === 0) {
101
101
  return { positions: new Map() };
@@ -175,7 +175,8 @@ export const fitInStencil = (positions, stencil) => {
175
175
  const ordered = [...places].sort((one, other) => (targets[one][axis] ?? 0) - (targets[other][axis] ?? 0));
176
176
  return [ordered.slice(0, first), ordered.slice(first)];
177
177
  };
178
- const halve = (ours, theirs) => {
178
+ const keysOf = new Map(notes.map((note) => [note.id, clustersOf?.(note.id) ?? []]));
179
+ const halve = (ours, theirs, level = 0) => {
179
180
  if (ours.length === 1) {
180
181
  fitted.set(ours[0].id, targets[theirs[0]]);
181
182
  return;
@@ -183,12 +184,58 @@ export const fitInStencil = (positions, stencil) => {
183
184
  if (ours.length === 0) {
184
185
  return;
185
186
  }
187
+ const clusters = new Map();
188
+ for (const note of ours) {
189
+ const key = keysOf.get(note.id)?.[level];
190
+ if (key !== undefined) {
191
+ const list = clusters.get(key);
192
+ if (list) {
193
+ list.push(note);
194
+ }
195
+ else {
196
+ clusters.set(key, [note]);
197
+ }
198
+ }
199
+ }
200
+ if (clusters.size === 1) {
201
+ halve(ours, theirs, level + 1);
202
+ return;
203
+ }
204
+ if (clusters.size > 1 &&
205
+ [...clusters.values()].reduce((sum, list) => sum + list.length, 0) === ours.length) {
206
+ const middles = [...clusters.values()].map((list) => ({
207
+ list,
208
+ middle: {
209
+ x: list.reduce((sum, note) => sum + note.at.x, 0) / list.length,
210
+ y: list.reduce((sum, note) => sum + note.at.y, 0) / list.length,
211
+ z: list.reduce((sum, note) => sum + (note.at.z ?? 0), 0) / list.length,
212
+ },
213
+ }));
214
+ const axis = widest(middles.map((one) => one.middle));
215
+ middles.sort((one, other) => one.middle[axis] - other.middle[axis]);
216
+ let cut = 1;
217
+ let count = 0;
218
+ let best = Number.POSITIVE_INFINITY;
219
+ let first = 0;
220
+ for (let at = 0; at < middles.length - 1; at++) {
221
+ count += middles[at].list.length;
222
+ if (Math.abs(count - ours.length / 2) < best) {
223
+ best = Math.abs(count - ours.length / 2);
224
+ cut = at + 1;
225
+ first = count;
226
+ }
227
+ }
228
+ const [near, rest] = halveTargets(theirs, first);
229
+ halve(middles.slice(0, cut).flatMap((one) => one.list), near, level);
230
+ halve(middles.slice(cut).flatMap((one) => one.list), rest, level);
231
+ return;
232
+ }
186
233
  const mine = widest(ours.map((note) => note.at));
187
234
  ours.sort((one, other) => (one.at[mine] ?? 0) - (other.at[mine] ?? 0));
188
235
  const half = Math.floor(ours.length / 2);
189
236
  const [near, rest] = halveTargets(theirs, half);
190
- halve(ours.slice(0, half), near);
191
- halve(ours.slice(half), rest);
237
+ halve(ours.slice(0, half), near, level);
238
+ halve(ours.slice(half), rest, level);
192
239
  };
193
240
  halve([...notes], targets.map((_, at) => at));
194
241
  return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
@@ -7,6 +7,11 @@ export type OnScreen = (id: string) => {
7
7
  x: number;
8
8
  y: number;
9
9
  } | undefined;
10
+ /** A point and how to bend toward it: the control point of a curve on screen. */
11
+ export type Bend = (from: string, to: string) => {
12
+ x: number;
13
+ y: number;
14
+ } | undefined;
10
15
  /** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
11
16
  export declare const isLight: (color: string) => boolean;
12
17
  /** Eases in and out, so a signal leaves and arrives gently. */
@@ -24,7 +29,7 @@ export interface Box {
24
29
  * over ten thousand notes draw ten dots. It draws only while something is in
25
30
  * flight, and takes no pointer, so a press reaches the note under it.
26
31
  */
27
- export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) => {
32
+ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen, bend?: Bend) => {
28
33
  look: (looks: GraphLooks) => void;
29
34
  signal: (from: string, to: string, colors: {
30
35
  from: string;
@@ -2,68 +2,111 @@ import { mixRgb } from '../connect-graph/looks.js';
2
2
  const SPEED = 340;
3
3
  const QUICKEST = 450;
4
4
  const SLOWEST = 1400;
5
- const ARRIVAL = 380;
5
+ const ARRIVAL = 520;
6
6
  const FLYING = 400;
7
7
  const withAlpha = (color, alpha) => {
8
8
  const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
9
9
  return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
10
10
  };
11
- const TAIL = 110;
12
- const pulse = (context, leaves, arrives, from, to, along, through, glowing) => {
13
- const strength = glowing ? 1.3 : 1;
11
+ const TAIL = 120;
12
+ const PIECES = 14;
13
+ const BOW = 0.16;
14
+ const BOW_MOST = 90;
15
+ const SPARK = 0.22;
16
+ const on = (a, c, b, t) => {
17
+ const u = 1 - t;
18
+ return {
19
+ x: u * u * a.x + 2 * u * t * c.x + t * t * b.x,
20
+ y: u * u * a.y + 2 * u * t * c.y + t * t * b.y,
21
+ };
22
+ };
23
+ const bowed = (from, to) => {
24
+ const dx = to.x - from.x;
25
+ const dy = to.y - from.y;
26
+ const length = Math.hypot(dx, dy) || 1;
27
+ const bow = Math.min(length * BOW, BOW_MOST) * 2;
28
+ return {
29
+ x: (from.x + to.x) / 2 - (dy / length) * bow,
30
+ y: (from.y + to.y) / 2 + (dx / length) * bow,
31
+ };
32
+ };
33
+ const bounded = (from, to, toward) => {
34
+ const middle = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
35
+ const far = Math.hypot(toward.x - middle.x, toward.y - middle.y);
36
+ const most = Math.max(Math.hypot(to.x - from.x, to.y - from.y) * 0.9, 12);
37
+ if (far <= most) {
38
+ return toward;
39
+ }
40
+ return {
41
+ x: middle.x + ((toward.x - middle.x) * most) / far,
42
+ y: middle.y + ((toward.y - middle.y) * most) / far,
43
+ };
44
+ };
45
+ const trace = (context, a, c, b) => {
46
+ context.beginPath();
47
+ context.moveTo(a.x, a.y);
48
+ context.quadraticCurveTo(c.x, c.y, b.x, b.y);
49
+ };
50
+ const comet = (context, leaves, arrives, from, bend, to, along, through, glowing) => {
51
+ const strength = glowing ? 1.25 : 1;
52
+ const length = Math.hypot(bend.x - from.x, bend.y - from.y) + Math.hypot(to.x - bend.x, to.y - bend.y) || 1;
14
53
  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
54
  context.lineCap = 'round';
20
- context.globalAlpha = 0.14 * Math.sin(Math.PI * through);
21
55
  const line = context.createLinearGradient(from.x, from.y, to.x, to.y);
22
- line.addColorStop(0, leaves);
23
- line.addColorStop(1, arrives);
56
+ line.addColorStop(0, withAlpha(leaves, 0.16 * Math.sin(Math.PI * through)));
57
+ line.addColorStop(1, withAlpha(arrives, 0.16 * Math.sin(Math.PI * through)));
24
58
  context.strokeStyle = line;
25
59
  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;
60
+ trace(context, from, bend, to);
47
61
  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));
62
+ const reach = Math.min(TAIL / length, along);
63
+ context.lineCap = 'butt';
64
+ let behind = on(from, bend, to, along - reach);
65
+ for (let piece = 1; piece <= PIECES; piece++) {
66
+ const share = piece / PIECES;
67
+ const t = along - reach * (1 - share);
68
+ const ahead = on(from, bend, to, t);
69
+ context.strokeStyle = withAlpha(mixRgb(leaves, arrives, t), (glowing ? 0.85 : 1) * share * share);
70
+ context.lineWidth = (0.4 + 2.2 * share) * strength;
71
+ context.beginPath();
72
+ context.moveTo(behind.x, behind.y);
73
+ context.lineTo(ahead.x, ahead.y);
74
+ context.stroke();
75
+ behind = ahead;
76
+ }
77
+ const head = on(from, bend, to, along);
78
+ const bloom = 9 * strength;
79
+ const light = context.createRadialGradient(head.x, head.y, 0, head.x, head.y, bloom);
80
+ light.addColorStop(0, glowing ? 'rgba(255, 255, 255, 0.95)' : withAlpha(color, 1));
81
+ light.addColorStop(0.18, glowing ? withAlpha(mixRgb(color, 'rgb(255, 255, 255)', 0.5), 0.9) : withAlpha(color, 0.85));
82
+ light.addColorStop(0.45, withAlpha(color, 0.35));
52
83
  light.addColorStop(1, withAlpha(color, 0));
53
84
  context.fillStyle = light;
54
85
  context.beginPath();
55
- context.arc(head.x, head.y, reach, 0, Math.PI * 2);
86
+ context.arc(head.x, head.y, bloom, 0, Math.PI * 2);
56
87
  context.fill();
57
88
  };
89
+ const spark = (context, color, at, gone) => {
90
+ context.strokeStyle = withAlpha(color, 0.6 * (1 - gone) ** 2);
91
+ context.lineWidth = 1.2;
92
+ context.beginPath();
93
+ context.arc(at.x, at.y, at.radius + 2 + 8 * ease(gone), 0, Math.PI * 2);
94
+ context.stroke();
95
+ };
58
96
  const arrive = (context, color, at, gone) => {
59
- const reach = at.radius + 6;
97
+ const reach = at.radius + 7;
60
98
  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));
99
+ glow.addColorStop(0, withAlpha(color, 0.55 * (1 - gone) ** 2));
62
100
  glow.addColorStop(1, withAlpha(color, 0));
63
101
  context.fillStyle = glow;
64
102
  context.beginPath();
65
103
  context.arc(at.x, at.y, reach, 0, Math.PI * 2);
66
104
  context.fill();
105
+ context.strokeStyle = withAlpha(color, 0.7 * (1 - gone) ** 1.5);
106
+ context.lineWidth = 1.5 * (1 - gone) + 0.5;
107
+ context.beginPath();
108
+ context.arc(at.x, at.y, at.radius + 3 + 16 * ease(gone), 0, Math.PI * 2);
109
+ context.stroke();
67
110
  };
68
111
  export const isLight = (color) => {
69
112
  const [red = 0, green = 0, blue = 0] = color.match(/[\d.]+/g)?.map(Number) ?? [];
@@ -91,7 +134,7 @@ const nameGroups = (context, groups, looks, at) => {
91
134
  points.push(point);
92
135
  }
93
136
  }
94
- if (count < 2) {
137
+ if (count < Math.max(2, group.ids.length * 0.3)) {
95
138
  continue;
96
139
  }
97
140
  x /= count;
@@ -159,7 +202,7 @@ const fresh = (canvas, container) => {
159
202
  context.clearRect(0, 0, width, height);
160
203
  return context;
161
204
  };
162
- export const signalsOver = (container, onScreen) => {
205
+ export const signalsOver = (container, onScreen, bend) => {
163
206
  let overlay;
164
207
  let flights = [];
165
208
  let flying = 0;
@@ -178,16 +221,20 @@ export const signalsOver = (container, onScreen) => {
178
221
  if (!from || !to) {
179
222
  return false;
180
223
  }
181
- flight.crossing ??= Math.min(Math.max((Math.hypot(to.x - from.x, to.y - from.y) / SPEED) * 1000, QUICKEST), SLOWEST);
224
+ flight.crossing ??= Math.min(Math.max(((Math.hypot(to.x - from.x, to.y - from.y) * 1.08) / SPEED) * 1000, QUICKEST), SLOWEST);
182
225
  const crossing = flight.crossing;
183
226
  const gone = (now - flight.started) / crossing;
184
227
  if (gone >= 1 + ARRIVAL / crossing) {
185
228
  return false;
186
229
  }
230
+ const toward = bounded(from, to, bend?.(flight.from, flight.to) ?? bowed(from, to));
187
231
  context.save();
188
232
  if (gone < 1) {
189
233
  if (flight.animate) {
190
- pulse(context, flight.leaves, flight.arrives, from, to, ease(gone), gone, glowing);
234
+ comet(context, flight.leaves, flight.arrives, from, toward, to, ease(gone), gone, glowing);
235
+ if (gone < SPARK) {
236
+ spark(context, flight.leaves, from, gone / SPARK);
237
+ }
191
238
  }
192
239
  else {
193
240
  const line = context.createLinearGradient(from.x, from.y, to.x, to.y);
@@ -196,9 +243,7 @@ export const signalsOver = (container, onScreen) => {
196
243
  context.globalAlpha = 1 - gone;
197
244
  context.strokeStyle = line;
198
245
  context.lineWidth = 2;
199
- context.beginPath();
200
- context.moveTo(from.x, from.y);
201
- context.lineTo(to.x, to.y);
246
+ trace(context, from, toward, to);
202
247
  context.stroke();
203
248
  }
204
249
  }
@@ -1,4 +1,5 @@
1
1
  export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, type ShapeField, } from '../connect-graph/shape-field.js';
2
+ export { surfaceOf } from '../three/surface.js';
2
3
  export { type ImageOptions, shapeFromImage, shapeFromSvg } from './flat.js';
3
4
  export { shapeFromModel } from './model.js';
4
5
  export { box, onSphere, sphere, torus } from './solids.js';
@@ -1,4 +1,5 @@
1
1
  export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, } from '../connect-graph/shape-field.js';
2
+ export { surfaceOf } from '../three/surface.js';
2
3
  export { shapeFromImage, shapeFromSvg } from './flat.js';
3
4
  export { shapeFromModel } from './model.js';
4
5
  export { box, onSphere, sphere, torus } from './solids.js';
@@ -70,9 +70,23 @@ export const shapeFromTriangles = (positions, index, { resolution = SOLID } = {}
70
70
  }
71
71
  }
72
72
  }
73
- return fieldFromCells(cells, [nx, ny, nz], cell, {
73
+ const meshCorners = new Float32Array(corners * 3);
74
+ for (let at = 0; at < corners; at++) {
75
+ meshCorners[at * 3] = positions[at * 3] - origin[0];
76
+ meshCorners[at * 3 + 1] = high[1] + cell - positions[at * 3 + 1];
77
+ meshCorners[at * 3 + 2] = positions[at * 3 + 2] - origin[2];
78
+ }
79
+ const meshTriangles = index
80
+ ? Uint32Array.from({ length: triangles * 3 }, (_, at) => index[at])
81
+ : Uint32Array.from({ length: triangles * 3 }, (_, at) => at);
82
+ const field = fieldFromCells(cells, [nx, ny, nz], cell, {
74
83
  depth: nz * cell,
75
84
  height: ny * cell,
76
85
  width: nx * cell,
77
86
  });
87
+ return {
88
+ ...field,
89
+ distance: field.distance,
90
+ mesh: { corners: meshCorners, triangles: meshTriangles },
91
+ };
78
92
  };
@@ -110,6 +110,11 @@ void main(void) {
110
110
  color = mix(color, vec3(1.0), rim * 0.14);
111
111
  alpha = 0.86;
112
112
  } else {
113
+ /* Flat, which unset is: matte, with a gentle fall of light across it so
114
+ a note reads as round, brighter toward its middle the larger it is
115
+ drawn, and a fine rim a shade darker so notes that touch stay two. */
116
+ color *= mix(0.82, 1.1, lit);
117
+ color = mix(color, min(color * 1.25, vec3(1.0)), (1.0 - smoothstep(0.0, 0.8, length(p - vec2(-0.25, 0.3)))) * 0.35 * drawn);
113
118
  color = mix(color, color * 0.72, rim * 0.6);
114
119
  }
115
120
 
@@ -7,7 +7,12 @@ import type { ShapeField } from '../connect-graph/shape-field.js';
7
7
  * each edge it crosses. Smooth enough to hide what is behind a shape, and
8
8
  * made in a moment.
9
9
  */
10
- export declare const surfaceOf: (field: ShapeField, at?: number) => {
10
+ export declare const surfaceOf: (field: ShapeField, { at, cells, }?: {
11
+ /** The distance the surface is drawn at: below zero inside the shape. 0 by default. */
12
+ at?: number | undefined;
13
+ /** How many cells the field is read in along its longest side. 72 by default. */
14
+ cells?: number | undefined;
15
+ }) => {
11
16
  corners: Float32Array;
12
17
  triangles: Uint32Array;
13
18
  };
@@ -1,7 +1,7 @@
1
1
  const CELLS = 72;
2
- export const surfaceOf = (field, at = 0) => {
2
+ export const surfaceOf = (field, { at = 0, cells = CELLS, } = {}) => {
3
3
  const { depth, height, width } = field;
4
- const cell = Math.max(width, height, depth) / CELLS;
4
+ const cell = Math.max(width, height, depth) / cells;
5
5
  const nx = Math.ceil(width / cell) + 3;
6
6
  const ny = Math.ceil(height / cell) + 3;
7
7
  const nz = Math.ceil(depth / cell) + 3;
@@ -1,6 +1,7 @@
1
1
  import { forceCenter, forceLink, forceManyBody, forceSimulation, } from 'd3-force-3d';
2
- import { AdditiveBlending, BufferAttribute, BufferGeometry, DoubleSide, LineSegments, Mesh, NormalBlending, PerspectiveCamera, Points, Raycaster, Scene, ShaderMaterial, TOUCH, Vector2, Vector3, WebGLRenderer, } from 'three';
2
+ import { AdditiveBlending, BoxGeometry, BufferAttribute, BufferGeometry, DoubleSide, Euler, InstancedBufferAttribute, InstancedMesh, LineSegments, Matrix4, Mesh, NormalBlending, OctahedronGeometry, PerspectiveCamera, Points, Quaternion, Raycaster, Scene, ShaderMaterial, TOUCH, Vector2, Vector3, WebGLRenderer, } from 'three';
3
3
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
4
+ import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
4
5
  import { ease, gesturesHeld, groupsOver, isLight, namesOver, signalsOver, } from '../overlay/overlay.js';
5
6
  import { surfaceOf } from './surface.js';
6
7
  const SHAPES = { circle: 0, diamond: 2, ring: 0, square: 1 };
@@ -11,6 +12,10 @@ const ZOOM = 1.5;
11
12
  const SAMPLE = 96;
12
13
  const LINKS = { dark: { close: 0.3, far: 0.05 }, light: { close: 0.55, far: 0.12 } };
13
14
  const REST = 8000;
15
+ const WASH = { dark: 0.14, full: 1.5, light: 0.18, notes: 6, reach: 3.5 };
16
+ const BLOOM = { dark: 0.28, light: 0 };
17
+ const LIGHT = new Vector3(-0.4, 0.8, 0.45).normalize();
18
+ const LIFT = 0.42;
14
19
  const FOV = 40;
15
20
  const FADE = `
16
21
  uniform float middle;
@@ -23,6 +28,29 @@ float fade(float depth) {
23
28
  return mix(gentle, sharp, steep);
24
29
  }
25
30
  `;
31
+ const ORB = `
32
+ uniform vec3 light;
33
+ uniform float dark;
34
+
35
+ vec4 orb(vec3 base, vec3 n) {
36
+ float diffuse = max(dot(n, light), 0.0);
37
+ float rim = pow(1.0 - clamp(n.z, 0.0, 1.0), 2.2);
38
+ float core = pow(clamp(n.z, 0.0, 1.0), 5.0);
39
+ float glint = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 140.0);
40
+ vec3 energy = base * (0.08 + 0.2 * diffuse);
41
+ energy += base * core * 0.5;
42
+ energy += mix(base, vec3(1.0), 0.6) * pow(clamp(n.z, 0.0, 1.0), 40.0) * 0.55;
43
+ energy += mix(base, vec3(1.0), 0.25) * rim * 1.2;
44
+ energy += vec3(glint * 0.55);
45
+ vec3 frost = mix(base, vec3(1.0), 0.45 * pow(clamp(n.z, 0.0, 1.0), 1.5));
46
+ frost *= 0.86 + 0.14 * diffuse;
47
+ frost = mix(frost, base * 0.78, rim * 0.75);
48
+ frost += vec3(glint * 0.6);
49
+ return dark > 0.5
50
+ ? vec4(energy, mix(0.5, 1.0, max(rim, core)))
51
+ : vec4(frost, mix(0.78, 1.0, rim));
52
+ }
53
+ `;
26
54
  const VERTEX = `
27
55
  ${FADE}
28
56
  attribute float size;
@@ -32,18 +60,29 @@ attribute float glow;
32
60
 
33
61
  uniform float scale;
34
62
  uniform float unit;
63
+ uniform float bloom;
64
+ uniform float halo;
35
65
 
36
66
  varying vec4 vTint;
37
67
  varying float vShape;
38
68
  varying float vGlow;
39
69
  varying float vFade;
70
+ varying float vRoom;
71
+ varying vec3 vCenter;
72
+ varying float vRadius;
40
73
 
41
74
  void main() {
42
75
  vec4 view = modelViewMatrix * vec4(position, 1.0);
43
76
  gl_Position = projectionMatrix * view;
44
77
  float depth = max(-view.z, 0.001);
78
+ /* The halo's pass has room around the note for it, which every note has
79
+ where the ground is dark, and more for a glowing one; the body's pass
80
+ is the note and no more. */
81
+ vRoom = halo > 0.5 ? 1.0 + (bloom > 0.0 ? 0.9 : 0.0) + glow * 1.4 : 1.0;
45
82
  /* As large as the note is, at the distance it is: nearer is larger. */
46
- gl_PointSize = size * unit * scale / depth * (1.0 + glow * 1.4);
83
+ gl_PointSize = size * unit * scale / depth * vRoom;
84
+ vCenter = view.xyz;
85
+ vRadius = size * unit * 0.5;
47
86
  vFade = fade(depth);
48
87
  vTint = tint;
49
88
  vShape = shape;
@@ -51,24 +90,88 @@ void main() {
51
90
  }
52
91
  `;
53
92
  const FRAGMENT = `
93
+ uniform mat4 projectionMatrix;
94
+ ${ORB}
95
+ varying vec4 vTint;
96
+ varying float vShape;
97
+ varying float vFade;
98
+ varying vec3 vCenter;
99
+ varying float vRadius;
100
+
101
+ void main() {
102
+ if (vShape > 0.5) discard;
103
+ vec2 q = (gl_PointCoord * 2.0 - 1.0) * vec2(1.0, -1.0);
104
+ float d2 = dot(q, q);
105
+ if (d2 > 1.0) discard;
106
+ vec3 n = vec3(q, sqrt(1.0 - d2));
107
+ vec4 clip = projectionMatrix * vec4(vCenter + n * vRadius, 1.0);
108
+ gl_FragDepth = clip.z / clip.w * 0.5 + 0.5;
109
+ /* Antialiased in the last few percent only, so the glowing rim runs to the edge. */
110
+ float edge = 1.0 - smoothstep(0.965, 1.0, sqrt(d2));
111
+ vec4 lit = orb(vTint.rgb, n);
112
+ float alpha = edge * lit.a * vTint.a * vFade;
113
+ if (alpha < 0.01) discard;
114
+ gl_FragColor = vec4(lit.rgb, alpha);
115
+ }
116
+ `;
117
+ const HALO_FRAGMENT = `
118
+ uniform float bloom;
54
119
  varying vec4 vTint;
55
120
  varying float vShape;
56
121
  varying float vGlow;
57
122
  varying float vFade;
123
+ varying float vRoom;
124
+
125
+ void main() {
126
+ vec2 q = (gl_PointCoord * 2.0 - 1.0) * vRoom;
127
+ float d = vShape > 1.5 ? (abs(q.x) + abs(q.y)) * 0.78 : vShape > 0.5 ? max(abs(q.x), abs(q.y)) * 1.08 : length(q);
128
+ /* From just inside the body's edge, under its antialiasing, so no seam of
129
+ dark ground shows between the body and its halo. */
130
+ if (d < 0.965) discard;
131
+ float past = clamp((d - 0.965) / max(vRoom - 0.965, 0.001), 0.0, 1.0);
132
+ float alpha = (bloom + vGlow * 0.6) * pow(1.0 - past, 2.4) * vTint.a * vFade;
133
+ if (alpha < 0.01) discard;
134
+ gl_FragColor = vec4(vTint.rgb, alpha);
135
+ }
136
+ `;
137
+ const SHAPE_VERTEX = `
138
+ ${FADE}
139
+ attribute vec4 tone;
140
+ attribute vec4 frame;
141
+ varying vec3 vNormal;
142
+ varying vec4 vTone;
143
+ varying vec4 vFrame;
144
+ varying float vFade;
145
+
146
+ void main() {
147
+ vec4 view = viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
148
+ gl_Position = projectionMatrix * view;
149
+ vNormal = normalize(normalMatrix * mat3(instanceMatrix) * normal);
150
+ vTone = tone;
151
+ vFrame = frame;
152
+ vFade = fade(max(-view.z, 0.001));
153
+ }
154
+ `;
155
+ const SHAPE_FRAGMENT = `
156
+ uniform float dark;
157
+ varying vec3 vNormal;
158
+ varying vec4 vTone;
159
+ varying vec4 vFrame;
160
+ varying float vFade;
58
161
 
59
162
  void main() {
60
- vec2 p = gl_PointCoord * 2.0 - 1.0;
61
- /* The note's body inside the sprite; the rest is room for its glow. */
62
- float body = 1.0 / (1.0 + vGlow * 1.4);
63
- float reach = vShape > 1.5 ? (abs(p.x) + abs(p.y)) * 0.78 : vShape > 0.5 ? max(abs(p.x), abs(p.y)) * 1.08 : length(p);
64
- float d = reach / body;
65
- float inside = 1.0 - smoothstep(0.86, 1.0, d);
66
- /* Lit from the middle out, so a note reads as round. */
67
- vec3 color = vTint.rgb * mix(1.15, 0.8, clamp(d, 0.0, 1.0));
68
- float halo = vGlow * (1.0 - smoothstep(1.0, 1.0 / body, d)) * 0.55;
69
- float alpha = (inside + halo * (1.0 - inside)) * vTint.a * vFade;
163
+ float e = min(min(vFrame.x, vFrame.y), min(vFrame.z, vFrame.w));
164
+ float w = fwidth(e);
165
+ /* The edge as a line about a pixel and a half wide, whatever the size. */
166
+ float line = 1.0 - smoothstep(w * 0.6, w * 2.2, e);
167
+ float inner = exp(-e * 12.0);
168
+ float facing = abs(normalize(vNormal).z);
169
+ vec3 edge = dark > 0.5 ? mix(vTone.rgb, vec3(1.0), 0.35) : vTone.rgb * 0.8;
170
+ vec3 face = vTone.rgb * (dark > 0.5 ? 0.3 + 0.7 * inner : 1.0);
171
+ float faceAlpha = (dark > 0.5 ? 0.1 : 0.16) + 0.3 * inner + 0.12 * (1.0 - facing);
172
+ float alpha = mix(faceAlpha, 1.0, line) * vTone.a * vFade;
70
173
  if (alpha < 0.01) discard;
71
- gl_FragColor = vec4(color, alpha);
174
+ gl_FragColor = vec4(mix(face, edge, line), alpha);
72
175
  }
73
176
  `;
74
177
  const LINE_VERTEX = `
@@ -94,29 +197,100 @@ void main() {
94
197
  }
95
198
  `;
96
199
  const SOLID_VERTEX = `
200
+ attribute vec4 wash;
97
201
  varying vec3 vNormal;
98
202
  varying vec3 vView;
203
+ varying vec4 vWash;
99
204
 
100
205
  void main() {
101
206
  vec4 view = modelViewMatrix * vec4(position, 1.0);
102
207
  gl_Position = projectionMatrix * view;
103
208
  vNormal = normalize(normalMatrix * normal);
104
209
  vView = normalize(-view.xyz);
210
+ vWash = wash;
105
211
  }
106
212
  `;
107
213
  const SOLID_FRAGMENT = `
108
214
  uniform vec3 ground;
109
215
  uniform vec3 ink;
216
+ uniform float strength;
110
217
  varying vec3 vNormal;
111
218
  varying vec3 vView;
219
+ varying vec4 vWash;
112
220
 
113
221
  void main() {
114
- /* The ground's color, a breath of ink at the edge turning away, so the
115
- shape is felt and not seen: the notes on it are what is drawn. */
116
- float edge = 1.0 - abs(dot(normalize(vNormal), normalize(vView)));
117
- gl_FragColor = vec4(mix(ground, ink, 0.16 * pow(edge, 2.0)), 1.0);
222
+ /* **Each region washed in its notes' color**, as a translucent stain on
223
+ the ground's color, and lit: a breath of ink where light from above and
224
+ to the left falls, and the region's own color glowing at the edge
225
+ turning away. Felt more than seen: the notes on it are what is drawn. */
226
+ vec3 normal = normalize(vNormal);
227
+ vec3 view = normalize(vView);
228
+ if (dot(normal, view) < 0.0) normal = -normal;
229
+ float edge = 1.0 - dot(normal, view);
230
+ float lit = max(dot(normal, normalize(vec3(-0.4, 0.8, 0.45))), 0.0);
231
+ float washed = strength * vWash.a;
232
+ vec3 base = mix(ground, vWash.rgb, washed);
233
+ vec3 shaded = mix(base, ink, 0.1 * pow(edge, 2.0) + 0.17 * lit * lit);
234
+ vec3 rim = mix(shaded, vWash.rgb, washed * 1.6 * pow(edge, 3.0));
235
+ gl_FragColor = vec4(rim, 1.0);
118
236
  }
119
237
  `;
238
+ const framed = (geometry) => {
239
+ const uv = geometry.getAttribute('uv');
240
+ const count = geometry.getAttribute('position').count;
241
+ const frame = new Float32Array(count * 4);
242
+ if (geometry.getIndex() && uv) {
243
+ for (let at = 0; at < count; at++) {
244
+ const u = uv.getX(at);
245
+ const v = uv.getY(at);
246
+ frame.set([u, 1 - u, v, 1 - v], at * 4);
247
+ }
248
+ }
249
+ else {
250
+ for (let at = 0; at < count; at++) {
251
+ const corner = at % 3;
252
+ frame.set([corner === 0 ? 1 : 0, corner === 1 ? 1 : 0, corner === 2 ? 1 : 0, 1], at * 4);
253
+ }
254
+ }
255
+ geometry.setAttribute('frame', new BufferAttribute(frame, 4));
256
+ return geometry;
257
+ };
258
+ const SMOOTH = { surface: 3, ways: 6 };
259
+ const neighborsOf = (index, count) => {
260
+ const sets = Array.from({ length: count }, () => new Set());
261
+ for (let at = 0; at < index.count; at += 3) {
262
+ const a = index.getX(at);
263
+ const b = index.getX(at + 1);
264
+ const c = index.getX(at + 2);
265
+ sets[a]?.add(b).add(c);
266
+ sets[b]?.add(a).add(c);
267
+ sets[c]?.add(a).add(b);
268
+ }
269
+ return sets.map((set) => Int32Array.from(set));
270
+ };
271
+ const averaged = (values, neighbors, share) => {
272
+ const out = new Float32Array(values.length);
273
+ neighbors.forEach((around, at) => {
274
+ let x = 0;
275
+ let y = 0;
276
+ let z = 0;
277
+ for (const other of around) {
278
+ x += values[other * 3];
279
+ y += values[other * 3 + 1];
280
+ z += values[other * 3 + 2];
281
+ }
282
+ const many = around.length || 1;
283
+ for (const [axis, sum] of [
284
+ [0, x],
285
+ [1, y],
286
+ [2, z],
287
+ ]) {
288
+ const value = values[at * 3 + axis];
289
+ out[at * 3 + axis] = around.length ? value + share * (sum / many - value) : value;
290
+ }
291
+ });
292
+ return out;
293
+ };
120
294
  const channels = (color) => {
121
295
  const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
122
296
  return [red / 255, green / 255, blue / 255];
@@ -160,6 +334,9 @@ export const threeDriver = (container, options = {}) => {
160
334
  let scale = 1;
161
335
  let positions = new Float32Array();
162
336
  const uniforms = {
337
+ bloom: { value: BLOOM.dark },
338
+ dark: { value: 1 },
339
+ light: { value: new Vector3(-0.4, 0.8, 0.45).normalize() },
163
340
  middle: { value: 1 },
164
341
  radius: { value: 1 },
165
342
  scale: { value: 1 },
@@ -179,13 +356,46 @@ export const threeDriver = (container, options = {}) => {
179
356
  vertexShader: LINE_VERTEX,
180
357
  });
181
358
  const points = new Points(new BufferGeometry(), new ShaderMaterial({
182
- depthWrite: false,
183
359
  fragmentShader: FRAGMENT,
184
360
  transparent: true,
185
- uniforms,
361
+ uniforms: { ...uniforms, halo: { value: 0 } },
186
362
  vertexShader: VERTEX,
187
363
  }));
188
364
  points.frustumCulled = false;
365
+ const halos = new Points(points.geometry, new ShaderMaterial({
366
+ depthWrite: false,
367
+ fragmentShader: HALO_FRAGMENT,
368
+ transparent: true,
369
+ uniforms: { ...uniforms, halo: { value: 1 } },
370
+ vertexShader: VERTEX,
371
+ }));
372
+ halos.frustumCulled = false;
373
+ halos.renderOrder = 1;
374
+ const shapeMaterial = new ShaderMaterial({
375
+ depthWrite: false,
376
+ fragmentShader: SHAPE_FRAGMENT,
377
+ side: DoubleSide,
378
+ transparent: true,
379
+ uniforms: {
380
+ dark: uniforms.dark,
381
+ middle: uniforms.middle,
382
+ radius: uniforms.radius,
383
+ steep: uniforms.steep,
384
+ },
385
+ vertexShader: SHAPE_VERTEX,
386
+ });
387
+ const solids = {
388
+ cube: {
389
+ geometry: framed(new BoxGeometry(1.5, 1.5, 1.5)),
390
+ mesh: undefined,
391
+ spots: [],
392
+ },
393
+ octahedron: {
394
+ geometry: framed(new OctahedronGeometry(1.15)),
395
+ mesh: undefined,
396
+ spots: [],
397
+ },
398
+ };
189
399
  const quiet = new LineSegments(new BufferGeometry(), lines(LINKS.dark.far));
190
400
  const lit = new LineSegments(new BufferGeometry(), lines(0.9));
191
401
  quiet.frustumCulled = false;
@@ -196,13 +406,17 @@ export const threeDriver = (container, options = {}) => {
196
406
  polygonOffsetFactor: 2,
197
407
  polygonOffsetUnits: 2,
198
408
  side: DoubleSide,
199
- uniforms: { ground: { value: [0, 0, 0] }, ink: { value: [1, 1, 1] } },
409
+ uniforms: {
410
+ ground: { value: [0, 0, 0] },
411
+ ink: { value: [1, 1, 1] },
412
+ strength: { value: WASH.dark },
413
+ },
200
414
  vertexShader: SOLID_VERTEX,
201
415
  }));
202
416
  solid.renderOrder = -1;
203
417
  solid.visible = false;
204
418
  solid.frustumCulled = false;
205
- scene.add(solid, quiet, lit, points);
419
+ scene.add(solid, quiet, lit, points, halos);
206
420
  let shapeFrame;
207
421
  let surfaced = false;
208
422
  let solidAt = Number.NaN;
@@ -217,21 +431,160 @@ export const threeDriver = (container, options = {}) => {
217
431
  return;
218
432
  }
219
433
  solidAt = scale;
220
- const { corners, triangles } = surfaceOf(frame.field, -frame.shell * 0.6);
221
- const { depth, height, width } = frame.field;
222
- for (let at = 0; at < corners.length; at += 3) {
223
- corners[at] = (frame.middle.x + (corners[at] - width / 2) * frame.scale) * scale;
224
- corners[at + 1] =
225
- (frame.middle.y - (corners[at + 1] - height / 2) * frame.scale) * scale;
226
- corners[at + 2] =
227
- (frame.middle.z + (corners[at + 2] - depth / 2) * frame.scale) * scale;
434
+ const { field } = frame;
435
+ const inset = frame.shell * 1.15;
436
+ let geometry = new BufferGeometry();
437
+ if (field.mesh) {
438
+ geometry.setAttribute('position', new BufferAttribute(field.mesh.corners.slice(), 3));
439
+ geometry.setIndex(new BufferAttribute(field.mesh.triangles, 1));
440
+ geometry = mergeVertices(geometry);
441
+ const place = geometry.getAttribute('position');
442
+ const count = place.count;
443
+ const neighbors = neighborsOf(geometry.getIndex(), count);
444
+ const step = Math.max(field.width, field.height, field.depth) / 64;
445
+ let way = new Float32Array(count * 3);
446
+ for (let at = 0; at < count; at++) {
447
+ const x = place.getX(at);
448
+ const y = place.getY(at);
449
+ const z = place.getZ(at);
450
+ way[at * 3] = field.distance(x + step, y, z) - field.distance(x - step, y, z);
451
+ way[at * 3 + 1] = field.distance(x, y + step, z) - field.distance(x, y - step, z);
452
+ way[at * 3 + 2] = field.distance(x, y, z + step) - field.distance(x, y, z - step);
453
+ }
454
+ for (let pass = 0; pass < SMOOTH.ways; pass++) {
455
+ way = averaged(way, neighbors, 0.5);
456
+ }
457
+ for (let at = 0; at < count; at++) {
458
+ const length = Math.hypot(way[at * 3], way[at * 3 + 1], way[at * 3 + 2]) ||
459
+ 1;
460
+ place.setXYZ(at, place.getX(at) - (way[at * 3] / length) * inset, place.getY(at) - (way[at * 3 + 1] / length) * inset, place.getZ(at) - (way[at * 3 + 2] / length) * inset);
461
+ }
462
+ let corners = place.array;
463
+ for (let pass = 0; pass < SMOOTH.surface; pass++) {
464
+ corners = averaged(averaged(corners, neighbors, 0.5), neighbors, -0.53);
465
+ }
466
+ place.array.set(corners);
228
467
  }
468
+ else {
469
+ const { corners, triangles } = surfaceOf(field, { at: -inset });
470
+ geometry.setAttribute('position', new BufferAttribute(corners, 3));
471
+ geometry.setIndex(new BufferAttribute(triangles, 1));
472
+ }
473
+ const place = geometry.getAttribute('position');
474
+ const { depth, height, width } = field;
475
+ for (let at = 0; at < place.count; at++) {
476
+ place.setXYZ(at, (frame.middle.x + (place.getX(at) - width / 2) * frame.scale) * scale, (frame.middle.y - (place.getY(at) - height / 2) * frame.scale) * scale, (frame.middle.z + (place.getZ(at) - depth / 2) * frame.scale) * scale);
477
+ }
478
+ geometry.computeVertexNormals();
229
479
  solid.geometry.dispose();
230
- solid.geometry = new BufferGeometry();
231
- solid.geometry.setAttribute('position', new BufferAttribute(corners, 3));
232
- solid.geometry.setIndex(new BufferAttribute(triangles, 1));
233
- solid.geometry.computeVertexNormals();
480
+ solid.geometry = geometry;
234
481
  solid.visible = true;
482
+ tieSurface();
483
+ };
484
+ let washFrom = new Int32Array();
485
+ let washWeight = new Float32Array();
486
+ let noteTints = new Float32Array();
487
+ const tieSurface = () => {
488
+ const place = solid.geometry.getAttribute('position');
489
+ if (!solid.visible || !place || ids.length === 0) {
490
+ washFrom = new Int32Array();
491
+ return;
492
+ }
493
+ const sample = Math.min(ids.length, 200);
494
+ const nearest = [];
495
+ for (let step = 0; step < sample; step++) {
496
+ const one = Math.floor((step * ids.length) / sample);
497
+ let best = Number.POSITIVE_INFINITY;
498
+ for (let other = 0; other < ids.length; other++) {
499
+ if (other !== one) {
500
+ best = Math.min(best, at(one).distanceToSquared(at(other)));
501
+ }
502
+ }
503
+ nearest.push(Math.sqrt(best));
504
+ }
505
+ nearest.sort((one, other) => one - other);
506
+ const reach = (nearest[Math.floor(nearest.length / 2)] ?? 1) * WASH.reach;
507
+ const cells = new Map();
508
+ const key = (x, y, z) => `${Math.floor(x / reach)},${Math.floor(y / reach)},${Math.floor(z / reach)}`;
509
+ for (let spot = 0; spot < ids.length; spot++) {
510
+ const k = key(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
511
+ const list = cells.get(k);
512
+ if (list) {
513
+ list.push(spot);
514
+ }
515
+ else {
516
+ cells.set(k, [spot]);
517
+ }
518
+ }
519
+ washFrom = new Int32Array(place.count * WASH.notes).fill(-1);
520
+ washWeight = new Float32Array(place.count * WASH.notes);
521
+ const best = new Float32Array(WASH.notes);
522
+ const which = new Int32Array(WASH.notes);
523
+ for (let corner = 0; corner < place.count; corner++) {
524
+ const x = place.getX(corner);
525
+ const y = place.getY(corner);
526
+ const z = place.getZ(corner);
527
+ best.fill(Number.POSITIVE_INFINITY);
528
+ which.fill(-1);
529
+ const cx = Math.floor(x / reach);
530
+ const cy = Math.floor(y / reach);
531
+ const cz = Math.floor(z / reach);
532
+ for (let dx = -1; dx <= 1; dx++) {
533
+ for (let dy = -1; dy <= 1; dy++) {
534
+ for (let dz = -1; dz <= 1; dz++) {
535
+ for (const spot of cells.get(`${cx + dx},${cy + dy},${cz + dz}`) ?? []) {
536
+ const far = (positions[spot * 3] - x) ** 2 +
537
+ (positions[spot * 3 + 1] - y) ** 2 +
538
+ (positions[spot * 3 + 2] - z) ** 2;
539
+ if (far < best[WASH.notes - 1]) {
540
+ let slot = WASH.notes - 1;
541
+ while (slot > 0 && best[slot - 1] > far) {
542
+ best[slot] = best[slot - 1];
543
+ which[slot] = which[slot - 1];
544
+ slot -= 1;
545
+ }
546
+ best[slot] = far;
547
+ which[slot] = spot;
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ for (let slot = 0; slot < WASH.notes; slot++) {
554
+ washFrom[corner * WASH.notes + slot] = which[slot];
555
+ const far = Math.sqrt(best[slot]) / reach;
556
+ washWeight[corner * WASH.notes + slot] = far < 1 ? (1 - far * far) ** 2 : 0;
557
+ }
558
+ }
559
+ washSurface();
560
+ };
561
+ const washSurface = () => {
562
+ const place = solid.geometry.getAttribute('position');
563
+ if (!place || washFrom.length !== place.count * WASH.notes || noteTints.length === 0) {
564
+ return;
565
+ }
566
+ const wash = new Float32Array(place.count * 4);
567
+ for (let corner = 0; corner < place.count; corner++) {
568
+ let red = 0;
569
+ let green = 0;
570
+ let blue = 0;
571
+ let total = 0;
572
+ for (let slot = 0; slot < WASH.notes; slot++) {
573
+ const spot = washFrom[corner * WASH.notes + slot];
574
+ const weight = washWeight[corner * WASH.notes + slot] * (noteTints[spot * 4 + 3] ?? 0);
575
+ if (spot < 0 || weight <= 0) {
576
+ continue;
577
+ }
578
+ red += noteTints[spot * 4] * weight;
579
+ green += noteTints[spot * 4 + 1] * weight;
580
+ blue += noteTints[spot * 4 + 2] * weight;
581
+ total += weight;
582
+ }
583
+ if (total > 0) {
584
+ wash.set([red / total, green / total, blue / total, Math.min(total / WASH.full, 1)], corner * 4);
585
+ }
586
+ }
587
+ solid.geometry.setAttribute('wash', new BufferAttribute(wash, 4));
235
588
  };
236
589
  const at = (spot) => new Vector3(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
237
590
  const onScreen = (id) => {
@@ -256,7 +609,30 @@ export const threeDriver = (container, options = {}) => {
256
609
  };
257
610
  };
258
611
  const held = gesturesHeld(container);
259
- const signals = signalsOver(container, onScreen);
612
+ const arc = (from, to) => {
613
+ const one = index.get(from);
614
+ const other = index.get(to);
615
+ if (one === undefined || other === undefined) {
616
+ return undefined;
617
+ }
618
+ const a = at(one);
619
+ const b = at(other);
620
+ const middle = a.clone().add(b).multiplyScalar(0.5);
621
+ const center = points.geometry.boundingSphere?.center ?? controls.target;
622
+ const out = middle.clone().sub(center);
623
+ if (out.lengthSq() < 1e-9) {
624
+ out.copy(camera.up);
625
+ }
626
+ const peak = middle.add(out.normalize().multiplyScalar(a.distanceTo(b) * LIFT * 2));
627
+ const depth = peak.clone().applyMatrix4(camera.matrixWorldInverse).z;
628
+ if (depth > 0) {
629
+ return undefined;
630
+ }
631
+ peak.project(camera);
632
+ const { clientHeight: height, clientWidth: width } = container;
633
+ return { x: ((peak.x + 1) / 2) * width, y: ((1 - peak.y) / 2) * height };
634
+ };
635
+ const signals = signalsOver(container, (id) => nearOnScreen(id), arc);
260
636
  const nearOnScreen = (id) => {
261
637
  const spot = index.get(id);
262
638
  if (spot === undefined) {
@@ -345,22 +721,91 @@ export const threeDriver = (container, options = {}) => {
345
721
  });
346
722
  const geometry = points.geometry;
347
723
  geometry.setAttribute('tint', new BufferAttribute(tints, 4));
724
+ noteTints = tints;
725
+ washSurface();
348
726
  geometry.setAttribute('size', new BufferAttribute(drawn, 1));
349
727
  geometry.setAttribute('shape', new BufferAttribute(shapes, 1));
350
728
  geometry.setAttribute('glow', new BufferAttribute(glows, 1));
729
+ drawnSizes = drawn;
730
+ meshShapes(shapes, tints);
351
731
  dark = !isLight(looks.ground);
352
732
  const solidUniforms = solid.material.uniforms;
353
733
  solidUniforms.ground.value = channels(looks.ground);
354
734
  solidUniforms.ink.value = channels(looks.ink);
735
+ solid.material.uniforms.strength.value = dark ? WASH.dark : WASH.light;
736
+ uniforms.bloom.value = dark ? BLOOM.dark : BLOOM.light;
737
+ uniforms.dark.value = dark ? 1 : 0;
355
738
  const blending = dark ? AdditiveBlending : NormalBlending;
356
- points.material.blending = blending;
739
+ halos.material.blending = blending;
740
+ shapeMaterial.blending = blending;
741
+ shapeMaterial.needsUpdate = true;
357
742
  quiet.material.blending = blending;
358
743
  lit.material.blending = blending;
359
- points.material.needsUpdate = true;
744
+ halos.material.needsUpdate = true;
360
745
  quiet.material.needsUpdate = true;
361
746
  lit.material.needsUpdate = true;
362
747
  paintLinks();
363
748
  };
749
+ let drawnSizes = new Float32Array();
750
+ const meshShapes = (shapes, tints) => {
751
+ for (const [code, solidShape] of [
752
+ [SHAPES.square, solids.cube],
753
+ [SHAPES.diamond, solids.octahedron],
754
+ ]) {
755
+ const spots = [];
756
+ shapes.forEach((shape, spot) => {
757
+ if (shape === code) {
758
+ spots.push(spot);
759
+ }
760
+ });
761
+ solidShape.spots = spots;
762
+ if (!solidShape.mesh || solidShape.mesh.count !== spots.length) {
763
+ if (solidShape.mesh) {
764
+ scene.remove(solidShape.mesh);
765
+ solidShape.mesh.dispose();
766
+ }
767
+ solidShape.mesh =
768
+ spots.length > 0
769
+ ? new InstancedMesh(solidShape.geometry, shapeMaterial, spots.length)
770
+ : undefined;
771
+ if (solidShape.mesh) {
772
+ solidShape.mesh.frustumCulled = false;
773
+ scene.add(solidShape.mesh);
774
+ }
775
+ }
776
+ const mesh = solidShape.mesh;
777
+ if (mesh) {
778
+ const tone = new Float32Array(spots.length * 4);
779
+ spots.forEach((spot, at) => {
780
+ tone.set(tints.subarray(spot * 4, spot * 4 + 4), at * 4);
781
+ });
782
+ mesh.geometry = solidShape.geometry;
783
+ mesh.geometry.setAttribute('tone', new InstancedBufferAttribute(tone, 4));
784
+ }
785
+ }
786
+ placeShapes();
787
+ };
788
+ const turned = new Quaternion();
789
+ const placed = new Matrix4();
790
+ const where = new Vector3();
791
+ const scaled = new Vector3();
792
+ const placeShapes = () => {
793
+ for (const solidShape of [solids.cube, solids.octahedron]) {
794
+ const mesh = solidShape.mesh;
795
+ if (!mesh) {
796
+ continue;
797
+ }
798
+ solidShape.spots.forEach((spot, at) => {
799
+ const size = (drawnSizes[spot] ?? 1) * uniforms.unit.value * 0.5;
800
+ const twist = depthOf(ids[spot] ?? '');
801
+ turned.setFromEuler(new Euler(0.45 + twist * 0.3, 0.6 + twist, 0.2 * twist));
802
+ where.set(positions[spot * 3], positions[spot * 3 + 1], positions[spot * 3 + 2]);
803
+ scaled.setScalar(size);
804
+ mesh.setMatrixAt(at, placed.compose(where, turned, scaled));
805
+ });
806
+ mesh.instanceMatrix.needsUpdate = true;
807
+ }
808
+ };
364
809
  const paintLinks = () => {
365
810
  if (!looks) {
366
811
  return;
@@ -425,6 +870,9 @@ export const threeDriver = (container, options = {}) => {
425
870
  points.geometry.computeBoundingSphere();
426
871
  hovered = undefined;
427
872
  paint();
873
+ if (solid.visible) {
874
+ tieSurface();
875
+ }
428
876
  };
429
877
  const toWorld = (node) => [
430
878
  node.x * scale,
@@ -434,6 +882,8 @@ export const threeDriver = (container, options = {}) => {
434
882
  let morph;
435
883
  let layout;
436
884
  const cue = () => {
885
+ camera.updateMatrixWorld();
886
+ uniforms.light.value.copy(LIGHT).transformDirection(camera.matrixWorldInverse);
437
887
  const distance = camera.position.distanceTo(controls.target);
438
888
  const { center, radius } = points.geometry.boundingSphere ?? {
439
889
  center: controls.target,
@@ -518,6 +968,9 @@ export const threeDriver = (container, options = {}) => {
518
968
  now - handled > REST;
519
969
  controls.update();
520
970
  cue();
971
+ if (solids.cube.mesh || solids.octahedron.mesh) {
972
+ placeShapes();
973
+ }
521
974
  renderer.render(scene, camera);
522
975
  groups.draw();
523
976
  drawNames();
@@ -725,6 +1178,12 @@ export const threeDriver = (container, options = {}) => {
725
1178
  canvas.removeEventListener('pointermove', hover);
726
1179
  canvas.removeEventListener('pointerleave', leave);
727
1180
  controls.dispose();
1181
+ for (const solidShape of [solids.cube, solids.octahedron]) {
1182
+ solidShape.mesh?.dispose();
1183
+ solidShape.geometry.dispose();
1184
+ }
1185
+ shapeMaterial.dispose();
1186
+ halos.material.dispose();
728
1187
  for (const object of [points, quiet, lit, solid]) {
729
1188
  object.geometry.dispose();
730
1189
  object.material.dispose();
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.123.0",
4
+ "version": "0.125.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -47,12 +47,12 @@
47
47
  },
48
48
  "bugs": "https://github.com/project-fusion/fusion/issues",
49
49
  "dependencies": {
50
- "@fusion.dev/helix-core": "0.123.0"
50
+ "@fusion.dev/helix-core": "0.125.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@cosmos.gl/graph": "^3.4.2",
54
- "@fusion.dev/helix-headless": "0.123.0",
55
- "@fusion.dev/helix-testing": "0.123.0",
54
+ "@fusion.dev/helix-headless": "0.125.0",
55
+ "@fusion.dev/helix-testing": "0.125.0",
56
56
  "@types/bun": "^1.3.0",
57
57
  "@types/three": "^0.186.0",
58
58
  "d3-force-3d": "^3.0.6",