@fusion.dev/helix-graph 0.120.0 → 0.121.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
@@ -1,6 +1,6 @@
1
1
  # @fusion.dev/helix-graph
2
2
 
3
- Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, or a
3
+ Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, cosmos.gl, or a
4
4
  driver of your own, colored from your tokens.
5
5
 
6
6
  ```sh
@@ -75,8 +75,9 @@ 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 traveling from one to the
79
- other and arriving with a pulse, in the tone's color, over the graph and without
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
80
81
  redrawing it, so a busy feed costs a dot each. A reader who asks for less motion
81
82
  sees the link lit for a moment instead.
82
83
 
@@ -88,27 +89,159 @@ frame.signal('Payments', 'Fraud review', { tone: 'danger' })
88
89
  A signal is drawn and never said: a screen reader does not hear it. Where what
89
90
  passed matters to a reader, say it in the page's own words as well.
90
91
 
92
+ ## How notes look
93
+
94
+ Each note can say how it is drawn, in the words Helix's other components use:
95
+
96
+ | Field | Values | What it does |
97
+ |---|---|---|
98
+ | `shape` | `circle`, `square`, `diamond`, `ring` | What kind of note it is at a glance |
99
+ | `emphasis` | `solid`, `outlined`, `subtle` | Filled, a ring with the ground showing through, or a tinted fill with a ring |
100
+ | `material` | `glass`, `frosted` | A shaded sphere with a highlight and a lit rim, or a milky body with a soft fall of light |
101
+ | `tone` | `primary`, `accent`, `info`, `success`, `warning`, `danger`, `neutral` | What it means, which colors it over its group |
102
+
103
+ ```js
104
+ frame.nodes = [
105
+ { id: 'Roadmap', group: 'Product', shape: 'square' },
106
+ { id: 'Outage 14', group: 'Support', tone: 'danger', emphasis: 'outlined' },
107
+ ]
108
+ ```
109
+
110
+ `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
112
+ a ring and a glow in that color.
113
+
114
+ **A note without a `size` is as large as it is connected**, so hubs read at a
115
+ glance. Links are tinted by the note they leave, the selected note's and the
116
+ hovered note's in their color, and choosing a note in the list frames it with
117
+ its neighbors.
118
+
119
+ **Notes are named on hover**, and the selected note and its neighbors where
120
+ there is room, each name ringed in the ground's color so it reads over the
121
+ links. Each group's name is drawn over its notes, which is how a reader finds
122
+ their way. `labels: 'hubs'` names the busiest notes at rest as well, quietly,
123
+ and `labels: 'all'` every note where its name does not cover another.
124
+
125
+ ## Large graphs
126
+
127
+ **Past fifty thousand notes, draw with cosmos.gl**, which lays the graph out
128
+ and draws it on the GPU. The frame, the list, selection and signals are the
129
+ same; only the driver changes:
130
+
131
+ ```sh
132
+ bun add @cosmos.gl/graph
133
+ ```
134
+
135
+ ```js
136
+ import { Graph } from '@cosmos.gl/graph'
137
+ import { connectGraph } from '@fusion.dev/helix-graph'
138
+ import { cosmosDriver } from '@fusion.dev/helix-graph/cosmos'
139
+
140
+ const box = document.getElementById('drawing')
141
+ const graph = new Graph(box, { attribution: '', enableSimulation: true, scalePointsOnZoom: false })
142
+ connectGraph(frame, cosmosDriver(graph, box))
143
+ ```
144
+
145
+ cosmos.gl draws notes with its own shaders, so a note's `shape` is honored and
146
+ its `material` and `emphasis` are not. Each group is given a place of its own
147
+ and its notes are drawn toward it, so twenty thousand notes in twelve groups
148
+ come apart into twelve regions. The view follows the layout as it opens out,
149
+ until the reader moves it. Zoomed out, a note is a fine point and a leaf is
150
+ drawn partly clear, so a group reads as a field of points with its hubs
151
+ standing out; notes grow as the reader zooms in.
152
+
153
+ **cosmos.gl's own start reports a `ResizeObserver loop` error**, which is
154
+ harmless and shows in the console: it observes its canvas from inside
155
+ luma.gl's resize callback. Make the GPU device yourself and hand it over a
156
+ frame after its canvas is measured, and cosmos.gl starts outside any callback:
157
+
158
+ ```js
159
+ import { luma } from '@luma.gl/core'
160
+ import { webgl2Adapter } from '@luma.gl/webgl'
161
+
162
+ const canvas = Object.assign(document.createElement('canvas'), {
163
+ style: 'width:100%;height:100%',
164
+ })
165
+ box.append(canvas)
166
+ const device = luma
167
+ .createDevice({
168
+ adapters: [webgl2Adapter],
169
+ createCanvasContext: { autoResize: true, canvas, useDevicePixels: true },
170
+ type: 'webgl',
171
+ webgl: { antialias: false },
172
+ })
173
+ .then(async (made) => {
174
+ await made.canvasContext?.initialized
175
+ await new Promise((done) => requestAnimationFrame(done))
176
+ return made
177
+ })
178
+ const graph = new Graph(box, { attribution: '', enableSimulation: true }, device)
179
+ ```
180
+
181
+ Use the `@luma.gl` version `@cosmos.gl/graph` depends on, since two versions
182
+ on one page throw, and destroy the device after the graph.
183
+
184
+ **The driver is for `@cosmos.gl/graph`, which is MIT.** Cosmograph, the
185
+ product built on it, is licensed for noncommercial use only.
186
+
187
+ ## Shapes
188
+
189
+ **`stencil` draws the notes in a shape**, such as a brain or a country's
190
+ outline. Pass an SVG path and the box it is drawn in, as the SVG's `viewBox`
191
+ gives it:
192
+
193
+ ```js
194
+ connectGraph(frame, driver, {
195
+ stencil: { path: brain.getAttribute('d'), width: 220, height: 170 },
196
+ })
197
+ ```
198
+
199
+ No layout runs, so the shape is there from the first frame. Each group takes
200
+ a region of the shape, each note sits by the hub it links to, and the notes
201
+ fill the shape evenly, with borders between regions that curve. Holes are cut
202
+ by the even-odd rule, which is how the grooves of a brain show. Only a graph
203
+ with no places of its own is shaped; a note added later is placed by the
204
+ layout beside its neighbors. `stencil` also takes `inside(x, y)`, a test of
205
+ whether a point in the box is in the shape, in place of `path`, and
206
+ `fitToStencil` fits places a page has into one.
207
+
91
208
  ## What it exports
