@fusion.dev/helix-graph 0.125.0 → 0.127.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.
Files changed (41) hide show
  1. package/README.md +84 -35
  2. package/dist/connect-graph/arrange.d.ts +4 -2
  3. package/dist/connect-graph/arrange.js +5 -22
  4. package/dist/connect-graph/connect-graph.d.ts +18 -6
  5. package/dist/connect-graph/connect-graph.js +76 -32
  6. package/dist/connect-graph/diff.d.ts +7 -0
  7. package/dist/connect-graph/looks.d.ts +8 -0
  8. package/dist/connect-graph/looks.js +6 -3
  9. package/dist/connect-graph/seed.d.ts +9 -0
  10. package/dist/connect-graph/seed.js +21 -15
  11. package/dist/connect-graph/stencil.d.ts +11 -1
  12. package/dist/connect-graph/stencil.js +109 -11
  13. package/dist/cosmos/cosmos-driver.d.ts +5 -4
  14. package/dist/cosmos/cosmos-driver.js +122 -39
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +2 -2
  17. package/dist/model/index.d.ts +1 -0
  18. package/dist/model/index.js +1 -0
  19. package/dist/model/model.d.ts +16 -0
  20. package/dist/model/model.js +50 -0
  21. package/dist/overlay/overlay.d.ts +14 -8
  22. package/dist/overlay/overlay.js +64 -62
  23. package/dist/shapes/flat.d.ts +3 -2
  24. package/dist/shapes/flat.js +59 -6
  25. package/dist/shapes/index.d.ts +3 -4
  26. package/dist/shapes/index.js +3 -4
  27. package/dist/{connect-graph → shapes}/shape-field.d.ts +12 -3
  28. package/dist/{connect-graph → shapes}/shape-field.js +4 -2
  29. package/dist/shapes/solids.d.ts +3 -3
  30. package/dist/shapes/solids.js +25 -9
  31. package/dist/{three → shapes}/surface.d.ts +1 -1
  32. package/dist/shapes/triangles.d.ts +12 -3
  33. package/dist/shapes/triangles.js +83 -2
  34. package/dist/sigma/sigma-driver.d.ts +33 -17
  35. package/dist/sigma/sigma-driver.js +69 -17
  36. package/dist/three/three-driver.d.ts +12 -3
  37. package/dist/three/three-driver.js +376 -201
  38. package/package.json +9 -5
  39. package/dist/shapes/model.d.ts +0 -12
  40. package/dist/shapes/model.js +0 -31
  41. /package/dist/{three → shapes}/surface.js +0 -0
@@ -0,0 +1,50 @@
1
+ import { shapeFromTriangles } from '../shapes/triangles.js';
2
+ export const shapeFromModel = async (source, options = {}) => {
3
+ const which = typeof source === 'string' ? `The model ${source}` : 'The model given as bytes';
4
+ const [{ GLTFLoader }, { Mesh, Vector3 }] = await Promise.all([
5
+ import('three/addons/loaders/GLTFLoader.js'),
6
+ import('three'),
7
+ ]).catch((cause) => {
8
+ throw new Error(`${which} could not be read, because three.js could not be loaded. Install three alongside @fusion.dev/helix-graph.`, { cause });
9
+ });
10
+ const loader = new GLTFLoader();
11
+ const model = await (typeof source === 'string'
12
+ ? loader.loadAsync(source)
13
+ : loader.parseAsync(source, '')).catch((cause) => {
14
+ throw new Error(`${which} could not be read as a glTF model: ${String(cause)}`, { cause });
15
+ });
16
+ model.scene.updateMatrixWorld(true);
17
+ const corners = [];
18
+ const names = [];
19
+ const ofTriangle = [];
20
+ const point = new Vector3();
21
+ model.scene.traverse((object) => {
22
+ if (!(object instanceof Mesh)) {
23
+ return;
24
+ }
25
+ const geometry = object.geometry;
26
+ const place = geometry.getAttribute('position');
27
+ if (!place) {
28
+ return;
29
+ }
30
+ const name = object.name || geometry.name || '';
31
+ let part = names.indexOf(name);
32
+ if (name && part < 0) {
33
+ part = names.push(name) - 1;
34
+ }
35
+ const index = geometry.getIndex();
36
+ const count = index ? index.count : place.count;
37
+ for (let at = 0; at < count; at++) {
38
+ point.fromBufferAttribute(place, index ? index.getX(at) : at).applyMatrix4(object.matrixWorld);
39
+ corners.push(point.x, point.y, point.z);
40
+ }
41
+ for (let triangle = 0; triangle < Math.floor(count / 3); triangle++) {
42
+ ofTriangle.push(part);
43
+ }
44
+ });
45
+ const named = names.length > 1 && !ofTriangle.includes(-1);
46
+ return shapeFromTriangles(corners, null, {
47
+ ...options,
48
+ ...(named ? { regions: { names, ofTriangle } } : {}),
49
+ });
50
+ };
@@ -7,11 +7,6 @@ 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;
15
10
  /** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
16
11
  export declare const isLight: (color: string) => boolean;
17
12
  /** Eases in and out, so a signal leaves and arrives gently. */
