@luciole-sh/flow-graph 0.0.0-stage → 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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +671 -2
  3. package/dist/Flow.d.ts +41 -0
  4. package/dist/Flow.d.ts.map +1 -0
  5. package/dist/Flow.js +489 -0
  6. package/dist/Flow.js.map +1 -0
  7. package/dist/components.d.ts +28 -0
  8. package/dist/components.d.ts.map +1 -0
  9. package/dist/components.js +100 -0
  10. package/dist/components.js.map +1 -0
  11. package/dist/frame.d.ts +68 -0
  12. package/dist/frame.d.ts.map +1 -0
  13. package/dist/frame.js +136 -0
  14. package/dist/frame.js.map +1 -0
  15. package/dist/geometry.d.ts +60 -0
  16. package/dist/geometry.d.ts.map +1 -0
  17. package/dist/geometry.js +205 -0
  18. package/dist/geometry.js.map +1 -0
  19. package/dist/hooks.d.ts +56 -0
  20. package/dist/hooks.d.ts.map +1 -0
  21. package/dist/hooks.js +65 -0
  22. package/dist/hooks.js.map +1 -0
  23. package/dist/index.d.ts +15 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +14 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/keymap.d.ts +11 -0
  28. package/dist/keymap.d.ts.map +1 -0
  29. package/dist/keymap.js +42 -0
  30. package/dist/keymap.js.map +1 -0
  31. package/dist/navigation.d.ts +32 -0
  32. package/dist/navigation.d.ts.map +1 -0
  33. package/dist/navigation.js +68 -0
  34. package/dist/navigation.js.map +1 -0
  35. package/dist/nodes.d.ts +18 -0
  36. package/dist/nodes.d.ts.map +1 -0
  37. package/dist/nodes.js +46 -0
  38. package/dist/nodes.js.map +1 -0
  39. package/dist/raster.d.ts +112 -0
  40. package/dist/raster.d.ts.map +1 -0
  41. package/dist/raster.js +370 -0
  42. package/dist/raster.js.map +1 -0
  43. package/dist/store.d.ts +104 -0
  44. package/dist/store.d.ts.map +1 -0
  45. package/dist/store.js +186 -0
  46. package/dist/store.js.map +1 -0
  47. package/dist/theme.d.ts +25 -0
  48. package/dist/theme.d.ts.map +1 -0
  49. package/dist/theme.js +39 -0
  50. package/dist/theme.js.map +1 -0
  51. package/dist/types.d.ts +140 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +2 -0
  54. package/dist/types.js.map +1 -0
  55. package/dist/vendor/xyflow/LICENSE +21 -0
  56. package/dist/vendor/xyflow/bezier.d.ts +26 -0
  57. package/dist/vendor/xyflow/bezier.d.ts.map +1 -0
  58. package/dist/vendor/xyflow/bezier.js +47 -0
  59. package/dist/vendor/xyflow/bezier.js.map +1 -0
  60. package/dist/vendor/xyflow/changes.d.ts +9 -0
  61. package/dist/vendor/xyflow/changes.d.ts.map +1 -0
  62. package/dist/vendor/xyflow/changes.js +133 -0
  63. package/dist/vendor/xyflow/changes.js.map +1 -0
  64. package/dist/vendor/xyflow/smoothstep.d.ts +17 -0
  65. package/dist/vendor/xyflow/smoothstep.d.ts.map +1 -0
  66. package/dist/vendor/xyflow/smoothstep.js +166 -0
  67. package/dist/vendor/xyflow/smoothstep.js.map +1 -0
  68. package/package.json +61 -4
  69. package/src/Flow.tsx +684 -0
  70. package/src/components.tsx +180 -0
  71. package/src/frame.ts +203 -0
  72. package/src/geometry.ts +274 -0
  73. package/src/hooks.tsx +121 -0
  74. package/src/index.ts +21 -0
  75. package/src/keymap.tsx +45 -0
  76. package/src/navigation.ts +86 -0
  77. package/src/nodes.tsx +86 -0
  78. package/src/raster.ts +453 -0
  79. package/src/store.ts +240 -0
  80. package/src/theme.ts +61 -0
  81. package/src/types.ts +130 -0
  82. package/src/vendor/xyflow/LICENSE +21 -0
  83. package/src/vendor/xyflow/README.md +20 -0
  84. package/src/vendor/xyflow/bezier.ts +102 -0
  85. package/src/vendor/xyflow/changes.ts +177 -0
  86. package/src/vendor/xyflow/smoothstep.ts +219 -0
  87. package/tsconfig.json +22 -0
