@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/src/Flow.tsx ADDED
@@ -0,0 +1,684 @@
1
+ import {
2
+ useContext,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { RGBA, type BoxRenderable, type MouseEvent, type OptimizedBuffer } from "@opentui/core";
11
+ import { useBindings } from "@opentui/keymap/react";
12
+ import { composeFrame, dropTarget, hitTest, type EdgeType, type Frame } from "./frame.ts";
13
+ import { absolutePositions, detailFor, pickAnchor, placeNodes, type Placed } from "./geometry.ts";
14
+ import { NodeIdContext, StoreContext, useStoreVersion } from "./hooks.tsx";
15
+ import { cycle, follow, nearest, readingOrder, sibling } from "./navigation.ts";
16
+ import { FlowKeymap } from "./keymap.tsx";
17
+ import { builtinNodeTypes } from "./nodes.tsx";
18
+ import { FlowStore, type CanvasNodeChange } from "./store.ts";
19
+ import { defaultTheme, roleColor, ThemeContext, useFlowTheme, type FlowTheme } from "./theme.ts";
20
+ import type {
21
+ Connection,
22
+ Edge,
23
+ EdgeChange,
24
+ Node,
25
+ NodeChange,
26
+ NodeComponent,
27
+ Viewport,
28
+ XY,
29
+ } from "./types.ts";
30
+
31
+ const PAN_X = 4;
32
+ const PAN_Y = 2;
33
+ const ANIMATION_MS = 150;
34
+ const ANIMATION_STEPS = 12;
35
+ const LAYER_Z = 0;
36
+ const NODE_Z = 10;
37
+ const COLOR_CACHE_LIMIT = 256;
38
+ const MIN_COMPACT_WIDTH = 4;
39
+
40
+ export type FlowProps<N extends Node = Node, E extends Edge = Edge> = {
41
+ nodes: N[];
42
+ edges: E[];
43
+ /** What the canvas proposes for nodes (drag, selection, removal); without it they stay put. */
44
+ onNodesChange?: (changes: NodeChange<N>[]) => void;
45
+ onEdgesChange?: (changes: EdgeChange<E>[]) => void;
46
+ /** A connection made by dragging from a handle, or with the keyboard (`c`, then Enter). */
47
+ onConnect?: (connection: Connection) => void;
48
+ onNodeClick?: (node: N) => void;
49
+ onEdgeClick?: (edge: E) => void;
50
+ onPaneClick?: (at: XY) => void;
51
+ /** The end of a mouse drag, with the nodes it moved at their new positions. */
52
+ onNodeDragStop?: (nodes: N[]) => void;
53
+ onViewportChange?: (viewport: Viewport) => void;
54
+ nodeTypes?: Record<string, NodeComponent<N>>;
55
+ edgeTypes?: Record<string, EdgeType>;
56
+ /** The type of edges that name none. Default `smoothstep`. */
57
+ defaultEdgeType?: string;
58
+ /** Fit every node in view once the canvas has a size. */
59
+ fitView?: boolean;
60
+ defaultViewport?: Viewport;
61
+ /** Keyboard bindings (group `flow`); off while a field elsewhere takes the keys. */
62
+ keyboard?: boolean;
63
+ /** Curves in braille. Without, bezier and slanted edges are drawn as smoothstep. */
64
+ braille?: boolean;
65
+ theme?: Partial<FlowTheme>;
66
+ id?: string;
67
+ /** `<Background>`, `<MiniMap>`, `<Controls>`, `<Panel>`. */
68
+ children?: ReactNode;
69
+ };
70
+
71
+ /**
72
+ * A node graph on the terminal, after React Flow's `<ReactFlow>`: controlled `nodes` and
73
+ * `edges`, changes proposed through `onNodesChange`/`onEdgesChange`. Pan, zoom and drag
74
+ * stay on the Client; nothing reaches the Server unless the application sends it.
75
+ */
76
+ export function Flow<N extends Node = Node, E extends Edge = Edge>(props: FlowProps<N, E>) {
77
+ const outer = useContext(StoreContext);
78
+ const [own] = useState(() => (outer ? null : new FlowStore({ viewport: props.defaultViewport })));
79
+ const store = outer ?? own;
80
+ const theme = useMemo(() => ({ ...defaultTheme, ...props.theme }), [props.theme]);
81
+ if (!store) return null;
82
+ return (
83
+ <StoreContext.Provider value={store}>
84
+ <ThemeContext.Provider value={theme}>
85
+ <FlowKeymap>
86
+ <Canvas {...props} />
87
+ </FlowKeymap>
88
+ </ThemeContext.Provider>
89
+ </StoreContext.Provider>
90
+ );
91
+ }
92
+
93
+ type Drag =
94
+ | { kind: "nodes"; start: XY; origins: Map<string, XY>; delta: XY | null }
95
+ | { kind: "pan"; start: XY; viewport: Viewport }
96
+ | { kind: "connect" };
97
+
98
+ function Canvas<N extends Node, E extends Edge>(props: FlowProps<N, E>) {
99
+ const store = useStoreVersion();
100
+ const theme = useFlowTheme();
101
+ const canvas = useRef<BoxRenderable>(null);
102
+ const frameRef = useRef<Frame | null>(null);
103
+ const drag = useRef<Drag | null>(null);
104
+ const [focus, setFocus] = useState<string | undefined>(undefined);
105
+ const { nodes, edges, keyboard = true } = props;
106
+
107
+ useLayoutEffect(() =>
108
+ store.sync({
109
+ nodes,
110
+ edges,
111
+ callbacks: {
112
+ onNodesChange: props.onNodesChange,
113
+ onEdgesChange: props.onEdgesChange,
114
+ onConnect: props.onConnect,
115
+ onViewportChange: props.onViewportChange,
116
+ },
117
+ }),
118
+ );
119
+ const fit = props.fitView ?? false;
120
+ useLayoutEffect(() => {
121
+ if (fit) store.fitView();
122
+ }, [store, fit]);
123
+
124
+ const detail = store.detail;
125
+ const placed = placeNodes(nodes, store.viewport, store.measured);
126
+ const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
127
+ const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
128
+ const selectedNodes = nodes.filter((n) => n.selected).map((n) => n.id);
129
+ const selectedEdges = edges.filter((e) => e.selected).map((e) => e.id);
130
+ const current = focus && selectedNodes.includes(focus) ? focus : selectedNodes[0];
131
+
132
+ const local = (event: MouseEvent): XY => ({
133
+ x: event.x - (canvas.current?.x ?? 0),
134
+ y: event.y - (canvas.current?.y ?? 0),
135
+ });
136
+ const selectNode = (id: string, options: { reveal?: boolean } = {}) => {
137
+ setFocus(id);
138
+ store.select({ nodes: [id] });
139
+ if (options.reveal) store.reveal(id);
140
+ };
141
+
142
+ // ── Mouse ───────────────────────────────────────────────────────────────
143
+ const onMouseDown = (event: MouseEvent) => {
144
+ const frame = frameRef.current;
145
+ if (!frame || event.button !== 0) return;
146
+ const at = local(event);
147
+ const hit = hitTest(frame, at);
148
+ if (hit.kind === "handle" && props.onConnect) {
149
+ store.setConnecting({ source: hit.anchor, pointer: at });
150
+ drag.current = { kind: "connect" };
151
+ return;
152
+ }
153
+ if (hit.kind === "node" || hit.kind === "handle") {
154
+ const node = hit.kind === "node" ? hit.node : byId.get(hit.anchor.nodeId);
155
+ if (!node) return;
156
+ const original = byId.get(node.id);
157
+ if (node.selectable !== false) {
158
+ if (event.modifiers.shift)
159
+ store.select({
160
+ nodes: node.selected
161
+ ? selectedNodes.filter((id) => id !== node.id)
162
+ : [...selectedNodes, node.id],
163
+ edges: selectedEdges,
164
+ });
165
+ else if (!node.selected) selectNode(node.id);
166
+ setFocus(node.id);
167
+ }
168
+ if (original) props.onNodeClick?.(original);
169
+ const moving = (node.selected ? nodes.filter((n) => n.selected) : [node]).filter(
170
+ (n) => n.draggable !== false,
171
+ );
172
+ drag.current = {
173
+ kind: "nodes",
174
+ start: at,
175
+ origins: new Map(moving.map((n) => [n.id, n.position])),
176
+ delta: null,
177
+ };
178
+ return;
179
+ }
180
+ if (hit.kind === "edge") {
181
+ store.select({
182
+ edges: event.modifiers.shift ? [...selectedEdges, hit.id] : [hit.id],
183
+ });
184
+ const edge = edgeById.get(hit.id);
185
+ if (edge) props.onEdgeClick?.(edge);
186
+ return;
187
+ }
188
+ store.select({});
189
+ props.onPaneClick?.(store.toFlow(at));
190
+ drag.current = { kind: "pan", start: at, viewport: store.viewport };
191
+ };
192
+
193
+ const onMouseDrag = (event: MouseEvent) => {
194
+ const d = drag.current;
195
+ if (!d) return;
196
+ const at = local(event);
197
+ if (d.kind === "connect") {
198
+ if (store.connecting) store.setConnecting({ ...store.connecting, pointer: at });
199
+ return;
200
+ }
201
+ if (d.kind === "pan") {
202
+ store.setViewport({
203
+ ...d.viewport,
204
+ x: d.viewport.x + at.x - d.start.x,
205
+ y: d.viewport.y + at.y - d.start.y,
206
+ });
207
+ return;
208
+ }
209
+ const { zoom } = store.viewport;
210
+ const dx = Math.round((at.x - d.start.x) / zoom);
211
+ const dy = Math.round((at.y - d.start.y) / zoom);
212
+ if (dx === 0 && dy === 0 && !d.delta) return;
213
+ d.delta = { x: dx, y: dy };
214
+ store.proposeNodes(
215
+ [...d.origins].map(([id, o]) => ({
216
+ id,
217
+ type: "position",
218
+ position: { x: o.x + dx, y: o.y + dy },
219
+ dragging: true,
220
+ })),
221
+ );
222
+ };
223
+
224
+ const onMouseDragEnd = (event: MouseEvent) => {
225
+ const d = drag.current;
226
+ drag.current = null;
227
+ if (!d) return;
228
+ if (d.kind === "connect") {
229
+ const connecting = store.connecting;
230
+ const frame = frameRef.current;
231
+ store.setConnecting(null);
232
+ if (!connecting || !frame) return;
233
+ const target = dropTarget(frame, local(event), connecting.source);
234
+ if (target)
235
+ store.connect({
236
+ source: connecting.source.nodeId,
237
+ sourceHandle: connecting.source.handle,
238
+ target: target.nodeId,
239
+ targetHandle: target.handle,
240
+ });
241
+ return;
242
+ }
243
+ const delta = d.kind === "nodes" ? d.delta : null;
244
+ if (d.kind !== "nodes" || !delta) return;
245
+ // The last proposal, not the nodes: the application may not have rendered it yet.
246
+ const moved = [...d.origins].map(([id, o]) => ({
247
+ id,
248
+ position: { x: o.x + delta.x, y: o.y + delta.y },
249
+ }));
250
+ store.proposeNodes(moved.map((m) => ({ ...m, type: "position", dragging: false })));
251
+ props.onNodeDragStop?.(
252
+ moved.flatMap((m) => {
253
+ const original = byId.get(m.id);
254
+ return original ? [{ ...original, position: m.position, dragging: false }] : [];
255
+ }),
256
+ );
257
+ };
258
+
259
+ // A click without drag still ends a connection started on a handle.
260
+ const onMouseUp = () => {
261
+ if (drag.current?.kind === "connect") {
262
+ drag.current = null;
263
+ store.setConnecting(null);
264
+ } else if (drag.current?.kind !== "nodes") drag.current = null;
265
+ };
266
+
267
+ const onMouseScroll = (event: MouseEvent) => {
268
+ const direction = event.scroll?.direction;
269
+ if (!direction) return;
270
+ if (event.modifiers.ctrl || event.modifiers.alt) {
271
+ if (direction === "up" || direction === "down")
272
+ store.zoom(direction === "up" ? 1 : -1, local(event));
273
+ return;
274
+ }
275
+ const horizontal = event.modifiers.shift
276
+ ? direction === "up"
277
+ ? "left"
278
+ : direction === "down"
279
+ ? "right"
280
+ : direction
281
+ : direction;
282
+ if (horizontal === "up") store.panBy(0, PAN_Y);
283
+ else if (horizontal === "down") store.panBy(0, -PAN_Y);
284
+ else if (horizontal === "left") store.panBy(PAN_X, 0);
285
+ else store.panBy(-PAN_X, 0);
286
+ };
287
+
288
+ // ── Keyboard ────────────────────────────────────────────────────────────
289
+ const order = readingOrder(nodes, absolutePositions(nodes));
290
+ const connecting = store.connecting;
291
+ const moveSelected = (dx: number, dy: number) => {
292
+ const step = Math.max(1, Math.round(1 / store.viewport.zoom));
293
+ const changes: CanvasNodeChange[] = nodes
294
+ .filter((n) => n.selected && n.draggable !== false)
295
+ .map((n) => ({
296
+ id: n.id,
297
+ type: "position",
298
+ position: { x: n.position.x + dx * step, y: n.position.y + dy * step },
299
+ }));
300
+ store.proposeNodes(changes);
301
+ if (current) store.reveal(current);
302
+ };
303
+ const go = (id: string | undefined) => {
304
+ if (!id) return;
305
+ if (connecting) {
306
+ store.setConnecting({ ...connecting, target: id });
307
+ store.reveal(id);
308
+ } else selectNode(id, { reveal: true });
309
+ };
310
+ const startConnect = () => {
311
+ if (!current || !props.onConnect) return;
312
+ const p = placed.find((x) => x.node.id === current);
313
+ const source = p && pickAnchor(store.anchors(p), "source", null);
314
+ if (!source) return;
315
+ const others = order.filter((n) => n.id !== current);
316
+ const target = nearest(order, current, "right") ?? others[0]?.id;
317
+ store.setConnecting({ source, target });
318
+ if (target) store.reveal(target);
319
+ };
320
+ const confirmConnect = () => {
321
+ if (!connecting?.target) return;
322
+ const target = connecting.target;
323
+ const p = placed.find((x) => x.node.id === target);
324
+ const anchor = p && pickAnchor(store.anchors(p), "target", null);
325
+ store.setConnecting(null);
326
+ if (!anchor) return;
327
+ store.connect({
328
+ source: connecting.source.nodeId,
329
+ sourceHandle: connecting.source.handle,
330
+ target: anchor.nodeId,
331
+ targetHandle: anchor.handle,
332
+ });
333
+ };
334
+ const at = connecting?.target ?? current;
335
+ const nextEdge = () => {
336
+ const list = edges.filter(
337
+ (e) => !e.hidden && (!current || e.source === current || e.target === current),
338
+ );
339
+ if (list.length === 0) return;
340
+ const index = list.findIndex((e) => e.selected);
341
+ const edge = list[(index + 1) % list.length];
342
+ if (edge) store.select({ nodes: current ? [current] : [], edges: [edge.id] });
343
+ };
344
+
345
+ useBindings(
346
+ () => ({
347
+ bindings: keyboard
348
+ ? [
349
+ { key: "h", cmd: () => store.panBy(PAN_X, 0) },
350
+ { key: "left", cmd: () => store.panBy(PAN_X, 0) },
351
+ { key: "l", cmd: () => store.panBy(-PAN_X, 0) },
352
+ { key: "right", cmd: () => store.panBy(-PAN_X, 0) },
353
+ { key: "k", cmd: () => store.panBy(0, PAN_Y) },
354
+ { key: "up", cmd: () => store.panBy(0, PAN_Y) },
355
+ { key: "j", cmd: () => store.panBy(0, -PAN_Y) },
356
+ { key: "down", cmd: () => store.panBy(0, -PAN_Y) },
357
+ ...(current && !connecting
358
+ ? [
359
+ {
360
+ key: "shift+h",
361
+ cmd: () => moveSelected(-1, 0),
362
+ },
363
+ { key: "shift+left", cmd: () => moveSelected(-1, 0) },
364
+ { key: "shift+l", cmd: () => moveSelected(1, 0) },
365
+ { key: "shift+right", cmd: () => moveSelected(1, 0) },
366
+ { key: "shift+k", cmd: () => moveSelected(0, -1) },
367
+ { key: "shift+up", cmd: () => moveSelected(0, -1) },
368
+ { key: "shift+j", cmd: () => moveSelected(0, 1) },
369
+ { key: "shift+down", cmd: () => moveSelected(0, 1) },
370
+ ]
371
+ : []),
372
+ {
373
+ key: "tab",
374
+ cmd: () =>
375
+ go(
376
+ cycle(
377
+ order.filter((n) => n.id !== connecting?.source.nodeId),
378
+ at,
379
+ 1,
380
+ ),
381
+ ),
382
+ desc: "next",
383
+ group: "flow",
384
+ },
385
+ {
386
+ key: "shift+tab",
387
+ cmd: () =>
388
+ go(
389
+ cycle(
390
+ order.filter((n) => n.id !== connecting?.source.nodeId),
391
+ at,
392
+ -1,
393
+ ),
394
+ ),
395
+ },
396
+ ...(at
397
+ ? [
398
+ {
399
+ key: "]",
400
+ cmd: () => go(follow(edges, order, at, "down")),
401
+ desc: "downstream",
402
+ group: "flow",
403
+ },
404
+ {
405
+ key: "[",
406
+ cmd: () => go(follow(edges, order, at, "up")),
407
+ desc: "upstream",
408
+ group: "flow",
409
+ },
410
+ { key: "}", cmd: () => go(sibling(edges, order, at, 1)) },
411
+ { key: "{", cmd: () => go(sibling(edges, order, at, -1)) },
412
+ ]
413
+ : []),
414
+ ...(connecting
415
+ ? [
416
+ { key: "return", cmd: confirmConnect, desc: "connect", group: "flow" },
417
+ {
418
+ key: "escape",
419
+ cmd: () => store.setConnecting(null),
420
+ desc: "cancel",
421
+ group: "flow",
422
+ },
423
+ ]
424
+ : [
425
+ ...(current && props.onConnect
426
+ ? [{ key: "c", cmd: startConnect, desc: "connect", group: "flow" }]
427
+ : []),
428
+ ...(selectedNodes.length > 0 || selectedEdges.length > 0
429
+ ? [
430
+ {
431
+ key: "x",
432
+ cmd: () =>
433
+ store.deleteElements({ nodes: selectedNodes, edges: selectedEdges }),
434
+ desc: "delete",
435
+ group: "flow",
436
+ },
437
+ {
438
+ key: "delete",
439
+ cmd: () =>
440
+ store.deleteElements({ nodes: selectedNodes, edges: selectedEdges }),
441
+ },
442
+ { key: "escape", cmd: () => store.select({}) },
443
+ ]
444
+ : []),
445
+ ...(edges.length > 0
446
+ ? [{ key: "e", cmd: nextEdge, desc: "edges", group: "flow" }]
447
+ : []),
448
+ ]),
449
+ { key: "=", cmd: () => store.zoom(1), desc: "zoom in", group: "flow" },
450
+ { key: "+", cmd: () => store.zoom(1) },
451
+ { key: "-", cmd: () => store.zoom(-1), desc: "zoom out", group: "flow" },
452
+ { key: "0", cmd: () => store.fitView(), desc: "fit", group: "flow" },
453
+ ]
454
+ : [],
455
+ }),
456
+ [
457
+ keyboard,
458
+ store,
459
+ current,
460
+ at,
461
+ connecting,
462
+ order,
463
+ edges,
464
+ nodes,
465
+ selectedNodes.join(),
466
+ selectedEdges.join(),
467
+ ],
468
+ );
469
+
470
+ // ── Render ──────────────────────────────────────────────────────────────
471
+ // Compact nodes are as wide as they were at full detail, scaled: spaced as they were.
472
+ const compactWidth = (node: Node) => {
473
+ const full = store.full.get(node.id);
474
+ if (detail !== "compact" || node.type === "group" || !full) return undefined;
475
+ return Math.max(MIN_COMPACT_WIDTH, Math.round(full.width * store.viewport.zoom));
476
+ };
477
+ const typeOf = (type: string | undefined): NodeComponent<N> | undefined =>
478
+ props.nodeTypes?.[type ?? "default"] ??
479
+ builtinNodeTypes[type ?? "default"] ??
480
+ builtinNodeTypes.default;
481
+ return (
482
+ <box
483
+ ref={canvas}
484
+ id={props.id}
485
+ flexGrow={1}
486
+ position="relative"
487
+ overflow="hidden"
488
+ onSizeChange={() => {
489
+ const box = canvas.current;
490
+ if (box) store.setSize({ width: box.width, height: box.height });
491
+ }}
492
+ onMouseDown={onMouseDown}
493
+ onMouseDrag={onMouseDrag}
494
+ onMouseDragEnd={onMouseDragEnd}
495
+ onMouseUp={onMouseUp}
496
+ onMouseScroll={onMouseScroll}
497
+ >
498
+ <EdgeLayer
499
+ store={store}
500
+ nodes={nodes}
501
+ edges={edges}
502
+ theme={theme}
503
+ edgeTypes={props.edgeTypes}
504
+ defaultEdgeType={props.defaultEdgeType}
505
+ braille={props.braille}
506
+ frameRef={frameRef}
507
+ />
508
+ {placed.map((p, index) => {
509
+ const node = byId.get(p.node.id);
510
+ const Component = typeOf(p.node.type);
511
+ if (!node || !Component) return null;
512
+ return (
513
+ <NodeView
514
+ key={p.node.id}
515
+ store={store}
516
+ placed={p}
517
+ zIndex={NODE_Z + index}
518
+ dot={detail === "dot" && node.type !== "group"}
519
+ maxWidth={compactWidth(node)}
520
+ color={node.color ?? (node.selected ? theme.selected : theme.text)}
521
+ >
522
+ <Component
523
+ id={node.id}
524
+ data={node.data}
525
+ type={node.type ?? "default"}
526
+ selected={Boolean(node.selected)}
527
+ dragging={Boolean(node.dragging)}
528
+ detail={detail}
529
+ connectTarget={connecting?.target === node.id}
530
+ width={node.width === undefined ? undefined : p.rect.width}
531
+ height={node.height === undefined ? undefined : p.rect.height}
532
+ />
533
+ </NodeView>
534
+ );
535
+ })}
536
+ {props.children}
537
+ </box>
538
+ );
539
+ }
540
+
541
+ /** A node at its place, measured after each layout. */
542
+ function NodeView({
543
+ store,
544
+ placed,
545
+ zIndex,
546
+ dot,
547
+ color,
548
+ maxWidth,
549
+ children,
550
+ }: {
551
+ store: FlowStore;
552
+ placed: Placed;
553
+ zIndex: number;
554
+ dot: boolean;
555
+ color: string;
556
+ /** Clips what the node draws: a compact label never spills over its neighbours. */
557
+ maxWidth?: number;
558
+ children: ReactNode;
559
+ }) {
560
+ const ref = useRef<BoxRenderable>(null);
561
+ const { node, rect } = placed;
562
+ const detail = detailFor(store.viewport.zoom);
563
+ const measure = () => {
564
+ const box = ref.current;
565
+ if (box && box.width > 0 && box.height > 0)
566
+ store.measure(node.id, { width: box.width, height: box.height, detail });
567
+ };
568
+ return (
569
+ <box
570
+ ref={ref}
571
+ position="absolute"
572
+ left={rect.x}
573
+ top={rect.y}
574
+ zIndex={zIndex}
575
+ flexDirection="column"
576
+ width={maxWidth ?? "auto"}
577
+ overflow={maxWidth === undefined ? "visible" : "hidden"}
578
+ onSizeChange={measure}
579
+ >
580
+ <NodeIdContext.Provider value={node.id}>
581
+ {dot ? (
582
+ <text selectable={false} fg={color}>
583
+ {node.selected ? "◉" : "●"}
584
+ </text>
585
+ ) : (
586
+ children
587
+ )}
588
+ </NodeIdContext.Provider>
589
+ </box>
590
+ );
591
+ }
592
+
593
+ const colors = new Map<string, RGBA>();
594
+ function rgba(hex: string): RGBA {
595
+ const known = colors.get(hex);
596
+ if (known) return known;
597
+ if (colors.size > COLOR_CACHE_LIMIT) colors.clear();
598
+ const color = RGBA.fromHex(hex);
599
+ colors.set(hex, color);
600
+ return color;
601
+ }
602
+ const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0);
603
+
604
+ /** Edges, labels, handles and the background: one grid, copied under the nodes. */
605
+ function EdgeLayer({
606
+ store,
607
+ nodes,
608
+ edges,
609
+ theme,
610
+ edgeTypes,
611
+ defaultEdgeType,
612
+ braille,
613
+ frameRef,
614
+ }: {
615
+ store: FlowStore;
616
+ nodes: readonly Node[];
617
+ edges: readonly Edge[];
618
+ theme: FlowTheme;
619
+ edgeTypes?: Record<string, EdgeType>;
620
+ defaultEdgeType?: string;
621
+ braille?: boolean;
622
+ frameRef: { current: Frame | null };
623
+ }) {
624
+ const layer = useRef<BoxRenderable>(null);
625
+ const [phase, setPhase] = useState(0);
626
+ const animated = edges.some((e) => e.animated && !e.hidden);
627
+ useEffect(() => {
628
+ if (!animated) return;
629
+ const timer = setInterval(() => setPhase((p) => (p + 1) % ANIMATION_STEPS), ANIMATION_MS);
630
+ return () => clearInterval(timer);
631
+ }, [animated]);
632
+
633
+ const frame = composeFrame({
634
+ size: store.size,
635
+ viewport: store.viewport,
636
+ nodes,
637
+ edges,
638
+ measured: store.measured,
639
+ handles: store.handles,
640
+ background: store.background,
641
+ connecting: store.connecting,
642
+ edgeTypes,
643
+ defaultEdgeType,
644
+ braille,
645
+ phase,
646
+ });
647
+ const labelBg = rgba(theme.labelBg);
648
+ const background = store.background;
649
+
650
+ // Hit tests read the frame the user sees.
651
+ useLayoutEffect(() => {
652
+ frameRef.current = frame;
653
+ layer.current?.requestRender();
654
+ });
655
+ return (
656
+ <box
657
+ ref={layer}
658
+ position="absolute"
659
+ left={0}
660
+ top={0}
661
+ width="100%"
662
+ height="100%"
663
+ zIndex={LAYER_Z}
664
+ renderBefore={function (this: { x: number; y: number }, buffer: OptimizedBuffer) {
665
+ for (const [x, y, cell] of frame.grid.entries()) {
666
+ // A selected edge shows as selected, whatever its own color.
667
+ const color =
668
+ cell.role === "background" && background?.color
669
+ ? background.color
670
+ : cell.role === "selected"
671
+ ? theme.selected
672
+ : (cell.color ?? roleColor(theme, cell.role));
673
+ buffer.setCellWithAlphaBlending(
674
+ this.x + x,
675
+ this.y + y,
676
+ cell.char,
677
+ rgba(color),
678
+ cell.role === "label" ? labelBg : TRANSPARENT,
679
+ );
680
+ }
681
+ }}
682
+ />
683
+ );
684
+ }