@fusion.dev/helix-graph 0.120.0 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,8 @@ export const diffGraph = (before, after) => {
20
20
  else if (was.label !== node.label ||
21
21
  was.size !== node.size ||
22
22
  was.x !== node.x ||
23
- was.y !== node.y) {
23
+ was.y !== node.y ||
24
+ was.z !== node.z) {
24
25
  changes.changeNodes.push(node);
25
26
  }
26
27
  }
@@ -58,11 +58,31 @@ export interface NodeLook {
58
58
  * neighbors drew their names over each other in a knot nobody could read.
59
59
  */
60
60
  label: 'always' | 'never' | 'room';
61
+ /** How heavily it is drawn, where the note says. */
62
+ emphasis?: 'outlined' | 'solid' | 'subtle' | undefined;
63
+ /** How strongly it glows, from 0 to 1: the selected note's halo. */
64
+ glow: number;
65
+ /** What it is drawn as made of, where the note says. */
66
+ material?: 'frosted' | 'glass' | undefined;
61
67
  /** Drawn over the rest, as the selected note and its neighbors are. */
62
68
  raised: boolean;
63
69
  /** The selected note. */
64
70
  selected: boolean;
71
+ /** Its shape, where the note says. */
72
+ shape?: 'circle' | 'diamond' | 'ring' | 'square' | undefined;
65
73
  }
74
+ /** A tone from the `tone` axis. */
75
+ export type GraphTone = 'accent' | 'danger' | 'info' | 'neutral' | 'primary' | 'success' | 'warning';
76
+ /** What the looks read off one note. */
77
+ export interface NoteFacts {
78
+ emphasis?: NodeLook['emphasis'];
79
+ group?: string | undefined;
80
+ material?: NodeLook['material'];
81
+ shape?: NodeLook['shape'];
82
+ tone?: GraphTone | undefined;
83
+ }
84
+ /** A tone's color: `neutral` is the muted ink, and no tone is `primary`. */
85
+ export declare const toneColor: (colors: GraphColors, tone: GraphTone | undefined) => string;
66
86
  /** How one link is drawn. */
