@fusion.dev/helix-graph 0.121.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.
@@ -1,5 +1,6 @@
1
- const RASTER = 1024;
2
- const PROBE = 64;
1
+ import { fieldFromInside, fieldFromPath } from './shape-field.js';
2
+ const PROBE = { flat: 64, solid: 32 };
3
+ const SHELL = 0.04;
3
4
  const WAVE = 0.07;
4
5
  const seeded = (seed) => {
5
6
  let state = seed >>> 0 || 1;
@@ -8,57 +9,60 @@ const seeded = (seed) => {
8
9
  return state / 4294967296;
9
10
  };
10
11
  };
11
- const insideOf = (stencil) => {
12
- if ('inside' in stencil) {
13
- return stencil.inside;
12
+ const fields = new WeakMap();
13
+ export const fieldOf = (stencil) => {
14
+ if ('distance' in stencil) {
15
+ return stencil;
14
16
  }
15
- const scale = RASTER / Math.max(stencil.width, stencil.height, 1);
16
- const width = Math.max(1, Math.ceil(stencil.width * scale));
17
- const height = Math.max(1, Math.ceil(stencil.height * scale));
18
- const canvas = document.createElement('canvas');
19
- canvas.width = width;
20
- canvas.height = height;
21
- const context = canvas.getContext('2d', { willReadFrequently: true });
22
- if (!context) {
23
- return () => true;
17
+ const made = fields.get(stencil);
18
+ if (made) {
19
+ return made;
24
20
  }
25
- context.scale(scale, scale);
26
- context.fill(new Path2D(stencil.path), 'evenodd');
27
- const alpha = context.getImageData(0, 0, width, height).data;
28
- return (x, y) => {
29
- const column = Math.floor(x * scale);
30
- const row = Math.floor(y * scale);
31
- return (column >= 0 &&
32
- row >= 0 &&
33
- column < width &&
34
- row < height &&
35
- (alpha[(row * width + column) * 4 + 3] ?? 0) > 127);
36
- };
21
+ const field = 'path' in stencil
22
+ ? fieldFromPath(stencil.path, stencil.width, stencil.height)
23
+ : fieldFromInside((x, y) => stencil.inside(x, y), stencil);
24
+ fields.set(stencil, field);
25
+ return field;
37
26
  };
38
- export const placesIn = (inside, width, height, count) => {
39
- let filled = 0;
40
- for (let row = 0; row < PROBE; row++) {
41
- for (let column = 0; column < PROBE; column++) {
42
- if (inside(((column + 0.5) / PROBE) * width, ((row + 0.5) / PROBE) * height)) {
43
- filled += 1;
27
+ export const placesIn = (field, count, { place = 'fill', shell = SHELL } = {}) => {
28
+ const solid = field.depth > 0;
29
+ const { depth, height, width } = field;
30
+ const thickness = shell * Math.max(width, height, depth);
31
+ const takes = (x, y, z) => {
32
+ const distance = field.distance(x, y, z);
33
+ return distance < 0 && (place === 'fill' || distance > -thickness);
34
+ };
35
+ const probe = solid ? PROBE.solid : PROBE.flat;
36
+ let taken = 0;
37
+ for (let layer = 0; layer < (solid ? probe : 1); layer++) {
38
+ for (let row = 0; row < probe; row++) {
39
+ for (let column = 0; column < probe; column++) {
40
+ if (takes(((column + 0.5) / probe) * width, ((row + 0.5) / probe) * height, solid ? ((layer + 0.5) / probe) * depth : 0)) {
41
+ taken += 1;
42
+ }
44
43
  }
45
44
  }
46
45
  }
47
- if (count === 0 || filled === 0) {
46
+ if (count === 0 || taken === 0) {
48
47
  return [];
49
48
  }
50
49
  const random = seeded(count);
51
- let step = Math.sqrt(((filled / PROBE ** 2) * width * height) / count);
50
+ const room = (taken / probe ** (solid ? 3 : 2)) * width * height * (solid ? depth : 1);
51
+ let step = (room / count) ** (1 / (solid ? 3 : 2));
52
52
  for (let tries = 0; tries < 8; tries++) {
53
53
  const places = [];
54
- for (let y = step / 2; y < height; y += step) {
55
- for (let x = step / 2; x < width; x += step) {
56
- const place = {
57
- x: x + (random() - 0.5) * step * 0.8,
58
- y: y + (random() - 0.5) * step * 0.8,
59
- };
60
- if (inside(place.x, place.y)) {
61
- places.push(place);
54
+ const layers = solid ? Math.ceil(depth / step) : 1;
55
+ for (let layer = 0; layer < layers; layer++) {
56
+ for (let y = step / 2; y < height; y += step) {
57
+ for (let x = step / 2; x < width; x += step) {
58
+ const place = {
59
+ x: x + (random() - 0.5) * step * 0.8,
60
+ y: y + (random() - 0.5) * step * 0.8,
61
+ ...(solid ? { z: (layer + 0.5 + (random() - 0.5) * 0.8) * step } : {}),
62
+ };
63
+ if (takes(place.x, place.y, place.z ?? 0)) {
64
+ places.push(place);
65
+ }
62
66
  }
63
67
  }
64
68
  }
@@ -69,53 +73,127 @@ export const placesIn = (inside, width, height, count) => {
69
73
  }
70
74
  return places.slice(0, count);
71
75
  }
72
- step *= Math.sqrt(places.length / count) * 0.97 || 0.5;
76
+ step *= (places.length / count) ** (1 / (solid ? 3 : 2)) * 0.97 || 0.5;
73
77
  }
74
78
  return [];
75
79
  };
80
+ const nested = (items, keys) => {
81
+ const [key, ...rest] = keys;
82
+ if (!key) {
83
+ return items;
84
+ }
85
+ const sorted = [...items].sort((one, other) => key(one) - key(other));
86
+ if (rest.length === 0) {
87
+ return sorted;
88
+ }
89
+ const slice = Math.ceil(items.length ** (rest.length / keys.length));
90
+ const out = [];
91
+ for (let from = 0; from < sorted.length; from += slice) {
92
+ out.push(...nested(sorted.slice(from, from + slice), rest));
93
+ }
94
+ return out;
95
+ };
76
96
  export const fitToStencil = (positions, stencil) => {
77
97
  const notes = [...positions].map(([id, at]) => ({ at, id }));
78
98
  if (notes.length === 0) {
79
99
  return new Map();
80
100
  }
81
- const places = placesIn(insideOf(stencil), stencil.width, stencil.height, notes.length);
101
+ const field = fieldOf(stencil);
102
+ const places = placesIn(field, notes.length, {
103
+ place: stencil.place ?? 'fill',
104
+ shell: stencil.shell ?? SHELL,
105
+ });
82
106
  if (places.length < notes.length) {
83
107
  return new Map(positions);
84
108
  }
85
- let left = Number.POSITIVE_INFINITY;
86
- let right = Number.NEGATIVE_INFINITY;
87
- let bottom = Number.POSITIVE_INFINITY;
88
- let top = Number.NEGATIVE_INFINITY;
109
+ const solid = field.depth > 0;
110
+ const low = {
111
+ x: Number.POSITIVE_INFINITY,
112
+ y: Number.POSITIVE_INFINITY,
113
+ z: Number.POSITIVE_INFINITY,
114
+ };
115
+ const high = {
116
+ x: Number.NEGATIVE_INFINITY,
117
+ y: Number.NEGATIVE_INFINITY,
118
+ z: Number.NEGATIVE_INFINITY,
119
+ };
89
120
  for (const { at } of notes) {
90
- left = Math.min(left, at.x);
91
- right = Math.max(right, at.x);
92
- bottom = Math.min(bottom, at.y);
93
- top = Math.max(top, at.y);
121
+ for (const axis of ['x', 'y', 'z']) {
122
+ const value = at[axis] ?? 0;
123
+ low[axis] = Math.min(low[axis], value);
124
+ high[axis] = Math.max(high[axis], value);
125
+ }
94
126
  }
95
- const scale = Math.max(right - left, top - bottom, 1) / Math.max(stencil.width, stencil.height);
96
- const { height, width } = stencil;
97
- const spots = places
98
- .map((place) => ({
99
- across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
100
- at: {
101
- x: (left + right) / 2 + (place.x - width / 2) * scale,
102
- y: (bottom + top) / 2 - (place.y - height / 2) * scale,
103
- },
104
- down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
105
- }))
106
- .sort((one, other) => one.across - other.across);
107
- notes.sort((one, other) => one.at.x - other.at.x || one.at.y - other.at.y);
108
- const strip = Math.ceil(Math.sqrt(notes.length));
127
+ const { depth, height, width } = field;
128
+ const scale = Math.max(high.x - low.x, high.y - low.y, solid ? high.z - low.z : 0, 1) /
129
+ Math.max(width, height, depth);
130
+ const middle = { x: (low.x + high.x) / 2, y: (low.y + high.y) / 2, z: (low.z + high.z) / 2 };
131
+ const spots = places.map((place) => {
132
+ const z = place.z ?? depth / 2;
133
+ return {
134
+ across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
135
+ at: {
136
+ x: middle.x + (place.x - width / 2) * scale,
137
+ y: middle.y - (place.y - height / 2) * scale,
138
+ ...(solid ? { z: middle.z + (z - depth / 2) * scale } : {}),
139
+ },
140
+ down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
141
+ };
142
+ });
109
143
  const fitted = new Map();
110
- for (let from = 0; from < notes.length; from += strip) {
111
- const ours = notes.slice(from, from + strip).sort((one, other) => one.at.y - other.at.y);
112
- const theirs = spots
113
- .slice(from, from + strip)
114
- .sort((one, other) => one.down - other.down)
115
- .map((spot) => spot.at);
116
- ours.forEach((note, at) => {
117
- fitted.set(note.id, theirs[at]);
118
- });
144
+ if (solid) {
145
+ const surface = (stencil.place ?? 'fill') === 'surface';
146
+ const way = (point) => {
147
+ if (!surface) {
148
+ return point;
149
+ }
150
+ const x = point.x - middle.x;
151
+ const y = point.y - middle.y;
152
+ const z = (point.z ?? 0) - middle.z;
153
+ const length = Math.hypot(x, y, z) || 1;
154
+ return { x: x / length, y: y / length, z: z / length };
155
+ };
156
+ const widest = (points) => {
157
+ let best = 'x';
158
+ let spread = Number.NEGATIVE_INFINITY;
159
+ for (const axis of ['x', 'y', 'z']) {
160
+ let low = Number.POSITIVE_INFINITY;
161
+ let high = Number.NEGATIVE_INFINITY;
162
+ for (const point of points) {
163
+ const value = point[axis] ?? 0;
164
+ low = Math.min(low, value);
165
+ high = Math.max(high, value);
166
+ }
167
+ if (high - low > spread) {
168
+ spread = high - low;
169
+ best = axis;
170
+ }
171
+ }
172
+ return best;
173
+ };
174
+ const halve = (ours, theirs) => {
175
+ if (ours.length === 1) {
176
+ fitted.set(ours[0].id, theirs[0].at);
177
+ return;
178
+ }
179
+ if (ours.length === 0) {
180
+ return;
181
+ }
182
+ const mine = widest(ours.map((note) => note.key));
183
+ const yours = widest(theirs.map((spot) => spot.key));
184
+ ours.sort((one, other) => (one.key[mine] ?? 0) - (other.key[mine] ?? 0));
185
+ theirs.sort((one, other) => (one.key[yours] ?? 0) - (other.key[yours] ?? 0));
186
+ const half = Math.floor(ours.length / 2);
187
+ halve(ours.slice(0, half), theirs.slice(0, half));
188
+ halve(ours.slice(half), theirs.slice(half));
189
+ };
190
+ halve(notes.map((note) => ({ ...note, key: way(note.at) })), spots.map((spot) => ({ at: spot.at, key: way(spot.at) })));
191
+ return fitted;
119
192
  }
193
+ const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
194
+ const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
195
+ ours.forEach((note, at) => {
196
+ fitted.set(note.id, theirs[at].at);
197
+ });
120
198
  return fitted;
121
199
  };
@@ -1,4 +1,4 @@
1
- import { gesturesHeld, groupsOver, signalsOver } from '../overlay/overlay.js';
1
+ import { gesturesHeld, groupsOver, namesOver, signalsOver, } from '../overlay/overlay.js';
2
2
  const SHAPES = { circle: 0, diamond: 3, ring: 0, square: 1 };
3
3
  const STEP = 250;
4
4
  const FAINT = 0.16;
@@ -81,61 +81,13 @@ export const cosmosDriver = (graph, container, options = {}) => {
81
81
  const held = gesturesHeld(container);
82
82
  const signals = signalsOver(container, onScreen);
83
83
  const groups = groupsOver(container, onScreen);
84
- const names = document.createElement('canvas');
85
- names.setAttribute('aria-hidden', 'true');
86
- names.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
87
- container.append(names);
84
+ const names = namesOver(container, onScreen);
88
85
  const drawNames = () => {
89
- const context = names.getContext('2d');
90
- if (!context || !looks) {
91
- return;
92
- }
93
- const ratio = window.devicePixelRatio || 1;
94
- const width = Math.round(container.clientWidth * ratio);
95
- const height = Math.round(container.clientHeight * ratio);
96
- if (names.width !== width || names.height !== height) {
97
- names.width = width;
98
- names.height = height;
99
- }
100
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
101
- context.clearRect(0, 0, width, height);
102
- const taken = [];
103
- const shown = [selected, hovered === undefined ? undefined : ids[hovered], ...near];
104
- for (const id of shown) {
105
- if (id === undefined) {
106
- continue;
107
- }
108
- const at = onScreen(id);
109
- const label = looks.node(id);
110
- if (!at || label.hidden) {
111
- continue;
112
- }
113
- const name = drawnNow.get(id)?.label ?? id;
114
- const strong = id === selected || id === ids[hovered ?? -1];
115
- context.font = `${strong ? 600 : 500} ${strong ? 13 : 12}px ${looks.font}`;
116
- const x = at.x + at.radius + 4;
117
- const y = at.y + 4;
118
- const box = {
119
- bottom: y + 4,
120
- left: x - 2,
121
- right: x + context.measureText(name).width + 2,
122
- top: y - 13,
123
- };
124
- const free = taken.every((other) => box.right < other.left ||
125
- box.left > other.right ||
126
- box.bottom < other.top ||
127
- box.top > other.bottom);
128
- if (!free && !strong) {
129
- continue;
130
- }
131
- taken.push(box);
132
- context.lineJoin = 'round';
133
- context.lineWidth = 3;
134
- context.strokeStyle = looks.ground;
135
- context.strokeText(name, x, y);
136
- context.fillStyle = strong ? looks.ink : looks.quiet;
137
- context.fillText(name, x, y);
138
- }
86
+ const lit = hovered === undefined ? undefined : ids[hovered];
87
+ const shown = [...new Set([selected, lit, ...near])].flatMap((id) => id === undefined
88
+ ? []
89
+ : [{ id, name: drawnNow.get(id)?.label ?? id, strong: id === selected || id === lit }]);
90
+ names.draw(looks, shown);
139
91
  };
140
92
  const redraw = () => {
141
93
  drawNames();
@@ -418,7 +370,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
418
370
  });
419
371
  signals.stop();
420
372
  groups.stop();
421
- names.remove();
373
+ names.stop();
422
374
  held.stop();
423
375
  },
424
376
  update: (changes, animate) => {
@@ -7,6 +7,8 @@ export type OnScreen = (id: string) => {
7
7
  x: number;
8
8
  y: number;
9
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;
10
12
  /** Eases in and out, so a signal leaves and arrives gently. */
11
13
  export declare const ease: (at: number) => number;
12
14
  /** A name's box on screen, in pixels. */
@@ -30,6 +32,22 @@ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) =
30
32
  } | undefined, animate: boolean) => void;
31
33
  stop: () => void;
32
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
+ };
33
51
  /**
34
52
  * **Group names are drawn on a canvas of their own**, under the signals', and
35
53
  * drawn again whenever the driver says the view moved, so they follow it.
@@ -65,7 +65,7 @@ const arrive = (context, color, at, gone) => {
65
65
  context.arc(at.x, at.y, reach, 0, Math.PI * 2);
66
66
  context.fill();
67
67
  };
68
- const isLight = (color) => {
68
+ export const isLight = (color) => {
69
69
  const [red = 0, green = 0, blue = 0] = color.match(/[\d.]+/g)?.map(Number) ?? [];
70
70
  return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 > 0.5;
71
71
  };
@@ -76,6 +76,7 @@ const nameGroups = (context, groups, looks, at) => {
76
76
  context.textAlign = 'center';
77
77
  context.textBaseline = 'middle';
78
78
  context.shadowColor = looks.ground;
79
+ const taken = [];
79
80
  for (const group of groups) {
80
81
  let x = 0;
81
82
  let y = 0;
@@ -100,6 +101,23 @@ const nameGroups = (context, groups, looks, at) => {
100
101
  continue;
101
102
  }
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));
103
121
  context.globalAlpha = group.faded ? 0.3 : 0.92;
104
122
  context.fillStyle = mixRgb(group.color, looks.ink, 0.6);
105
123
  context.shadowBlur = 10;
@@ -223,6 +241,50 @@ export const signalsOver = (container, onScreen) => {
223
241
  },
224
242
  };
225
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
+ };
226
288
  export const groupsOver = (container, onScreen) => {
227
289
  let canvas;
228
290
  let groups = [];
@@ -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 @@
1
+ export { type ThreeDriverOptions, threeDriver } from './three-driver.js';
@@ -0,0 +1 @@
1
+ export { threeDriver } from './three-driver.js';
@@ -0,0 +1,32 @@
1
+ import type { GraphDriver } from '../connect-graph/connect-graph.js';
2
+ import type { NodeLook } from '../connect-graph/looks.js';
3
+ /** How the three.js driver behaves where the page does not say. */
4
+ export interface ThreeDriverOptions {
5
+ /**
6
+ * Whether the graph turns slowly while nobody is handling it. On by
7
+ * default, and never for a reader who asks for less motion.
8
+ */
9
+ autoRotate?: boolean | undefined;
10
+ /** Whether each group's name is drawn over its notes. On by default. */
11
+ groupLabels?: boolean | undefined;
12
+ /** The longest a layout runs, in milliseconds, settled or not. 10 seconds by default. */
13
+ layoutLimit?: number | undefined;
14
+ /** A note's shape where it does not say: `circle` by default, `square` or `diamond`. */
15
+ shape?: NodeLook['shape'];
16
+ }
17
+ /**
18
+ * Drives three.js for a Graph frame: the graph drawn in three dimensions, to
19
+ * be turned and looked into. Pass the box inside the frame the drawing goes
20
+ * in, and `connectGraph` fills it:
21
+ *
22
+ * ```js
23
+ * connectGraph(frame, threeDriver(box))
24
+ * ```
25
+ *
26
+ * **A layout runs in three dimensions**, with d3-force-3d, where notes have no
27
+ * place; with a solid `stencil`, the notes are drawn in its shape and none
28
+ * runs. Dragging turns the graph, the wheel and a pinch bring it nearer, and
29
+ * it turns slowly by itself while nobody is handling it, unless the reader
30
+ * asks for less motion.
31
+ */
32
+ export declare const threeDriver: (container: HTMLElement, options?: ThreeDriverOptions) => GraphDriver;