92
209
 
93
210
  | Import | What |
94
211
  |---|---|
95
- | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver` and `GraphChanges` types |
212
+ | `@fusion.dev/helix-graph` | `connectGraph`, `diffGraph`, `fitToStencil`, `graphColorsFrom`, `seedPositions`, and the `GraphDriver`, `GraphChanges` and `GraphStencil` types |
96
213
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)` |
214
+ | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)` |
97
215
 
98
- **Neither Sigma nor graphology is a dependency.** The driver types the renderer
99
- by the calls it makes on it, so a page brings whichever version it has. A spec
100
- holds those shapes to the real classes, so a renamed method fails a typecheck
101
- here rather than a page.
216
+ **Sigma and cosmos.gl are optional peers, and graphology is the page's.** Each
217
+ driver types its renderer by the calls it makes on it, so a page brings
218
+ whichever version it has, and a spec holds those shapes to the real classes,
219
+ so a renamed method fails a typecheck here before it reaches a page. `@fusion.dev/helix-graph/sigma` imports
220
+ Sigma's rendering module for the program that draws the notes, which is why
221
+ Sigma 3 is a peer dependency.
102
222
 
103
223
  | `connectGraph` option | Default | What else it takes |
104
224
  |---|---|---|
105
225
  | `center` | `true` | `false` leaves the view where it is when a note is chosen in the list |
226
+ | `stencil` | none | A shape to draw the notes in: `{ path, width, height }` or `{ inside, width, height }` |
106
227
 
107
228
  | `sigmaDriver` option | Default | What else it takes |
108
229
  |---|---|---|
109
230
  | `layout` | none | A function that makes a layout for the graph, such as ForceAtlas2's worker |
231
+ | `material` | flat | `glass` or `frosted`, for every note that does not say |
232
+ | `shape` | `circle` | `square`, `diamond` or `ring`, for every note that does not say |
233
+ | `emphasis` | `solid` | `outlined` or `subtle`, for every note that does not say |
234
+ | `labels` | `'hover'` | `'hubs'` or `'all'`: which notes are named at rest |
235
+ | `groupLabels` | `true` | `false` leaves each group unnamed |
110
236
  | `layoutLimit` | `10000` | The longest a layout runs, in milliseconds, about where 10,000 notes settle |
111
237
 
238
+ | `cosmosDriver` option | Default | What else it takes |
239
+ |---|---|---|
240
+ | `shape` | `circle` | `square` or `diamond`, for every note that does not say |
241
+ | `groupLabels` | `true` | `false` leaves each group unnamed |
242
+ | `layoutLimit` | `15000` | The longest a layout runs, in milliseconds |
243
+ | `spaceSize` | `4096` | The size of cosmos.gl's space, as its own `spaceSize` says |
244
+
112
245
  ## Working on it
113
246
 
114
247
  ```sh
