@fusion.dev/helix-graph 0.122.0 → 0.124.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 +49 -12
- package/dist/connect-graph/arrange.d.ts +10 -0
- package/dist/connect-graph/arrange.js +6 -1
- package/dist/connect-graph/connect-graph.d.ts +5 -3
- package/dist/connect-graph/connect-graph.js +16 -6
- package/dist/connect-graph/shape-field.d.ts +24 -0
- package/dist/connect-graph/shape-field.js +3 -3
- package/dist/connect-graph/stencil.d.ts +30 -0
- package/dist/connect-graph/stencil.js +75 -25
- package/dist/connect-graph/surface-web.d.ts +18 -0
- package/dist/connect-graph/surface-web.js +99 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/overlay/overlay.d.ts +6 -1
- package/dist/overlay/overlay.js +91 -46
- 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 +92 -0
- package/dist/sigma/node-program.js +5 -0
- package/dist/three/surface.d.ts +18 -0
- package/dist/three/surface.js +112 -0
- package/dist/three/three-driver.js +516 -24
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -75,11 +75,15 @@ socket.addEventListener('message', ({ data }) => {
|
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
**`frame.signal(from, to, { tone })` shows something passing between two
|
|
78
|
-
notes**: a message, a request, a payment. It is drawn as a
|
|
79
|
-
|
|
80
|
-
second's, or in the tone's color where it
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
notes**: a message, a request, a payment. It is drawn as a comet traveling
|
|
79
|
+
from one to the other along a gently bowed path, its tail tapering and turning
|
|
80
|
+
from the first note's color to the second's, or in the tone's color where it
|
|
81
|
+
has one. A small ring spreads from the note it leaves, and the note it reaches
|
|
82
|
+
brightens and ripples. A reply on the same link bows the other way, so the two
|
|
83
|
+
do not run over each other, and in three dimensions the path arcs over the
|
|
84
|
+
shape. It is drawn over the graph without redrawing it, so a busy feed costs a
|
|
85
|
+
few strokes each. A reader who asks for less motion sees the path lit for a
|
|
86
|
+
moment instead.
|
|
83
87
|
|
|
84
88
|
```js
|
|
85
89
|
frame.signal('Checkout', 'Payments', { tone: 'success' })
|
|
@@ -108,7 +112,15 @@ frame.nodes = [
|
|
|
108
112
|
```
|
|
109
113
|
|
|
110
114
|
`sigmaDriver(sigma, { material, shape, emphasis })` sets what a note is drawn as
|
|
111
|
-
where it does not say.
|
|
115
|
+
where it does not say. A note without a material is matte, lit across so it
|
|
116
|
+
reads as round. In three dimensions every note is a solid: a circle is a
|
|
117
|
+
sphere, worked out for each pixel and written at its depth, so notes meet the
|
|
118
|
+
shape and each other as solids do; a square is a cube and a diamond an
|
|
119
|
+
octahedron. On a dark ground a sphere is an orb of energy, a dark glassy body
|
|
120
|
+
with a glowing rim and a hot point at its middle, and on a light ground frosted
|
|
121
|
+
glass; a cube or an octahedron is a hologram, faint faces with fine edges of
|
|
122
|
+
light. Each catches a glint from a light fixed in the scene, so the glint moves
|
|
123
|
+
as the reader turns the graph. The selected note keeps its own color and is told apart by
|
|
112
124
|
a ring and a glow in that color.
|
|
113
125
|
|
|
114
126
|
**A note without a `size` is as large as it is connected**, so hubs read at a
|
|
@@ -211,9 +223,27 @@ makes one from what a page has, and a field is a stencil itself:
|
|
|
211
223
|
|
|
212
224
|
| Function | Makes |
|
|
213
225
|
|---|---|
|
|
226
|
+
| `shapeFromModel(url)` | A solid from a glTF model, `.glb` or `.gltf`, every mesh where the model places it. Async; three.js reads the file |
|
|
227
|
+
| `shapeFromTriangles(positions, index)` | A solid from a closed surface of triangles a page already holds |
|
|
228
|
+
| `shapeFromSvg(svg)` | A flat field from an `<svg>` or its markup: every path, rectangle, circle, ellipse and polygon in its `viewBox` |
|
|
229
|
+
| `shapeFromImage(image, { threshold })` | A flat field from an image's opaque pixels |
|
|
214
230
|
| `shapeFromPath(path, width, height)` | A flat field from an SVG path |
|
|
215
231
|
| `shapeFromInside(inside, { width, height, depth })` | A flat field, or a solid where `depth` is given, from a test of what is inside |
|
|
216
232
|
| `inflate(field, { depth, roundness })` | A solid puffed out of a flat field, as a cushion is, cut through where the flat shape has a hole |
|
|
233
|
+
| `sphere`, `torus`, `box` | Solids measured exactly from their formulas |
|
|
234
|
+
| `onSphere(field, { radius, thickness })` | A flat map wrapped round a ball, as a world map is round a globe |
|
|
235
|
+
| `surfaceOf(field, { at, cells })` | The surface of a solid as triangles, to draw or to save as a model |
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
const stencil = { ...(await shapeFromModel('/models/brain.glb')), place: 'surface' }
|
|
239
|
+
connectGraph(frame, threeDriver(box), { stencil })
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**A model is read a column of cells at a time**, so one of a hundred thousand
|
|
243
|
+
triangles becomes a field in a moment. Any closed mesh works; a mesh with
|
|
244
|
+
holes is read as closed where it can be. A model served from another origin
|
|
245
|
+
needs that origin to allow it, as any `fetch` does. Use a model whose license
|
|
246
|
+
lets your readers copy it, since the examples a reader takes away name it.
|
|
217
247
|
|
|
218
248
|
## In three dimensions
|
|
219
249
|
|
|
@@ -237,15 +267,22 @@ connectGraph(frame, threeDriver(box), {
|
|
|
237
267
|
|
|
238
268
|
- **A solid stencil draws the notes in the solid**: all through it, or, with
|
|
239
269
|
`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
|
|
241
|
-
the shape, and each hub
|
|
270
|
+
solid reads as the thing it is shaped like. **Each group is given a piece
|
|
271
|
+
of the shape of its own**, as many places as it has notes, and each hub a
|
|
272
|
+
piece of its group's, so no group is cut in two: on a knot, a group is a
|
|
273
|
+
stretch of its tube, and on a brain, a region of it.
|
|
274
|
+
- **On a surface, the shape itself is drawn** just inside the notes, so what
|
|
275
|
+
is behind it is hidden and the pointer finds only the notes in front.
|
|
276
|
+
**Each region is washed in its notes' color**, a translucent stain that
|
|
277
|
+
fades into the next region's, and glows in that color at the edge turning
|
|
278
|
+
away. It is lit faintly from above, and a model's own surface is drawn,
|
|
279
|
+
with every fold the field's cells are too coarse to hold.
|
|
242
280
|
- **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
|
|
243
281
|
- **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
|
|
244
282
|
turns slowly by itself while nobody is handling it, and never for a reader
|
|
245
283
|
who asks for less motion.
|
|
246
|
-
- **The far side recedes.**
|
|
247
|
-
|
|
248
|
-
little quieter than its near ones.
|
|
284
|
+
- **The far side recedes.** Through a graph, its far notes are a little
|
|
285
|
+
quieter than its near ones.
|
|
249
286
|
- **Each group is named over its near side**, and a group's name moves a line
|
|
250
287
|
where it would cover another's.
|
|
251
288
|
|
|
@@ -264,7 +301,7 @@ connectGraph(frame, threeDriver(box), {
|
|
|
264
301
|
| `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
|
|
265
302
|
| `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
|
|
266
303
|
| `@fusion.dev/helix-graph/three` | `threeDriver(box, options)` |
|
|
267
|
-
| `@fusion.dev/helix-graph/shapes` | `shapeFromPath`, `shapeFromInside`, `inflate`, and the `ShapeField` type |
|
|
304
|
+
| `@fusion.dev/helix-graph/shapes` | `shapeFromModel`, `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `box`, `onSphere`, `surfaceOf`, and the `ShapeField` type |
|
|
268
305
|
|
|
269
306
|
**Sigma, cosmos.gl, three.js and d3-force-3d are optional peers, and graphology is the page's.** Each
|
|
270
307
|
driver types its renderer by the calls it makes on it, so a page brings
|
|
@@ -4,6 +4,16 @@ export interface ArrangedNode {
|
|
|
4
4
|
group?: string | undefined;
|
|
5
5
|
id: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* The hub each note hangs from, and how connected each note is: a note
|
|
9
|
+
* hangs from its most connected neighbor in its group, where that neighbor
|
|
10
|
+
* is more connected than it is, and the note at the end of the chain is the
|
|
11
|
+
* hub. A note nothing is more connected than is its own hub.
|
|
12
|
+
*/
|
|
13
|
+
export declare const hubsOf: (nodes: readonly ArrangedNode[], links: readonly SeedLink[]) => {
|
|
14
|
+
hubOf: (id: string) => string;
|
|
15
|
+
rank: (id: string) => number;
|
|
16
|
+
};
|
|
7
17
|
/**
|
|
8
18
|
* Places for every note from the graph's structure alone, with no layout to
|
|
9
19
|
* wait for: **each group around a circle, or a sphere in a solid, each hub in its group, and each
|
|
@@ -30,7 +30,7 @@ const plus = (one, other) => ({
|
|
|
30
30
|
y: one.y + other.y,
|
|
31
31
|
...(one.z === undefined && other.z === undefined ? {} : { z: (one.z ?? 0) + (other.z ?? 0) }),
|
|
32
32
|
});
|
|
33
|
-
export const
|
|
33
|
+
export const hubsOf = (nodes, links) => {
|
|
34
34
|
const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
|
|
35
35
|
const degree = new Map();
|
|
36
36
|
const neighbors = new Map();
|
|
@@ -75,6 +75,11 @@ export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
|
|
|
75
75
|
}
|
|
76
76
|
return at;
|
|
77
77
|
};
|
|
78
|
+
return { hubOf, rank };
|
|
79
|
+
};
|
|
80
|
+
export const arrangeByGroup = (nodes, links, { solid = false } = {}) => {
|
|
81
|
+
const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
|
|
82
|
+
const { hubOf, rank } = hubsOf(nodes, links);
|
|
78
83
|
const groups = new Map();
|
|
79
84
|
for (const { id } of nodes) {
|
|
80
85
|
const group = groupOf.get(id);
|
|
@@ -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
|
|
@@ -85,9 +85,11 @@ export interface GraphDriver {
|
|
|
85
85
|
* Where in a shape the notes are drawn, where a `stencil` gives one:
|
|
86
86
|
* `surface`, a shell, whose far side a renderer that draws in three
|
|
87
87
|
* dimensions sets back so its near side reads; `fill`, all through it; or
|
|
88
|
-
* undefined, no shape.
|
|
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.
|
|
89
91
|
*/
|
|
90
|
-
shape?(place: 'fill' | 'surface' | undefined): void;
|
|
92
|
+
shape?(place: 'fill' | 'surface' | undefined, frame?: StencilFrame): void;
|
|
91
93
|
/** Removes whatever the driver added to the renderer or the page, when the connection ends. */
|
|
92
94
|
stop?(): void;
|
|
93
95
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphSignalEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
|
|
2
|
-
import { arrangeByGroup } from './arrange.js';
|
|
2
|
+
import { arrangeByGroup, hubsOf } from './arrange.js';
|
|
3
3
|
import { diffGraph, isEmpty, linkKey, } from './diff.js';
|
|
4
4
|
import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
|
|
5
5
|
import { seedPositions } from './seed.js';
|
|
6
|
-
import { fieldOf,
|
|
6
|
+
import { fieldOf, fitInStencil } from './stencil.js';
|
|
7
|
+
const clustersOf = (nodes, links) => {
|
|
8
|
+
const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
|
|
9
|
+
const { hubOf } = hubsOf(nodes, links);
|
|
10
|
+
return (id) => [groupOf.get(id) ?? '', hubOf(id)];
|
|
11
|
+
};
|
|
7
12
|
const viewportOf = (frame) => {
|
|
8
13
|
const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
|
|
9
14
|
return { height: box.height, width: box.width };
|
|
@@ -12,6 +17,7 @@ const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-red
|
|
|
12
17
|
export const connectGraph = (frame, driver, options = {}) => {
|
|
13
18
|
const graph = frame;
|
|
14
19
|
let known = new Map();
|
|
20
|
+
let shapeFrame;
|
|
15
21
|
let drawnNodes = new Map();
|
|
16
22
|
let drawnLinks = new Map();
|
|
17
23
|
let first = true;
|
|
@@ -94,12 +100,16 @@ export const connectGraph = (frame, driver, options = {}) => {
|
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
102
|
let { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
|
|
97
|
-
const
|
|
98
|
-
?
|
|
103
|
+
const fit = options.stencil && ids.size > 0 && unplaced.size === ids.size
|
|
104
|
+
? fitInStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
|
|
99
105
|
solid: fieldOf(options.stencil).depth > 0,
|
|
100
|
-
}), options.stencil)
|
|
106
|
+
}), options.stencil, { clustersOf: clustersOf(nodes, [...nextLinks.values()]) })
|
|
101
107
|
: undefined;
|
|
102
|
-
|
|
108
|
+
const shaped = fit?.positions;
|
|
109
|
+
if (fit?.frame) {
|
|
110
|
+
shapeFrame = fit.frame;
|
|
111
|
+
}
|
|
112
|
+
driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined, shapeFrame);
|
|
103
113
|
if (shaped) {
|
|
104
114
|
positions = shaped;
|
|
105
115
|
unplaced = new Set();
|
|
@@ -14,9 +14,33 @@ export interface ShapeField {
|
|
|
14
14
|
distance(x: number, y: number, z?: number): number;
|
|
15
15
|
/** How tall the box is. Its y points down, as an SVG's does. */
|
|
16
16
|
readonly height: number;
|
|
17
|
+
/**
|
|
18
|
+
* The surface the field was read from, where it was read from one, in the
|
|
19
|
+
* box's units: corners three numbers each, and triangles three corners
|
|
20
|
+
* each. A renderer draws it in place of a surface it would make from the
|
|
21
|
+
* field, since it holds every detail the field's cells are too coarse for.
|
|
22
|
+
*/
|
|
23
|
+
readonly mesh?: {
|
|
24
|
+
corners: Float32Array;
|
|
25
|
+
triangles: Uint32Array;
|
|
26
|
+
} | undefined;
|
|
17
27
|
/** How wide the box is. */
|
|
18
28
|
readonly width: number;
|
|
19
29
|
}
|
|
30
|
+
/** How many cells a field has along its longer side, flat and solid. */
|
|
31
|
+
export declare const FLAT = 512;
|
|
32
|
+
export declare const SOLID = 96;
|
|
33
|
+
/**
|
|
34
|
+
* A field from a grid of cells marked inside, `cell` box units a cell, x
|
|
35
|
+
* fastest and z slowest: the distance to the nearest outside cell, taken
|
|
36
|
+
* from the distance to the nearest inside one, so the sign says which side
|
|
37
|
+
* of the edge a point is on.
|
|
38
|
+
*/
|
|
39
|
+
export declare const fieldFromCells: (inside: Uint8Array, sizes: readonly [number, number, number], cell: number, box: {
|
|
40
|
+
depth: number;
|
|
41
|
+
height: number;
|
|
42
|
+
width: number;
|
|
43
|
+
}) => ShapeField;
|
|
20
44
|
/**
|
|
21
45
|
* A field from a test of whether a point in the box is inside, sampled at
|
|
22
46
|
* the center of each cell: flat when `depth` is zero, solid otherwise.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const FLAT = 512;
|
|
2
|
-
const SOLID = 96;
|
|
1
|
+
export const FLAT = 512;
|
|
2
|
+
export const SOLID = 96;
|
|
3
3
|
const FAR = 1e20;
|
|
4
4
|
const line = (values, count, out) => {
|
|
5
5
|
const hull = new Int32Array(count);
|
|
@@ -67,7 +67,7 @@ const transform = (marked, sizes) => {
|
|
|
67
67
|
}
|
|
68
68
|
return grid;
|
|
69
69
|
};
|
|
70
|
-
const fieldFromCells = (inside, sizes, cell, box) => {
|
|
70
|
+
export const fieldFromCells = (inside, sizes, cell, box) => {
|
|
71
71
|
const outside = new Uint8Array(inside.length);
|
|
72
72
|
for (let at = 0; at < inside.length; at++) {
|
|
73
73
|
outside[at] = inside[at] ? 0 : 1;
|
|
@@ -48,3 +48,33 @@ export declare const placesIn: (field: ShapeField, count: number, { place, shell
|
|
|
48
48
|
* they were.
|
|
49
49
|
*/
|
|
50
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, { clustersOf, }?: {
|
|
71
|
+
/**
|
|
72
|
+
* The clusters a note is in, broadest first, such as its group and then
|
|
73
|
+
* its hub. Each cluster is given a piece of a solid of its own, as many
|
|
74
|
+
* places as it has notes, so no cluster is cut in two.
|
|
75
|
+
*/
|
|
76
|
+
clustersOf?: ((id: string) => readonly string[]) | undefined;
|
|
77
|
+
}) => {
|
|
78
|
+
frame?: StencilFrame | undefined;
|
|
79
|
+
positions: Map<string, GraphPoint>;
|
|
80
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fieldFromInside, fieldFromPath } from './shape-field.js';
|
|
2
|
+
import { surfaceWeb } from './surface-web.js';
|
|
2
3
|
const PROBE = { flat: 64, solid: 32 };
|
|
3
4
|
const SHELL = 0.04;
|
|
4
5
|
const WAVE = 0.07;
|
|
@@ -93,10 +94,11 @@ const nested = (items, keys) => {
|
|
|
93
94
|
}
|
|
94
95
|
return out;
|
|
95
96
|
};
|
|
96
|
-
export const fitToStencil = (positions, stencil) =>
|
|
97
|
+
export const fitToStencil = (positions, stencil) => fitInStencil(positions, stencil).positions;
|
|
98
|
+
export const fitInStencil = (positions, stencil, { clustersOf, } = {}) => {
|
|
97
99
|
const notes = [...positions].map(([id, at]) => ({ at, id }));
|
|
98
100
|
if (notes.length === 0) {
|
|
99
|
-
return new Map();
|
|
101
|
+
return { positions: new Map() };
|
|
100
102
|
}
|
|
101
103
|
const field = fieldOf(stencil);
|
|
102
104
|
const places = placesIn(field, notes.length, {
|
|
@@ -104,9 +106,12 @@ export const fitToStencil = (positions, stencil) => {
|
|
|
104
106
|
shell: stencil.shell ?? SHELL,
|
|
105
107
|
});
|
|
106
108
|
if (places.length < notes.length) {
|
|
107
|
-
return new Map(positions);
|
|
109
|
+
return { positions: new Map(positions) };
|
|
108
110
|
}
|
|
109
111
|
const solid = field.depth > 0;
|
|
112
|
+
const thickness = (stencil.place ?? 'fill') === 'surface'
|
|
113
|
+
? (stencil.shell ?? SHELL) * Math.max(field.width, field.height, field.depth)
|
|
114
|
+
: 0;
|
|
110
115
|
const low = {
|
|
111
116
|
x: Number.POSITIVE_INFINITY,
|
|
112
117
|
y: Number.POSITIVE_INFINITY,
|
|
@@ -142,17 +147,6 @@ export const fitToStencil = (positions, stencil) => {
|
|
|
142
147
|
});
|
|
143
148
|
const fitted = new Map();
|
|
144
149
|
if (solid) {
|
|
145
|
-
const surface = (stencil.place ?? 'fill') === 'surface';
|
|
146
|
-
const way = (point) => {
|
|
147
|
-
if (!surface) {
|
|
148
|
-
return point;
|
|
149
|
-
}
|
|
150
|
-
const x = point.x - middle.x;
|
|
151
|
-
const y = point.y - middle.y;
|
|
152
|
-
const z = (point.z ?? 0) - middle.z;
|
|
153
|
-
const length = Math.hypot(x, y, z) || 1;
|
|
154
|
-
return { x: x / length, y: y / length, z: z / length };
|
|
155
|
-
};
|
|
156
150
|
const widest = (points) => {
|
|
157
151
|
let best = 'x';
|
|
158
152
|
let spread = Number.NEGATIVE_INFINITY;
|
|
@@ -171,29 +165,85 @@ export const fitToStencil = (positions, stencil) => {
|
|
|
171
165
|
}
|
|
172
166
|
return best;
|
|
173
167
|
};
|
|
174
|
-
const
|
|
168
|
+
const targets = spots.map((spot) => spot.at);
|
|
169
|
+
const web = (stencil.place ?? 'fill') === 'surface' ? surfaceWeb(targets) : undefined;
|
|
170
|
+
const halveTargets = (places, first) => {
|
|
171
|
+
if (web && places.length > 2) {
|
|
172
|
+
return web.halve(places, first);
|
|
173
|
+
}
|
|
174
|
+
const axis = widest(places.map((at) => targets[at]));
|
|
175
|
+
const ordered = [...places].sort((one, other) => (targets[one][axis] ?? 0) - (targets[other][axis] ?? 0));
|
|
176
|
+
return [ordered.slice(0, first), ordered.slice(first)];
|
|
177
|
+
};
|
|
178
|
+
const keysOf = new Map(notes.map((note) => [note.id, clustersOf?.(note.id) ?? []]));
|
|
179
|
+
const halve = (ours, theirs, level = 0) => {
|
|
175
180
|
if (ours.length === 1) {
|
|
176
|
-
fitted.set(ours[0].id, theirs[0]
|
|
181
|
+
fitted.set(ours[0].id, targets[theirs[0]]);
|
|
177
182
|
return;
|
|
178
183
|
}
|
|
179
184
|
if (ours.length === 0) {
|
|
180
185
|
return;
|
|
181
186
|
}
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
const clusters = new Map();
|
|
188
|
+
for (const note of ours) {
|
|
189
|
+
const key = keysOf.get(note.id)?.[level];
|
|
190
|
+
if (key !== undefined) {
|
|
191
|
+
const list = clusters.get(key);
|
|
192
|
+
if (list) {
|
|
193
|
+
list.push(note);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
clusters.set(key, [note]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (clusters.size === 1) {
|
|
201
|
+
halve(ours, theirs, level + 1);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (clusters.size > 1 &&
|
|
205
|
+
[...clusters.values()].reduce((sum, list) => sum + list.length, 0) === ours.length) {
|
|
206
|
+
const middles = [...clusters.values()].map((list) => ({
|
|
207
|
+
list,
|
|
208
|
+
middle: {
|
|
209
|
+
x: list.reduce((sum, note) => sum + note.at.x, 0) / list.length,
|
|
210
|
+
y: list.reduce((sum, note) => sum + note.at.y, 0) / list.length,
|
|
211
|
+
z: list.reduce((sum, note) => sum + (note.at.z ?? 0), 0) / list.length,
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
const axis = widest(middles.map((one) => one.middle));
|
|
215
|
+
middles.sort((one, other) => one.middle[axis] - other.middle[axis]);
|
|
216
|
+
let cut = 1;
|
|
217
|
+
let count = 0;
|
|
218
|
+
let best = Number.POSITIVE_INFINITY;
|
|
219
|
+
let first = 0;
|
|
220
|
+
for (let at = 0; at < middles.length - 1; at++) {
|
|
221
|
+
count += middles[at].list.length;
|
|
222
|
+
if (Math.abs(count - ours.length / 2) < best) {
|
|
223
|
+
best = Math.abs(count - ours.length / 2);
|
|
224
|
+
cut = at + 1;
|
|
225
|
+
first = count;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const [near, rest] = halveTargets(theirs, first);
|
|
229
|
+
halve(middles.slice(0, cut).flatMap((one) => one.list), near, level);
|
|
230
|
+
halve(middles.slice(cut).flatMap((one) => one.list), rest, level);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const mine = widest(ours.map((note) => note.at));
|
|
234
|
+
ours.sort((one, other) => (one.at[mine] ?? 0) - (other.at[mine] ?? 0));
|
|
186
235
|
const half = Math.floor(ours.length / 2);
|
|
187
|
-
|
|
188
|
-
halve(ours.slice(half),
|
|
236
|
+
const [near, rest] = halveTargets(theirs, half);
|
|
237
|
+
halve(ours.slice(0, half), near, level);
|
|
238
|
+
halve(ours.slice(half), rest, level);
|
|
189
239
|
};
|
|
190
|
-
halve(notes.map((
|
|
191
|
-
return fitted;
|
|
240
|
+
halve([...notes], targets.map((_, at) => at));
|
|
241
|
+
return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
|
|
192
242
|
}
|
|
193
243
|
const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
|
|
194
244
|
const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
|
|
195
245
|
ours.forEach((note, at) => {
|
|
196
246
|
fitted.set(note.id, theirs[at].at);
|
|
197
247
|
});
|
|
198
|
-
return fitted;
|
|
248
|
+
return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
|
|
199
249
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GraphPoint } from './seed.js';
|
|
2
|
+
/**
|
|
3
|
+
* Places on a surface, joined to their near neighbors, and split in halves
|
|
4
|
+
* by how far apart they are across the surface, not through the air.
|
|
5
|
+
*
|
|
6
|
+
* **Half a surface measured across it is one piece of it**: a stretch of a
|
|
7
|
+
* knot's tube, or one lobe of a brain. Half of it by position can be its
|
|
8
|
+
* front and its back, and half of it by direction from its middle can be
|
|
9
|
+
* three passes of a tube that crosses itself.
|
|
10
|
+
*/
|
|
11
|
+
export declare const surfaceWeb: (points: readonly GraphPoint[]) => {
|
|
12
|
+
/**
|
|
13
|
+
* A set of places in two halves, `first` as many as the first: the
|
|
14
|
+
* places nearer one end of the set, across the surface, and the rest.
|
|
15
|
+
* The ends are two places about as far apart across it as any.
|
|
16
|
+
*/
|
|
17
|
+
halve: (places: readonly number[], first: number) => [number[], number[]];
|
|
18
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const surfaceWeb = (points) => {
|
|
2
|
+
const count = points.length;
|
|
3
|
+
const x = (at) => points[at].x;
|
|
4
|
+
const y = (at) => points[at].y;
|
|
5
|
+
const z = (at) => points[at].z ?? 0;
|
|
6
|
+
const far = (one, other) => Math.hypot(x(one) - x(other), y(one) - y(other), z(one) - z(other));
|
|
7
|
+
const sample = Math.min(count, 256);
|
|
8
|
+
const nearest = [];
|
|
9
|
+
for (let step = 0; step < sample; step++) {
|
|
10
|
+
const one = Math.floor((step * count) / sample);
|
|
11
|
+
let best = Number.POSITIVE_INFINITY;
|
|
12
|
+
for (let other = 0; other < count; other++) {
|
|
13
|
+
if (other !== one) {
|
|
14
|
+
best = Math.min(best, far(one, other));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (Number.isFinite(best)) {
|
|
18
|
+
nearest.push(best);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
nearest.sort((one, other) => one - other);
|
|
22
|
+
const reach = (nearest[Math.floor(nearest.length / 2)] ?? 1) * 2.2;
|
|
23
|
+
const cells = new Map();
|
|
24
|
+
const cellOf = (at) => [
|
|
25
|
+
Math.floor(x(at) / reach),
|
|
26
|
+
Math.floor(y(at) / reach),
|
|
27
|
+
Math.floor(z(at) / reach),
|
|
28
|
+
];
|
|
29
|
+
for (let at = 0; at < count; at++) {
|
|
30
|
+
const key = cellOf(at).join(',');
|
|
31
|
+
const list = cells.get(key);
|
|
32
|
+
if (list) {
|
|
33
|
+
list.push(at);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
cells.set(key, [at]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const neighbors = Array.from({ length: count }, () => []);
|
|
40
|
+
for (let at = 0; at < count; at++) {
|
|
41
|
+
const [cx, cy, cz] = cellOf(at);
|
|
42
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
43
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
44
|
+
for (let dz = -1; dz <= 1; dz++) {
|
|
45
|
+
for (const other of cells.get(`${cx + dx},${cy + dy},${cz + dz}`) ?? []) {
|
|
46
|
+
if (other > at && far(at, other) <= reach) {
|
|
47
|
+
;
|
|
48
|
+
neighbors[at].push(other);
|
|
49
|
+
neighbors[other].push(at);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const member = new Int32Array(count).fill(-1);
|
|
57
|
+
let round = 0;
|
|
58
|
+
const steps = (from, out) => {
|
|
59
|
+
out.fill(-1);
|
|
60
|
+
const queue = [from];
|
|
61
|
+
out[from] = 0;
|
|
62
|
+
let last = from;
|
|
63
|
+
for (let head = 0; head < queue.length; head++) {
|
|
64
|
+
const at = queue[head];
|
|
65
|
+
last = at;
|
|
66
|
+
for (const next of neighbors[at]) {
|
|
67
|
+
if (member[next] === round && out[next] === -1) {
|
|
68
|
+
out[next] = out[at] + 1;
|
|
69
|
+
queue.push(next);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return last;
|
|
74
|
+
};
|
|
75
|
+
const fromA = new Int32Array(count);
|
|
76
|
+
const fromB = new Int32Array(count);
|
|
77
|
+
return {
|
|
78
|
+
halve: (places, first) => {
|
|
79
|
+
round += 1;
|
|
80
|
+
for (const at of places) {
|
|
81
|
+
member[at] = round;
|
|
82
|
+
}
|
|
83
|
+
const a = steps(places[0], fromA);
|
|
84
|
+
const b = steps(a, fromA);
|
|
85
|
+
steps(b, fromB);
|
|
86
|
+
const unreached = places.length * 4;
|
|
87
|
+
const key = (at) => {
|
|
88
|
+
const one = fromA[at];
|
|
89
|
+
const other = fromB[at];
|
|
90
|
+
if (one < 0 || other < 0) {
|
|
91
|
+
return unreached + far(at, a) - far(at, b);
|
|
92
|
+
}
|
|
93
|
+
return one - other;
|
|
94
|
+
};
|
|
95
|
+
const ordered = [...places].sort((one, other) => key(one) - key(other));
|
|
96
|
+
return [ordered.slice(0, first), ordered.slice(first)];
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { type ConnectGraphOptions, connectGraph, type DrawnLink, type DrawnNode,
|
|
|
2
2
|
export { diffGraph } from './connect-graph/diff.js';
|
|
3
3
|
export { type GraphColors, type GraphLooks, graphColorsFrom, type LinkLook, type NodeLook, } from './connect-graph/looks.js';
|
|
4
4
|
export { type GraphPoint, seedPositions } from './connect-graph/seed.js';
|
|
5
|
-
export { fitToStencil, type GraphStencil } from './connect-graph/stencil.js';
|
|
5
|
+
export { fitToStencil, type GraphStencil, type StencilFrame, } from './connect-graph/stencil.js';
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,4 @@ export { connectGraph, } from './connect-graph/connect-graph.js';
|
|
|
2
2
|
export { diffGraph } from './connect-graph/diff.js';
|
|
3
3
|
export { graphColorsFrom, } from './connect-graph/looks.js';
|
|
4
4
|
export { seedPositions } from './connect-graph/seed.js';
|
|
5
|
-
export { fitToStencil } from './connect-graph/stencil.js';
|
|
5
|
+
export { fitToStencil, } from './connect-graph/stencil.js';
|
|
@@ -7,6 +7,11 @@ export type OnScreen = (id: string) => {
|
|
|
7
7
|
x: number;
|
|
8
8
|
y: number;
|
|
9
9
|
} | undefined;
|
|
10
|
+
/** A point and how to bend toward it: the control point of a curve on screen. */
|
|
11
|
+
export type Bend = (from: string, to: string) => {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
} | undefined;
|
|
10
15
|
/** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
|
|
11
16
|
export declare const isLight: (color: string) => boolean;
|
|
12
17
|
/** Eases in and out, so a signal leaves and arrives gently. */
|
|
@@ -24,7 +29,7 @@ export interface Box {
|
|
|
24
29
|
* over ten thousand notes draw ten dots. It draws only while something is in
|
|
25
30
|
* flight, and takes no pointer, so a press reaches the note under it.
|
|
26
31
|
*/
|
|
27
|
-
export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) => {
|
|
32
|
+
export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen, bend?: Bend) => {
|
|
28
33
|
look: (looks: GraphLooks) => void;
|
|
29
34
|
signal: (from: string, to: string, colors: {
|
|
30
35
|
from: string;
|