@@ -23,13 +18,19 @@ export interface Box {
23
18
  right: number;
24
19
  top: number;
25
20
  }
21
+ /**
22
+ * How many pixels a layer has for each of the page's: the screen's own, and
23
+ * never fewer than two, so on a screen of one a name's edges and a signal's
24
+ * tail are drawn twice as finely and smoothed down. At most three.
25
+ */
26
+ export declare const overlayDensity: () => number;
26
27
  /**
27
28
  * **Signals are drawn on a canvas of the driver's own**, over the renderer's,
28
29
  * so a signal never makes the renderer redraw the graph: ten signals a second
29
30
  * over ten thousand notes draw ten dots. It draws only while something is in
30
31
  * flight, and takes no pointer, so a press reaches the note under it.
31
32
  */
32
- export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen, bend?: Bend) => {
33
+ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) => {
33
34
  look: (looks: GraphLooks) => void;
34
35
  signal: (from: string, to: string, colors: {
35
36
  from: string;
@@ -50,14 +51,18 @@ export interface Named {
50
51
  * order given.
51
52
  */
52
53
  export declare const namesOver: (container: HTMLElement, onScreen: OnScreen) => {
54
+ /** The boxes the names last drawn take. */
55
+ boxes: () => readonly Box[];
53
56
  draw: (looks: GraphLooks | undefined, named: readonly Named[]) => void;
54
57
  stop: () => void;
55
58
  };
56
59
  /**
57
60
  * **Group names are drawn on a canvas of their own**, under the signals', and
58
61
  * drawn again whenever the driver says the view moved, so they follow it.
62
+ * `avoid` says where notes' names are drawn, which a group's name keeps
63
+ * clear of: moved a line, or left out where no line is clear.
59
64
  */
60
- export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen) => {
65
+ export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen, avoid?: () => readonly Box[]) => {
61
66
  draw: () => void;
62
67
  look: (next: GraphLooks) => void;
63
68
  set: (next: readonly GraphGroup[]) => void;
@@ -69,7 +74,8 @@ export declare const groupsOver: (container: HTMLElement, onScreen: OnScreen) =>
69
74
  * frame's buttons stopped working with them. A listener on the container in
70
75
  * the capture phase runs before the renderer's, and keeps from it what the
71
76
  * page has: the plain wheel and one finger while cooperative, and every
72
- * gesture while a waiting surface is off.
77
+ * gesture while a waiting surface is off, a double click among them, which
78
+ * zooms where a renderer zooms on one. A single click still selects a note.
73
79
  */
74
80
  export declare const gesturesHeld: (container: HTMLElement) => {
75
81
  /**
@@ -1,55 +1,28 @@
1
- import { mixRgb } from '../connect-graph/looks.js';
1
+ import { mixRgb, rgbOf } from '../connect-graph/looks.js';
2
2
  const SPEED = 340;
3
3
  const QUICKEST = 450;
4
4
  const SLOWEST = 1400;
5
5
  const ARRIVAL = 520;
6
6
  const FLYING = 400;
7
7
  const withAlpha = (color, alpha) => {
8
- const [red = 128, green = 128, blue = 128] = color.match(/[\d.]+/g)?.map(Number) ?? [];
8
+ const [red, green, blue] = rgbOf(color);
9
9
  return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
10
10
  };
11
11
  const TAIL = 120;
12
12
  const PIECES = 14;
13
- const BOW = 0.16;
14
- const BOW_MOST = 90;
15
13
  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) => {
14
+ const on = (a, b, t) => ({
15
+ x: a.x + (b.x - a.x) * t,
16
+ y: a.y + (b.y - a.y) * t,
17
+ });
18
+ const trace = (context, a, b) => {
46
19
  context.beginPath();
47
20
  context.moveTo(a.x, a.y);
48
- context.quadraticCurveTo(c.x, c.y, b.x, b.y);
21
+ context.lineTo(b.x, b.y);
49
22
  };
50
- const comet = (context, leaves, arrives, from, bend, to, along, through, glowing) => {
23
+ const comet = (context, leaves, arrives, from, to, along, through, glowing) => {
51
24
  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;
25
+ const length = Math.hypot(to.x - from.x, to.y - from.y) || 1;
53
26
  const color = mixRgb(leaves, arrives, along);
54
27
  context.lineCap = 'round';
55
28
  const line = context.createLinearGradient(from.x, from.y, to.x, to.y);
@@ -57,15 +30,15 @@ const comet = (context, leaves, arrives, from, bend, to, along, through, glowing
57
30
  line.addColorStop(1, withAlpha(arrives, 0.16 * Math.sin(Math.PI * through)));
58
31
  context.strokeStyle = line;
59
32
  context.lineWidth = 1;
60
- trace(context, from, bend, to);
33
+ trace(context, from, to);
61
34
  context.stroke();
62
35
  const reach = Math.min(TAIL / length, along);
63
36
  context.lineCap = 'butt';
64
- let behind = on(from, bend, to, along - reach);
37
+ let behind = on(from, to, along - reach);
65
38
  for (let piece = 1; piece <= PIECES; piece++) {
66
39
  const share = piece / PIECES;
67
40
  const t = along - reach * (1 - share);
68
- const ahead = on(from, bend, to, t);
41
+ const ahead = on(from, to, t);
69
42
  context.strokeStyle = withAlpha(mixRgb(leaves, arrives, t), (glowing ? 0.85 : 1) * share * share);
70
43
  context.lineWidth = (0.4 + 2.2 * share) * strength;
71
44
  context.beginPath();
@@ -74,7 +47,7 @@ const comet = (context, leaves, arrives, from, bend, to, along, through, glowing
74
47
  context.stroke();
75
48
  behind = ahead;
76
49
  }
77
- const head = on(from, bend, to, along);
50
+ const head = on(from, to, along);
78
51
  const bloom = 9 * strength;
79
52
  const light = context.createRadialGradient(head.x, head.y, 0, head.x, head.y, bloom);
80
53
  light.addColorStop(0, glowing ? 'rgba(255, 255, 255, 0.95)' : withAlpha(color, 1));
@@ -109,24 +82,28 @@ const arrive = (context, color, at, gone) => {
109
82
  context.stroke();
110
83
  };
111
84
  export const isLight = (color) => {
112
- const [red = 0, green = 0, blue = 0] = color.match(/[\d.]+/g)?.map(Number) ?? [];
85
+ const [red, green, blue] = rgbOf(color, 0);
113
86
  return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 > 0.5;
114
87
  };
115
88
  export const ease = (at) => (at < 0.5 ? 2 * at * at : 1 - (-2 * at + 2) ** 2 / 2);
116
- const nameGroups = (context, groups, looks, at) => {
89
+ const SAMPLED = 96;
90
+ const nameGroups = (context, groups, looks, at, avoid) => {
117
91
  context.font = `650 11px ${looks.font}`;
118
92
  context.letterSpacing = '0.14em';
119
93
  context.textAlign = 'center';
120
94
  context.textBaseline = 'middle';
121
95
  context.shadowColor = looks.ground;
122
- const taken = [];
96
+ const taken = [...avoid];
123
97
  for (const group of groups) {
124
98
  let x = 0;
125
99
  let y = 0;
126
100
  let count = 0;
127
101
  const points = [];
128
- for (const id of group.ids) {
129
- const point = at(id);
102
+ const step = Math.max(1, Math.floor(group.ids.length / SAMPLED));
103
+ let asked = 0;
104
+ for (let index = 0; index < group.ids.length; index += step) {
105
+ asked += 1;
106
+ const point = at(group.ids[index]);
130
107
  if (point) {
131
108
  x += point.x;
132
109
  y += point.y;
@@ -134,7 +111,7 @@ const nameGroups = (context, groups, looks, at) => {
134
111
  points.push(point);
135
112
  }
136
113
  }
137
- if (count < Math.max(2, group.ids.length * 0.3)) {
114
+ if (count < Math.max(2, asked * 0.3)) {
138
115
  continue;
139
116
  }
140
117
  x /= count;
@@ -186,12 +163,13 @@ const layer = (container, kind) => {
186
163
  }
187
164
  return canvas;
188
165
  };
166
+ export const overlayDensity = () => Math.min(Math.max(window.devicePixelRatio || 1, 2), 3);
189
167
  const fresh = (canvas, container) => {
190
168
  const context = canvas.getContext('2d');
191
169
  if (!context) {
192
170
  return null;
193
171
  }
194
- const ratio = window.devicePixelRatio || 1;
172
+ const ratio = overlayDensity();
195
173
  const width = Math.round(container.clientWidth * ratio);
196
174
  const height = Math.round(container.clientHeight * ratio);
197
175
  if (canvas.width !== width || canvas.height !== height) {
@@ -202,14 +180,14 @@ const fresh = (canvas, container) => {
202
180
  context.clearRect(0, 0, width, height);
203
181
  return context;
204
182
  };
205
- export const signalsOver = (container, onScreen, bend) => {
183
+ export const signalsOver = (container, onScreen) => {
206
184
  let overlay;
207
185
  let flights = [];
208
186
  let flying = 0;
209
187
  let ink = 'rgb(128, 128, 128)';
210
188
  let glowing = false;
211
189
  const fly = (now) => {
212
- const context = overlay ? fresh(overlay, container) : null;
190
+ const context = overlay && container.isConnected ? fresh(overlay, container) : null;
213
191
  if (!context) {
214
192
  flights = [];
215
193
  return;
@@ -221,17 +199,16 @@ export const signalsOver = (container, onScreen, bend) => {
221
199
  if (!from || !to) {
222
200
  return false;
223
201
  }
224
- flight.crossing ??= Math.min(Math.max(((Math.hypot(to.x - from.x, to.y - from.y) * 1.08) / SPEED) * 1000, QUICKEST), SLOWEST);
202
+ flight.crossing ??= Math.min(Math.max((Math.hypot(to.x - from.x, to.y - from.y) / SPEED) * 1000, QUICKEST), SLOWEST);
225
203
  const crossing = flight.crossing;
226
204
  const gone = (now - flight.started) / crossing;
227
205
  if (gone >= 1 + ARRIVAL / crossing) {
228
206
  return false;
229
207
  }
230
- const toward = bounded(from, to, bend?.(flight.from, flight.to) ?? bowed(from, to));
231
208
  context.save();
232
209
  if (gone < 1) {
233
210
  if (flight.animate) {
234
- comet(context, flight.leaves, flight.arrives, from, toward, to, ease(gone), gone, glowing);
211
+ comet(context, flight.leaves, flight.arrives, from, to, ease(gone), gone, glowing);
235
212
  if (gone < SPARK) {
236
213
  spark(context, flight.leaves, from, gone / SPARK);
237
214
  }
@@ -243,7 +220,7 @@ export const signalsOver = (container, onScreen, bend) => {
243
220
  context.globalAlpha = 1 - gone;
244
221
  context.strokeStyle = line;
245
222
  context.lineWidth = 2;
246
- trace(context, from, toward, to);
223
+ trace(context, from, to);
247
224
  context.stroke();
248
225
  }
249
226
  }
@@ -261,6 +238,9 @@ export const signalsOver = (container, onScreen, bend) => {
261
238
  glowing = isLight(looks.ink);
262
239
  },
263
240
  signal: (from, to, colors, animate) => {
241
+ if (!container.isConnected) {
242
+ return;
243
+ }
264
244
  overlay ??= layer(container, 'signals');
265
245
  flights.push({
266
246
  animate,
@@ -288,13 +268,15 @@ export const signalsOver = (container, onScreen, bend) => {
288
268
  };
289
269
  export const namesOver = (container, onScreen) => {
290
270
  const canvas = layer(container, 'names');
271
+ let taken = [];
291
272
  return {
273
+ boxes: () => taken,
292
274
  draw: (looks, named) => {
293
275
  const context = looks ? fresh(canvas, container) : null;
294
276
  if (!context || !looks) {
295
277
  return;
296
278
  }
297
- const taken = [];
279
+ taken = [];
298
280
  for (const { id, name, strong } of named) {
299
281
  const at = onScreen(id);
300
282
  if (!at || looks.node(id).hidden) {
@@ -330,14 +312,14 @@ export const namesOver = (container, onScreen) => {
330
312
  },
331
313
  };
332
314
  };
333
- export const groupsOver = (container, onScreen) => {
315
+ export const groupsOver = (container, onScreen, avoid = () => []) => {
334
316
  let canvas;
335
317
  let groups = [];
336
318
  let looks;
337
319
  const draw = () => {
338
320
  const context = canvas && looks ? fresh(canvas, container) : null;
339
321
  if (context && looks) {
340
- nameGroups(context, groups, looks, onScreen);
322
+ nameGroups(context, groups, looks, onScreen, avoid());
341
323
  }
342
324
  };
343
325
  return {
@@ -357,14 +339,29 @@ export const groupsOver = (container, onScreen) => {
357
339
  },
358
340
  };
359
341
  };
342
+ const GESTURES = 'data-helix-graph-gestures';
343
+ const touchRule = (container) => {
344
+ const root = container.getRootNode();
345
+ const holder = root instanceof ShadowRoot ? root : document.head;
346
+ if (!holder || holder.querySelector(`style[${GESTURES}]`)) {
347
+ return;
348
+ }
349
+ const style = document.createElement('style');
350
+ style.setAttribute(GESTURES, '');
351
+ style.textContent = `[${GESTURES}="cooperative"], [${GESTURES}="cooperative"] * { touch-action: pan-x pan-y !important; }
352
+ [${GESTURES}="off"], [${GESTURES}="off"] * { touch-action: auto !important; }`;
353
+ holder.append(style);
354
+ };
360
355
  export const gesturesHeld = (container) => {
361
356
  let mode = 'free';
362
- const touchAction = container.style.touchAction;
363
357
  const hold = (event) => {
364
358
  if (mode === 'free') {
365
359
  return;
366
360
  }
367
361
  if (mode === 'off') {
362
+ if (event.type === 'click' && event.detail < 2) {
363
+ return;
364
+ }
368
365
  event.stopImmediatePropagation();
369
366
  return;
370
367
  }
@@ -383,21 +380,26 @@ export const gesturesHeld = (container) => {
383
380
  }
384
381
  }
385
382
  };
386
- const held = ['wheel', 'touchstart', 'touchmove', 'mousedown'];
383
+ const held = ['wheel', 'touchstart', 'touchmove', 'mousedown', 'click', 'dblclick'];
387
384
  for (const type of held) {
388
385
  container.addEventListener(type, hold, { capture: true, passive: false });
389
386
  }
390
387
  return {
391
388
  set: (next) => {
392
389
  mode = next;
393
- container.style.touchAction =
394
- next === 'free' ? touchAction : next === 'cooperative' ? 'pan-x pan-y' : 'auto';
390
+ if (next === 'free') {
391
+ container.removeAttribute(GESTURES);
392
+ }
393
+ else {
394
+ touchRule(container);
395
+ container.setAttribute(GESTURES, next);
396
+ }
395
397
  },
396
398
  stop: () => {
397
399
  for (const type of held) {
398
400
  container.removeEventListener(type, hold, { capture: true });
399
401
  }
400
- container.style.touchAction = touchAction;
402
+ container.removeAttribute(GESTURES);
401
403
  },
402
404
  };
403
405
  };
@@ -1,8 +1,9 @@
1
- import { type ShapeField } from '../connect-graph/shape-field.js';
1
+ import { type ShapeField } from './shape-field.js';
2
2
  /**
3
3
  * A flat field from an SVG: an `<svg>` element, or its markup. Its box is
4
4
  * its `viewBox`, and its shape every path, rectangle, circle, ellipse and
5
- * polygon in it, together, their holes cut by the even-odd rule.
5
+ * polygon in it, together, their holes cut by the even-odd rule. Each shape
6
+ * with an `id`, or in a group with one, is a named part.
6
7
  *
7
8
  * Transforms on the shapes are not read: draw the outline in the `viewBox`'s
8
9
  * own units, as an exported icon or logo is.
@@ -1,4 +1,4 @@
1
- import { FLAT, fieldFromCells, fieldFromPath, } from '../connect-graph/shape-field.js';
1
+ import { FLAT, fieldFromCells, fieldFromPath } from './shape-field.js';
2
2
  const pathOf = (element) => {
3
3
  const number = (name) => Number(element.getAttribute(name) ?? 0);
4
4
  switch (element.localName) {
@@ -27,18 +27,71 @@ export const shapeFromSvg = (svg, resolution = FLAT) => {
27
27
  const element = typeof svg === 'string'
28
28
  ? new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
29
29
  : svg;
30
- const [, , width = 0, height = 0] = (element.getAttribute('viewBox') ?? '')
30
+ const [left = 0, top = 0, width = 0, height = 0] = (element.getAttribute('viewBox') ?? '')
31
31
  .trim()
32
32
  .split(/[\s,]+/)
33
33
  .map(Number);
34
+ const origin = { x: Number.isFinite(left) ? left : 0, y: Number.isFinite(top) ? top : 0 };
34
35
  const box = {
35
36
  height: height || Number(element.getAttribute('height') ?? 0),
36
37
  width: width || Number(element.getAttribute('width') ?? 0),
37
38
  };
38
- const path = [...element.querySelectorAll('path, rect, circle, ellipse, polygon')]
39
- .map(pathOf)
40
- .join(' ');
41
- return fieldFromPath(path, box.width || 1, box.height || 1, resolution);
39
+ const shapes = [...element.querySelectorAll('path, rect, circle, ellipse, polygon')];
40
+ const path = shapes.map(pathOf).join(' ');
41
+ const field = fieldFromPath(path, box.width || 1, box.height || 1, resolution, origin);
42
+ const named = shapes.flatMap((shape) => {
43
+ const name = shape.closest('[id]')?.getAttribute('id');
44
+ return name && shape.closest('[id]') !== element ? [{ name, path: pathOf(shape) }] : [];
45
+ });
46
+ return named.length > 0
47
+ ? { ...field, regionOf: partsOf(named, field, resolution, origin) }
48
+ : field;
49
+ };
50
+ const partsOf = (named, box, resolution, origin) => {
51
+ const cell = Math.max(box.width, box.height, 1) / resolution;
52
+ const nx = Math.max(1, Math.ceil(box.width / cell));
53
+ const ny = Math.max(1, Math.ceil(box.height / cell));
54
+ const canvas = document.createElement('canvas');
55
+ canvas.width = nx;
56
+ canvas.height = ny;
57
+ const context = canvas.getContext('2d', { willReadFrequently: true });
58
+ const labels = new Uint16Array(nx * ny);
59
+ if (context) {
60
+ context.scale(1 / cell, 1 / cell);
61
+ context.translate(-origin.x, -origin.y);
62
+ named.forEach(({ path }, at) => {
63
+ const label = at + 1;
64
+ context.fillStyle = `rgb(${label & 255}, ${(label >> 8) & 255}, 0)`;
65
+ context.fill(new Path2D(path), 'evenodd');
66
+ });
67
+ const pixels = context.getImageData(0, 0, nx, ny).data;
68
+ for (let at = 0; at < labels.length; at++) {
69
+ if ((pixels[at * 4 + 3] ?? 0) === 255 && (pixels[at * 4 + 2] ?? 0) === 0) {
70
+ const label = (pixels[at * 4] ?? 0) + ((pixels[at * 4 + 1] ?? 0) << 8);
71
+ labels[at] = label <= named.length ? label : 0;
72
+ }
73
+ }
74
+ }
75
+ return (x, y) => {
76
+ const column = Math.floor(x / cell);
77
+ const row = Math.floor(y / cell);
78
+ for (let ring = 0; ring <= 3; ring++) {
79
+ for (let dy = -ring; dy <= ring; dy++) {
80
+ for (let dx = -ring; dx <= ring; dx++) {
81
+ const c = column + dx;
82
+ const r = row + dy;
83
+ if (c < 0 || r < 0 || c >= nx || r >= ny) {
84
+ continue;
85
+ }
86
+ const label = labels[r * nx + c] ?? 0;
87
+ if (label > 0) {
88
+ return named[label - 1]?.name;
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return undefined;
94
+ };
42
95
  };
43
96
  export const shapeFromImage = (image, { resolution = FLAT, threshold = 0.5 } = {}) => {
44
97
  const size = (value) => typeof value === 'number' ? value : value.baseVal.value;
@@ -1,6 +1,5 @@
1
- export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, type ShapeField, } from '../connect-graph/shape-field.js';
2
- export { surfaceOf } from '../three/surface.js';
3
1
  export { type ImageOptions, shapeFromImage, shapeFromSvg } from './flat.js';
4
- export { shapeFromModel } from './model.js';
5
- export { box, onSphere, sphere, torus } from './solids.js';
2
+ export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, type ShapeField, } from './shape-field.js';
3
+ export { cuboid, onSphere, sphere, torus } from './solids.js';
4
+ export { surfaceOf } from './surface.js';
6
5
  export { shapeFromTriangles, type TriangleOptions } from './triangles.js';
@@ -1,6 +1,5 @@
1
- export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, } from '../connect-graph/shape-field.js';
2
- export { surfaceOf } from '../three/surface.js';
3
1
  export { shapeFromImage, shapeFromSvg } from './flat.js';
4
- export { shapeFromModel } from './model.js';
5
- export { box, onSphere, sphere, torus } from './solids.js';
2
+ export { fieldFromInside as shapeFromInside, fieldFromPath as shapeFromPath, inflate, } from './shape-field.js';
3
+ export { cuboid, onSphere, sphere, torus } from './solids.js';
4
+ export { surfaceOf } from './surface.js';
6
5
  export { shapeFromTriangles } from './triangles.js';
@@ -24,12 +24,17 @@ export interface ShapeField {
24
24
  corners: Float32Array;
25
25
  triangles: Uint32Array;
26
26
  } | undefined;
27
+ /**
28
+ * Which named part of the shape a point is in, where the shape has named
29
+ * parts: the part nearest it, or undefined where it has none.
30
+ */
31
+ regionOf?(x: number, y: number, z?: number): string | undefined;
27
32
  /** How wide the box is. */
28
33
  readonly width: number;
29
34
  }
30
35
  /** How many cells a field has along its longer side, flat and solid. */
31
36
  export declare const FLAT = 512;
32
- export declare const SOLID = 96;
37
+ export declare const SOLID = 144;
33
38
  /**
34
39
  * A field from a grid of cells marked inside, `cell` box units a cell, x
35
40
  * fastest and z slowest: the distance to the nearest outside cell, taken
@@ -53,9 +58,13 @@ export declare const fieldFromInside: (inside: (x: number, y: number, z: number)
53
58
  /**
54
59
  * A flat field from an SVG path in a box `width` by `height`, its holes cut by
55
60
  * the even-odd rule. Drawn once to a canvas and read off it, which is what
56
- * lets a detailed outline become a field in a moment.
61
+ * lets a detailed outline become a field in a moment. A box that starts
62
+ * somewhere other than 0, as an SVG `viewBox` can, is given as `origin`.
57
63
  */
58
- export declare const fieldFromPath: (path: string, width: number, height: number, resolution?: number) => ShapeField;
64
+ export declare const fieldFromPath: (path: string, width: number, height: number, resolution?: number, origin?: {
65
+ x: number;
66
+ y: number;
67
+ }) => ShapeField;
59
68
  /**
60
69
  * A solid puffed out of a flat shape, as a cushion is: as thick as `depth`
61
70
  * where the shape is widest, rounding to nothing at its edge, and cut
@@ -1,5 +1,5 @@
1
1
  export const FLAT = 512;
2
- export const SOLID = 96;
2
+ export const SOLID = 144;
3
3
  const FAR = 1e20;
4
4
  const line = (values, count, out) => {
5
5
  const hull = new Int32Array(count);
@@ -126,7 +126,7 @@ export const fieldFromInside = (inside, box, resolution = box.depth ? SOLID : FL
126
126
  width: box.width,
127
127
  });
128
128
  };
129
- export const fieldFromPath = (path, width, height, resolution = FLAT) => {
129
+ export const fieldFromPath = (path, width, height, resolution = FLAT, origin = { x: 0, y: 0 }) => {
130
130
  const cell = Math.max(width, height, 1) / resolution;
131
131
  const nx = Math.max(1, Math.ceil(width / cell));
132
132
  const ny = Math.max(1, Math.ceil(height / cell));
@@ -137,6 +137,7 @@ export const fieldFromPath = (path, width, height, resolution = FLAT) => {
137
137
  const cells = new Uint8Array(nx * ny);
138
138
  if (context) {
139
139
  context.scale(1 / cell, 1 / cell);
140
+ context.translate(-origin.x, -origin.y);
140
141
  context.fill(new Path2D(path), 'evenodd');
141
142
  const alpha = context.getImageData(0, 0, nx, ny).data;
142
143
  for (let at = 0; at < cells.length; at++) {
@@ -168,5 +169,6 @@ export const inflate = (flat, { depth, roundness = 1 }) => {
168
169
  },
169
170
  height: flat.height,
170
171
  width: flat.width,
172
+ ...(flat.regionOf ? { regionOf: (x, y) => flat.regionOf?.(x, y) } : {}),
171
173
  };
172
174
  };
@@ -1,4 +1,4 @@
1
- import type { ShapeField } from '../connect-graph/shape-field.js';
1
+ import type { ShapeField } from './shape-field.js';
2
2
  /** A ball `radius` across from its middle. */
3
3
  export declare const sphere: ({ radius }?: {
4
4
  radius?: number;
@@ -8,8 +8,8 @@ export declare const torus: ({ radius, tube, }?: {
8
8
  radius?: number;
9
9
  tube?: number;
10
10
  }) => ShapeField;
11
- /** A box `width` by `height` by `depth`, its edges rounded `round` in. */
12
- export declare const box: ({ depth, height, round, width, }?: {
11
+ /** A cuboid `width` by `height` by `depth`, its edges rounded `round` in. */
12
+ export declare const cuboid: ({ depth, height, round, width, }?: {
13
13
  depth?: number;
14
14
  height?: number;
15
15
  round?: number;
@@ -13,7 +13,7 @@ export const torus = ({ radius = 50, tube = 18, } = {}) => {
13
13
  width: reach * 2,
14
14
  };
15
15
  };
16
- export const box = ({ depth = 100, height = 100, round = 0, width = 100, } = {}) => ({
16
+ export const cuboid = ({ depth = 100, height = 100, round = 0, width = 100, } = {}) => ({
17
17
  depth,
18
18
  distance: (x, y, z = depth / 2) => {
19
19
  const qx = Math.abs(x - width / 2) - width / 2 + round;
@@ -28,20 +28,36 @@ export const box = ({ depth = 100, height = 100, round = 0, width = 100, } = {})
28
28
  });
29
29
  export const onSphere = (flat, { radius = 50, thickness = radius * 0.08 } = {}) => {
30
30
  const along = (Math.PI * 2 * radius) / Math.max(flat.width, 1e-9);
31
+ const onMap = (x, y, z) => {
32
+ const dx = x - radius;
33
+ const dy = y - radius;
34
+ const dz = z - radius;
35
+ const out = Math.hypot(dx, dy, dz);
36
+ const longitude = Math.atan2(dx, dz);
37
+ const latitude = Math.acos(Math.min(Math.max(dy / (out || 1), -1), 1));
38
+ return {
39
+ out,
40
+ u: ((longitude + Math.PI) / (Math.PI * 2)) * flat.width,
41
+ v: (latitude / Math.PI) * flat.height,
42
+ };
43
+ };
44
+ const regionOf = flat.regionOf;
31
45
  return {
32
46
  depth: radius * 2,
33
47
  distance: (x, y, z = radius) => {
34
- const dx = x - radius;
35
- const dy = y - radius;
36
- const dz = z - radius;
37
- const out = Math.hypot(dx, dy, dz);
48
+ const { out, u, v } = onMap(x, y, z);
38
49
  const shell = Math.abs(out - (radius - thickness / 2)) - thickness / 2;
39
- const longitude = Math.atan2(dx, dz);
40
- const latitude = Math.acos(Math.min(Math.max(dy / (out || 1), -1), 1));
41
- const map = flat.distance(((longitude + Math.PI) / (Math.PI * 2)) * flat.width, (latitude / Math.PI) * flat.height);
42
- return Math.max(shell, map * along);
50
+ return Math.max(shell, flat.distance(u, v) * along);
43
51
  },
44
52
  height: radius * 2,
45
53
  width: radius * 2,
54
+ ...(regionOf
55
+ ? {
56
+ regionOf: (x, y, z = radius) => {
57
+ const { u, v } = onMap(x, y, z);
58
+ return regionOf(u, v);
59
+ },
60
+ }
61
+ : {}),
46
62
  };
47
63
  };