@@ -0,0 +1,18 @@
1
+ import type { GraphPoint, SeedLink } from './seed.js';
2
+ /** A note as the arrangement reads it: its group, if it has one. */
3
+ export interface ArrangedNode {
4
+ group?: string | undefined;
5
+ id: string;
6
+ }
7
+ /**
8
+ * Places for every note from the graph's structure alone, with no layout to
9
+ * wait for: **each group around a circle, each hub in its group, and each
10
+ * note beside the hub it hangs from**, the most connected note it links to
11
+ * in its group.
12
+ *
13
+ * Not a layout to look at, since fans overlap and nothing is pushed apart:
14
+ * it is where a stencil starts, which reads only which notes are near which,
15
+ * so the shape is drawn from the first frame with each group a region and
16
+ * each hub's notes around it.
17
+ */
18
+ export declare const arrangeByGroup: (nodes: readonly ArrangedNode[], links: readonly SeedLink[]) => Map<string, GraphPoint>;
@@ -0,0 +1,92 @@
1
+ const TURN = Math.PI * (3 - Math.sqrt(5));
2
+ const spiral = (at, spacing) => ({
3
+ x: Math.cos(at * TURN) * Math.sqrt(at + 0.5) * spacing,
4
+ y: Math.sin(at * TURN) * Math.sqrt(at + 0.5) * spacing,
5
+ });
6
+ export const arrangeByGroup = (nodes, links) => {
7
+ const groupOf = new Map(nodes.map((node) => [node.id, node.group ?? '']));
8
+ const degree = new Map();
9
+ const neighbors = new Map();
10
+ const add = (from, to) => {
11
+ degree.set(from, (degree.get(from) ?? 0) + 1);
12
+ const list = neighbors.get(from);
13
+ if (list) {
14
+ list.push(to);
15
+ }
16
+ else {
17
+ neighbors.set(from, [to]);
18
+ }
19
+ };
20
+ for (const { source, target } of links) {
21
+ if (groupOf.has(source) && groupOf.has(target)) {
22
+ add(source, target);
23
+ add(target, source);
24
+ }
25
+ }
26
+ const rank = (id) => degree.get(id) ?? 0;
27
+ const above = (one, other) => rank(one) > rank(other) || (rank(one) === rank(other) && one < other);
28
+ const parent = new Map();
29
+ for (const { id } of nodes) {
30
+ let best = id;
31
+ for (const near of neighbors.get(id) ?? []) {
32
+ if (groupOf.get(near) === groupOf.get(id) && above(near, best)) {
33
+ best = near;
34
+ }
35
+ }
36
+ parent.set(id, best);
37
+ }
38
+ const hubOf = (id) => {
39
+ let at = id;
40
+ while (parent.get(at) !== at) {
41
+ at = parent.get(at);
42
+ }
43
+ let step = id;
44
+ while (step !== at) {
45
+ const next = parent.get(step);
46
+ parent.set(step, at);
47
+ step = next;
48
+ }
49
+ return at;
50
+ };
51
+ const groups = new Map();
52
+ for (const { id } of nodes) {
53
+ const group = groupOf.get(id);
54
+ const hubs = groups.get(group) ?? new Map();
55
+ groups.set(group, hubs);
56
+ const hub = hubOf(id);
57
+ const members = hubs.get(hub) ?? [];
58
+ hubs.set(hub, members);
59
+ if (hub !== id) {
60
+ members.push(id);
61
+ }
62
+ }
63
+ const radiusOf = (hubs) => {
64
+ let count = 0;
65
+ for (const members of hubs.values()) {
66
+ count += members.length + 1;
67
+ }
68
+ return Math.sqrt(count) * 1.5;
69
+ };
70
+ const radii = [...groups.values()].map(radiusOf);
71
+ const widest = Math.max(0, ...radii);
72
+ const ring = groups.size > 1 ? (widest * 1.2) / Math.sin(Math.PI / groups.size) : 0;
73
+ const positions = new Map();
74
+ [...groups.values()].forEach((hubs, at) => {
75
+ const turn = (at / groups.size) * Math.PI * 2;
76
+ const middle = { x: Math.cos(turn) * ring, y: Math.sin(turn) * ring };
77
+ const ordered = [...hubs].sort(([, one], [, other]) => other.length - one.length);
78
+ const mean = ordered.reduce((sum, [, members]) => sum + members.length + 1, 0) / ordered.length;
79
+ ordered.forEach(([hub, members], place) => {
80
+ const spot = spiral(place, Math.sqrt(mean) * 2);
81
+ const center = { x: middle.x + spot.x, y: middle.y + spot.y };
82
+ positions.set(hub, center);
83
+ members
84
+ .sort((one, other) => rank(other) - rank(one))
85
+ .forEach((member, step) => {
86
+ const off = spiral(step + 1, 1);
87
+ positions.set(member, { x: center.x + off.x, y: center.y + off.y });
88
+ });
89
+ });
90
+ });
91
+ return positions;
92
+ };
@@ -2,6 +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
6
  export type { DrawnLink, DrawnNode, GraphChanges } from './diff.js';