67
87
  export interface LinkLook {
68
88
  color: string;
@@ -71,12 +91,18 @@ export interface LinkLook {
71
91
  }
72
92
  /** How every note and link is drawn, asked one at a time, so ten thousand cost nothing until drawn. */
73
93
  export interface GraphLooks {
94
+ /** A note's own color, its tone's or its group's, whatever the selection fades. */
95
+ colorOf(id: string): string;
74
96
  /** The typeface the labels are drawn in: the first of the frame's own. */
75
97
  font: string;
98
+ /** What the drawing is laid on, which a name is ringed in so it reads over the links. */
99
+ ground: string;
76
100
  /** The color the labels are drawn in. */
77
101
  ink: string;
78
102
  /** What a name is drawn on while the pointer is over its note. */
79
103
  overlay: string;
104
+ /** The quieter ink a name at rest is drawn in, beside the selected note's and its neighbors'. */
105
+ quiet: string;
80
106
  link(source: string, target: string): LinkLook;
81
107
  node(id: string): NodeLook;
82
108
  }
@@ -84,8 +110,8 @@ export interface GraphLooks {
84
110
  * How the graph is drawn for one selection.
85
111
  *
86
112
  * **The neighborhood stands out and the rest steps back**, which is what
87
- * selecting a note in a graph of thousands is for: the selected note in the
88
- * brand color, the notes it links to and from in their groups' colors with
113
+ * selecting a note in a graph of thousands is for: the selected note
114
+ * glowing, the notes it links to and from in their groups' colors with
89
115
  * their names drawn where there is room, and every other note faded toward
90
116
  * the ground, unnamed. With
91
117
  * nothing selected, every note is in its group's color.
@@ -96,9 +122,9 @@ export interface GraphLooks {
96
122
  export declare const looksOf: (options: {
97
123
  colors: GraphColors;
98
124
  font?: string | undefined;
99
- groupOf: (id: string) => string | undefined;
100
125
  groups: readonly string[];
101
126
  near: ReadonlySet<string>;
127
+ noteOf: (id: string) => NoteFacts | undefined;
102
128
  selected: string | undefined;
103
129
  shown: ReadonlySet<string> | undefined;
104
130
  }) => GraphLooks;
@@ -54,12 +54,21 @@ export const mixRgb = (from, to, amount) => {
54
54
  const channel = (at) => Math.round((one[at] ?? 0) + ((other[at] ?? 0) - (one[at] ?? 0)) * amount);
55
55
  return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
56
56
  };
57
+ export const toneColor = (colors, tone) => tone === undefined || tone === 'primary'
58
+ ? colors.primary
59
+ : tone === 'neutral'
60
+ ? colors.muted
61
+ : colors[tone];
57
62
  const FADED = 0.75;
58
63
  export const looksOf = (options) => {
59
- const { colors, groupOf, near, selected, shown } = options;
64
+ const { colors, near, noteOf, selected, shown } = options;
60
65
  const order = new Map([...new Set(options.groups)].sort().map((group, at) => [group, at]));
61
66
  const own = (id) => {
62
- const group = groupOf(id);
67
+ const note = noteOf(id);
68
+ if (note?.tone) {
69
+ return toneColor(colors, note.tone);
70
+ }
71
+ const group = note?.group;
63
72
  const at = group === undefined ? undefined : order.get(group);
64
73
  return at === undefined || colors.series.length === 0
65
74
  ? colors.muted
@@ -68,24 +77,30 @@ export const looksOf = (options) => {
68
77
  const focused = selected !== undefined;
69
78
  const hidden = (id) => shown !== undefined && !shown.has(id);
70
79
  return {
80
+ colorOf: own,
71
81
  font: options.font || 'sans-serif',
82
+ ground: colors.ground,
72
83
  ink: colors.ink,
73
84
  link: (source, target) => {
74
85
  const touches = focused && (source === selected || target === selected);
75
86
  return {
76
87
  color: touches
77
- ? colors.muted
88
+ ? own(selected)
78
89
  : focused
79
- ? mixRgb(colors.line, colors.ground, 0.5)
80
- : colors.line,
90
+ ? mixRgb(colors.line, colors.ground, 0.6)
91
+ : mixRgb(own(source), colors.ground, 0.72),
81
92
  hidden: hidden(source) || hidden(target),
82
93
  raised: touches,
83
94
  };
84
95
  },
85
96
  node: (id) => {
97
+ const note = noteOf(id);
98
+ const facts = { emphasis: note?.emphasis, material: note?.material, shape: note?.shape };
86
99
  if (id === selected) {
87
100
  return {
88
- color: colors.primary,
101
+ ...facts,
102
+ color: own(id),
103
+ glow: 1,
89
104
  hidden: hidden(id),
90
105
  label: 'always',
91
106
  raised: true,
@@ -94,7 +109,9 @@ export const looksOf = (options) => {
94
109
  }
95
110
  const neighbor = focused && near.has(id);
96
111
  return {
112
+ ...facts,
97
113
  color: focused && !neighbor ? mixRgb(own(id), colors.ground, FADED) : own(id),
114
+ glow: 0,
98
115
  hidden: hidden(id),
99
116
  label: focused && !neighbor ? 'never' : 'room',
100
117
  raised: neighbor,
@@ -102,5 +119,6 @@ export const looksOf = (options) => {
102
119
  };
103
120
  },
104
121
  overlay: colors.overlay,
122
+ quiet: colors.muted,
105
123
  };
106
124
  };
@@ -2,12 +2,15 @@
2
2
  export interface GraphPoint {
3
3
  x: number;
4
4
  y: number;
5
+ /** How near the viewer, for a renderer that draws in three dimensions. */
6
+ z?: number | undefined;
5
7
  }
6
8
  /** A note as the connector reads it off the frame. */
7
9
  export interface SeedNode {
8
10
  id: string;
9
11
  x?: number | undefined;
10
12
  y?: number | undefined;
13
+ z?: number | undefined;
11
14
  }
12
15
  /** A link as the connector reads it off the frame. */
13
16
  export interface SeedLink {
@@ -13,7 +13,11 @@ export const seedPositions = (nodes, links, known = new Map()) => {
13
13
  const unplaced = new Set();
14
14
  for (const node of nodes) {
15
15
  if (Number.isFinite(node.x) && Number.isFinite(node.y)) {
16
- positions.set(node.id, { x: node.x, y: node.y });
16
+ positions.set(node.id, {
17
+ x: node.x,
18
+ y: node.y,
19
+ ...(Number.isFinite(node.z) ? { z: node.z } : {}),
20
+ });
17
21
  }
18
22
  else {
19
23
  const kept = known.get(node.id);
@@ -23,9 +27,18 @@ export const seedPositions = (nodes, links, known = new Map()) => {
23
27
  }
24
28
  }
25
29
  const neighbors = new Map();
30
+ const add = (from, to) => {
31
+ const list = neighbors.get(from);
32
+ if (list) {
33
+ list.push(to);
34
+ }
35
+ else {
36
+ neighbors.set(from, [to]);
37
+ }
38
+ };
26
39
  for (const { source, target } of links) {
27
- neighbors.set(source, [...(neighbors.get(source) ?? []), target]);
28
- neighbors.set(target, [...(neighbors.get(target) ?? []), source]);
40
+ add(source, target);
41
+ add(target, source);
29
42
  }
30
43
  let spiral = positions.size;
31
44
  for (const node of nodes) {
@@ -37,7 +50,11 @@ export const seedPositions = (nodes, links, known = new Map()) => {
37
50
  const beside = placed === undefined ? undefined : positions.get(placed);
38
51
  if (beside) {
39
52
  const offset = nudge(node.id);
40
- positions.set(node.id, { x: beside.x + offset.x, y: beside.y + offset.y });
53
+ positions.set(node.id, {
54
+ x: beside.x + offset.x,
55
+ y: beside.y + offset.y,
56
+ ...(beside.z === undefined ? {} : { z: beside.z + offset.x * offset.y }),
57
+ });
41
58
  continue;
42
59
  }
43
60
  const radius = Math.sqrt(spiral + 0.5);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A shape as a signed distance field: for any point in its box, how far it is
3
+ * from the shape's edge, below zero inside and above zero outside, in the
4
+ * box's units. **Flat when `depth` is zero**, and solid otherwise.
5
+ *
6
+ * One form for every shape, however it was given, so placing notes inside a
7
+ * shape, on its surface, or at the nearest point inside it is the same work
8
+ * in two dimensions and three.
9
+ */
10
+ export interface ShapeField {
11
+ /** How deep the box is, from front to back: zero for a flat shape. */
12
+ readonly depth: number;
13
+ /** How far a point is from the shape's edge: below zero inside. `z` is ignored for a flat shape. */
14
+ distance(x: number, y: number, z?: number): number;
15
+ /** How tall the box is. Its y points down, as an SVG's does. */
16
+ readonly height: number;
17
+ /** How wide the box is. */
18
+ readonly width: number;
19
+ }
20
+ /**
21
+ * A field from a test of whether a point in the box is inside, sampled at
22
+ * the center of each cell: flat when `depth` is zero, solid otherwise.
23
+ */
24
+ export declare const fieldFromInside: (inside: (x: number, y: number, z: number) => boolean, box: {
25
+ depth?: number | undefined;
26
+ height: number;
27
+ width: number;
28
+ }, resolution?: number) => ShapeField;
29
+ /**
30
+ * A flat field from an SVG path in a box `width` by `height`, its holes cut by
31
+ * the even-odd rule. Drawn once to a canvas and read off it, which is what
32
+ * lets a detailed outline become a field in a moment.
33
+ */
34
+ export declare const fieldFromPath: (path: string, width: number, height: number, resolution?: number) => ShapeField;
35
+ /**
36
+ * A solid puffed out of a flat shape, as a cushion is: as thick as `depth`
37
+ * where the shape is widest, rounding to nothing at its edge, and cut
38
+ * through wherever the flat shape has a hole.
39
+ *
40
+ * `roundness`, from 0 to 1, is how far in from the edge the rounding runs, as
41
+ * a share of the way to the shape's middle: 1, the default, rounds the whole
42
+ * way, as a pebble; less leaves a flat face, as a coin.
43
+ */
44
+ export declare const inflate: (flat: ShapeField, { depth, roundness }: {
45
+ depth: number;
46
+ roundness?: number;
47
+ }) => ShapeField;
@@ -0,0 +1,172 @@
1
+ const FLAT = 512;
2
+ const SOLID = 96;
3
+ const FAR = 1e20;
4
+ const line = (values, count, out) => {
5
+ const hull = new Int32Array(count);
6
+ const bounds = new Float64Array(count + 1);
7
+ const crossing = (at, from) => (values[at] + at * at - (values[from] + from * from)) /
8
+ (2 * at - 2 * from);
9
+ let top = 0;
10
+ hull[0] = 0;
11
+ bounds[0] = -FAR;
12
+ bounds[1] = FAR;
13
+ for (let at = 1; at < count; at++) {
14
+ let cross = crossing(at, hull[top]);
15
+ while (cross <= bounds[top]) {
16
+ top -= 1;
17
+ cross = crossing(at, hull[top]);
18
+ }
19
+ top += 1;
20
+ hull[top] = at;
21
+ bounds[top] = cross;
22
+ bounds[top + 1] = FAR;
23
+ }
24
+ top = 0;
25
+ for (let at = 0; at < count; at++) {
26
+ while (bounds[top + 1] < at) {
27
+ top += 1;
28
+ }
29
+ const from = hull[top];
30
+ out[at] = (at - from) * (at - from) + values[from];
31
+ }
32
+ };
33
+ const transform = (marked, sizes) => {
34
+ const [nx, ny, nz] = sizes;
35
+ const grid = new Float64Array(nx * ny * nz);
36
+ for (let at = 0; at < grid.length; at++) {
37
+ grid[at] = marked[at] ? 0 : FAR;
38
+ }
39
+ const longest = Math.max(nx, ny, nz);
40
+ const values = new Float64Array(longest);
41
+ const out = new Float64Array(longest);
42
+ const pass = (count, stride, starts) => {
43
+ for (const start of starts) {
44
+ for (let step = 0; step < count; step++) {
45
+ values[step] = grid[start + step * stride];
46
+ }
47
+ line(values, count, out);
48
+ for (let step = 0; step < count; step++) {
49
+ grid[start + step * stride] = out[step];
50
+ }
51
+ }
52
+ };
53
+ const along = function* (outer, inner, outerStride, innerStride) {
54
+ for (let one = 0; one < outer; one++) {
55
+ for (let two = 0; two < inner; two++) {
56
+ yield one * outerStride + two * innerStride;
57
+ }
58
+ }
59
+ };
60
+ pass(nx, 1, along(nz, ny, nx * ny, nx));
61
+ pass(ny, nx, along(nz, nx, nx * ny, 1));
62
+ if (nz > 1) {
63
+ pass(nz, nx * ny, along(ny, nx, nx, 1));
64
+ }
65
+ for (let at = 0; at < grid.length; at++) {
66
+ grid[at] = Math.sqrt(grid[at]);
67
+ }
68
+ return grid;
69
+ };
70
+ const fieldFromCells = (inside, sizes, cell, box) => {
71
+ const outside = new Uint8Array(inside.length);
72
+ for (let at = 0; at < inside.length; at++) {
73
+ outside[at] = inside[at] ? 0 : 1;
74
+ }
75
+ const toInside = transform(inside, sizes);
76
+ const toOutside = transform(outside, sizes);
77
+ const [nx, ny, nz] = sizes;
78
+ const signed = new Float32Array(inside.length);
79
+ for (let at = 0; at < inside.length; at++) {
80
+ signed[at] = inside[at]
81
+ ? -(toOutside[at] - 0.5) * cell
82
+ : (toInside[at] - 0.5) * cell;
83
+ }
84
+ const read = (x, y, z) => signed[Math.min(Math.max(z, 0), nz - 1) * nx * ny +
85
+ Math.min(Math.max(y, 0), ny - 1) * nx +
86
+ Math.min(Math.max(x, 0), nx - 1)];
87
+ return {
88
+ ...box,
89
+ distance: (x, y, z = 0) => {
90
+ const fx = x / cell - 0.5;
91
+ const fy = y / cell - 0.5;
92
+ const fz = nz > 1 ? z / cell - 0.5 : 0;
93
+ const x0 = Math.floor(fx);
94
+ const y0 = Math.floor(fy);
95
+ const z0 = Math.floor(fz);
96
+ const tx = fx - x0;
97
+ const ty = fy - y0;
98
+ const tz = fz - z0;
99
+ const plane = (layer) => (read(x0, y0, layer) * (1 - tx) + read(x0 + 1, y0, layer) * tx) * (1 - ty) +
100
+ (read(x0, y0 + 1, layer) * (1 - tx) + read(x0 + 1, y0 + 1, layer) * tx) * ty;
101
+ const within = nz > 1 ? plane(z0) * (1 - tz) + plane(z0 + 1) * tz : plane(0);
102
+ const past = Math.hypot(Math.max(-x, x - box.width, 0), Math.max(-y, y - box.height, 0), nz > 1 ? Math.max(-z, z - box.depth, 0) : 0);
103
+ return past > 0 ? Math.max(within, 0) + past : within;
104
+ },
105
+ };
106
+ };
107
+ export const fieldFromInside = (inside, box, resolution = box.depth ? SOLID : FLAT) => {
108
+ const depth = box.depth ?? 0;
109
+ const cell = Math.max(box.width, box.height, depth) / resolution;
110
+ const nx = Math.max(1, Math.ceil(box.width / cell));
111
+ const ny = Math.max(1, Math.ceil(box.height / cell));
112
+ const nz = depth > 0 ? Math.max(1, Math.ceil(depth / cell)) : 1;
113
+ const cells = new Uint8Array(nx * ny * nz);
114
+ for (let z = 0; z < nz; z++) {
115
+ for (let y = 0; y < ny; y++) {
116
+ for (let x = 0; x < nx; x++) {
117
+ cells[z * nx * ny + y * nx + x] = inside((x + 0.5) * cell, (y + 0.5) * cell, depth > 0 ? (z + 0.5) * cell : 0)
118
+ ? 1
119
+ : 0;
120
+ }
121
+ }
122
+ }
123
+ return fieldFromCells(cells, [nx, ny, nz], cell, {
124
+ depth,
125
+ height: box.height,
126
+ width: box.width,
127
+ });
128
+ };
129
+ export const fieldFromPath = (path, width, height, resolution = FLAT) => {
130
+ const cell = Math.max(width, height, 1) / resolution;
131
+ const nx = Math.max(1, Math.ceil(width / cell));
132
+ const ny = Math.max(1, Math.ceil(height / cell));
133
+ const canvas = document.createElement('canvas');
134
+ canvas.width = nx;
135
+ canvas.height = ny;
136
+ const context = canvas.getContext('2d', { willReadFrequently: true });
137
+ const cells = new Uint8Array(nx * ny);
138
+ if (context) {
139
+ context.scale(1 / cell, 1 / cell);
140
+ context.fill(new Path2D(path), 'evenodd');
141
+ const alpha = context.getImageData(0, 0, nx, ny).data;
142
+ for (let at = 0; at < cells.length; at++) {
143
+ cells[at] = (alpha[at * 4 + 3] ?? 0) > 127 ? 1 : 0;
144
+ }
145
+ }
146
+ else {
147
+ cells.fill(1);
148
+ }
149
+ return fieldFromCells(cells, [nx, ny, 1], cell, { depth: 0, height, width });
150
+ };
151
+ export const inflate = (flat, { depth, roundness = 1 }) => {
152
+ let deepest = 0;
153
+ const probes = 128;
154
+ for (let row = 0; row < probes; row++) {
155
+ for (let column = 0; column < probes; column++) {
156
+ deepest = Math.max(deepest, -flat.distance(((column + 0.5) / probes) * flat.width, ((row + 0.5) / probes) * flat.height));
157
+ }
158
+ }
159
+ const reach = Math.max(deepest * Math.min(Math.max(roundness, 0.01), 1), 1e-6);
160
+ const middle = depth / 2;
161
+ return {
162
+ depth,
163
+ distance: (x, y, z = middle) => {
164
+ const across = flat.distance(x, y);
165
+ const inset = Math.min(Math.max(-across, 0) / reach, 1);
166
+ const half = middle * Math.sqrt(1 - (1 - inset) ** 2);
167
+ return Math.max(across, Math.abs(z - middle) - half);
168
+ },
169
+ height: flat.height,
170
+ width: flat.width,
171
+ };
172
+ };
@@ -0,0 +1,50 @@
1
+ import type { GraphPoint } from './seed.js';
2
+ import { type ShapeField } from './shape-field.js';
3
+ /**
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.
8
+ */
9
+ export type GraphStencil = (ShapeField | {
10
+ height: number;
11
+ path: string;
12
+ width: number;
13
+ } | {
14
+ height: number;
15
+ inside: (x: number, y: number) => boolean;
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;
26
+ };
27
+ /** The field a stencil is, or is made into. */
28
+ export declare const fieldOf: (stencil: GraphStencil) => ShapeField;
29
+ /**
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.
34
+ */
35
+ export declare const placesIn: (field: ShapeField, count: number, { place, shell }?: {
36
+ place?: 'fill' | 'surface';
37
+ shell?: number;
38
+ }) => GraphPoint[];
39
+ /**
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.
44
+ *
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.
49
+ */
50
+ export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => Map<string, GraphPoint>;