@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.
package/README.md CHANGED
@@ -205,6 +205,57 @@ layout beside its neighbors. `stencil` also takes `inside(x, y)`, a test of
205
205
  whether a point in the box is in the shape, in place of `path`, and
206
206
  `fitToStencil` fits places a page has into one.
207
207
 
208
+ **Every shape becomes a distance field**: for each point in its box, how far
209
+ it is from the shape's edge, below zero inside. `@fusion.dev/helix-graph/shapes`
210
+ makes one from what a page has, and a field is a stencil itself:
211
+
212
+ | Function | Makes |
213
+ |---|---|
214
+ | `shapeFromPath(path, width, height)` | A flat field from an SVG path |
215
+ | `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
216
+ | `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 |
217
+
218
+ ## In three dimensions
219
+
220
+ **`threeDriver(box)` draws the graph in three dimensions** with three.js, to
221
+ be turned and looked into. The frame, the list, selection, names and signals
222
+ are the same as in two:
223
+
224
+ ```sh
225
+ bun add three d3-force-3d
226
+ ```
227
+
228
+ ```js
229
+ import { connectGraph } from '@fusion.dev/helix-graph'
230
+ import { inflate, shapeFromPath } from '@fusion.dev/helix-graph/shapes'
231
+ import { threeDriver } from '@fusion.dev/helix-graph/three'
232
+
233
+ connectGraph(frame, threeDriver(box), {
234
+ stencil: { ...inflate(shapeFromPath(path, 220, 170), { depth: 140 }), place: 'surface' },
235
+ })
236
+ ```
237
+
238
+ - **A solid stencil draws the notes in the solid**: all through it, or, with
239
+ `place: 'surface'`, in a thin shell just inside its edge, which is how a
240
+ solid reads as the thing it is shaped like. Each group takes one side of
241
+ the shape, and each hub's notes a patch around it.
242
+ - **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
243
+ - **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
244
+ turns slowly by itself while nobody is handling it, and never for a reader
245
+ who asks for less motion.
246
+ - **The far side recedes.** On a surface, the far side of the shape falls to
247
+ a tenth, so the near side reads; through a graph, its far notes are a
248
+ little quieter than its near ones.
249
+ - **Each group is named over its near side**, and a group's name moves a line
250
+ where it would cover another's.
251
+
252
+ | `threeDriver` option | Default | What else it takes |
253
+ |---|---|---|
254
+ | `autoRotate` | `true` | `false` leaves the graph still until the reader turns it |
255
+ | `shape` | `circle` | `square` or `diamond`, for every note that does not say |
256
+ | `groupLabels` | `true` | `false` leaves each group unnamed |
257
+ | `layoutLimit` | `10000` | The longest a layout runs, in milliseconds |
258
+
208
259
  ## What it exports
209
260
 
210
261
  | Import | What |