6
7
  /**
7
8
  * What a renderer has to answer for the frame to drive it, and nothing that is
@@ -30,12 +31,16 @@ export interface GraphDriver {
30
31
  */
31
32
  update?(changes: GraphChanges, animate: boolean): void;
32
33
  /**
33
- * Draws something passing from one note to another, in `color`, traveling
34
- * the way it went and arriving with a pulse, or, where `animate` is false,
35
- * the link between them lit for a moment. `color` is undefined while the
36
- * page's colors cannot be read.
34
+ * Draws something passing from one note to another, traveling the way it
35
+ * went and arriving with a pulse, or, where `animate` is false, the link
36
+ * between them lit for a moment. It leaves in `colors.from` and arrives in
37
+ * `colors.to`, which are one color where the signal has a tone. `colors` is
38
+ * undefined while the page's colors cannot be read.
37
39
  */
38
- signal?(from: string, to: string, color: string | undefined, animate: boolean): void;
40
+ signal?(from: string, to: string, colors: {
41
+ from: string;
42
+ to: string;
43
+ } | undefined, animate: boolean): void;
39
44
  /**
40
45
  * How each note and link is drawn from now on, asked one at a time as the
41
46
  * renderer draws them: a selection or a theme changed.
@@ -47,6 +52,11 @@ export interface GraphDriver {
47
52
  zoomBy(direction: 1 | -1, animate: boolean): void;
48
53
  /** Brings a note into the middle of the view, where the renderer can. */
49
54
  center?(id: string, animate: boolean): void;
55
+ /**
56
+ * Frames a set of notes, the selected one and its neighbors, in the middle
57
+ * of the view at a zoom that holds them all, where the renderer can.
58
+ */
59
+ fit?(ids: readonly string[], animate: boolean): void;
50
60
  /** Calls `press` with a note's `id` when the reader presses it in the drawing. Returns what stops listening. */
51
61
  onPress?(press: (id: string) => void): () => void;
52
62
  /**
@@ -65,9 +75,26 @@ export interface GraphDriver {
65
75
  gestures?(mode: ViewportGestureMode): void;
66
76
  /** The element the renderer made focusable, which the connector takes out of the tab order. */
67
77
  focusTarget?(): HTMLElement | null;
78
+ /**
79
+ * The graph's groups, each named over its notes, which is how a reader
80
+ * finds their way while notes are named only on hover. `faded` while a note
81
+ * outside the group is selected.
82
+ */
83
+ groups?(groups: readonly GraphGroup[]): void;
68
84
  /** Removes whatever the driver added to the renderer or the page, when the connection ends. */
69
85
  stop?(): void;
70
86
  }
87
+ /** One group of notes, as the renderer names it over them. */
88
+ export interface GraphGroup {
89
+ /** The group's color, which its name is drawn in. */
90
+ color: string;
91
+ /** Whether a note outside the group is selected, which steps its name back. */
92
+ faded: boolean;
93
+ /** The notes in the group, by `id`. */
94
+ ids: readonly string[];
95
+ /** The group's name, as the notes give it. */
96
+ name: string;
97
+ }
71
98
  /** How a connection behaves where the page does not say. */
