@fusion.dev/helix-graph 0.121.0 → 0.123.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 +75 -2
- package/dist/connect-graph/arrange.d.ts +4 -2
- package/dist/connect-graph/arrange.js +45 -12
- package/dist/connect-graph/connect-graph.d.ts +10 -1
- package/dist/connect-graph/connect-graph.js +13 -4
- package/dist/connect-graph/diff.d.ts +2 -0
- package/dist/connect-graph/diff.js +2 -1
- package/dist/connect-graph/seed.d.ts +3 -0
- package/dist/connect-graph/seed.js +10 -2
- package/dist/connect-graph/shape-field.d.ts +61 -0
- package/dist/connect-graph/shape-field.js +172 -0
- package/dist/connect-graph/stencil.d.ts +56 -17
- package/dist/connect-graph/stencil.js +160 -79
- package/dist/connect-graph/surface-web.d.ts +18 -0
- package/dist/connect-graph/surface-web.js +99 -0
- package/dist/cosmos/cosmos-driver.js +8 -56
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/overlay/overlay.d.ts +18 -0
- package/dist/overlay/overlay.js +63 -1
- package/dist/shapes/flat.d.ts +28 -0
- package/dist/shapes/flat.js +63 -0
- package/dist/shapes/index.d.ts +5 -0
- package/dist/shapes/index.js +5 -0
- package/dist/shapes/model.d.ts +12 -0
- package/dist/shapes/model.js +31 -0
- package/dist/shapes/solids.d.ts +28 -0
- package/dist/shapes/solids.js +47 -0
- package/dist/shapes/triangles.d.ts +20 -0
- package/dist/shapes/triangles.js +78 -0
- package/dist/three/index.d.ts +1 -0
- package/dist/three/index.js +1 -0
- package/dist/three/surface.d.ts +13 -0
- package/dist/three/surface.js +112 -0
- package/dist/three/three-driver.d.ts +32 -0
- package/dist/three/three-driver.js +797 -0
- package/package.json +28 -7
package/README.md
CHANGED
|
@@ -205,6 +205,77 @@ 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
|
+
| `shapeFromModel(url)` | A solid from a glTF model, `.glb` or `.gltf`, every mesh where the model places it. Async; three.js reads the file |
|
|
215
|
+
| `shapeFromTriangles(positions, index)` | A solid from a closed surface of triangles a page already holds |
|
|
216
|
+
| `shapeFromSvg(svg)` | A flat field from an `<svg>` or its markup: every path, rectangle, circle, ellipse and polygon in its `viewBox` |
|
|
217
|
+
| `shapeFromImage(image, { threshold })` | A flat field from an image's opaque pixels |
|
|
218
|
+
| `shapeFromPath(path, width, height)` | A flat field from an SVG path |
|
|
219
|
+
| `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
|
|
220
|
+
| `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
|
+
| `sphere`, `torus`, `box` | Solids measured exactly from their formulas |
|
|
222
|
+
| `onSphere(field, { radius, thickness })` | A flat map wrapped round a ball, as a world map is round a globe |
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' }
|
|
226
|
+
connectGraph(frame, threeDriver(box), { stencil })
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**A model is read a column of cells at a time**, so one of a hundred thousand
|
|
230
|
+
triangles becomes a field in a moment. Any closed mesh works; a mesh with
|
|
231
|
+
holes is read as closed where it can be. A model served from another origin
|
|
232
|
+
needs that origin to allow it, as any `fetch` does. Use a model whose license
|
|
233
|
+
lets your readers copy it, since the examples a reader takes away name it.
|
|
234
|
+
|
|
235
|
+
## In three dimensions
|
|
236
|
+
|
|
237
|
+
**`threeDriver(box)` draws the graph in three dimensions** with three.js, to
|
|
238
|
+
be turned and looked into. The frame, the list, selection, names and signals
|
|
239
|
+
are the same as in two:
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
bun add three d3-force-3d
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```js
|
|
246
|
+
import { connectGraph } from '@fusion.dev/helix-graph'
|
|
247
|
+
import { inflate, shapeFromPath } from '@fusion.dev/helix-graph/shapes'
|
|
248
|
+
import { threeDriver } from '@fusion.dev/helix-graph/three'
|
|
249
|
+
|
|
250
|
+
connectGraph(frame, threeDriver(box), {
|
|
251
|
+
stencil: { ...inflate(shapeFromPath(path, 220, 170), { depth: 140 }), place: 'surface' },
|
|
252
|
+
})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- **A solid stencil draws the notes in the solid**: all through it, or, with
|
|
256
|
+
`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.
|
|
263
|
+
- **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
|
|
264
|
+
- **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
|
|
265
|
+
turns slowly by itself while nobody is handling it, and never for a reader
|
|
266
|
+
who asks for less motion.
|
|
267
|
+
- **The far side recedes.** Through a graph, its far notes are a little
|
|
268
|
+
quieter than its near ones.
|
|
269
|
+
- **Each group is named over its near side**, and a group's name moves a line
|
|
270
|
+
where it would cover another's.
|
|
271
|
+
|
|
272
|
+
| `threeDriver` option | Default | What else it takes |
|
|
273
|
+
|---|---|---|
|
|
274
|
+
| `autoRotate` | `true` | `false` leaves the graph still until the reader turns it |
|
|
275
|
+
| `shape` | `circle` | `square` or `diamond`, for every note that does not say |
|
|
276
|
+
| `groupLabels` | `true` | `false` leaves each group unnamed |
|
|
277
|
+
| `layoutLimit` | `10000` | The longest a layout runs, in milliseconds |
|
|
278
|
+
|
|
208
279
|
## What it exports
|
|
209
280
|
|
|
210
281
|
| Import | What |
|
|
@@ -212,8 +283,10 @@ whether a point in the box is in the shape, in place of `path`, and
|
|
|
212
283
|
| `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`, `GraphChanges` and `GraphStencil` types |
|
|
213
284
|
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
214
285
|
| `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
|
|
286
|
+
| `@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 |
|
|
215
288
|
|
|
216
|
-
**Sigma
|
|
289
|
+
**Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
|
|
217
290
|
driver types its renderer by the calls it makes on it, so a page brings
|
|
218
291
|
whichever version it has, and a spec holds those shapes to the real classes,
|
|
219
292
|
so a renamed method fails a typecheck here before it reaches a page. `@fusion.dev/helix-graph/sigma` imports
|
|
@@ -223,7 +296,7 @@ Sigma 3 is a peer dependency.
|
|
|
223
296
|
| `connectGraph` option | Default | What else it takes |
|
|
224
297
|
|---|---|---|
|
|
225
298
|
| `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 }` |
|
|
299
|
+
| `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
300
|
|
|
228
301
|
| `sigmaDriver` option | Default | What else it takes |
|
|
229
302
|
|---|---|---|
|
|
@@ -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[]
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
76
|
-
const middle = {
|
|
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
|
|
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
|
-
|
|
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
|
});
|
|
@@ -2,7 +2,7 @@ import { type ViewportGestureMode } from '@fusion.dev/helix-core';
|
|
|
2
2
|
import { type DrawnLink, type DrawnNode, type GraphChanges } from './diff.js';
|
|
3
3
|
import { type GraphLooks } from './looks.js';
|
|
4
4
|
import { type GraphPoint } from './seed.js';
|
|
5
|
-
import { type GraphStencil } from './stencil.js';
|
|
5
|
+
import { type GraphStencil, type StencilFrame } from './stencil.js';
|
|
6
6
|
export type { DrawnLink, DrawnNode, GraphChanges } from './diff.js';
|
|
7
7
|
/**
|
|
8
8
|
* What a renderer has to answer for the frame to drive it, and nothing that is
|
|
@@ -81,6 +81,15 @@ 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. `frame` is the shape and where it was put among the
|
|
89
|
+
* notes, once the connector has drawn the notes in it, for a renderer that
|
|
90
|
+
* draws the shape as well.
|
|
91
|
+
*/
|
|
92
|
+
shape?(place: 'fill' | 'surface' | undefined, frame?: StencilFrame): void;
|
|
84
93
|
/** Removes whatever the driver added to the renderer or the page, when the connection ends. */
|
|
85
94
|
stop?(): void;
|
|
86
95
|
}
|
|
@@ -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 {
|
|
6
|
+
import { fieldOf, fitInStencil } 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 };
|
|
@@ -12,6 +12,7 @@ const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-red
|
|
|
12
12
|
export const connectGraph = (frame, driver, options = {}) => {
|
|
13
13
|
const graph = frame;
|
|
14
14
|
let known = new Map();
|
|
15
|
+
let shapeFrame;
|
|
15
16
|
let drawnNodes = new Map();
|
|
16
17
|
let drawnLinks = new Map();
|
|
17
18
|
let first = true;
|
|
@@ -94,9 +95,16 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
|
|
97
|
-
const
|
|
98
|
-
?
|
|
98
|
+
const fit = options.stencil && ids.size > 0 && unplaced.size === ids.size
|
|
99
|
+
? fitInStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
|
|
100
|
+
solid: fieldOf(options.stencil).depth > 0,
|
|
101
|
+
}), options.stencil)
|
|
99
102
|
: undefined;
|
|
103
|
+
const shaped = fit?.positions;
|
|
104
|
+
if (fit?.frame) {
|
|
105
|
+
shapeFrame = fit.frame;
|
|
106
|
+
}
|
|
107
|
+
driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined, shapeFrame);
|
|
100
108
|
if (shaped) {
|
|
101
109
|
positions = shaped;
|
|
102
110
|
unplaced = new Set();
|
|
@@ -118,6 +126,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
118
126
|
: 1 + Math.min(Math.sqrt(degree.get(node.id) ?? 0), 6),
|
|
119
127
|
x: at.x,
|
|
120
128
|
y: at.y,
|
|
129
|
+
...(at.z === undefined ? {} : { z: at.z }),
|
|
121
130
|
});
|
|
122
131
|
}
|
|
123
132
|
for (const id of waiting) {
|
|
@@ -153,7 +162,7 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
153
162
|
for (const [id, at] of settled) {
|
|
154
163
|
const node = drawnNodes.get(id);
|
|
155
164
|
if (node) {
|
|
156
|
-
drawnNodes.set(id, { ...node, x: at.x, y: at.y });
|
|
165
|
+
drawnNodes.set(id, { ...node, x: at.x, y: at.y, z: at.z });
|
|
157
166
|
}
|
|
158
167
|
}
|
|
159
168
|
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 {
|
|
@@ -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, {
|
|
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, {
|
|
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,61 @@
|
|
|
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
|
+
/** How many cells a field has along its longer side, flat and solid. */
|
|
21
|
+
export declare const FLAT = 512;
|
|
22
|
+
export declare const SOLID = 96;
|
|
23
|
+
/**
|
|
24
|
+
* A field from a grid of cells marked inside, `cell` box units a cell, x
|
|
25
|
+
* fastest and z slowest: the distance to the nearest outside cell, taken
|
|
26
|
+
* from the distance to the nearest inside one, so the sign says which side
|
|
27
|
+
* of the edge a point is on.
|
|
28
|
+
*/
|
|
29
|
+
export declare const fieldFromCells: (inside: Uint8Array, sizes: readonly [number, number, number], cell: number, box: {
|
|
30
|
+
depth: number;
|
|
31
|
+
height: number;
|
|
32
|
+
width: number;
|
|
33
|
+
}) => ShapeField;
|
|
34
|
+
/**
|
|
35
|
+
* A field from a test of whether a point in the box is inside, sampled at
|
|
36
|
+
* the center of each cell: flat when `depth` is zero, solid otherwise.
|
|
37
|
+
*/
|
|
38
|
+
export declare const fieldFromInside: (inside: (x: number, y: number, z: number) => boolean, box: {
|
|
39
|
+
depth?: number | undefined;
|
|
40
|
+
height: number;
|
|
41
|
+
width: number;
|
|
42
|
+
}, resolution?: number) => ShapeField;
|
|
43
|
+
/**
|
|
44
|
+
* A flat field from an SVG path in a box `width` by `height`, its holes cut by
|
|
45
|
+
* the even-odd rule. Drawn once to a canvas and read off it, which is what
|
|
46
|
+
* lets a detailed outline become a field in a moment.
|
|
47
|
+
*/
|
|
48
|
+
export declare const fieldFromPath: (path: string, width: number, height: number, resolution?: number) => ShapeField;
|
|
49
|
+
/**
|
|
50
|
+
* A solid puffed out of a flat shape, as a cushion is: as thick as `depth`
|
|
51
|
+
* where the shape is widest, rounding to nothing at its edge, and cut
|
|
52
|
+
* through wherever the flat shape has a hole.
|
|
53
|
+
*
|
|
54
|
+
* `roundness`, from 0 to 1, is how far in from the edge the rounding runs, as
|
|
55
|
+
* a share of the way to the shape's middle: 1, the default, rounds the whole
|
|
56
|
+
* way, as a pebble; less leaves a flat face, as a coin.
|
|
57
|
+
*/
|
|
58
|
+
export declare const inflate: (flat: ShapeField, { depth, roundness }: {
|
|
59
|
+
depth: number;
|
|
60
|
+
roundness?: number;
|
|
61
|
+
}) => ShapeField;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export const FLAT = 512;
|
|
2
|
+
export 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
|
+
export 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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* point in
|
|
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,60 @@ 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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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: (
|
|
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
|
|
25
|
-
* evenly. **The notes keep their order**, left to
|
|
26
|
-
*
|
|
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
|
|
30
|
-
* as an SVG's does, and the
|
|
31
|
-
* Places the shape cannot hold, as when it has no inside, come back as
|
|
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>;
|
|
51
|
+
/**
|
|
52
|
+
* Where a stencil's box was put, in the places' units: its middle, and how
|
|
53
|
+
* many of the places' units one of the box's is. A point in the box at `x`,
|
|
54
|
+
* `y` and `z` is at `middle.x + (x - width / 2) * scale`,
|
|
55
|
+
* `middle.y - (y - height / 2) * scale` and `middle.z + (z - depth / 2) * scale`.
|
|
56
|
+
*/
|
|
57
|
+
export interface StencilFrame {
|
|
58
|
+
field: ShapeField;
|
|
59
|
+
middle: {
|
|
60
|
+
x: number;
|
|
61
|
+
y: number;
|
|
62
|
+
z: number;
|
|
63
|
+
};
|
|
64
|
+
/** How many of the places' units one of the box's is. */
|
|
65
|
+
scale: number;
|
|
66
|
+
/** How thick the shell the notes were placed in is, in the box's units: none where they fill the shape. */
|
|
67
|
+
shell: number;
|
|
68
|
+
}
|
|
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) => {
|
|
71
|
+
frame?: StencilFrame | undefined;
|
|
72
|
+
positions: Map<string, GraphPoint>;
|
|
73
|
+
};
|