@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.
Files changed (37) hide show
  1. package/README.md +75 -2
  2. package/dist/connect-graph/arrange.d.ts +4 -2
  3. package/dist/connect-graph/arrange.js +45 -12
  4. package/dist/connect-graph/connect-graph.d.ts +10 -1
  5. package/dist/connect-graph/connect-graph.js +13 -4
  6. package/dist/connect-graph/diff.d.ts +2 -0
  7. package/dist/connect-graph/diff.js +2 -1
  8. package/dist/connect-graph/seed.d.ts +3 -0
  9. package/dist/connect-graph/seed.js +10 -2
  10. package/dist/connect-graph/shape-field.d.ts +61 -0
  11. package/dist/connect-graph/shape-field.js +172 -0
  12. package/dist/connect-graph/stencil.d.ts +56 -17
  13. package/dist/connect-graph/stencil.js +160 -79
  14. package/dist/connect-graph/surface-web.d.ts +18 -0
  15. package/dist/connect-graph/surface-web.js +99 -0
  16. package/dist/cosmos/cosmos-driver.js +8 -56
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/overlay/overlay.d.ts +18 -0
  20. package/dist/overlay/overlay.js +63 -1
  21. package/dist/shapes/flat.d.ts +28 -0
  22. package/dist/shapes/flat.js +63 -0
  23. package/dist/shapes/index.d.ts +5 -0
  24. package/dist/shapes/index.js +5 -0
  25. package/dist/shapes/model.d.ts +12 -0
  26. package/dist/shapes/model.js +31 -0
  27. package/dist/shapes/solids.d.ts +28 -0
  28. package/dist/shapes/solids.js +47 -0
  29. package/dist/shapes/triangles.d.ts +20 -0
  30. package/dist/shapes/triangles.js +78 -0
  31. package/dist/three/index.d.ts +1 -0
  32. package/dist/three/index.js +1 -0
  33. package/dist/three/surface.d.ts +13 -0
  34. package/dist/three/surface.js +112 -0
  35. package/dist/three/three-driver.d.ts +32 -0
  36. package/dist/three/three-driver.js +797 -0
  37. package/package.json +28 -7
@@ -1,5 +1,7 @@
1
- const RASTER = 1024;
2
- const PROBE = 64;
1
+ import { fieldFromInside, fieldFromPath } from './shape-field.js';
2
+ import { surfaceWeb } from './surface-web.js';
3
+ const PROBE = { flat: 64, solid: 32 };
4
+ const SHELL = 0.04;
3
5
  const WAVE = 0.07;
4
6
  const seeded = (seed) => {
5
7
  let state = seed >>> 0 || 1;
@@ -8,57 +10,60 @@ const seeded = (seed) => {
8
10
  return state / 4294967296;
9
11
  };
10
12
  };