72
99
  export interface ConnectGraphOptions {
73
100
  /**
@@ -76,6 +103,14 @@ export interface ConnectGraphOptions {
76
103
  * reader is already looking at it.
77
104
  */
78
105
  center?: boolean | undefined;
106
+ /**
107
+ * A shape the notes are drawn in, such as a brain or a country's outline:
108
+ * an SVG path and the box it is drawn in. **No layout runs**: each group
109
+ * takes a region of the shape and each note sits by the hub it links to,
110
+ * from the first frame. Only a graph with no places of its own is shaped;
111
+ * a note added later is placed by the layout beside its neighbors.
112
+ */
113
+ stencil?: GraphStencil | undefined;
79
114
  }
80
115
  /** A connection, which is ended rather than left to leak. */
81
116
  export interface GraphConnection {
@@ -1,16 +1,13 @@
1
1
  import { HelixGraphPanEvent, HelixGraphPartChangeEvent, HelixGraphSignalEvent, HelixGraphZoomEvent, isRtl, watchTheme, } from '@fusion.dev/helix-core';
2
+ import { arrangeByGroup } from './arrange.js';
2
3
  import { diffGraph, isEmpty, linkKey, } from './diff.js';
3
- import { graphColorsFrom, looksOf } from './looks.js';
4
+ import { graphColorsFrom, looksOf, toneColor, } from './looks.js';
4
5
  import { seedPositions } from './seed.js';
6
+ import { fitToStencil } from './stencil.js';
5
7
  const viewportOf = (frame) => {
6
8
  const box = (frame.shadowRoot?.querySelector('[part="viewport"]') ?? frame).getBoundingClientRect();
7
9
  return { height: box.height, width: box.width };
8
10
  };
9
- const toneColor = (colors, tone) => tone === undefined || tone === 'primary'
10
- ? colors.primary
11
- : tone === 'neutral'
12
- ? colors.muted
13
- : colors[tone];
14
11
  const still = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
15
12
  export const connectGraph = (frame, driver, options = {}) => {
16
13
  const graph = frame;
@@ -19,7 +16,9 @@ export const connectGraph = (frame, driver, options = {}) => {
19
16
  let drawnLinks = new Map();
20
17
  let first = true;
21
18
  let waiting = new Set();
19
+ let owed = false;
22
20
  let colors = null;
21
+ let current;
23
22
  let stopSettling;
24
23
  let pressed;
25
24
  let pending = 0;
@@ -43,19 +42,42 @@ export const connectGraph = (frame, driver, options = {}) => {
43
42
  }
44
43
  }
45
44
  }
46
- const groups = new Map(nodes.map((node) => [node.id, node.group]));
47
- driver.look(looksOf({
45
+ const notes = new Map(nodes.map((node) => [node.id, node]));
46
+ current = looksOf({
48
47
  colors,
49
48
  font: getComputedStyle(frame)
50
49
  .fontFamily.split(',')[0]
51
50
  ?.trim()
52
51
  .replace(/^["']|["']$/g, ''),
53
- groupOf: (id) => groups.get(id),
54
52
  groups: nodes.flatMap((node) => (node.group === undefined ? [] : [node.group])),
55
53
  near,
54
+ noteOf: (id) => notes.get(id),
56
55
  selected,
57
56
  shown: graph.shown,
58
- }));
57
+ });
58
+ driver.look(current);
59
+ if (driver.groups) {
60
+ const members = new Map();
61
+ for (const node of nodes) {
62
+ if (node.group !== undefined) {
63
+ const list = members.get(node.group);
64
+ if (list) {
65
+ list.push(node.id);
66
+ }
67
+ else {
68
+ members.set(node.group, [node.id]);
69
+ }
70
+ }
71
+ }
72
+ const own = selected === undefined ? undefined : notes.get(selected)?.group;
73
+ const looks = current;
74
+ driver.groups([...members].map(([name, ids]) => ({
75
+ color: looks.colorOf(ids.find((id) => notes.get(id)?.tone === undefined) ?? ids[0] ?? ''),
76
+ faded: selected !== undefined && own !== name,
77
+ ids,
78
+ name,
79
+ })));
80
+ }
59
81
  };
60
82
  const draw = (whole = false) => {
61
83
  if (stopSettling) {
@@ -71,14 +93,29 @@ export const connectGraph = (frame, driver, options = {}) => {
71
93
  nextLinks.set(linkKey({ source, target }), { label, source, target });
72
94
  }
73
95
  }
74
- const { positions, unplaced } = seedPositions(nodes, [...nextLinks.values()], known);
96
+ 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()]), options.stencil)
99
+ : undefined;
100
+ if (shaped) {
101
+ positions = shaped;
102
+ unplaced = new Set();
103
+ known = new Map(shaped);
104
+ }
105
+ const degree = new Map();
106
+ for (const { source, target } of nextLinks.values()) {
107
+ degree.set(source, (degree.get(source) ?? 0) + 1);
108
+ degree.set(target, (degree.get(target) ?? 0) + 1);
109
+ }
75
110
  const nextNodes = new Map();