@@ -212,8 +263,10 @@ whether a point in the box is in the shape, in place of `path`, and
212
263
  | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`, `GraphChanges` and `GraphStencil` types |
213
264
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
214
265
  | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
266
+ | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
267
+ | `@fusion.dev/helix-graph/shapes` | `shapeFromPath`, `shapeFromInside`, `inflate`, and the `ShapeField` type |
215
268
 
216
- **Sigma and cosmos.gl are optional peers, and graphology is the page's.** Each
269
+ **Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
217
270
  driver types its renderer by the calls it makes on it, so a page brings
218
271
  whichever version it has, and a spec holds those shapes to the real classes,
219
272
  so a renamed method fails a typecheck here before it reaches a page. `@fusion.dev/helix-graph/sigma` imports
@@ -223,7 +276,7 @@ Sigma 3 is a peer dependency.
223
276
  | `connectGraph` option | Default | What else it takes |
224
277
  |---|---|---|
225
278
  | `center` | `true` | `false` leaves the view where it is when a note is chosen in the list |
226
- | `stencil` | none | A shape to draw the notes in: `{ path, width, height }` or `{ inside, width, height }` |
279
+ | `stencil` | none | A shape to draw the notes in: a field from `shapes`, `{ path, width, height }` or `{ inside, width, height }`, with `place`, `fill` or `surface` |
227
280
 
228
281
  | `sigmaDriver` option | Default | What else it takes |
229
282
  |---|---|---|
@@ -6,7 +6,7 @@ export interface ArrangedNode {
6
6
  }
7
7
  /**
8
8
  * Places for every note from the graph's structure alone, with no layout to
9
- * wait for: **each group around a circle, each hub in its group, and each
9
+ * wait for: **each group around a circle, or a sphere in a solid, each hub in its group, and each
10
10
  * note beside the hub it hangs from**, the most connected note it links to
11
11
  * in its group.
12
12
  *
@@ -15,4 +15,6 @@ export interface ArrangedNode {
15
15
  * so the shape is drawn from the first frame with each group a region and
16
16
  * each hub's notes around it.
17
17
  */
18
- export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[]) => Map<string, GraphPoint>;
18
+ export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[], { solid }?: {
19
+ solid?: boolean;
20
+ }) => Map<string, GraphPoint>;
@@ -1,9 +1,36 @@
1
1
  const TURN = Math.PI * (3 - Math.sqrt(5));
2
- const spiral = (at, spacing) => ({
3
- x: Math.cos(at * TURN) * Math.sqrt(at + 0.5) * spacing,
4
- y: Math.sin(at * TURN) * Math.sqrt(at + 0.5) * spacing,
2
+ const STEP = (Math.sqrt(5) - 1) / 2;
3
+ const spiral = (at, spacing, solid) => {
4
+ if (!solid) {
5
+ return {
6
+ x: Math.cos(at * TURN) * Math.sqrt(at + 0.5) * spacing,
7
+ y: Math.sin(at * TURN) * Math.sqrt(at + 0.5) * spacing,
8
+ };
9
+ }
10
+ const height = 1 - 2 * ((at * STEP) % 1);
11
+ const around = Math.sqrt(1 - height * height);
12
+ const out = Math.cbrt(at + 0.5) * spacing;
13
+ return {
14
+ x: Math.cos(at * TURN) * around * out,
15
+ y: height * out,
16
+ z: Math.sin(at * TURN) * around * out,
17
+ };
18
+ };
19
+ const around = (at, count, solid) => {
20
+ if (!solid) {
21
+ const turn = (at / count) * Math.PI * 2;
22
+ return { x: Math.cos(turn), y: Math.sin(turn) };
23
+ }
24
+ const height = 1 - (2 * (at + 0.5)) / count;
25
+ const ring = Math.sqrt(1 - height * height);
26
+ return { x: Math.cos(at * TURN) * ring, y: height, z: Math.sin(at * TURN) * ring };
27
+ };
28
+ const plus = (one, other) => ({
29
+ x: one.x + other.x,
30
+ y: one.y + other.y,
31
+ ...(one.z === undefined && other.z === undefined ? {} : { z: (one.z ?? 0) + (other.z ?? 0) }),
5
32
  });
6
- export const arrangeByGroup = (nodes, links) => {
33
+ export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
7
34
  const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
8
35
  const degree = new Map();
9
36
  const neighbors = new Map();
@@ -65,26 +92,32 @@ export const arrangeByGroup = (nodes, links) => {
65
92
  for (const members of hubs.values()) {
66
93
  count += members.length + 1;
67
94
  }
68
- return Math.sqrt(count) * 1.5;
95
+ return (solid ? Math.cbrt(count) : Math.sqrt(count)) * 1.5;
69
96
  };
70
97
  const radii = [...groups.values()].map(radiusOf);
71
98
  const widest = Math.max(0, ...radii);
72
- const ring = groups.size > 1 ? (widest * 1.2) / Math.sin(Math.PI / groups.size) : 0;
99
+ const ring = groups.size > 1
100
+ ? solid
101
+ ? (widest * 2.4) / Math.sqrt((4 * Math.PI) / groups.size)
102
+ : (widest * 1.2) / Math.sin(Math.PI / groups.size)
103
+ : 0;
73
104
  const positions = new Map();
74
105
  [...groups.values()].forEach((hubs, at) => {
75
- const turn = (at / groups.size) * Math.PI * 2;
76
- const middle = { x: Math.cos(turn) * ring, y: Math.sin(turn) * ring };
106
+ const way = around(at, groups.size, solid);
107
+ const middle = {
108
+ x: way.x * ring,
109
+ y: way.y * ring,
110
+ ...(solid ? { z: (way.z ?? 0) * ring } : {}),
111
+ };
77
112
  const ordered = [...hubs].sort(([, one], [, other]) => other.length - one.length);
78
113
  const mean = ordered.reduce((sum, [, members]) => sum + members.length + 1, 0) / ordered.length;
79
114
  ordered.forEach(([hub, members], place) => {
80
- const spot = spiral(place, Math.sqrt(mean) * 2);
81
- const center = { x: middle.x + spot.x, y: middle.y + spot.y };
115
+ const center = plus(middle, spiral(place, (solid ? Math.cbrt(mean) : Math.sqrt(mean)) * 2, solid));
82
116
  positions.set(hub, center);
83
117
  members
84
118
  .sort((one, other) => rank(other) - rank(one))
85
119
  .forEach((member, step) => {
86
- const off = spiral(step + 1, 1);
87
- positions.set(member, { x: center.x + off.x, y: center.y + off.y });
120
+ positions.set(member, plus(center, spiral(step + 1, 1, solid)));
88
121
  });
89
122
  });
90
123
  });
@@ -81,6 +81,13 @@ export interface GraphDriver {
81
81
  * outside the group is selected.
82
82
  */
83
83
  groups?(groups: readonly GraphGroup[]): void;
84
+ /**
85
+ * Where in a shape the notes are drawn, where a `stencil` gives one:
86
+ * `surface`, a shell, whose far side a renderer that draws in three
87
+ * dimensions sets back so its near side reads; `fill`, all through it; or
88
+ * undefined, no shape.
89
+ */
90
+ shape?(place: 'fill' | 'surface' | undefined): void;
84
91
  /** Removes whatever the driver added to the renderer or the page, when the connection ends. */
85
92
  stop?(): void;
86
93
  }
@@ -3,7 +3,7 @@ import { arrangeByGroup } 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
- import { fitToStencil } from './stencil.js';
6
+ import { fieldOf, fitToStencil } from './stencil.js';
7
7
  const viewportOf = (frame) => {
8
8
  const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
9
9
  return { height: box.height, width: box.width };
@@ -95,8 +95,11 @@ export const connectGraph = (frame, driver, options = {}) => {
95
95
  }
96
96
  let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
97
97
  const shaped = options.stencil && ids.size > 0 && unplaced.size === ids.size
98
- ? fitToStencil(arrangeByGroup(nodes, [...nextLinks.values()]), options.stencil)
98
+ ? fitToStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
99
+ solid: fieldOf(options.stencil).depth > 0,
100
+ }), options.stencil)
99
101
  : undefined;
102
+ driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined);
100
103
  if (shaped) {
101
104
  positions = shaped;
102
105
  unplaced = new Set();
@@ -118,6 +121,7 @@ export const connectGraph = (frame, driver, options = {}) => {
118
121
  : 1 + Math.min(Math.sqrt(degree.get(node.id) ?? 0), 6),
119
122
  x: at.x,
120
123
  y: at.y,
124
+ ...(at.z === undefined ? {} : { z: at.z }),
121
125
  });
122
126
  }
123
127
  for (const id of waiting) {
@@ -153,7 +157,7 @@ export const connectGraph = (frame, driver, options = {}) => {
153
157
  for (const [id, at] of settled) {
154
158
  const node = drawnNodes.get(id);
155
159
  if (node) {
156
- drawnNodes.set(id, { ...node, x: at.x, y: at.y });
160
+ drawnNodes.set(id, { ...node, x: at.x, y: at.y, z: at.z });
157
161
  }
158
162
  }
159
163
  graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
@@ -6,6 +6,8 @@ export interface DrawnNode {
6
6
  size: number;
7
7
  x: number;
8
8
  y: number;
9
+ /** How near the viewer, for a renderer that draws in three dimensions. */
10
+ z?: number | undefined;
9
11
  }
10
12
  /** A link as the renderer draws it, between two notes the graph holds. */
11
13
  export interface DrawnLink {
@@ -20,7 +20,8 @@ export const diffGraph = (before, after) => {
20
20
  else if (was.label !== node.label ||
21
21
  was.size !== node.size ||
22
22
  was.x !== node.x ||
23
- was.y !== node.y) {
23
+ was.y !== node.y ||
24
+ was.z !== node.z) {
24
25
  changes.changeNodes.push(node);
25
26
  }
26
27
  }
@@ -2,12 +2,15 @@
2
2
  export interface GraphPoint {
3
3
  x: number;
4
4
  y: number;
5
+ /** How near the viewer, for a renderer that draws in three dimensions. */
6
+ z?: number | undefined;
5
7
  }
6
8
  /** A note as the connector reads it off the frame. */
7
9
  export interface SeedNode {
8
10
  id: string;
9
11
  x?: number | undefined;
10
12
  y?: number | undefined;
13
+ z?: number | undefined;
11
14
  }
12
15
  /** A link as the connector reads it off the frame. */
13
16
  export interface SeedLink {
@@ -13,7 +13,11 @@ export const seedPositions = (nodes, links, known = new Map()) => {
13
13
  const unplaced = new Set();
14
14
  for (const node of nodes) {
15
15
  if (Number.isFinite(node.x) && Number.isFinite(node.y)) {
16
- positions.set(node.id, { x: node.x, y: node.y });
16
+ positions.set(node.id, {
17
+ x: node.x,
18
+ y: node.y,
19
+ ...(Number.isFinite(node.z) ? { z: node.z } : {}),
20
+ });
17
21
  }
18
22
  else {
19
23
  const kept = known.get(node.id);
@@ -46,7 +50,11 @@ export const seedPositions = (nodes, links, known = new Map()) => {
46
50
  const beside = placed === undefined ? undefined : positions.get(placed);
47
51
  if (beside) {
48
52
  const offset = nudge(node.id);
49
- positions.set(node.id, { x: beside.x + offset.x, y: beside.y + offset.y });
53
+ positions.set(node.id, {
54
+ x: beside.x + offset.x,
55
+ y: beside.y + offset.y,
56
+ ...(beside.z === undefined ? {} : { z: beside.z + offset.x * offset.y }),
57
+ });
50
58
  continue;
51
59
  }
52
60
  const radius = Math.sqrt(spiral + 0.5);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A shape as a signed distance field: for any point in its box, how far it is
3
+ * from the shape's edge, below zero inside and above zero outside, in the
4
+ * box's units. **Flat when `depth` is zero**, and solid otherwise.
5
+ *
6
+ * One form for every shape, however it was given, so placing notes inside a
7
+ * shape, on its surface, or at the nearest point inside it is the same work
8
+ * in two dimensions and three.
9
+ */
10
+ export interface ShapeField {
11
+ /** How deep the box is, from front to back: zero for a flat shape. */
12
+ readonly depth: number;
13
+ /** How far a point is from the shape's edge: below zero inside. `z` is ignored for a flat shape. */
14
+ distance(x: number, y: number, z?: number): number;
15
+ /** How tall the box is. Its y points down, as an SVG's does. */
16
+ readonly height: number;
17
+ /** How wide the box is. */
18
+ readonly width: number;
19
+ }
20
+ /**
21
+ * A field from a test of whether a point in the box is inside, sampled at
22
+ * the center of each cell: flat when `depth` is zero, solid otherwise.
23
+ */
24
+ export declare const fieldFromInside: (inside: (x: number, y: number, z: number) => boolean, box: {
25
+ depth?: number | undefined;
26
+ height: number;
27
+ width: number;
28
+ }, resolution?: number) => ShapeField;
29
+ /**
30
+ * A flat field from an SVG path in a box `width` by `height`, its holes cut by
31
+ * the even-odd rule. Drawn once to a canvas and read off it, which is what
32
+ * lets a detailed outline become a field in a moment.
33
+ */
34
+ export declare const fieldFromPath: (path: string, width: number, height: number, resolution?: number) => ShapeField;
35
+ /**
36
+ * A solid puffed out of a flat shape, as a cushion is: as thick as `depth`
37
+ * where the shape is widest, rounding to nothing at its edge, and cut
38
+ * through wherever the flat shape has a hole.
39
+ *
40
+ * `roundness`, from 0 to 1, is how far in from the edge the rounding runs, as
41
+ * a share of the way to the shape's middle: 1, the default, rounds the whole
42
+ * way, as a pebble; less leaves a flat face, as a coin.
43
+ */
44
+ export declare const inflate: (flat: ShapeField, { depth, roundness }: {
45
+ depth: number;
46
+ roundness?: number;
47
+ }) => ShapeField;
@@ -0,0 +1,172 @@
1
+ const FLAT = 512;
2
+ const SOLID = 96;
3
+ const FAR = 1e20;
4
+ const line = (values, count, out) => {
5
+ const hull = new Int32Array(count);
6
+ const bounds = new Float64Array(count + 1);
7
+ const crossing = (at, from) => (values[at] + at * at - (values[from] + from * from)) /
8
+ (2 * at - 2 * from);
9
+ let top = 0;
10
+ hull[0] = 0;
11
+ bounds[0] = -FAR;
12
+ bounds[1] = FAR;
13
+ for (let at = 1; at < count; at++) {
14
+ let cross = crossing(at, hull[top]);
15
+ while (cross <= bounds[top]) {
16
+ top -= 1;
17
+ cross = crossing(at, hull[top]);
18
+ }
19
+ top += 1;
20
+ hull[top] = at;
21
+ bounds[top] = cross;
22
+ bounds[top + 1] = FAR;
23
+ }
24
+ top = 0;
25
+ for (let at = 0; at < count; at++) {
26
+ while (bounds[top + 1] < at) {
27
+ top += 1;
28
+ }
29
+ const from = hull[top];
30
+ out[at] = (at - from) * (at - from) + values[from];
31
+ }
32
+ };
33
+ const transform = (marked, sizes) => {
34
+ const [nx, ny, nz] = sizes;
35
+ const grid = new Float64Array(nx * ny * nz);
36
+ for (let at = 0; at < grid.length; at++) {
37
+ grid[at] = marked[at] ? 0 : FAR;
38
+ }
39
+ const longest = Math.max(nx, ny, nz);
40
+ const values = new Float64Array(longest);
41
+ const out = new Float64Array(longest);
42
+ const pass = (count, stride, starts) => {
43
+ for (const start of starts) {
44
+ for (let step = 0; step < count; step++) {
45
+ values[step] = grid[start + step * stride];
46
+ }
47
+ line(values, count, out);
48
+ for (let step = 0; step < count; step++) {
49
+ grid[start + step * stride] = out[step];
50
+ }
51
+ }
52
+ };
53
+ const along = function* (outer, inner, outerStride, innerStride) {
54
+ for (let one = 0; one < outer; one++) {
55
+ for (let two = 0; two < inner; two++) {
56
+ yield one * outerStride + two * innerStride;
57
+ }
58
+ }
59
+ };
60
+ pass(nx, 1, along(nz, ny, nx * ny, nx));
61
+ pass(ny, nx, along(nz, nx, nx * ny, 1));
62
+ if (nz > 1) {
63
+ pass(nz, nx * ny, along(ny, nx, nx, 1));
64
+ }
65
+ for (let at = 0; at < grid.length; at++) {
66
+ grid[at] = Math.sqrt(grid[at]);
67
+ }
68
+ return grid;
69
+ };
70
+ const fieldFromCells = (inside, sizes, cell, box) => {
71
+ const outside = new Uint8Array(inside.length);
72
+ for (let at = 0; at < inside.length; at++) {
73
+ outside[at] = inside[at] ? 0 : 1;
74
+ }
75
+ const toInside = transform(inside, sizes);
76
+ const toOutside = transform(outside, sizes);
77
+ const [nx, ny, nz] = sizes;
78
+ const signed = new Float32Array(inside.length);
79
+ for (let at = 0; at < inside.length; at++) {
80
+ signed[at] = inside[at]
81
+ ? -(toOutside[at] - 0.5) * cell
82
+ : (toInside[at] - 0.5) * cell;
83
+ }
84
+ const read = (x, y, z) => signed[Math.min(Math.max(z, 0), nz - 1) * nx * ny +
85
+ Math.min(Math.max(y, 0), ny - 1) * nx +
86
+ Math.min(Math.max(x, 0), nx - 1)];
87
+ return {
88
+ ...box,
89
+ distance: (x, y, z = 0) => {
90
+ const fx = x / cell - 0.5;
91
+ const fy = y / cell - 0.5;
92
+ const fz = nz > 1 ? z / cell - 0.5 : 0;
93
+ const x0 = Math.floor(fx);
94
+ const y0 = Math.floor(fy);
95
+ const z0 = Math.floor(fz);
96
+ const tx = fx - x0;
97
+ const ty = fy - y0;
98
+ const tz = fz - z0;
99
+ const plane = (layer) => (read(x0, y0, layer) * (1 - tx) + read(x0 + 1, y0, layer) * tx) * (1 - ty) +
100
+ (read(x0, y0 + 1, layer) * (1 - tx) + read(x0 + 1, y0 + 1, layer) * tx) * ty;
101
+ const within = nz > 1 ? plane(z0) * (1 - tz) + plane(z0 + 1) * tz : plane(0);
102
+ const past = Math.hypot(Math.max(-x, x - box.width, 0), Math.max(-y, y - box.height, 0), nz > 1 ? Math.max(-z, z - box.depth, 0) : 0);
103
+ return past > 0 ? Math.max(within, 0) + past : within;
104
+ },
105
+ };
106
+ };
107
+ export const fieldFromInside = (inside, box, resolution = box.depth ? SOLID : FLAT) => {
108
+ const depth = box.depth ?? 0;
109
+ const cell = Math.max(box.width, box.height, depth) / resolution;
110
+ const nx = Math.max(1, Math.ceil(box.width / cell));
111
+ const ny = Math.max(1, Math.ceil(box.height / cell));
112
+ const nz = depth > 0 ? Math.max(1, Math.ceil(depth / cell)) : 1;
113
+ const cells = new Uint8Array(nx * ny * nz);
114
+ for (let z = 0; z < nz; z++) {
115
+ for (let y = 0; y < ny; y++) {
116
+ for (let x = 0; x < nx; x++) {
117
+ cells[z * nx * ny + y * nx + x] = inside((x + 0.5) * cell, (y + 0.5) * cell, depth > 0 ? (z + 0.5) * cell : 0)
118
+ ? 1
119
+ : 0;
120
+ }
121
+ }
122
+ }
123
+ return fieldFromCells(cells, [nx, ny, nz], cell, {
124
+ depth,
125
+ height: box.height,
126
+ width: box.width,
127
+ });
128
+ };
129
+ export const fieldFromPath = (path, width, height, resolution = FLAT) => {
130
+ const cell = Math.max(width, height, 1) / resolution;
131
+ const nx = Math.max(1, Math.ceil(width / cell));
132
+ const ny = Math.max(1, Math.ceil(height / cell));
133
+ const canvas = document.createElement('canvas');
134
+ canvas.width = nx;
135
+ canvas.height = ny;
136
+ const context = canvas.getContext('2d', { willReadFrequently: true });
137
+ const cells = new Uint8Array(nx * ny);
138
+ if (context) {
139
+ context.scale(1 / cell, 1 / cell);
140
+ context.fill(new Path2D(path), 'evenodd');
141
+ const alpha = context.getImageData(0, 0, nx, ny).data;
142
+ for (let at = 0; at < cells.length; at++) {
143
+ cells[at] = (alpha[at * 4 + 3] ?? 0) > 127 ? 1 : 0;
144
+ }
145
+ }
146
+ else {
147
+ cells.fill(1);
148
+ }
149
+ return fieldFromCells(cells, [nx, ny, 1], cell, { depth: 0, height, width });
150
+ };
151
+ export const inflate = (flat, { depth, roundness = 1 }) => {
152
+ let deepest = 0;
153
+ const probes = 128;
154
+ for (let row = 0; row < probes; row++) {
155
+ for (let column = 0; column < probes; column++) {
156
+ deepest = Math.max(deepest, -flat.distance(((column + 0.5) / probes) * flat.width, ((row + 0.5) / probes) * flat.height));
157
+ }
158
+ }
159
+ const reach = Math.max(deepest * Math.min(Math.max(roundness, 0.01), 1), 1e-6);
160
+ const middle = depth / 2;
161
+ return {
162
+ depth,
163
+ distance: (x, y, z = middle) => {
164
+ const across = flat.distance(x, y);
165
+ const inset = Math.min(Math.max(-across, 0) / reach, 1);
166
+ const half = middle * Math.sqrt(1 - (1 - inset) ** 2);
167
+ return Math.max(across, Math.abs(z - middle) - half);
168
+ },
169
+ height: flat.height,
170
+ width: flat.width,
171
+ };
172
+ };
@@ -1,11 +1,12 @@
1
1
  import type { GraphPoint } from './seed.js';
2
+ import { type ShapeField } from './shape-field.js';
2
3
  /**
3
- * A shape the notes are fitted into once a layout settles, in a box `width`
4
- * by `height`, as an SVG's `viewBox="0 0 width height"` gives one: an SVG
5
- * path, whose holes are cut by the even-odd rule, or a test of whether a
6
- * point in the box is inside.
4
+ * A shape the notes are drawn in. **Any shape field**, flat or solid, such as
5
+ * `inflate` makes; or, flat, an SVG path in a box `width` by `height`, as an
6
+ * SVG's `viewBox="0 0 width height"` gives one, whose holes are cut by the
7
+ * even-odd rule; or a test of whether a point in that box is inside.
7
8
  */
8
- export type GraphStencil = {
9
+ export type GraphStencil = (ShapeField | {
9
10
  height: number;
10
11
  path: string;
11
12
  width: number;
@@ -13,22 +14,37 @@ export type GraphStencil = {
13
14
  height: number;
14
15
  inside: (x: number, y: number) => boolean;
15
16
  width: number;
17
+ }) & {
18
+ /**
19
+ * Where in the shape the notes go: `fill`, all through it, which unset is;
20
+ * or `surface`, a thin shell just inside its edge, which is how a solid
21
+ * reads as the thing it is shaped like.
22
+ */
23
+ place?: 'fill' | 'surface' | undefined;
24
+ /** How thick the surface's shell is, as a share of the shape's size. 0.04 by default. */
25
+ shell?: number | undefined;
16
26
  };
27
+ /** The field a stencil is, or is made into. */
28
+ export declare const fieldOf: (stencil: GraphStencil) => ShapeField;
17
29
  /**
18
- * `count` places spread evenly over the inside of a shape: a grid sized to
19
- * the shape's area, each place shaken within its cell so no rows show, and
20
- * the grid made finer until it holds enough.
30
+ * `count` places spread evenly over a shape: through all of it, or through a
31
+ * shell just inside its edge. A grid sized to the room there is, each place
32
+ * shaken within its cell so no rows show, and made finer until it holds
33
+ * enough.
21
34
  */
22
- export declare const placesIn: (inside: (x: number, y: number) => boolean, width: number, height: number, count: number) => GraphPoint[];
35
+ export declare const placesIn: (field: ShapeField, count: number, { place, shell }?: {
36
+ place?: 'fill' | 'surface';
37
+ shell?: number;
38
+ }) => GraphPoint[];
23
39
  /**
24
- * Places a layout put anywhere, moved into the stencil's shape and filling it
25
- * evenly. **The notes keep their order**, left to right and, within each
26
- * strip, top to bottom, so notes the layout put together land together, and
27
- * each group takes a region of the shape.
40
+ * Places a layout or an arrangement put anywhere, moved into the stencil's
41
+ * shape and spread evenly over it. **The notes keep their order**, left to
42
+ * right, top to bottom, and front to back in a solid, so notes put together
43
+ * land together, and each group takes a region of the shape.
28
44
  *
29
- * Centered where the layout was, and as large. The stencil's y points down,
30
- * as an SVG's does, and the layout's up, as Sigma and cosmos.gl draw it.
31
- * Places the shape cannot hold, as when it has no inside, come back as they
32
- * were.
45
+ * Centered where the places were, and as large. The stencil's y points down,
46
+ * as an SVG's does, and the places' up, as Sigma, cosmos.gl and three.js draw
47
+ * them. Places the shape cannot hold, as when it has no inside, come back as
48
+ * they were.
33
49
  */
34
50
  export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => Map<string, GraphPoint>;