@stealthscale/component-graphs 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stealth Scale B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,636 @@
1
+ # @stealthscale/component-graphs
2
+
3
+ `@stealthscale/component-graphs` renders node graphs over React Flow. `Graph` renders a figure
4
+ around a canvas of cards with named ports, edges a person can remove, a toolbar that zooms and fits
5
+ the view, an overview map and a caption. An editable canvas reports each finished edit as the graph
6
+ after it, and `Graph.PaletteItem` adds a node by a drag onto the canvas or by a press.
7
+ `DirectedGraph` renders a directed graph from the caller's own nodes and edges: a focused node
8
+ traces what feeds it and what it feeds, and a branch opens and closes with the count of what it
9
+ hides. `NetworkGraph` renders a network of discs sized by weight, where a focused node lights the
10
+ nodes it connects to. `diffGraphs` compares two versions of a graph, and a canvas marks the changes
11
+ on its cards and edges. `layoutGraph` places a graph's nodes in ranks with dagre, so its edges run
12
+ down the canvas or across it, and `layoutForce` places them with d3-force. The preset under
13
+ `./theme` registers the recipe with an application's compiler.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @stealthscale/component-graphs
19
+ ```
20
+
21
+ The package depends on `@xyflow/react` 12.12.0, `@dagrejs/dagre` 3.1.1 and `d3-force` 3.0.0, each
22
+ pinned exactly, because the recipe restyles the classes React Flow writes and the layouts' pictures
23
+ depend on the engines' arithmetic. It depends on `@stealthscale/component-a11y` for the toolbar's
24
+ roving focus, `@stealthscale/component-actions` for its buttons and `@stealthscale/component-data`
25
+ for a node's status and relation. It peers on `react`, `react-dom`, `@stealthscale/theme`,
26
+ `@stealthscale/hooks` and `@stealthscale/provider-locale`. The package does not import a stylesheet.
27
+
28
+ ## Graph
29
+
30
+ ```tsx
31
+ import { MaximizeIcon, MinusIcon, PlusIcon } from "lucide-react";
32
+
33
+ import { Graph, layoutGraph } from "@stealthscale/component-graphs";
34
+
35
+ const nodes = layoutGraph(steps, links, { ranksep: 64 });
36
+
37
+ <Graph.Root>
38
+ <Graph.Controls label="Pipeline view">
39
+ <Graph.Control action="zoomIn" label="Zoom in">
40
+ <PlusIcon />
41
+ </Graph.Control>
42
+ <Graph.Control action="zoomOut" label="Zoom out">
43
+ <MinusIcon />
44
+ </Graph.Control>
45
+ <Graph.Control action="fit" label="Fit the graph in view">
46
+ <MaximizeIcon />
47
+ </Graph.Control>
48
+ <Graph.ZoomLevel />
49
+ </Graph.Controls>
50
+ <Graph.Canvas edges={links} label="Nightly pipeline" nodes={nodes} readOnly />
51
+ <Graph.Caption>The churn model failed after the join of orders and customers.</Graph.Caption>
52
+ </Graph.Root>;
53
+ ```
54
+
55
+ | Part | Element | What it renders |
56
+ | ------------- | ------------ | -------------------------------------------------------------------------------------- |
57
+ | `Root` | `figure` | The figure inside React Flow's provider. It takes `direction` and the ratio |
58
+ | `Canvas` | `div` | React Flow in a panel as tall as the ratio, with the kit's nodes and edges |
59
+ | `Controls` | `div` | A toolbar above the canvas, one tab stop whose arrow keys move between its controls |
60
+ | `Control` | `button` | Zooms in, zooms out or fits the graph in view, by `action`, with the caller's glyph |
61
+ | `ZoomLevel` | `output` | The canvas's zoom as a percentage in `locale`, else the provider's, else the runtime's |
62
+ | `MiniMap` | `div` | React Flow's minimap below the canvas at the row's end, which pans and zooms the view |
63
+ | `Summary` | `div` | A row below the canvas for what the selection means, which wraps at a narrow width |
64
+ | `Empty` | `div` | A message in the canvas's place at the canvas's ratio, in a box with a dashed edge |
65
+ | `Caption` | `figcaption` | The finding in words, which is the figure's accessible name |
66
+ | `PaletteItem` | `button` | A button that adds one kind of node, by a drag onto the canvas or by a press |
67
+ | `Node` | `div` | A card for a custom node type: its header, body, problem line, tag, branch and ports |
68
+ | `LabelNode` | `div` | The kit's card for React Flow's `default`, `input` and `output` types, from the data |
69
+ | `Edge` | `g` | React Flow's bezier between two ports and, on an editable canvas, its remove control |
70
+
71
+ `Graph.nodeTypes` and `Graph.edgeTypes` map React Flow's built-in types to `LabelNode` and `Edge`. A
72
+ caller that adds a type spreads them into its own map, so the built-in types keep the kit's look.
73
+
74
+ | Axis | Values | Default |
75
+ | ------- | ------------------------------------------------------------------------- | ------- |
76
+ | `ratio` | `square`, `landscape`, `portrait`, `golden`, `video`, `wide`, `ultrawide` | `video` |
77
+
78
+ React Flow renders nothing in a box without a height. The ratio gives the canvas its height before
79
+ React Flow measures it. `Graph.Empty` takes the same ratio and keeps a figure's height while it has
80
+ no node.
81
+
82
+ ### The canvas
83
+
84
+ | Prop | Takes | Default |
85
+ | ------------------ | ---------------------------------------------------------------------------- | ----------------------------------- |
86
+ | `label` | The canvas's accessible name | Required |
87
+ | `nodes`, `edges` | The graph React Flow renders, controlled | None |
88
+ | `readOnly` | Whether the graph is read and not edited | `false` |
89
+ | `onGraphChange` | Called with the graph after each finished edit and the step that names it | None |
90
+ | `onDropItem` | Called with a palette item's id and the point a person dropped it at | None |
91
+ | `removeGlyph` | The glyph of every edge's remove control | None |
92
+ | `removeName` | Writes the name of an edge's remove control from the names of its ends | `Remove the connection from A to B` |
93
+ | `background` | Whether the dotted background renders | `true` |
94
+ | `edgeName` | Writes an edge's name from the names of its ends | `Source to Target` |
95
+ | `nodeDescription` | The instruction every node of an editable canvas is described by | The keys it takes |
96
+ | `edgeDescription` | The instruction every edge of an editable canvas is described by | The keys it takes |
97
+ | `moveAnnouncement` | Writes the announcement of a move the arrow keys make | The direction |
98
+ | `changes` | A comparison whose changes the canvas marks, such as `diffGraphs`'s result | None |
99
+ | `addedLabel` | The word of an added node or edge | `Added` |
100
+ | `changedLabel` | The word of a changed node | `Changed` |
101
+ | `removedLabel` | The word of a removed node or edge | `Removed` |
102
+ | `children` | Elements React Flow renders over the canvas, such as a panel of the caller's | None |
103
+
104
+ - The canvas passes every other prop to React Flow. A caller's prop applies over the canvas's own
105
+ switches.
106
+ - The canvas is controlled. React Flow reports each change a person makes through `onNodesChange`,
107
+ `onEdgesChange` and `onConnect`, and the caller applies it with `applyNodeChanges`,
108
+ `applyEdgeChanges` and `addEdge`, or keeps the graph in `useNodesState` and `useEdgesState`.
109
+ - Delete and Backspace remove the selection.
110
+ - `readOnly` keeps panning, zooming and each node's tab stop. It turns off selecting, dragging,
111
+ connecting, the keys that move and remove, the edges' tab stops and the remove controls.
112
+
113
+ The view fits the graph on the first render at most at its own size and down to 10%. The fit keeps
114
+ 24px clear at either side and 32px at the top and the bottom: the top for a tag above a card, and
115
+ the bottom for React Flow's attribution. The toolbar's fit control frames it the same way.
116
+
117
+ ### Editing
118
+
119
+ ```tsx
120
+ import { type XYPosition, useEdgesState, useNodesState } from "@xyflow/react";
121
+
122
+ import { Graph } from "@stealthscale/component-graphs";
123
+
124
+ const [nodes, setNodes, onNodesChange] = useNodesState(steps);
125
+ const [edges, setEdges, onEdgesChange] = useEdgesState(links);
126
+
127
+ function add(kind: string, position: XYPosition) {
128
+ const node = { data: { label: names[kind] }, id: nextId(kind), origin: [0.5, 0.5], position };
129
+
130
+ setNodes([...nodes, node]);
131
+ }
132
+
133
+ <Graph.Root>
134
+ <Graph.PaletteItem item="fraud" onAdd={add}>
135
+ Add a fraud check
136
+ </Graph.PaletteItem>
137
+ <Graph.Canvas
138
+ edges={edges}
139
+ label="Order workflow"
140
+ nodes={nodes}
141
+ onDropItem={add}
142
+ onEdgesChange={onEdgesChange}
143
+ onGraphChange={(graph, step) => {
144
+ remember(graph, step);
145
+ setNodes(graph.nodes);
146
+ setEdges(graph.edges);
147
+ }}
148
+ onNodesChange={onNodesChange}
149
+ />
150
+ </Graph.Root>;
151
+ ```
152
+
153
+ `onGraphChange(graph, step)` reports each finished edit once. `graph` is `{ nodes, edges }` after
154
+ the edit, and `step` is `{ type, nodes, edges }` with the ids of the nodes and edges the edit
155
+ touched:
156
+
157
+ | `type` | Reported when | `nodes` and `edges` |
158
+ | --------- | ------------------------------------------------------------------ | ------------------------------------ |
159
+ | `connect` | A connection adds an edge | The new edge |
160
+ | `move` | A drag ends, or an arrow key moves the selected nodes | The moved nodes |
161
+ | `remove` | Delete, Backspace or an edge's remove control removes the elements | The removed nodes and the edges gone |
162
+
163
+ - A drag reports its step when it ends. A selection or a measurement is not an edit.
164
+ - The canvas does not report a connection that repeats an edge the graph has already.
165
+ - A removed node takes its edges with it, and the step lists them.
166
+ - The canvas calls the caller's `onConnect`, `onDelete` and `onNodesChange` before it reports, so a
167
+ caller that applies React Flow's changes keeps doing so.
168
+ - `graph` is the canvas's `nodes` and `edges` with the edit applied.
169
+
170
+ `Graph.PaletteItem` is the actions `Button` in the outline look, for one kind of node:
171
+
172
+ - A drag writes the item's id into the drag's data. The canvas's `onDropItem(item, position)`
173
+ reports the point where the pointer released it, in the graph's coordinates through React Flow's
174
+ pan and zoom. A drop without a palette item, such as a file, is left to the browser.
175
+ - A press, Enter or Space calls `onAdd(item, position)` with the middle of what the canvas shows, so
176
+ a person without a pointer adds a node too. The item renders inside `Graph.Root`.
177
+ - The caller adds the node. A node whose `origin` is `[0.5, 0.5]` is centred on the point.
178
+
179
+ ### Nodes
180
+
181
+ `LabelNode` renders a node's data:
182
+
183
+ | Data field | Takes | Default |
184
+ | ---------- | ---------------------------------------------------------- | ----------------------------------- |
185
+ | `label` | The title, which is also the node's accessible name | Required |
186
+ | `subtitle` | A line under the title, such as the kind of step | None |
187
+ | `detail` | A line in the card's body | None |
188
+ | `icon` | A glyph before the title, hidden from assistive technology | None |
189
+ | `status` | `{ label, palette }`: a dot in one of the eight palettes | None |
190
+ | `invalid` | Whether the card is edged in the error ink | `false` |
191
+ | `problem` | The fault of an invalid node in one line under the body | None |
192
+ | `inputs` | `{ id, label, disabled, unused }` per port edges enter | `In`, and none on an `input` node |
193
+ | `outputs` | `{ id, label, disabled, unused }` per port edges leave | `Out`, and none on an `output` node |
194
+
195
+ A caller's own node type renders `Graph.Node` with the same props: `title`, `subtitle`, `icon`,
196
+ `status`, `actions`, `children` for the body, `invalid`, `problem`, `dimmed`, `tag`, `branch`,
197
+ `inputs`, `outputs` and its own `direction`.
198
+
199
+ ```tsx
200
+ function Step({ data }: NodeProps<StepNode>) {
201
+ return (
202
+ <Graph.Node actions={<StepMenu step={data.step} />} subtitle={data.kind} title={data.label}>
203
+ {data.summary}
204
+ </Graph.Node>
205
+ );
206
+ }
207
+
208
+ const nodeTypes = { ...Graph.nodeTypes, step: Step };
209
+ ```
210
+
211
+ - The card is 256px wide. A status is the data package's `Status`, a dot beside its word, so a color
212
+ is never the only signal. `dimmed` recedes the card by a dashed edge and a muted title.
213
+ - The actions are outside React Flow's drag surface, so a press on one does not move the node.
214
+ - `tag` renders above the card's top edge at its end, so a tag that comes and goes never changes the
215
+ card's size. `branch` straddles the middle of the card's bottom edge, and the card keeps half a
216
+ small control's height free under its last line.
217
+ - Inputs are on the side edges enter, the top while the graph runs down and the left while it runs
218
+ right, and outputs are on the opposite side. The ports of one side are spread evenly along it.
219
+ - A side with two or more ports writes each port's name beside its handle. A side with one port
220
+ hides its name visually and keeps it for a screen reader.
221
+ - A port starts and ends new connections while the canvas lets nodes connect and the port is not
222
+ `disabled`. An edge refers to its ports through `sourceHandle` and `targetHandle`.
223
+ - React Flow measures a node's handles when it measures the node, and never measures a handle that
224
+ mounts later. A node whose edges come and go keeps its ports and marks a port without an edge
225
+ `unused`, which renders it invisible.
226
+
227
+ ### Edges
228
+
229
+ On an editable canvas with `removeGlyph`, every edge renders the actions `IconButton` at its middle,
230
+ unless the edge states `deletable: false`. The control is named by the edge data's `removeLabel`,
231
+ else by the canvas's `removeName` over the names of the edge's ends, so an edge a person connects
232
+ has a control too:
233
+
234
+ ```tsx
235
+ <Graph.Canvas
236
+ edges={edges}
237
+ label="Alert routing"
238
+ nodes={nodes}
239
+ removeGlyph={<XIcon />}
240
+ removeName={({ source, target }) => t("remove", { source, target })}
241
+ />
242
+ ```
243
+
244
+ Without `removeName`, the control is named `Remove the connection from Webhook to Enrich customer`.
245
+ A press removes the edge through React Flow, which calls `onEdgesDelete` and `onEdgesChange` as the
246
+ Delete key does. The control leaves with its edge, so focus then moves to the node the edge left,
247
+ unless `onBeforeDelete` refused the removal.
248
+
249
+ An edge whose element has `data-trace="on"` renders in the ink twice as wide, and one with
250
+ `data-trace="off"` fades to the theme's backdrop opacity. `DirectedGraph` marks its edges this way
251
+ while a trace shows relations.
252
+
253
+ ### Accessibility
254
+
255
+ - The figure is named by its caption, and React Flow's application element by `label`.
256
+ - Each node is a `group` named by its data's `label`, and each edge is named by the names of its
257
+ ends, unless the node or the edge states its own `ariaLabel`.
258
+ - On an editable canvas every node and edge is described by the keys it takes: Enter and Space
259
+ select, the arrow keys move a selected node, Delete removes and Escape clears the selection. A
260
+ read-only canvas describes no keys, because its nodes can only be read.
261
+ - React Flow announces a move before it applies it, so the position it passes to `moveAnnouncement`
262
+ is the one the node left. The default announcement states the direction.
263
+ - A focused node takes the focus ring around its card, and a focused or selected edge the focus ink
264
+ twice as wide.
265
+ - Under forced colors the edges and the handles render in `CanvasText`, and a selected edge and a
266
+ handle that takes connections in `Highlight`.
267
+
268
+ ## DirectedGraph
269
+
270
+ ```tsx
271
+ import { MaximizeIcon, MinusIcon, PlusIcon } from "lucide-react";
272
+
273
+ import { DirectedGraph, Graph } from "@stealthscale/component-graphs";
274
+
275
+ <DirectedGraph
276
+ caption="Customer dimensions feed the executive overview and the churn features."
277
+ controls={
278
+ <Graph.Controls label="Lineage controls">
279
+ <Graph.Control action="zoomIn" label="Zoom in">
280
+ <PlusIcon />
281
+ </Graph.Control>
282
+ <Graph.Control action="zoomOut" label="Zoom out">
283
+ <MinusIcon />
284
+ </Graph.Control>
285
+ <Graph.Control action="fit" label="Fit the graph in view">
286
+ <MaximizeIcon />
287
+ </Graph.Control>
288
+ <Graph.ZoomLevel />
289
+ </Graph.Controls>
290
+ }
291
+ defaultFocus="dim_customer"
292
+ direction="right"
293
+ edges={lineage}
294
+ label="Revenue lineage"
295
+ nodes={datasets}
296
+ overview={<Graph.MiniMap label="Overview of the revenue lineage" />}
297
+ />;
298
+ ```
299
+
300
+ The graph is the caller's own shape. A node is `{ id, label, kind, icon, status, detail }` and an
301
+ edge is `{ id, source, target }`, whose id defaults to the ids of its ends joined by a hyphen.
302
+
303
+ | Prop | Takes | Default |
304
+ | ------------------- | ------------------------------------------------------------------------ | ----------- |
305
+ | `nodes`, `edges` | The graph, in any order | Required |
306
+ | `label` | The canvas's accessible name | Required |
307
+ | `focus` | The focused node's id, controlled, or `null` for none | None |
308
+ | `defaultFocus` | The node focused on the first render | None |
309
+ | `onFocusChange` | Called with the focused id, or `null` when the focus is dropped | None |
310
+ | `trace` | `highlight`, `isolate` or `off`, what a focus does to the other nodes | `highlight` |
311
+ | `depth` | The number of hops each way a trace follows | Every hop |
312
+ | `collapsible` | Whether a node whose branch a press changes renders a control | `false` |
313
+ | `collapsed` | The ids of the closed branches, controlled | None |
314
+ | `defaultCollapsed` | The ids of the branches closed on the first render | None |
315
+ | `onCollapsedChange` | Called with the ids of the closed branches | None |
316
+ | `controls` | A toolbar above the canvas, such as `Graph.Controls` | None |
317
+ | `overview` | An overview map at the end of the readout's row, such as `Graph.MiniMap` | None |
318
+ | `caption` | The finding in words, which names the figure | None |
319
+ | `direction` | `down` or `right`, the way the edges run | `down` |
320
+ | `ratio` | The canvas's ratio, one of the kit's ratios | `video` |
321
+
322
+ Every other prop goes to the figure.
323
+
324
+ ### Focus and trace
325
+
326
+ - A press on a node, Enter or Space focuses it. Escape, a press on the canvas's background or the
327
+ readout's control clears the focus. One node at most is focused, because a modifier key does not
328
+ add a second node and Shift does not start a selection box.
329
+ - `highlight` dims the nodes the trace leaves out and fades the edges off the traced path. `isolate`
330
+ renders the focused node and the nodes it traced alone, laid out again. `off` keeps the focus as a
331
+ selection and states no relation.
332
+ - Each related card states its relation, `Upstream`, `Downstream` or `Focus`, in a badge above its
333
+ top edge, because both directions share a color. The node's accessible name ends with the same
334
+ word. A node that is both upstream and downstream, which happens only in a cycle, is downstream.
335
+ - The readout below the canvas states the trace in the caller's words in an `output`, which a screen
336
+ reader reads when it changes, and the prompt while nothing is focused. Its clear control is always
337
+ rendered and is `aria-disabled` while nothing is focused, so a press that clears the focus keeps
338
+ the keyboard focus on it.
339
+ - Nodes cannot be dragged, connected or removed, and edges are no tab stops.
340
+
341
+ ### Branches
342
+
343
+ `collapsible` gives a node a control on its bottom edge while a press on it would hide or show
344
+ nodes. The control states the number of nodes a press changes and has `aria-expanded`. A press on it
345
+ leaves the focus where it is.
346
+
347
+ A closed node remains shown and hides every node that only routes through it lead to. A node shows
348
+ while a route from a root leads to it without passing a closed node, and a root is a node no edge
349
+ enters. This is an org chart's rule for a tree, and it applies to a graph where a node has more than
350
+ one parent. A cycle that no root leads to is shown whole.
351
+
352
+ `treeEdges(items, parentOf)` turns a flat list whose items state their parent's id into edges, and
353
+ leaves out a parent that is not an item:
354
+
355
+ ```tsx
356
+ import { DirectedGraph, treeEdges } from "@stealthscale/component-graphs";
357
+
358
+ const edges = treeEdges(people, (person) => person.managerId);
359
+
360
+ <DirectedGraph
361
+ collapsible
362
+ edges={edges}
363
+ label="Organisation chart"
364
+ nodes={people.map((person) => ({ id: person.id, kind: person.title, label: person.name }))}
365
+ trace="off"
366
+ />;
367
+ ```
368
+
369
+ ### Layout
370
+
371
+ The canvas renders every node once, hidden, until React Flow has measured every node. It then lays
372
+ the nodes out with `layoutGraph` by their measured sizes and shows them once React Flow's store has
373
+ every node where the layout put it. A later change of what shows does not need a second measurement.
374
+ The view fits the layout whenever the layout moves a node. A change that moves no node, such as a
375
+ new focus, keeps the view where a person panned and zoomed it. A graph without a node renders its
376
+ message in the canvas's place, without the controls, the readout and the overview.
377
+
378
+ ### Words
379
+
380
+ | Prop | Writes | Default |
381
+ | ----------------- | -------------------------------------------------------------------- | ----------------------------------- |
382
+ | `upstreamLabel` | The word for a node that feeds the focus, and the name of each input | `Upstream` |
383
+ | `downstreamLabel` | The word for a node the focus feeds, and the name of each output | `Downstream` |
384
+ | `focusLabel` | The word for the focused node | `Focus` |
385
+ | `summary` | The readout from `{ name, upstream, downstream }` | `Revenue: 3 upstream, 2 downstream` |
386
+ | `promptLabel` | The readout while nothing is focused | A prompt to select a node |
387
+ | `clearLabel` | The readout's control | `Clear focus` |
388
+ | `collapseLabel` | A branch control from the number of nodes a press hides | `Hide 5` |
389
+ | `expandLabel` | A branch control from the number of nodes a press shows | `Show 5` |
390
+ | `emptyLabel` | The message while the graph has no node | `No nodes to show.` |
391
+ | `edgeName` | An edge's name from the names of its ends | `Orders to Revenue` |
392
+ | `nodeDescription` | The instruction every node is described by | Enter, Space and Escape |
393
+
394
+ ### Functions
395
+
396
+ The graph traces and folds with these functions, which work without a canvas too, such as in a
397
+ deploy check that asks what a change breaks.
398
+
399
+ | Function | Returns |
400
+ | --------------------------------- | ---------------------------------------------------------------- |
401
+ | `traceGraph(edges, id, depth)` | `{ upstream, downstream }`, the ids each way within `depth` hops |
402
+ | `relationOf(id, focus, trace)` | `focus`, `upstream`, `downstream` or `unrelated` |
403
+ | `descendantsOf(edges, id)` | The ids of every node a node leads to |
404
+ | `hiddenBy(ids, edges, collapsed)` | The ids the closed branches hide |
405
+ | `treeEdges(items, parentOf)` | An edge from each item's parent to the item |
406
+
407
+ The focused node is in neither set of a trace, so a cycle that leads back to it does not make it its
408
+ own ancestor. Every walk stops at a node it visited before, so a cycle never hangs it.
409
+
410
+ ## Comparing two versions
411
+
412
+ ```tsx
413
+ import { diffGraphs, Graph } from "@stealthscale/component-graphs";
414
+
415
+ const diff = diffGraphs(before, after);
416
+
417
+ <Graph.Root>
418
+ <Graph.Canvas
419
+ changes={diff}
420
+ edges={before.edges}
421
+ label="Version 3"
422
+ nodes={before.nodes}
423
+ readOnly
424
+ />
425
+ </Graph.Root>;
426
+ <Graph.Root>
427
+ <Graph.Canvas changes={diff} edges={after.edges} label="Version 4" nodes={after.nodes} readOnly />
428
+ </Graph.Root>;
429
+ ```
430
+
431
+ `diffGraphs(before, after, edgeName)` returns `{ nodes, edges }` with one entry for each node and
432
+ each edge of either version: `{ change, fields, id, label }`, where `change` is `added`, `changed`,
433
+ `removed` or `unchanged`.
434
+
435
+ - A node is the same node in both versions while its id is the same. It is changed while a field of
436
+ its `data` differs, and `fields` names those fields in order.
437
+ - Each field is compared by its JSON with object keys sorted, so a node that only moved, or whose
438
+ data lists its keys in another order, is unchanged. A React element in the data, such as an icon,
439
+ is compared by its component and its props.
440
+ - An edge is the same edge in both versions while it joins the same ports of the same nodes, so an
441
+ edge is added or removed, never changed. Its `id` is the edge's own, from the version it is in.
442
+ - A node's `label` is its data's string `label`, else its id. An edge's `label` comes from
443
+ `edgeName` and the names of its ends in its version, `Source to Target` unless stated.
444
+ - The entries follow the later version's order, and the removed ones follow in the earlier version's
445
+ order.
446
+
447
+ A canvas given `changes` marks every node and edge the comparison lists, so two canvases side by
448
+ side show what each version has that the other lacks:
449
+
450
+ - A card that was added, changed or removed takes the change as its tag: the data package's `Badge`
451
+ in the success, warning or error palette with a mark, `+`, `~` or `−`, before the word. A card
452
+ that states its own `tag` keeps it.
453
+ - An unchanged card recedes as `dimmed` does, unless it states `dimmed`.
454
+ - An added edge is the success ink twice as wide, a removed edge the error ink dashed and twice as
455
+ wide, and an unchanged edge fades. Under forced colors every edge is `CanvasText`, and the dash
456
+ still tells a removed edge apart.
457
+ - The name of an added, changed or removed node or edge ends with the change's word, because the tag
458
+ and the ink are hidden from a screen reader.
459
+
460
+ The comparison is data, so a list of the changes composes from the collections `DataList` and the
461
+ data package's `Badge`, as the catalogue's change list does.
462
+
463
+ ## NetworkGraph
464
+
465
+ ```tsx
466
+ import { ServerIcon } from "lucide-react";
467
+
468
+ import { Graph, NetworkGraph } from "@stealthscale/component-graphs";
469
+
470
+ <NetworkGraph
471
+ caption="Checkout writes to Kafka and Postgres, which the ledger reads."
472
+ defaultFocus="checkout-api"
473
+ label="Service topology"
474
+ links={[
475
+ { source: "api-gateway", strength: 2, target: "checkout-api" },
476
+ { source: "checkout-api", strength: 2, target: "kafka-events" },
477
+ ]}
478
+ nodes={[
479
+ { icon: <ServerIcon />, id: "api-gateway", label: "api-gateway", weight: 9 },
480
+ { icon: <ServerIcon />, id: "checkout-api", label: "checkout-api", weight: 8 },
481
+ { icon: <ServerIcon />, id: "kafka-events", label: "kafka-events", weight: 7 },
482
+ ]}
483
+ overview={<Graph.MiniMap label="Overview of the service topology" />}
484
+ />;
485
+ ```
486
+
487
+ A node is `{ id, label, weight, icon }` and a link `{ id, source, target, strength }`, in the
488
+ caller's own shape, and a link reads the same either way round. A link's id defaults to the ids of
489
+ its ends joined by a hyphen. A link whose end is not a node, or that joins a node to itself, is
490
+ neither rendered nor counted.
491
+
492
+ | Prop | Takes | Default |
493
+ | --------------- | ------------------------------------------------------------------------ | -------- |
494
+ | `nodes` | The graph's nodes, in any order | Required |
495
+ | `links` | The links between the nodes | Required |
496
+ | `label` | The canvas's accessible name | Required |
497
+ | `focus` | The focused node's id, controlled, or `null` for none | None |
498
+ | `defaultFocus` | The node focused on the first render | None |
499
+ | `onFocusChange` | Called with the focused id, or `null` when the focus is dropped | None |
500
+ | `depth` | The number of hops from the focused node within which a node is lit | `1` |
501
+ | `draggable` | Whether a person can move a node by dragging it or by the arrow keys | `true` |
502
+ | `seed` | The number the layout starts from | `1` |
503
+ | `iterations` | The number of the layout's steps | `300` |
504
+ | `controls` | A toolbar above the canvas, such as `Graph.Controls` | None |
505
+ | `overview` | An overview map at the end of the readout's row, such as `Graph.MiniMap` | None |
506
+ | `caption` | The finding in words, which names the figure | None |
507
+ | `ratio` | The canvas's ratio, one of the kit's ratios | `video` |
508
+
509
+ Every other prop goes to the figure.
510
+
511
+ ### Discs and links
512
+
513
+ - A disc is 36px across for no weight and 68px for the heaviest node. Its area grows with the
514
+ weight, so its diameter grows with the weight's square root. A node without a weight weighs the
515
+ number of its links, and a weight under zero counts as zero.
516
+ - The icon renders inside the disc and is hidden from assistive technology. The name renders on a
517
+ pill under the disc in the panel's color, so a link under it does not cross the letters.
518
+ - A link is a straight line between two disc centres, as many hairlines wide as its strength from 1
519
+ to 2.
520
+
521
+ ### Focus
522
+
523
+ - A press on a disc, Enter or Space focuses the node. Escape, a press on the canvas's background or
524
+ the readout's control clears the focus. One node at most is focused.
525
+ - A focus lights the nodes within `depth` hops and dims the rest by a dashed disc and a muted name.
526
+ A link between two lit nodes renders twice as wide, and every other link fades. The accessible
527
+ name of the focused node ends with `focusLabel`, and that of each lit node with `neighborLabel`.
528
+ - The readout below the canvas states the focus in the caller's words in an `output`, and the prompt
529
+ while nothing is focused. Its clear control is always rendered and is `aria-disabled` while
530
+ nothing is focused.
531
+ - A person moves a node by dragging it, or moves the focused node with the arrow keys. A moved node
532
+ keeps its position until the layout changes, such as for new data or a new seed, and a move leaves
533
+ the view where it is. `draggable={false}` pins every node. Nodes cannot be connected or removed.
534
+ Links are no tab stops.
535
+
536
+ ### Layout
537
+
538
+ The same graph and seed give the same picture in every browser and theme, because the preset lays
539
+ the graph out with `layoutForce` from the data alone and ignores the sizes React Flow measures. Each
540
+ node is a box as wide as its disc or its name, whichever is wider, and as tall as the disc and the
541
+ name's line. A name is taken as 8.5px a character plus 8px of padding. Its line is taken as 26px.
542
+
543
+ - A name in a script with wider letters, such as Chinese or Japanese, can overlap a neighbouring
544
+ name. For 79 Latin names in the ten themes in Firefox and Chromium, the circle the layout keeps
545
+ clear around each box contains the rendered node to within 0.1px.
546
+ - A link is a straight line, even where it passes a disc it does not end at. Another `seed` gives
547
+ another picture, which may have no such link.
548
+ - React Flow's viewport is hidden until React Flow has measured every node and its store has the
549
+ layout. The view fits the layout then, and again whenever the layout moves a node. A focus moves
550
+ no node and keeps the view where a person panned and zoomed it.
551
+
552
+ ### Words
553
+
554
+ | Prop | Writes | Default |
555
+ | ------------------ | ----------------------------------------------------- | ------------------------- |
556
+ | `summary` | The readout from `{ name, count }` | `Orders: 3 connections` |
557
+ | `promptLabel` | The readout while nothing is focused | A prompt to select a node |
558
+ | `clearLabel` | The readout's control | `Clear focus` |
559
+ | `focusLabel` | The word the focused node's accessible name ends with | `Focus` |
560
+ | `neighborLabel` | The word a lit node's accessible name ends with | `Connected` |
561
+ | `emptyLabel` | The message while the graph has no node | `No nodes to show.` |
562
+ | `edgeName` | A link's name from the names of its ends | `Orders and Billing` |
563
+ | `nodeDescription` | The instruction every node is described by | The keys a node takes |
564
+ | `moveAnnouncement` | The announcement of a move the arrow keys make | The direction |
565
+
566
+ `neighborsOf(links, id, depth)` returns the ids within `depth` hops of a node, following each link
567
+ both ways. It never counts the node it starts from, and stops at a node it visited before.
568
+
569
+ ## Layouts
570
+
571
+ `layoutGraph(nodes, edges, options)` returns React Flow's nodes placed in ranks:
572
+
573
+ ```ts
574
+ import { layoutGraph } from "@stealthscale/component-graphs";
575
+
576
+ const placed = layoutGraph(nodes, edges, { direction: "right" });
577
+ ```
578
+
579
+ | Option | Takes | Default |
580
+ | ----------- | ------------------------------------------------ | ------- |
581
+ | `direction` | `down` or `right`, the way the edges run | `down` |
582
+ | `ranksep` | The space between two ranks, in pixels | `128` |
583
+ | `nodesep` | The space between two nodes of a rank, in pixels | `48` |
584
+
585
+ - A node is placed by the size React Flow measured, else its `width` and `height`, else its
586
+ `initialWidth` and `initialHeight`, else 256 by 44 pixels.
587
+ - Each node comes back with that size as its `initialWidth` and `initialHeight`, which React Flow
588
+ applies until it measures the card and which the overview map sizes the node by. Each node also
589
+ comes back with the sides its edges leave and enter: bottom and top when the edges run down, right
590
+ and left when they run across.
591
+ - Dagre does not model ports. The targets of a node, and its sources, follow the order of their
592
+ edges, so edges listed in their ports' order do not cross.
593
+ - An edge whose ends are not both nodes of the graph takes no part.
594
+
595
+ A caller that applies React Flow's measurements to its nodes lays them out again with the measured
596
+ sizes, for cards taller than 44 pixels. `DirectedGraph` does this for its own graph.
597
+
598
+ `layoutForce(nodes, links, options)` returns React Flow's nodes placed by a force simulation:
599
+
600
+ ```ts
601
+ import { layoutForce } from "@stealthscale/component-graphs";
602
+
603
+ const placed = layoutForce(nodes, links, { seed: 6 });
604
+ ```
605
+
606
+ | Option | Takes | Default |
607
+ | ------------ | --------------------------------------- | ------- |
608
+ | `seed` | The number the start's order comes from | `1` |
609
+ | `iterations` | The number of the simulation's steps | `300` |
610
+
611
+ - The simulation starts with each connected part as a tree around its most-linked node, one ring
612
+ further out for each hop, in an order the seed shuffles. Linked nodes start close, so links seldom
613
+ cross.
614
+ - A link pulls its ends 110px apart, and 55px apart at a `strength` of 2. Every node pushes the
615
+ others away, and a collision keeps the circles around the nodes' boxes from overlapping.
616
+ - The pull to the middle is 3 times stronger up and down than across, so a picture is wider than
617
+ tall, as a canvas is.
618
+ - A node is placed by its size as `layoutGraph` reads it, and comes back with that size as its
619
+ initial size.
620
+ - The same graph and seed give the same positions. A link whose ends are not both nodes of the
621
+ graph, or that joins a node to itself, takes no part.
622
+
623
+ ## Not offered
624
+
625
+ - React Flow's own `Controls`, `MiniMap` over the canvas and `NodeToolbar`. `Graph.Controls` and
626
+ `Graph.MiniMap` render beside the canvas, so no control covers a node.
627
+ - Labels on edges other than the remove control.
628
+ - Editing in `DirectedGraph`. Its nodes cannot be dragged, connected or removed.
629
+ - Connecting or removing in `NetworkGraph`, and curved links. A network's link is a straight line
630
+ between two disc centres.
631
+ - An undo stack, a history list and a flow editor's inspector. `onGraphChange` gives a history each
632
+ finished edit, and the list and its undo are the application's, as the catalogue's example shows.
633
+
634
+ ## Licence
635
+
636
+ MIT. See [LICENSE](LICENSE).