76
111
  for (const node of nodes) {
77
112
  const at = positions.get(node.id);
78
113
  nextNodes.set(node.id, {
79
114
  id: node.id,
80
115
  label: node.label || node.id,
81
- size: Number.isFinite(node.size) ? node.size : 1,
116
+ size: Number.isFinite(node.size)
117
+ ? node.size
118
+ : 1 + Math.min(Math.sqrt(degree.get(node.id) ?? 0), 6),
82
119
  x: at.x,
83
120
  y: at.y,
84
121
  });
@@ -103,7 +140,9 @@ export const connectGraph = (frame, driver, options = {}) => {
103
140
  drawnLinks = nextLinks;
104
141
  known = positions;
105
142
  restyle();
106
- const opening = first || whole;
143
+ if (first || whole) {
144
+ owed = true;
145
+ }
107
146
  first = false;
108
147
  if (unplaced.size > 0 && driver.settle) {
109
148
  waiting = unplaced;
@@ -118,16 +157,21 @@ export const connectGraph = (frame, driver, options = {}) => {
118
157
  }
119
158
  }
120
159
  graph.laidOut?.(Object.fromEntries([...settled].map(([id, at]) => [id, { ...at }])));
121
- if (opening) {
160
+ if (owed) {
161
+ owed = false;
122
162
  graph.drawn?.(true);
123
163
  }
124
164
  });
125
165
  return;
126
166
  }
127
167
  waiting = new Set();
128
- if (opening) {
168
+ if (shaped) {
169
+ graph.laidOut?.(Object.fromEntries([...shaped].map(([id, at]) => [id, { ...at }])));
170
+ }
171
+ if (owed) {
129
172
  pending = requestAnimationFrame(() => {
130
- if (connected) {
173
+ if (connected && owed) {
174
+ owed = false;
131
175
  graph.drawn?.(true);
132
176
  }
133
177
  });
@@ -139,7 +183,12 @@ export const connectGraph = (frame, driver, options = {}) => {
139
183
  return;
140
184
  }
141
185
  colors ??= graphColorsFrom(frame);
142
- driver.signal(from, to, colors ? toneColor(colors, tone) : undefined, !still());
186
+ const toned = tone !== undefined && colors ? toneColor(colors, tone) : undefined;
187
+ driver.signal(from, to, toned
188
+ ? { from: toned, to: toned }
189
+ : current
190
+ ? { from: current.colorOf(from), to: current.colorOf(to) }
191
+ : undefined, !still());
143
192
  };
144
193
  const handlePartChange = (event) => {
145
194
  const { kind } = event.detail;
@@ -150,7 +199,21 @@ export const connectGraph = (frame, driver, options = {}) => {
150
199
  restyle();
151
200
  const selected = graph.active;
152
201
  if (selected && selected !== pressed && options.center !== false) {
153
- driver.center?.(selected, !still());
202
+ const near = [selected];
203
+ for (const { source, target } of drawnLinks.values()) {
204
+ if (source === selected) {
205
+ near.push(target);
206
+ }
207
+ else if (target === selected) {
208
+ near.push(source);
209
+ }
210
+ }
211
+ if (driver.fit && near.length > 1) {
212
+ driver.fit(near, !still());
213
+ }
214
+ else {
215
+ driver.center?.(selected, !still());
216
+ }
154
217
  }
155
218
  pressed = undefined;
156
219
  }
@@ -2,7 +2,7 @@
2
2
  export interface DrawnNode {
3
3
  id: string;
4
4
  label: string;
5
- /** How large it is drawn, relative to the others: the note's `size`, or 1. */
5
+ /** How large it is drawn, relative to the others: the note's `size`, or from how many links it has. */
6
6
  size: number;
7
7
  x: number;
8
8
  y: number;
@@ -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;