package/README.md CHANGED
@@ -1,3 +1,672 @@
1
- # Temporary Holding Version
1
+ # @luciole-sh/flow-graph
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@luciole-sh/flow-graph` draws node graphs in your terminal, with the names and the shape of
4
+ [React Flow](https://reactflow.dev). It is published on npm, and it renders through
5
+ [OpenTUI](https://github.com/anomalyco/opentui).
6
+
7
+ ## Install
8
+
9
+ It runs on Node 26.4 or newer, the version OpenTUI needs, or on Bun 1.3 or newer. The package
10
+ has no dependency of its own. React and OpenTUI are peers, so your project keeps one copy of
11
+ each.
12
+
13
+ ```sh
14
+ bun add @luciole-sh/flow-graph @opentui/core @opentui/keymap @opentui/react react
15
+ ```
16
+
17
+ The package is ESM only and ships its type declarations.
18
+
19
+ ## Run a first graph
20
+
21
+ In an empty project, add this `tsconfig.json`. It compiles JSX for OpenTUI.
22
+
23
+ ```json
24
+ {
25
+ "compilerOptions": {
26
+ "module": "ESNext",
27
+ "moduleResolution": "Bundler",
28
+ "jsx": "react-jsx",
29
+ "jsxImportSource": "@opentui/react",
30
+ "strict": true,
31
+ "noEmit": true
32
+ }
33
+ }
34
+ ```
35
+
36
+ Save this program as `main.tsx`, then run `bun main.tsx`. <kbd>Ctrl</kbd>+<kbd>C</kbd> quits.
37
+
38
+ ```tsx
39
+ import { createCliRenderer } from "@opentui/core";
40
+ import { createRoot } from "@opentui/react";
41
+ import {
42
+ addEdge,
43
+ Background,
44
+ Controls,
45
+ Flow,
46
+ MiniMap,
47
+ useEdgesState,
48
+ useNodesState,
49
+ type Edge,
50
+ type Node,
51
+ } from "@luciole-sh/flow-graph";
52
+
53
+ const initialNodes: Node[] = [
54
+ { id: "checkout", type: "input", position: { x: 0, y: 3 }, data: { label: "checkout" } },
55
+ { id: "lint", position: { x: 20, y: 0 }, data: { label: "lint" } },
56
+ { id: "test", position: { x: 20, y: 6 }, data: { label: "test" } },
57
+ { id: "build", type: "output", position: { x: 40, y: 3 }, data: { label: "build" } },
58
+ ];
59
+
60
+ const initialEdges: Edge[] = [
61
+ { id: "checkout-lint", source: "checkout", target: "lint" },
62
+ { id: "checkout-test", source: "checkout", target: "test", label: "fast" },
63
+ { id: "lint-build", source: "lint", target: "build" },
64
+ ];
65
+
66
+ function Pipeline() {
67
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
68
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
69
+ return (
70
+ <Flow
71
+ nodes={nodes}
72
+ edges={edges}
73
+ onNodesChange={onNodesChange}
74
+ onEdgesChange={onEdgesChange}
75
+ onConnect={(connection) => setEdges((current) => addEdge(connection, current))}
76
+ fitView
77
+ >
78
+ <Background />
79
+ <Controls />
80
+ <MiniMap />
81
+ </Flow>
82
+ );
83
+ }
84
+
85
+ const renderer = await createCliRenderer();
86
+ createRoot(renderer).render(<Pipeline />);
87
+ ```
88
+
89
+ The first frame is already fitted to your terminal:
90
+
91
+ ```text
92
+
93
+
94
+ · · · · · · · · ·
95
+
96
+
97
+
98
+ · · · · ╭──────╮ · · · ·
99
+ ╭──▶│ lint │──────╮
100
+ │ ╰──────╯ │
101
+ ╔══════════╗ │ │ ┏━━━━━━━┓
102
+ · ║ checkout ║────┤ · · ╰────▶┃ build ┃ ·
103
+ ╚══════════╝ │ ┗━━━━━━━┛
104
+ fast ╭──────╮ ⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⢤
105
+ ╰──▶│ test │ ⡇ ⢸
106
+ · · · · ╰──────╯ · ⡇ ⢠⣤⣤⡄ ⢸
107
+ ⡇ ⣤⣤⣤⣤ ⠸⠿⠿⠇ ⣤⣤⣤⡄ ⢸
108
+ ⡇ ⠿⠿⠿⠿ ⢠⣤⣤⡄ ⠿⠿⠿⠇ ⢸
109
+ ⡇ ⠸⠿⠿⠇ ⢸
110
+ · · · · · · ⡇ ⢸
111
+ + - fit full ⠓⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠒⠚
112
+ ```
113
+
114
+ _The program above, in a 72×20 terminal. The frame is captured from a real PTY by
115
+ [`website/scripts/capture.py`](https://github.com/sykar-f/luciole/blob/v0.1.0/website/scripts/capture.py)
116
+ (`python3 website/scripts/capture.py flow-graph`), and the program is
117
+ [`example/main.tsx`](https://github.com/sykar-f/luciole/blob/v0.1.0/packages/flow-graph/example/main.tsx)._
118
+
119
+ The graph is yours to edit. Drag a node, or select one and move it with the keys below.
120
+ Drag from the dot on a selected node to connect it to another node.
121
+
122
+ ### Run the example from a clone
123
+
124
+ The same program is in the repository, in `packages/flow-graph/example/`. From the root of a
125
+ clone, run:
126
+
127
+ ```sh
128
+ bun install
129
+ bun packages/flow-graph/example/main.tsx
130
+ ```
131
+
132
+ `bun run check` type-checks that program, and `tests/readme-examples.test.ts` fails when this
133
+ page and the file differ. The same test type-checks every other example of this page.
134
+
135
+ ## Use it in a luciole app
136
+
137
+ In a luciole app, use `<Flow>` in a Client Component. Pan, zoom, drag and selection stay on
138
+ the Client. Only what your app sends, such as a Server Function called from `onNodesChange`
139
+ or `onConnect`, reaches the Server.
140
+
141
+ [`examples/flow`](https://github.com/sykar-f/luciole/tree/v0.1.0/examples/flow) is a full app: a
142
+ CI pipeline editor with custom nodes, a side panel and live runs on the Server. The framework's
143
+ documentation is at <https://luciole.sh/docs/>.
144
+
145
+ ## Components
146
+
147
+ Every name on this page is imported from `@luciole-sh/flow-graph`. Coordinates are terminal
148
+ cells: `x` counts columns and `y` counts rows. Colours are `#rrggbb` strings.
149
+
150
+ ### `<Flow>`
151
+
152
+ The canvas. It is controlled: you own `nodes` and `edges`, and the canvas proposes changes
153
+ through `onNodesChange` and `onEdgesChange`. Without these callbacks, nodes stay where they are.
154
+
155
+ | Prop | Type | Default | Meaning |
156
+ | ------------------ | ------------------------------------ | ------------------------- | ------------------------------------------------------------------------ |
157
+ | `nodes` | `N[]` | required | The nodes. |
158
+ | `edges` | `E[]` | required | The edges. |
159
+ | `onNodesChange` | `(changes: NodeChange<N>[]) => void` | | Receives drags, selections and removals. Pass it to `applyNodeChanges`. |
160
+ | `onEdgesChange` | `(changes: EdgeChange<E>[]) => void` | | The same for edges. |
161
+ | `onConnect` | `(connection: Connection) => void` | | A connection made from a handle, or with the keyboard. |
162
+ | `onNodeClick` | `(node: N) => void` | | A click on a node. |
163
+ | `onEdgeClick` | `(edge: E) => void` | | A click on an edge. |
164
+ | `onPaneClick` | `(at: XY) => void` | | A click on the empty canvas, in flow coordinates. |
165
+ | `onNodeDragStop` | `(nodes: N[]) => void` | | The end of a mouse drag, with the moved nodes at their new positions. |
166
+ | `onViewportChange` | `(viewport: Viewport) => void` | | The view moved or zoomed. |
167
+ | `nodeTypes` | `Record<string, NodeComponent<N>>` | | Your node components, by `node.type`. |
168
+ | `edgeTypes` | `Record<string, EdgeType>` | | Your edge styles and routes, by `edge.type`. |
169
+ | `defaultEdgeType` | `string` | `"smoothstep"` | The type of an edge that names none. |
170
+ | `fitView` | `boolean` | `false` | Fits every node in view once the canvas has a size. |
171
+ | `defaultViewport` | `Viewport` | `{ x: 2, y: 1, zoom: 1 }` | The first view. |
172
+ | `keyboard` | `boolean` | `true` | Turns the key bindings off while a field elsewhere takes the keys. |
173
+ | `braille` | `boolean` | `true` | Draws curves in braille. When `false`, curves are drawn as `smoothstep`. |
174
+ | `theme` | `Partial<FlowTheme>` | `defaultTheme` | Overrides colours of the canvas. |
175
+ | `id` | `string` | | A name for the canvas. |
176
+ | `children` | `ReactNode` | | `<Background>`, `<MiniMap>`, `<Controls>` and `<Panel>`. |
177
+
178
+ `FlowProps<N, E>` is the type of these props. `N` extends `Node` and `E` extends `Edge`, so
179
+ the callbacks receive your own node and edge types:
180
+
181
+ ```tsx
182
+ import { Flow, type FlowProps, type Node } from "@luciole-sh/flow-graph";
183
+
184
+ type Step = Node<{ label: string; command: string }>;
185
+
186
+ export function Steps({ nodes, edges }: Pick<FlowProps<Step>, "nodes" | "edges">) {
187
+ return (
188
+ <Flow nodes={nodes} edges={edges} onNodeClick={(step) => console.log(step.data.command)} />
189
+ );
190
+ }
191
+ ```
192
+
193
+ `<Flow>` raises no error of its own. A node of an unknown `type` is drawn as a `default` node,
194
+ and an edge of an unknown `type` as a `smoothstep` edge.
195
+
196
+ ### `<Background>`
197
+
198
+ Marks every `gap` flow cells, under the edges.
199
+
200
+ | Prop | Type | Default | Meaning |
201
+ | --------- | ------------------- | ------------------ | ------------------------ |
202
+ | `variant` | `BackgroundVariant` | `"dots"` | The mark. |
203
+ | `gap` | `XY` | `{ x: 8, y: 4 }` | The spacing, in cells. |
204
+ | `color` | `string` | the theme's colour | The colour of the marks. |
205
+
206
+ `BackgroundVariant` is `"dots"`, `"lines"` or `"cross"`.
207
+
208
+ ### `<Controls>`
209
+
210
+ Zoom in, zoom out, fit the view, and show the current level.
211
+
212
+ | Prop | Type | Default | Meaning |
213
+ | ---------- | --------------- | --------------- | ------------------------------ |
214
+ | `position` | `PanelPosition` | `"bottom-left"` | The corner or edge it sits on. |
215
+
216
+ ### `<MiniMap>`
217
+
218
+ Every node in braille, with the part in view outlined. A click on it centres the view there.
219
+
220
+ | Prop | Type | Default | Meaning |
221
+ | ---------- | --------------- | ---------------- | ---------------------- |
222
+ | `position` | `PanelPosition` | `"bottom-right"` | Where it sits. |
223
+ | `width` | `number` | `24` | Its width, in columns. |
224
+ | `height` | `number` | `8` | Its height, in rows. |
225
+
226
+ ### `<Panel>`
227
+
228
+ Your own content over the canvas. A click on a panel does not select or pan the canvas.
229
+
230
+ | Prop | Type | Default | Meaning |
231
+ | ---------- | --------------- | ------------ | -------------- |
232
+ | `position` | `PanelPosition` | `"top-left"` | Where it sits. |
233
+ | `children` | `ReactNode` | required | What it shows. |
234
+
235
+ `PanelPosition` is `"top-left"`, `"top-center"`, `"top-right"`, `"bottom-left"`,
236
+ `"bottom-center"` or `"bottom-right"`.
237
+
238
+ ### `<FlowProvider>`
239
+
240
+ Shares one canvas with components outside `<Flow>`, such as a side panel that calls `fitView`.
241
+ Wrap the panel and the `<Flow>` in it. Without it, `<Flow>` keeps its own state.
242
+
243
+ | Prop | Type | Default | Meaning |
244
+ | ----------------- | ----------- | ------------------------- | ------------------------------------- |
245
+ | `children` | `ReactNode` | required | The panel and the `<Flow>`. |
246
+ | `defaultViewport` | `Viewport` | `{ x: 2, y: 1, zoom: 1 }` | The first view. |
247
+ | `fitView` | `boolean` | `false` | Fits every node in view at the start. |
248
+
249
+ `<Background>`, `<Controls>`, `<MiniMap>` and `<Handle>` read the canvas they belong to.
250
+ Rendered outside a `<Flow>` or a `<FlowProvider>`, they throw
251
+ `useFlow and the canvas components need a <Flow> or <FlowProvider>`.
252
+
253
+ ## Nodes and edges
254
+
255
+ ### `Node`
256
+
257
+ A node as your app keeps it. `Node<Data>` types its `data`, which defaults to
258
+ `Record<string, unknown>`.
259
+
260
+ | Field | Type | Default | Meaning |
261
+ | ---------------------------------------- | ---------- | ----------- | ------------------------------------------------------------------------------------ |
262
+ | `id` | `string` | required | Unique among the nodes. |
263
+ | `position` | `XY` | required | Its top-left cell, relative to its parent's when it has `parentId`. |
264
+ | `data` | `Data` | required | Your data. The built-in nodes show `data.label`, or the `id` without one. |
265
+ | `type` | `string` | `"default"` | A key of `nodeTypes`, or `default`, `input`, `output` or `group`. |
266
+ | `selected`, `dragging`, `hidden` | `boolean` | `false` | Set by the changes you apply. A hidden node is not drawn. |
267
+ | `parentId` | `string` | | The `group` node it sits in. |
268
+ | `width`, `height` | `number` | measured | A fixed size in cells. A `group` node needs both. |
269
+ | `sourcePosition` | `Position` | `"right"` | Where its edges leave, when it has no `<Handle>`. |
270
+ | `targetPosition` | `Position` | `"left"` | Where its edges arrive, when it has no `<Handle>`. |
271
+ | `draggable`, `selectable`, `connectable` | `boolean` | `true` | Whether you can drag it, select it, or start a connection from it. |
272
+ | `zIndex` | `number` | `0` | Its drawing order. A group is drawn under its children, the selection over the rest. |
273
+ | `color` | `string` | the theme's | Its colour where the canvas draws it itself: a dot when zoomed out, the minimap. |
274
+
275
+ `Position` is the side of a node: `"left"`, `"right"`, `"top"` or `"bottom"`. `XY` is
276
+ `{ x, y }`, and `Rect` is `{ x, y, width, height }`, both in cells.
277
+
278
+ ### `Edge`
279
+
280
+ An edge from a source node to a target node. `Edge<Data>` types its `data`.
281
+
282
+ | Field | Type | Default | Meaning |
283
+ | ------------------------------ | ---------------- | ----------------- | ---------------------------------------------------------------------- |
284
+ | `id` | `string` | required | Unique among the edges. |
285
+ | `source`, `target` | `string` | required | The ids of the nodes it joins. |
286
+ | `sourceHandle`, `targetHandle` | `string \| null` | the first handle | The `id` of a `<Handle>`, or the node's first handle of that type. |
287
+ | `type` | `string` | `defaultEdgeType` | A key of `edgeTypes`, or `smoothstep`, `step`, `straight` or `bezier`. |
288
+ | `label` | `string` | | Text drawn on the edge. |
289
+ | `animated` | `boolean` | `false` | Dashes that run from the source to the target. |
290
+ | `selected`, `hidden` | `boolean` | `false` | Set by the changes you apply. A hidden edge is not drawn. |
291
+ | `markerEnd` | `MarkerType` | `"arrow"` | The mark where it reaches the target. |
292
+ | `markerStart` | `MarkerType` | `"none"` | The mark where it leaves the source. |
293
+ | `color` | `string` | the theme's | The colour of its line. |
294
+ | `data` | `Data` | | Your data. |
295
+
296
+ `MarkerType` is `"arrow"` or `"none"`.
297
+
298
+ ### `Connection`
299
+
300
+ What `onConnect` receives: `{ source, target, sourceHandle, targetHandle }`. The handles are
301
+ `string | null`, and `null` stands for the node's default handle. Turn a connection into an edge
302
+ with `addEdge`.
303
+
304
+ ## Apply the canvas's changes
305
+
306
+ The canvas never edits your nodes. It sends `NodeChange` and `EdgeChange` objects, and you
307
+ apply the ones you accept with `applyNodeChanges` and `applyEdgeChanges`.
308
+
309
+ | Change | Shape | Who sends it |
310
+ | ---------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
311
+ | `NodePositionChange` | `{ id, type: "position", position?, dragging? }` | The canvas. A drag sends `dragging: true`, then `false` at the drop. A key move has no `dragging`. |
312
+ | `SelectionChange` | `{ id, type: "select", selected }` | The canvas, on a click or a key, and `select()`. |
313
+ | `RemoveChange` | `{ id, type: "remove" }` | The canvas, on `x` or `Delete`, and `deleteElements()`. |
314
+ | `NodeDimensionsChange` | `{ id, type: "dimensions", dimensions: { width, height } }` | Never the canvas. `applyNodeChanges` leaves the node as it is. |
315
+ | `AddChange<T>` | `{ type: "add", item, index? }` | Your app. The item goes in at `index`, or at the end. |
316
+ | `ReplaceChange<T>` | `{ id, type: "replace", item }` | Your app. The item takes the place of the element `id`. |
317
+
318
+ `NodeChange<N>` is any of the six, with `N` in `add` and `replace`. `EdgeChange<E>` is
319
+ `SelectionChange`, `RemoveChange`, `AddChange<E>` or `ReplaceChange<E>`.
320
+
321
+ This handler applies every change, and saves positions only when a drag ends or a key moves
322
+ a node:
323
+
324
+ ```ts
325
+ import {
326
+ applyNodeChanges,
327
+ type Node,
328
+ type NodeChange,
329
+ type NodePositionChange,
330
+ } from "@luciole-sh/flow-graph";
331
+
332
+ export function onNodesChange(
333
+ changes: NodeChange[],
334
+ nodes: Node[],
335
+ save: (moves: NodePositionChange[]) => void,
336
+ ): Node[] {
337
+ const moves = changes.filter(
338
+ (change): change is NodePositionChange =>
339
+ change.type === "position" && change.dragging !== true,
340
+ );
341
+ if (moves.length > 0) save(moves);
342
+ return applyNodeChanges(changes, nodes);
343
+ }
344
+ ```
345
+
346
+ Your app sends its own changes through the same function:
347
+
348
+ ```ts
349
+ import { applyNodeChanges, type Node, type NodeChange } from "@luciole-sh/flow-graph";
350
+
351
+ export const withDeploy = (nodes: Node[]): Node[] => {
352
+ const changes: NodeChange[] = [
353
+ { type: "add", item: { id: "deploy", position: { x: 60, y: 3 }, data: { label: "deploy" } } },
354
+ { type: "remove", id: "lint" },
355
+ ];
356
+ return applyNodeChanges(changes, nodes);
357
+ };
358
+ ```
359
+
360
+ ## Write your own nodes
361
+
362
+ ### `NodeComponent` and `NodeProps`
363
+
364
+ A node type is a `NodeComponent<N>`: a function component of `NodeProps<N>`. Register it in
365
+ `nodeTypes` under the name your nodes give in `type`.
366
+
367
+ | Prop | Type | Meaning |
368
+ | ----------------- | ----------- | ----------------------------------------------------------------- |
369
+ | `id` | `string` | The node's id. |
370
+ | `data` | `N["data"]` | The node's `data`. |
371
+ | `type` | `string` | The node's type. |
372
+ | `selected` | `boolean` | Whether the node is selected. |
373
+ | `dragging` | `boolean` | Whether the node is being dragged. |
374
+ | `detail` | `Detail` | The zoom level. At `dot`, the canvas draws the node itself. |
375
+ | `connectTarget` | `boolean` | Whether the node is the proposed target of a keyboard connection. |
376
+ | `width`, `height` | `number` | The node's fixed size in cells, when it has one. |
377
+
378
+ `Detail` is `"full"`, `"compact"` or `"dot"`: what a node shows at each zoom level. The
379
+ built-in `DefaultNode` draws `default`, `input` and `output`, and `GroupNode` draws `group`.
380
+ Wrap or reuse them in your own `nodeTypes`.
381
+
382
+ ### `<Handle>`
383
+
384
+ Declares where a custom node's edges attach. Render one per attachment point. A handle draws
385
+ nothing itself. The canvas marks the sources of the selected node, and the targets while you
386
+ connect. Handles that share a side are spread along it, in render order.
387
+
388
+ | Prop | Type | Default | Meaning |
389
+ | ---------- | ---------------- | -------- | -------------------------------------------------------- |
390
+ | `type` | `HandleType` | required | Whether edges leave or arrive here. |
391
+ | `position` | `Position` | required | The side of the node. |
392
+ | `id` | `string \| null` | `null` | Names the handle, for `sourceHandle` and `targetHandle`. |
393
+
394
+ `HandleType` is `"source"` or `"target"`. A `<Handle>` outside a node component does nothing.
395
+
396
+ This node shows a job's status and sends an edge from one of two handles:
397
+
398
+ ```tsx
399
+ import { Handle, type Node, type NodeComponent } from "@luciole-sh/flow-graph";
400
+
401
+ type Job = Node<{ label: string; failed: boolean }>;
402
+
403
+ export const JobNode: NodeComponent<Job> = ({ data, selected, detail }) =>
404
+ detail === "compact" ? (
405
+ <text selectable={false}>{data.label}</text>
406
+ ) : (
407
+ <box border borderStyle={selected ? "double" : "rounded"} paddingX={1}>
408
+ <text selectable={false}>{`${data.failed ? "✗" : "✓"} ${data.label}`}</text>
409
+ <Handle type="target" position="left" />
410
+ <Handle type="source" position="right" id="next" />
411
+ <Handle type="source" position="bottom" id="retry" />
412
+ </box>
413
+ );
414
+
415
+ export const nodeTypes = { job: JobNode };
416
+ ```
417
+
418
+ An edge leaves the bottom when it sets `sourceHandle: "retry"`.
419
+
420
+ ## Draw your own edges
421
+
422
+ ### `EdgeType`, `EdgeStyle` and `EdgeRoute`
423
+
424
+ An `edgeTypes` entry is an `EdgeType`: an `EdgeStyle` or an `EdgeRoute`.
425
+
426
+ - `EdgeStyle` is a built-in style: `"smoothstep"`, `"step"`, `"straight"` or `"bezier"`.
427
+ - `EdgeRoute` is a function. It receives `{ edge, source, sourcePosition, target, targetPosition }`,
428
+ where `source` and `target` are the anchor cells on the canvas. It returns the corners of the
429
+ path, both ends included.
430
+
431
+ The canvas draws a route in box characters, like a `step` edge. Two corners that share neither a
432
+ row nor a column are joined across, then down.
433
+
434
+ ```ts
435
+ import type { EdgeType } from "@luciole-sh/flow-graph";
436
+
437
+ // Runs two rows under the lower node, then rises to the target.
438
+ const under: EdgeType = ({ source, target }) => {
439
+ const floor = Math.max(source.y, target.y) + 2;
440
+ return [source, { x: source.x, y: floor }, { x: target.x, y: floor }, target];
441
+ };
442
+
443
+ export const edgeTypes: Record<string, EdgeType> = { under, curve: "bezier" };
444
+ ```
445
+
446
+ ## Change the colours
447
+
448
+ ### `FlowTheme`
449
+
450
+ The canvas's colours. Pass any of them to `<Flow theme>`, and `defaultTheme` gives the others.
451
+
452
+ | Key | Default | Colours |
453
+ | ------------ | --------- | ----------------------------------------------------------------------------------------- |
454
+ | `text` | `#e6edf3` | The labels of the built-in nodes, the buttons of `<Controls>`, and a node drawn as a dot. |
455
+ | `muted` | `#8b98a5` | The zoom level that `<Controls>` shows. |
456
+ | `border` | `#4d5966` | The border of a node. |
457
+ | `selected` | `#67d9bc` | A selected node or edge. |
458
+ | `connect` | `#f2cc60` | The border of the node proposed as the target of a connection. |
459
+ | `group` | `#3b4450` | The frame of a `group` node. |
460
+ | `edge` | `#6e7c8a` | An edge. |
461
+ | `animated` | `#79c0ff` | An animated edge. |
462
+ | `label` | `#c9d1d9` | The label of an edge. |
463
+ | `labelBg` | `#1f262e` | The background of an edge label and of the buttons of `<Controls>`. |
464
+ | `marker` | `#8b98a5` | The arrows at the ends of edges. |
465
+ | `background` | `#2d353e` | The marks of `<Background>`. |
466
+ | `handle` | `#f2cc60` | Handles, and the line of a connection being made. |
467
+ | `panelBg` | `#161b22` | The background of the built-in nodes, `<Controls>` and `<MiniMap>`. |
468
+
469
+ ```ts
470
+ import { defaultTheme, type FlowTheme } from "@luciole-sh/flow-graph";
471
+
472
+ export const light: FlowTheme = {
473
+ ...defaultTheme,
474
+ text: "#1f2328",
475
+ labelBg: "#f6f8fa",
476
+ panelBg: "#ffffff",
477
+ };
478
+ ```
479
+
480
+ ## Hooks and helpers
481
+
482
+ ### `useNodesState` and `useEdgesState`
483
+
484
+ `useNodesState(initial)` returns `[nodes, setNodes, onNodesChange]`. It is `useState`, plus the
485
+ callback that applies the canvas's changes. `useEdgesState(initial)` does the same for edges.
486
+ The program at the top of this page uses both.
487
+
488
+ ### `useFlow()` and `FlowInstance`
489
+
490
+ `useFlow()` returns the `FlowInstance` of the canvas around it, inside `<Flow>` or
491
+ `<FlowProvider>`. Elsewhere it throws
492
+ `useFlow and the canvas components need a <Flow> or <FlowProvider>`.
493
+
494
+ | Member | What it does |
495
+ | ---------------------------------------- | ------------------------------------------------------------------------- |
496
+ | `getNodes()`, `getEdges()` | The nodes and edges the canvas last received. |
497
+ | `getNode(id)` | One node, or `undefined`. |
498
+ | `getViewport()`, `setViewport(viewport)` | Read or set the view. |
499
+ | `fitView()` | The closest zoom level at which every node is seen, centred. |
500
+ | `zoomIn()`, `zoomOut()` | One zoom level in or out, around the centre. |
501
+ | `setCenter(point, zoom?)` | Centres the view on a flow point, at `zoom` or the current level. |
502
+ | `reveal(id)` | Pans just enough to show node `id` whole. |
503
+ | `select({ nodes, edges })` | Proposes selecting exactly these ids, through the change callbacks. |
504
+ | `deleteElements({ nodes, edges })` | Proposes removing these ids. A node takes its edges and children with it. |
505
+ | `center()` | The flow point at the centre of the view. |
506
+
507
+ ```tsx
508
+ import { useFlow, type FlowInstance } from "@luciole-sh/flow-graph";
509
+
510
+ export function FitButton() {
511
+ const flow: FlowInstance = useFlow();
512
+ return <text onMouseDown={() => flow.fitView()}>fit</text>;
513
+ }
514
+ ```
515
+
516
+ `useViewport()` returns the current `{ x, y, zoom, detail }` and re-renders when it changes. It
517
+ throws the same error outside a canvas.
518
+
519
+ ### `addEdge` and `getEdgeId`
520
+
521
+ `addEdge(connection, edges, getId?)` returns a new array with an edge for `connection`. The
522
+ array is unchanged when an edge joins the same nodes and handles, or when `source` or `target`
523
+ is empty. The new edge keeps the connection's `id`, or takes `getId(connection)`.
524
+
525
+ `getEdgeId(connection)` is the default `getId`. It returns
526
+ `xy-edge__<source><sourceHandle>-<target><targetHandle>`, with an empty string for a `null`
527
+ handle.
528
+
529
+ ```ts
530
+ import { addEdge, getEdgeId, type Edge } from "@luciole-sh/flow-graph";
531
+
532
+ const connection = { source: "lint", target: "build", sourceHandle: null, targetHandle: null };
533
+ getEdgeId(connection); // "xy-edge__lint-build"
534
+ export const edges: Edge[] = addEdge({ ...connection, animated: true }, []);
535
+ ```
536
+
537
+ ### `applyNodeChanges` and `applyEdgeChanges`
538
+
539
+ `applyNodeChanges(changes, nodes)` returns a new array. A node without a change keeps its
540
+ identity, and a changed node is a copy. `applyEdgeChanges(changes, edges)` does the same for
541
+ edges. Neither raises an error. A change for an unknown id is ignored. See
542
+ [Apply the canvas's changes](#apply-the-canvass-changes).
543
+
544
+ ### `ZOOMS` and `detailFor`
545
+
546
+ `ZOOMS` is `[1, 0.5, 0.25]`, the three zoom levels. `detailFor(zoom)` returns the `Detail` drawn
547
+ at a zoom: `full` from 1, `compact` from 0.5, `dot` below.
548
+
549
+ ```ts
550
+ import { detailFor } from "@luciole-sh/flow-graph";
551
+
552
+ detailFor(0.5); // "compact"
553
+ ```
554
+
555
+ ### `flowBounds` and `fitViewport`
556
+
557
+ `flowBounds(nodes, measured)` returns the `Rect` around the visible nodes, in flow cells, or
558
+ `null` when there is none. `measured` maps node ids to measured sizes. Pass `new Map()` to use
559
+ the sizes of the built-in nodes.
560
+
561
+ `fitViewport(bounds, canvas, padding?)` returns the `Viewport` that centres `bounds` in a canvas
562
+ of `{ width, height }` cells. It picks the closest level of `ZOOMS` at which `bounds` fits
563
+ inside `padding` cells, 2 by default. Its zoom is ¼ when nothing fits, and 1 when `bounds` is
564
+ `null`.
565
+
566
+ ```ts
567
+ import { fitViewport, flowBounds, type Node } from "@luciole-sh/flow-graph";
568
+
569
+ const nodes: Node[] = [
570
+ { id: "checkout", position: { x: 0, y: 3 }, data: { label: "checkout" } },
571
+ { id: "build", position: { x: 40, y: 3 }, data: { label: "build" } },
572
+ ];
573
+ const bounds = flowBounds(nodes, new Map()); // { x: 0, y: 3, width: 49, height: 3 }
574
+ fitViewport(bounds, { width: 80, height: 24 }); // { x: 16, y: 8, zoom: 1 }
575
+ ```
576
+
577
+ ### `composeFrame`, `hitTest`, `Grid` and `Frame`
578
+
579
+ The frame under the nodes, as pure functions, for tests and for rendering off the canvas.
580
+
581
+ - `composeFrame(input)` returns a `Frame`: `{ grid, placed, anchors }`. It draws the edges,
582
+ their labels and the background. It takes `size`, `viewport`, `nodes`, `edges` and
583
+ `measured`, plus the optional `handles`, `background`, `connecting`, `edgeTypes`,
584
+ `defaultEdgeType`, `braille` and `phase`.
585
+ - `handles` maps a node id to its `HandleSpec`s, each `{ id, type, position }` as a `<Handle>`
586
+ declares it.
587
+ - `Grid` holds the cells: `lines()` returns its rows as text, and `get(x, y)` returns one cell.
588
+ - `hitTest(frame, at)` returns what is at a canvas cell. Its `kind` is `"handle"` with the
589
+ `anchor`, `"node"` with the `node`, `"edge"` with the edge's `id`, or `"pane"`.
590
+
591
+ ```ts
592
+ import { composeFrame, hitTest, type Node } from "@luciole-sh/flow-graph";
593
+
594
+ const nodes: Node[] = [
595
+ { id: "a", position: { x: 0, y: 0 }, data: { label: "a" } },
596
+ { id: "b", position: { x: 16, y: 0 }, data: { label: "b" } },
597
+ ];
598
+ const frame = composeFrame({
599
+ size: { width: 24, height: 3 },
600
+ viewport: { x: 0, y: 0, zoom: 1 },
601
+ nodes,
602
+ edges: [{ id: "a-b", source: "a", target: "b", label: "go" }],
603
+ measured: new Map(),
604
+ });
605
+ frame.grid.lines()[1]; // " ── go ─▶"
606
+ hitTest(frame, { x: 10, y: 1 }); // { kind: "edge", id: "a-b" }
607
+ ```
608
+
609
+ ## What differs from React Flow
610
+
611
+ - **Cell coordinates.** A row is about twice as tall as a column is wide.
612
+ - **Semantic zoom.** `ZOOMS` has three levels: 1, ½ and ¼. Zooming out moves nodes closer and
613
+ draws less of each: the node's component (`full`), its label on one row (`compact`), then a
614
+ dot in `node.color` (`dot`).
615
+ - **Edges on the grid.** `smoothstep` (the default) and `step` use box characters, and the
616
+ lines of every edge in a cell merge into `├ ┤ ┬ ┴ ┼`. `bezier` and slanted `straight` edges use
617
+ braille. `animated` edges draw dashes that move toward the target.
618
+ - **Edge types.** An `edgeTypes` entry is a built-in style, or a function that returns the
619
+ corners of the path (`EdgeRoute`). There is no free render component, as SVG gives React Flow.
620
+ - **Node types.** `default` has a rounded border, `input` a double one, `output` a thick one,
621
+ and `group` a frame of fixed size whose children set `parentId`. Without a `<Handle>`, a node
622
+ takes edges on its left and sends them from its right.
623
+ - **Text in custom nodes.** Set `selectable={false}` on it. Otherwise a click starts a text
624
+ selection instead of a drag.
625
+
626
+ ## Keyboard and mouse
627
+
628
+ The keys belong to the group `flow`. Set `keyboard={false}` to turn them off. They run
629
+ through `@opentui/keymap`: inside an app with its own `<KeymapProvider>`, such as a luciole app,
630
+ they join its layers, and `<KeyHelp>` lists them. Elsewhere the canvas installs its own keymap.
631
+
632
+ | Keys | Action |
633
+ | ---------------------------------- | ---------------------------------------------------------------- |
634
+ | `h j k l`, arrows | Move the view |
635
+ | `H J K L`, <kbd>Shift</kbd>+arrows | Move the selected nodes by one cell |
636
+ | `tab`, <kbd>Shift</kbd>+`tab` | Next, previous node, by column from left to right |
637
+ | `]`, `[` | Follow an edge downstream, upstream |
638
+ | `}`, `{` | Next, previous sibling node |
639
+ | `c`, then `tab` or `]`, `Enter` | Connect the selected node to the proposed target (`Esc` cancels) |
640
+ | `e` | Select the node's edges in turn |
641
+ | `x`, `Delete` | Delete the selection. A node takes its edges with it |
642
+ | `=` or `+`, `-`, `0` | Zoom in, zoom out, fit everything |
643
+ | `Esc` | Clear the selection |
644
+
645
+ With the mouse:
646
+
647
+ - A click selects. <kbd>Shift</kbd>+click adds to the selection.
648
+ - Dragging a node moves it, with the other selected nodes.
649
+ - Dragging the background moves the view.
650
+ - Dragging from a handle (`●`, shown on the selected node) to a node connects them.
651
+ - The wheel moves the view, horizontally with <kbd>Shift</kbd>. <kbd>Ctrl</kbd> or <kbd>Alt</kbd> and the wheel zoom.
652
+ - A click in the minimap centres the view there.
653
+
654
+ ## Limits
655
+
656
+ - The package is 0.x. Until 1.0, a minor release may break the API.
657
+ - It is tested on Bun only. The `bun` export condition points to the TypeScript sources.
658
+ - Zoom has three fixed levels, because a terminal cannot scale glyphs.
659
+ - Curves need braille glyphs in your terminal's font. Set `braille={false}` where they are missing.
660
+ - A `group` node needs a `width` and a `height`.
661
+ - Edges are drawn in box characters. They cannot be arbitrary components.
662
+ - It does not lay nodes out for you. You give every node a `position`.
663
+
664
+ ## Origin of the code
665
+
666
+ The orthogonal routing of edges, the bezier control points and the application of changes come
667
+ from xyflow (MIT), in [`src/vendor/xyflow`](https://github.com/sykar-f/luciole/blob/v0.1.0/packages/flow-graph/src/vendor/xyflow/README.md). Rendering and
668
+ interaction are the package's own.
669
+
670
+ ## Licence
671
+
672
+ MIT.