@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 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 pulse of light
79
- traveling from one to the other, turning from the first note's color to the
80
- second's, or in the tone's color where it has one, over the graph and without
81
- redrawing it, so a busy feed costs a dot each. A reader who asks for less motion
82
- sees the link lit for a moment instead.
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. The selected note keeps its own color and is told apart by
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 takes one side of
241
- the shape, and each hub's notes a patch around it.
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.** 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.
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 arrangeByGroup = (nodes, links, { solid = false } = {}) => {
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, fitToStencil } from './stencil.js';
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 shaped = options.stencil && ids.size > 0 && unplaced.size === ids.size
98
- ? fitToStencil(arrangeByGroup(nodes, [...nextLinks.values()], {
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
- driver.shape?.(options.stencil ? (options.stencil.place ?? 'fill') : undefined);
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 halve = (ours, theirs) => {
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].at);
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 mine = widest(ours.map((note) => note.key));
183
- const yours = widest(theirs.map((spot) => spot.key));
184
- ours.sort((one, other) => (one.key[mine] ?? 0) - (other.key[mine] ?? 0));
185
- theirs.sort((one, other) => (one.key[yours] ?? 0) - (other.key[yours] ?? 0));
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
- halve(ours.slice(0, half), theirs.slice(0, half));
188
- halve(ours.slice(half), theirs.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((note) => ({ ...note, key: way(note.at) })), spots.map((spot) => ({ at: spot.at, key: way(spot.at) })));
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;