11
- const insideOf = (stencil) => {
12
- if ('inside' in stencil) {
13
- return stencil.inside;
13
+ const fields = new WeakMap();
14
+ export const fieldOf = (stencil) => {
15
+ if ('distance' in stencil) {
16
+ return stencil;
14
17
  }
15
- const scale = RASTER / Math.max(stencil.width, stencil.height, 1);
16
- const width = Math.max(1, Math.ceil(stencil.width * scale));
17
- const height = Math.max(1, Math.ceil(stencil.height * scale));
18
- const canvas = document.createElement('canvas');
19
- canvas.width = width;
20
- canvas.height = height;
21
- const context = canvas.getContext('2d', { willReadFrequently: true });
22
- if (!context) {
23
- return () => true;
18
+ const made = fields.get(stencil);
19
+ if (made) {
20
+ return made;
24
21
  }
25
- context.scale(scale, scale);
26
- context.fill(new Path2D(stencil.path), 'evenodd');
27
- const alpha = context.getImageData(0, 0, width, height).data;
28
- return (x, y) => {
29
- const column = Math.floor(x * scale);
30
- const row = Math.floor(y * scale);
31
- return (column >= 0 &&
32
- row >= 0 &&
33
- column < width &&
34
- row < height &&
35
- (alpha[(row * width + column) * 4 + 3] ?? 0) > 127);
36
- };
22
+ const field = 'path' in stencil
23
+ ? fieldFromPath(stencil.path, stencil.width, stencil.height)
24
+ : fieldFromInside((x, y) => stencil.inside(x, y), stencil);
25
+ fields.set(stencil, field);
26
+ return field;
37
27
  };
38
- export const placesIn = (inside, width, height, count) => {
39
- let filled = 0;
40
- for (let row = 0; row < PROBE; row++) {
41
- for (let column = 0; column < PROBE; column++) {
42
- if (inside(((column + 0.5) / PROBE) * width, ((row + 0.5) / PROBE) * height)) {
43
- filled += 1;
28
+ export const placesIn = (field, count, { place = 'fill', shell = SHELL } = {}) => {
29
+ const solid = field.depth > 0;
30
+ const { depth, height, width } = field;
31
+ const thickness = shell * Math.max(width, height, depth);
32
+ const takes = (x, y, z) => {
33
+ const distance = field.distance(x, y, z);
34
+ return distance < 0 && (place === 'fill' || distance > -thickness);
35
+ };
36
+ const probe = solid ? PROBE.solid : PROBE.flat;
37
+ let taken = 0;
38
+ for (let layer = 0; layer < (solid ? probe : 1); layer++) {
39
+ for (let row = 0; row < probe; row++) {
40
+ for (let column = 0; column < probe; column++) {
41
+ if (takes(((column + 0.5) / probe) * width, ((row + 0.5) / probe) * height, solid ? ((layer + 0.5) / probe) * depth : 0)) {
42
+ taken += 1;
43
+ }
44
44
  }
45
45
  }
46
46
  }
47
- if (count === 0 || filled === 0) {
47
+ if (count === 0 || taken === 0) {
48
48
  return [];
49
49
  }
50
50
  const random = seeded(count);
51
- let step = Math.sqrt(((filled / PROBE ** 2) * width * height) / count);
51
+ const room = (taken / probe ** (solid ? 3 : 2)) * width * height * (solid ? depth : 1);
52
+ let step = (room / count) ** (1 / (solid ? 3 : 2));
52
53
  for (let tries = 0; tries < 8; tries++) {
53
54
  const places = [];
54
- for (let y = step / 2; y < height; y += step) {
55
- for (let x = step / 2; x < width; x += step) {
56
- const place = {
57
- x: x + (random() - 0.5) * step * 0.8,
58
- y: y + (random() - 0.5) * step * 0.8,
59
- };
60
- if (inside(place.x, place.y)) {
61
- places.push(place);
55
+ const layers = solid ? Math.ceil(depth / step) : 1;
56
+ for (let layer = 0; layer < layers; layer++) {
57
+ for (let y = step / 2; y < height; y += step) {
58
+ for (let x = step / 2; x < width; x += step) {
59
+ const place = {
60
+ x: x + (random() - 0.5) * step * 0.8,
61
+ y: y + (random() - 0.5) * step * 0.8,
62
+ ...(solid ? { z: (layer + 0.5 + (random() - 0.5) * 0.8) * step } : {}),
63
+ };
64
+ if (takes(place.x, place.y, place.z ?? 0)) {
65
+ places.push(place);
66
+ }
62
67
  }
63
68
  }
64
69
  }
@@ -69,53 +74,129 @@ export const placesIn = (inside, width, height, count) => {
69
74
  }
70
75
  return places.slice(0, count);
71
76
  }
72
- step *= Math.sqrt(places.length / count) * 0.97 || 0.5;
77
+ step *= (places.length / count) ** (1 / (solid ? 3 : 2)) * 0.97 || 0.5;
73
78
  }
74
79
  return [];
75
80
  };
76
- export const fitToStencil = (positions, stencil) => {
81
+ const nested = (items, keys) => {
82
+ const [key, ...rest] = keys;
83
+ if (!key) {
84
+ return items;
85
+ }
86
+ const sorted = [...items].sort((one, other) => key(one) - key(other));
87
+ if (rest.length === 0) {
88
+ return sorted;
89
+ }
90
+ const slice = Math.ceil(items.length ** (rest.length / keys.length));
91
+ const out = [];
92
+ for (let from = 0; from < sorted.length; from += slice) {
93
+ out.push(...nested(sorted.slice(from, from + slice), rest));
94
+ }
95
+ return out;
96
+ };
97
+ export const fitToStencil = (positions, stencil) => fitInStencil(positions, stencil).positions;
98
+ export const fitInStencil = (positions, stencil) => {
77
99
  const notes = [...positions].map(([id, at]) => ({ at, id }));
78
100
  if (notes.length === 0) {
79
- return new Map();
101
+ return { positions: new Map() };
80
102
  }
81
- const places = placesIn(insideOf(stencil), stencil.width, stencil.height, notes.length);
103
+ const field = fieldOf(stencil);
104
+ const places = placesIn(field, notes.length, {
105
+ place: stencil.place ?? 'fill',
106
+ shell: stencil.shell ?? SHELL,
107
+ });
82
108
  if (places.length < notes.length) {
83
- return new Map(positions);
109
+ return { positions: new Map(positions) };
84
110
  }
85
- let left = Number.POSITIVE_INFINITY;
86
- let right = Number.NEGATIVE_INFINITY;
87
- let bottom = Number.POSITIVE_INFINITY;
88
- let top = Number.NEGATIVE_INFINITY;
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;
115
+ const low = {
116
+ x: Number.POSITIVE_INFINITY,
117
+ y: Number.POSITIVE_INFINITY,
118
+ z: Number.POSITIVE_INFINITY,
119
+ };
120
+ const high = {
121
+ x: Number.NEGATIVE_INFINITY,
122
+ y: Number.NEGATIVE_INFINITY,
123
+ z: Number.NEGATIVE_INFINITY,
124
+ };
89
125
  for (const { at } of notes) {
90
- left = Math.min(left, at.x);
91
- right = Math.max(right, at.x);
92
- bottom = Math.min(bottom, at.y);
93
- top = Math.max(top, at.y);
126
+ for (const axis of ['x', 'y', 'z']) {
127
+ const value = at[axis] ?? 0;
128
+ low[axis] = Math.min(low[axis], value);
129
+ high[axis] = Math.max(high[axis], value);
130
+ }
94
131
  }
95
- const scale = Math.max(right - left, top - bottom, 1) / Math.max(stencil.width, stencil.height);
96
- const { height, width } = stencil;
97
- const spots = places
98
- .map((place) => ({
99
- across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
100
- at: {
101
- x: (left + right) / 2 + (place.x - width / 2) * scale,
102
- y: (bottom + top) / 2 - (place.y - height / 2) * scale,
103
- },
104
- down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
105
- }))
106
- .sort((one, other) => one.across - other.across);
107
- notes.sort((one, other) => one.at.x - other.at.x || one.at.y - other.at.y);
108
- const strip = Math.ceil(Math.sqrt(notes.length));
132
+ const { depth, height, width } = field;
133
+ const scale = Math.max(high.x - low.x, high.y - low.y, solid ? high.z - low.z : 0, 1) /
134
+ Math.max(width, height, depth);
135
+ const middle = { x: (low.x + high.x) / 2, y: (low.y + high.y) / 2, z: (low.z + high.z) / 2 };
136
+ const spots = places.map((place) => {
137
+ const z = place.z ?? depth / 2;
138
+ return {
139
+ across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
140
+ at: {
141
+ x: middle.x + (place.x - width / 2) * scale,
142
+ y: middle.y - (place.y - height / 2) * scale,
143
+ ...(solid ? { z: middle.z + (z - depth / 2) * scale } : {}),
144
+ },
145
+ down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
146
+ };
147
+ });
109
148
  const fitted = new Map();
110
- for (let from = 0; from < notes.length; from += strip) {
111
- const ours = notes.slice(from, from + strip).sort((one, other) => one.at.y - other.at.y);
112
- const theirs = spots
113
- .slice(from, from + strip)
114
- .sort((one, other) => one.down - other.down)
115
- .map((spot) => spot.at);
116
- ours.forEach((note, at) => {
117
- fitted.set(note.id, theirs[at]);
118
- });
149
+ if (solid) {
150
+ const widest = (points) => {
151
+ let best = 'x';
152
+ let spread = Number.NEGATIVE_INFINITY;
153
+ for (const axis of ['x', 'y', 'z']) {
154
+ let low = Number.POSITIVE_INFINITY;
155
+ let high = Number.NEGATIVE_INFINITY;
156
+ for (const point of points) {
157
+ const value = point[axis] ?? 0;
158
+ low = Math.min(low, value);
159
+ high = Math.max(high, value);
160
+ }
161
+ if (high - low > spread) {
162
+ spread = high - low;
163
+ best = axis;
164
+ }
165
+ }
166
+ return best;
167
+ };
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 halve = (ours, theirs) => {
179
+ if (ours.length === 1) {
180
+ fitted.set(ours[0].id, targets[theirs[0]]);
181
+ return;
182
+ }
183
+ if (ours.length === 0) {
184
+ return;
185
+ }
186
+ const mine = widest(ours.map((note) => note.at));
187
+ ours.sort((one, other) => (one.at[mine] ?? 0) - (other.at[mine] ?? 0));
188
+ const half = Math.floor(ours.length / 2);
189
+ const [near, rest] = halveTargets(theirs, half);
190
+ halve(ours.slice(0, half), near);
191
+ halve(ours.slice(half), rest);
192
+ };
193
+ halve([...notes], targets.map((_, at) => at));
194
+ return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
119
195
  }
120
- return fitted;
196
+ const ours = nested(notes, [(note) => note.at.x, (note) => note.at.y]);
197
+ const theirs = nested(spots, [(spot) => spot.across, (spot) => spot.down]);
198
+ ours.forEach((note, at) => {
199
+ fitted.set(note.id, theirs[at].at);
200
+ });
201
+ return { frame: { field, middle, scale, shell: thickness }, positions: fitted };
121
202
  };
@@ -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
+ };
@@ -1,4 +1,4 @@
1
- import { gesturesHeld, groupsOver, signalsOver } from '../overlay/overlay.js';
1
+ import { gesturesHeld, groupsOver, namesOver, signalsOver, } from '../overlay/overlay.js';
2
2
  const SHAPES = { circle: 0, diamond: 3, ring: 0, square: 1 };
3
3
  const STEP = 250;
4
4
  const FAINT = 0.16;
@@ -81,61 +81,13 @@ export const cosmosDriver = (graph, container, options = {}) => {
81
81
  const held = gesturesHeld(container);
82
82
  const signals = signalsOver(container, onScreen);
83
83
  const groups = groupsOver(container, onScreen);
84
- const names = document.createElement('canvas');
85
- names.setAttribute('aria-hidden', 'true');
86
- names.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none';
87
- container.append(names);
84
+ const names = namesOver(container, onScreen);
88
85
  const drawNames = () => {
89
- const context = names.getContext('2d');
90
- if (!context || !looks) {
91
- return;
92
- }
93
- const ratio = window.devicePixelRatio || 1;
94
- const width = Math.round(container.clientWidth * ratio);
95
- const height = Math.round(container.clientHeight * ratio);
96
- if (names.width !== width || names.height !== height) {
97
- names.width = width;
98
- names.height = height;
99
- }
100
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
101
- context.clearRect(0, 0, width, height);
102
- const taken = [];
103
- const shown = [selected, hovered === undefined ? undefined : ids[hovered], ...near];
104
- for (const id of shown) {
105
- if (id === undefined) {
106
- continue;
107
- }
108
- const at = onScreen(id);
109
- const label = looks.node(id);
110
- if (!at || label.hidden) {
111
- continue;
112
- }
113
- const name = drawnNow.get(id)?.label ?? id;
114
- const strong = id === selected || id === ids[hovered ?? -1];
115
- context.font = `${strong ? 600 : 500} ${strong ? 13 : 12}px ${looks.font}`;
116
- const x = at.x + at.radius + 4;
117
- const y = at.y + 4;
118
- const box = {
119
- bottom: y + 4,
120
- left: x - 2,
121
- right: x + context.measureText(name).width + 2,
122
- top: y - 13,
123
- };
124
- const free = taken.every((other) => box.right < other.left ||
125
- box.left > other.right ||
126
- box.bottom < other.top ||
127
- box.top > other.bottom);
128
- if (!free && !strong) {
129
- continue;
130
- }
131
- taken.push(box);
132
- context.lineJoin = 'round';
133
- context.lineWidth = 3;
134
- context.strokeStyle = looks.ground;
135
- context.strokeText(name, x, y);
136
- context.fillStyle = strong ? looks.ink : looks.quiet;
137
- context.fillText(name, x, y);
138
- }
86
+ const lit = hovered === undefined ? undefined : ids[hovered];
87
+ const shown = [...new Set([selected, lit, ...near])].flatMap((id) => id === undefined
88
+ ? []
89
+ : [{ id, name: drawnNow.get(id)?.label ?? id, strong: id === selected || id === lit }]);
90
+ names.draw(looks, shown);
139
91
  };
140
92
  const redraw = () => {
141
93
  drawNames();
@@ -418,7 +370,7 @@ export const cosmosDriver = (graph, container, options = {}) => {
418
370
  });
419
371
  signals.stop();
420
372
  groups.stop();
421
- names.remove();
373
+ names.stop();
422
374
  held.stop();
423
375
  },
424
376
  update: (changes, animate) => {
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,8 @@ export type OnScreen = (id: string) => {
7
7
  x: number;
8
8
  y: number;
9
9
  } | undefined;
10
+ /** Whether an `rgb()` color is light, which a light ink says the ground is dark. */
11
+ export declare const isLight: (color: string) => boolean;
10
12
  /** Eases in and out, so a signal leaves and arrives gently. */
11
13
  export declare const ease: (at: number) => number;
12
14
  /** A name's box on screen, in pixels. */
@@ -30,6 +32,22 @@ export declare const signalsOver: (container: HTMLElement, onScreen: OnScreen) =
30
32
  } | undefined, animate: boolean) => void;
31
33
  stop: () => void;
32
34
  };
35
+ /** A note to name over the drawing: its name, and whether it is the one the reader is on. */
36
+ export interface Named {
37
+ id: string;
38
+ name: string;
39
+ strong: boolean;
40
+ }
41
+ /**
42
+ * **Names on a canvas of their own**, for a renderer that draws none: each
43
+ * note given, beside it, ringed in the ground's color so it reads over the
44
+ * links. The strong ones always, and the rest where there is room, in the
45
+ * order given.
46
+ */
47
+ export declare const namesOver: (container: HTMLElement, onScreen: OnScreen) => {
48
+ draw: (looks: GraphLooks | undefined, named: readonly Named[]) => void;
49
+ stop: () => void;
50
+ };
33
51
  /**
34
52
  * **Group names are drawn on a canvas of their own**, under the signals', and
35
53
  * drawn again whenever the driver says the view moved, so they follow it.
@@ -65,7 +65,7 @@ const arrive = (context, color, at, gone) => {
65
65
  context.arc(at.x, at.y, reach, 0, Math.PI * 2);
66
66
  context.fill();
67
67
  };
68
- const isLight = (color) => {
68
+ export const isLight = (color) => {
69
69
  const [red = 0, green = 0, blue = 0] = color.match(/[\d.]+/g)?.map(Number) ?? [];
70
70
  return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 > 0.5;
71
71
  };
@@ -76,6 +76,7 @@ const nameGroups = (context, groups, looks, at) => {
76
76
  context.textAlign = 'center';
77
77
  context.textBaseline = 'middle';
78
78
  context.shadowColor = looks.ground;
79
+ const taken = [];
79
80
  for (const group of groups) {
80
81
  let x = 0;
81
82
  let y = 0;
@@ -100,6 +101,23 @@ const nameGroups = (context, groups, looks, at) => {
100
101
  continue;
101
102
  }
102
103
  const name = group.name.toLocaleUpperCase();
104
+ const half = context.measureText(name).width / 2 + 4;
105
+ const boxAt = (middle) => ({
106
+ bottom: middle + 9,
107
+ left: x - half,
108
+ right: x + half,
109
+ top: middle - 9,
110
+ });
111
+ const clear = (box) => taken.every((other) => box.right < other.left ||
112
+ box.left > other.right ||
113
+ box.bottom < other.top ||
114
+ box.top > other.bottom);
115
+ const line = [0, 18, -18].map((step) => y + step).find((middle) => clear(boxAt(middle)));
116
+ if (line === undefined) {
117
+ continue;
118
+ }
119
+ y = line;
120
+ taken.push(boxAt(y));
103
121
  context.globalAlpha = group.faded ? 0.3 : 0.92;
104
122
  context.fillStyle = mixRgb(group.color, looks.ink, 0.6);
105
123
  context.shadowBlur = 10;
@@ -223,6 +241,50 @@ export const signalsOver = (container, onScreen) => {
223
241
  },
224
242
  };
225
243
  };
244
+ export const namesOver = (container, onScreen) => {
245
+ const canvas = layer(container, 'names');
246
+ return {
247
+ draw: (looks, named) => {
248
+ const context = looks ? fresh(canvas, container) : null;
249
+ if (!context || !looks) {
250
+ return;
251
+ }
252
+ const taken = [];
253
+ for (const { id, name, strong } of named) {
254
+ const at = onScreen(id);
255
+ if (!at || looks.node(id).hidden) {
256
+ continue;
257
+ }
258
+ context.font = `${strong ? 600 : 500} ${strong ? 13 : 12}px ${looks.font}`;
259
+ const x = at.x + at.radius + 4;
260
+ const y = at.y + 4;
261
+ const box = {
262
+ bottom: y + 4,
263
+ left: x - 2,
264
+ right: x + context.measureText(name).width + 2,
265
+ top: y - 13,
266
+ };
267
+ const free = taken.every((other) => box.right < other.left ||
268
+ box.left > other.right ||
269
+ box.bottom < other.top ||
270
+ box.top > other.bottom);
271
+ if (!free && !strong) {
272
+ continue;
273
+ }
274
+ taken.push(box);
275
+ context.lineJoin = 'round';
276
+ context.lineWidth = 3;
277
+ context.strokeStyle = looks.ground;
278
+ context.strokeText(name, x, y);
279
+ context.fillStyle = strong ? looks.ink : looks.quiet;
280
+ context.fillText(name, x, y);
281
+ }
282
+ },
283
+ stop: () => {
284
+ canvas.remove();
285
+ },
286
+ };
287
+ };
226
288
  export const groupsOver = (container, onScreen) => {
227
289
  let canvas;
228
290
  let groups = [];
@@ -0,0 +1,28 @@
1
+ import { type ShapeField } from '../connect-graph/shape-field.js';
2
+ /**
3
+ * A flat field from an SVG: an `<svg>` element, or its markup. Its box is
4
+ * its `viewBox`, and its shape every path, rectangle, circle, ellipse and
5
+ * polygon in it, together, their holes cut by the even-odd rule.
6
+ *
7
+ * Transforms on the shapes are not read: draw the outline in the `viewBox`'s
8
+ * own units, as an exported icon or logo is.
9
+ */
10
+ export declare const shapeFromSvg: (svg: SVGSVGElement | string, resolution?: number) => ShapeField;
11
+ /** How an image is read as a shape. */
12
+ export interface ImageOptions {
13
+ /** How opaque a pixel is to be inside, from 0 to 1. 0.5 by default. */
14
+ threshold?: number | undefined;
15
+ /** How many cells the field has along the image's longer side. 512 by default. */
16
+ resolution?: number | undefined;
17
+ }
18
+ /**
19
+ * A flat field from an image's opaque pixels: a logo, a silhouette, or a
20
+ * mask drawn for the purpose. Its box is the image's size in pixels.
21
+ *
22
+ * An image from another site is read only where that site allows it, as a
23
+ * canvas reads any image.
24
+ */
25
+ export declare const shapeFromImage: (image: CanvasImageSource & {
26
+ height: number | SVGAnimatedLength;
27
+ width: number | SVGAnimatedLength;
28
+ }, { resolution, threshold }?: ImageOptions) => ShapeField;