@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.
- package/LICENSE +21 -0
- package/README.md +671 -2
- package/dist/Flow.d.ts +41 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +489 -0
- package/dist/Flow.js.map +1 -0
- package/dist/components.d.ts +28 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +100 -0
- package/dist/components.js.map +1 -0
- package/dist/frame.d.ts +68 -0
- package/dist/frame.d.ts.map +1 -0
- package/dist/frame.js +136 -0
- package/dist/frame.js.map +1 -0
- package/dist/geometry.d.ts +60 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +205 -0
- package/dist/geometry.js.map +1 -0
- package/dist/hooks.d.ts +56 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +65 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/keymap.d.ts +11 -0
- package/dist/keymap.d.ts.map +1 -0
- package/dist/keymap.js +42 -0
- package/dist/keymap.js.map +1 -0
- package/dist/navigation.d.ts +32 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +68 -0
- package/dist/navigation.js.map +1 -0
- package/dist/nodes.d.ts +18 -0
- package/dist/nodes.d.ts.map +1 -0
- package/dist/nodes.js +46 -0
- package/dist/nodes.js.map +1 -0
- package/dist/raster.d.ts +112 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +370 -0
- package/dist/raster.js.map +1 -0
- package/dist/store.d.ts +104 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +186 -0
- package/dist/store.js.map +1 -0
- package/dist/theme.d.ts +25 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +39 -0
- package/dist/theme.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vendor/xyflow/LICENSE +21 -0
- package/dist/vendor/xyflow/bezier.d.ts +26 -0
- package/dist/vendor/xyflow/bezier.d.ts.map +1 -0
- package/dist/vendor/xyflow/bezier.js +47 -0
- package/dist/vendor/xyflow/bezier.js.map +1 -0
- package/dist/vendor/xyflow/changes.d.ts +9 -0
- package/dist/vendor/xyflow/changes.d.ts.map +1 -0
- package/dist/vendor/xyflow/changes.js +133 -0
- package/dist/vendor/xyflow/changes.js.map +1 -0
- package/dist/vendor/xyflow/smoothstep.d.ts +17 -0
- package/dist/vendor/xyflow/smoothstep.d.ts.map +1 -0
- package/dist/vendor/xyflow/smoothstep.js +166 -0
- package/dist/vendor/xyflow/smoothstep.js.map +1 -0
- package/package.json +61 -4
- package/src/Flow.tsx +684 -0
- package/src/components.tsx +180 -0
- package/src/frame.ts +203 -0
- package/src/geometry.ts +274 -0
- package/src/hooks.tsx +121 -0
- package/src/index.ts +21 -0
- package/src/keymap.tsx +45 -0
- package/src/navigation.ts +86 -0
- package/src/nodes.tsx +86 -0
- package/src/raster.ts +453 -0
- package/src/store.ts +240 -0
- package/src/theme.ts +61 -0
- package/src/types.ts +130 -0
- package/src/vendor/xyflow/LICENSE +21 -0
- package/src/vendor/xyflow/README.md +20 -0
- package/src/vendor/xyflow/bezier.ts +102 -0
- package/src/vendor/xyflow/changes.ts +177 -0
- package/src/vendor/xyflow/smoothstep.ts +219 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { RGBA, type BoxRenderable, type MouseEvent, type OptimizedBuffer } from "@opentui/core";
|
|
3
|
+
import { absolutePositions, flowBounds, toFlow } from "./geometry.ts";
|
|
4
|
+
import { useStore, useStoreVersion } from "./hooks.tsx";
|
|
5
|
+
import { drawMiniMap, Grid, type BackgroundVariant, type Role } from "./raster.ts";
|
|
6
|
+
import { roleColor, useFlowTheme } from "./theme.ts";
|
|
7
|
+
import type { Rect, XY } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const PANEL_Z = 100_000;
|
|
10
|
+
const DEFAULT_GAP: XY = { x: 8, y: 4 };
|
|
11
|
+
const MINIMAP_WIDTH = 24;
|
|
12
|
+
const MINIMAP_HEIGHT = 8;
|
|
13
|
+
|
|
14
|
+
/** Marks every `gap` flow cells, under the edges: dots, grid lines, or crosses. */
|
|
15
|
+
export function Background({
|
|
16
|
+
variant = "dots",
|
|
17
|
+
gap = DEFAULT_GAP,
|
|
18
|
+
color,
|
|
19
|
+
}: {
|
|
20
|
+
variant?: BackgroundVariant;
|
|
21
|
+
gap?: XY;
|
|
22
|
+
color?: string;
|
|
23
|
+
}) {
|
|
24
|
+
const store = useStore();
|
|
25
|
+
const { x, y } = gap;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
store.setBackground({ variant, gap: { x, y }, color });
|
|
28
|
+
return () => store.setBackground(null);
|
|
29
|
+
}, [store, variant, x, y, color]);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type PanelPosition =
|
|
34
|
+
| "top-left"
|
|
35
|
+
| "top-center"
|
|
36
|
+
| "top-right"
|
|
37
|
+
| "bottom-left"
|
|
38
|
+
| "bottom-center"
|
|
39
|
+
| "bottom-right";
|
|
40
|
+
|
|
41
|
+
/** Content over the canvas, in a corner or centered on an edge. */
|
|
42
|
+
export function Panel({
|
|
43
|
+
position = "top-left",
|
|
44
|
+
children,
|
|
45
|
+
}: {
|
|
46
|
+
position?: PanelPosition;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
const [vertical, horizontal] = position.split("-");
|
|
50
|
+
const place = {
|
|
51
|
+
...(vertical === "top" ? { top: 0 } : { bottom: 0 }),
|
|
52
|
+
...(horizontal === "left"
|
|
53
|
+
? { left: 0 }
|
|
54
|
+
: horizontal === "right"
|
|
55
|
+
? { right: 0 }
|
|
56
|
+
: { left: 0, right: 0, alignItems: "center" as const }),
|
|
57
|
+
};
|
|
58
|
+
// A click on a panel is its own: it neither selects nor pans the canvas.
|
|
59
|
+
return (
|
|
60
|
+
<box
|
|
61
|
+
position="absolute"
|
|
62
|
+
zIndex={PANEL_Z}
|
|
63
|
+
flexDirection="column"
|
|
64
|
+
{...place}
|
|
65
|
+
onMouseDown={(event: MouseEvent) => event.stopPropagation()}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Zoom in, zoom out, fit, and the current level. */
|
|
73
|
+
export function Controls({ position = "bottom-left" }: { position?: PanelPosition }) {
|
|
74
|
+
const store = useStoreVersion();
|
|
75
|
+
const theme = useFlowTheme();
|
|
76
|
+
const button = (label: string, action: () => void) => (
|
|
77
|
+
<text
|
|
78
|
+
selectable={false}
|
|
79
|
+
fg={theme.text}
|
|
80
|
+
bg={theme.labelBg}
|
|
81
|
+
onMouseDown={(event: MouseEvent) => {
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
action();
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{` ${label} `}
|
|
87
|
+
</text>
|
|
88
|
+
);
|
|
89
|
+
return (
|
|
90
|
+
<Panel position={position}>
|
|
91
|
+
<box flexDirection="row" gap={1} backgroundColor={theme.panelBg}>
|
|
92
|
+
{button("+", () => store.zoom(1))}
|
|
93
|
+
{button("-", () => store.zoom(-1))}
|
|
94
|
+
{button("fit", () => store.fitView())}
|
|
95
|
+
<text selectable={false} fg={theme.muted}>
|
|
96
|
+
{store.detail}
|
|
97
|
+
</text>
|
|
98
|
+
</box>
|
|
99
|
+
</Panel>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every node in braille, and the part in view outlined. A click moves the view there.
|
|
105
|
+
*/
|
|
106
|
+
export function MiniMap({
|
|
107
|
+
position = "bottom-right",
|
|
108
|
+
width = MINIMAP_WIDTH,
|
|
109
|
+
height = MINIMAP_HEIGHT,
|
|
110
|
+
}: {
|
|
111
|
+
position?: PanelPosition;
|
|
112
|
+
width?: number;
|
|
113
|
+
height?: number;
|
|
114
|
+
}) {
|
|
115
|
+
const store = useStoreVersion();
|
|
116
|
+
const theme = useFlowTheme();
|
|
117
|
+
const box = useRef<BoxRenderable>(null);
|
|
118
|
+
const grid = new Grid(width, height);
|
|
119
|
+
const view: Rect = (() => {
|
|
120
|
+
const topLeft = toFlow({ x: 0, y: 0 }, store.viewport);
|
|
121
|
+
return {
|
|
122
|
+
...topLeft,
|
|
123
|
+
width: store.size.width / store.viewport.zoom,
|
|
124
|
+
height: store.size.height / store.viewport.zoom,
|
|
125
|
+
};
|
|
126
|
+
})();
|
|
127
|
+
const nodes = flowBounds(store.nodes, store.full) ?? view;
|
|
128
|
+
// The world: every node and the view.
|
|
129
|
+
const x0 = Math.min(nodes.x, view.x);
|
|
130
|
+
const y0 = Math.min(nodes.y, view.y);
|
|
131
|
+
const world = {
|
|
132
|
+
x: x0,
|
|
133
|
+
y: y0,
|
|
134
|
+
width: Math.max(nodes.x + nodes.width, view.x + view.width) - x0,
|
|
135
|
+
height: Math.max(nodes.y + nodes.height, view.y + view.height) - y0,
|
|
136
|
+
};
|
|
137
|
+
const positions = absolutePositions(store.nodes);
|
|
138
|
+
const rects = store.nodes
|
|
139
|
+
.filter((n) => !n.hidden && n.type !== "group")
|
|
140
|
+
.map((n) => {
|
|
141
|
+
const position = positions.get(n.id) ?? n.position;
|
|
142
|
+
const one = flowBounds([{ ...n, parentId: undefined, position }], store.full);
|
|
143
|
+
const role: Role = n.selected ? "selected" : "edge";
|
|
144
|
+
return {
|
|
145
|
+
rect: one ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
146
|
+
role,
|
|
147
|
+
color: n.selected ? undefined : n.color,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
const toWorld = drawMiniMap(grid, world, rects, view);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
box.current?.requestRender();
|
|
153
|
+
});
|
|
154
|
+
return (
|
|
155
|
+
<Panel position={position}>
|
|
156
|
+
<box
|
|
157
|
+
ref={box}
|
|
158
|
+
width={width}
|
|
159
|
+
height={height}
|
|
160
|
+
backgroundColor={theme.panelBg}
|
|
161
|
+
onMouseDown={(event: MouseEvent) => {
|
|
162
|
+
event.stopPropagation();
|
|
163
|
+
const at = box.current;
|
|
164
|
+
if (!at) return;
|
|
165
|
+
store.setCenter(toWorld({ x: event.x - at.x, y: event.y - at.y }));
|
|
166
|
+
}}
|
|
167
|
+
renderAfter={function (this: { x: number; y: number }, buffer: OptimizedBuffer) {
|
|
168
|
+
for (const [x, y, cell] of grid.entries())
|
|
169
|
+
buffer.setCellWithAlphaBlending(
|
|
170
|
+
this.x + x,
|
|
171
|
+
this.y + y,
|
|
172
|
+
cell.char,
|
|
173
|
+
RGBA.fromHex(cell.color ?? roleColor(theme, cell.role)),
|
|
174
|
+
RGBA.fromHex(theme.panelBg),
|
|
175
|
+
);
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
</Panel>
|
|
179
|
+
);
|
|
180
|
+
}
|
package/src/frame.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One frame of the canvas under its nodes: edges, labels, markers, handles, the
|
|
3
|
+
* connection being made and the background, composed into a grid. And the reverse: what
|
|
4
|
+
* is at a canvas cell. Pure, so that a frame can be printed and compared in tests.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
anchorsOf,
|
|
8
|
+
contains,
|
|
9
|
+
detailFor,
|
|
10
|
+
pickAnchor,
|
|
11
|
+
placeNodes,
|
|
12
|
+
type Anchor,
|
|
13
|
+
type Measured,
|
|
14
|
+
type Placed,
|
|
15
|
+
type Size,
|
|
16
|
+
} from "./geometry.ts";
|
|
17
|
+
import {
|
|
18
|
+
drawBackground,
|
|
19
|
+
drawEdge,
|
|
20
|
+
drawLabel,
|
|
21
|
+
Grid,
|
|
22
|
+
type BackgroundVariant,
|
|
23
|
+
type EdgeDraw,
|
|
24
|
+
type EdgeStyle,
|
|
25
|
+
} from "./raster.ts";
|
|
26
|
+
import type { Edge, HandleSpec, Node, Position, Viewport, XY } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A custom edge type: the corner points of its path from the source anchor cell to the
|
|
30
|
+
* target's, drawn with box characters like a step edge.
|
|
31
|
+
*/
|
|
32
|
+
export type EdgeRoute = (params: {
|
|
33
|
+
edge: Edge;
|
|
34
|
+
source: XY;
|
|
35
|
+
sourcePosition: Position;
|
|
36
|
+
target: XY;
|
|
37
|
+
targetPosition: Position;
|
|
38
|
+
}) => XY[];
|
|
39
|
+
/** An edge type: a built-in style, or a route. */
|
|
40
|
+
export type EdgeType = EdgeStyle | EdgeRoute;
|
|
41
|
+
|
|
42
|
+
const BUILTIN_STYLES: ReadonlySet<string> = new Set(["smoothstep", "step", "straight", "bezier"]);
|
|
43
|
+
export const CONNECTION_ID = "__connection";
|
|
44
|
+
const SOURCE_HANDLE = "●";
|
|
45
|
+
const TARGET_HANDLE = "○";
|
|
46
|
+
|
|
47
|
+
export type FrameInput = {
|
|
48
|
+
size: Size;
|
|
49
|
+
viewport: Viewport;
|
|
50
|
+
nodes: readonly Node[];
|
|
51
|
+
edges: readonly Edge[];
|
|
52
|
+
measured: ReadonlyMap<string, Measured>;
|
|
53
|
+
handles?: ReadonlyMap<string, ReadonlyMap<string, HandleSpec>>;
|
|
54
|
+
background?: { variant: BackgroundVariant; gap: XY } | null;
|
|
55
|
+
connecting?: { source: Anchor; pointer?: XY; target?: string } | null;
|
|
56
|
+
edgeTypes?: Readonly<Record<string, EdgeType>>;
|
|
57
|
+
defaultEdgeType?: string;
|
|
58
|
+
/** Curves and slanted lines in braille; without, they are drawn as smoothstep edges. */
|
|
59
|
+
braille?: boolean;
|
|
60
|
+
/** Animation step: advances the dashes of animated edges. */
|
|
61
|
+
phase?: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type Frame = {
|
|
65
|
+
grid: Grid;
|
|
66
|
+
placed: Placed[];
|
|
67
|
+
anchors: Map<string, Anchor[]>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function composeFrame(input: FrameInput): Frame {
|
|
71
|
+
const detail = detailFor(input.viewport.zoom);
|
|
72
|
+
const placed = placeNodes(input.nodes, input.viewport, input.measured);
|
|
73
|
+
const anchors = new Map(
|
|
74
|
+
placed.map((p) => [
|
|
75
|
+
p.node.id,
|
|
76
|
+
anchorsOf(p, [...(input.handles?.get(p.node.id)?.values() ?? [])], detail),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
const grid = new Grid(input.size.width, input.size.height);
|
|
80
|
+
const labels: { at: XY; text: string; owner: string }[] = [];
|
|
81
|
+
|
|
82
|
+
// Selected edges last: their color wins where lines share a cell.
|
|
83
|
+
const rank = (e: Edge) => (e.selected ? 2 : e.animated ? 1 : 0);
|
|
84
|
+
const edges = [...input.edges].filter((e) => !e.hidden).sort((a, b) => rank(a) - rank(b));
|
|
85
|
+
for (const edge of edges) {
|
|
86
|
+
const source = pickAnchor(anchors.get(edge.source) ?? [], "source", edge.sourceHandle);
|
|
87
|
+
const target = pickAnchor(anchors.get(edge.target) ?? [], "target", edge.targetHandle);
|
|
88
|
+
if (!source || !target) continue;
|
|
89
|
+
const draw = edgeDraw(edge, source, target, input);
|
|
90
|
+
const at = drawEdge(grid, draw);
|
|
91
|
+
if (edge.label) labels.push({ at, text: edge.label, owner: edge.id });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const connecting = input.connecting;
|
|
95
|
+
if (connecting) {
|
|
96
|
+
const target = connecting.target
|
|
97
|
+
? pickAnchor(anchors.get(connecting.target) ?? [], "target", null)
|
|
98
|
+
: undefined;
|
|
99
|
+
const pointer = connecting.pointer;
|
|
100
|
+
const end = target?.cell ?? pointer;
|
|
101
|
+
if (end) {
|
|
102
|
+
const targetPosition: Position =
|
|
103
|
+
target?.position ?? (end.x >= connecting.source.cell.x ? "left" : "right");
|
|
104
|
+
drawEdge(grid, {
|
|
105
|
+
id: CONNECTION_ID,
|
|
106
|
+
style: "smoothstep",
|
|
107
|
+
source: connecting.source.cell,
|
|
108
|
+
sourcePosition: connecting.source.position,
|
|
109
|
+
target: end,
|
|
110
|
+
targetPosition,
|
|
111
|
+
role: "handle",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Where it can end: every other node's targets.
|
|
115
|
+
for (const [id, list] of anchors)
|
|
116
|
+
if (id !== connecting.source.nodeId)
|
|
117
|
+
for (const a of list)
|
|
118
|
+
if (a.type === "target" && !(target && a === target))
|
|
119
|
+
grid.set(a.cell.x, a.cell.y, { char: TARGET_HANDLE, role: "handle" });
|
|
120
|
+
}
|
|
121
|
+
// The selected nodes' sources: where a connection starts with the mouse.
|
|
122
|
+
for (const p of placed)
|
|
123
|
+
if (p.node.selected && p.node.connectable !== false)
|
|
124
|
+
for (const a of anchors.get(p.node.id) ?? [])
|
|
125
|
+
if (a.type === "source" && !grid.get(a.cell.x, a.cell.y))
|
|
126
|
+
grid.set(a.cell.x, a.cell.y, { char: SOURCE_HANDLE, role: "handle" });
|
|
127
|
+
|
|
128
|
+
for (const label of labels) drawLabel(grid, label.at, label.text, label.owner);
|
|
129
|
+
grid.resolve(input.phase ?? 0);
|
|
130
|
+
if (input.background)
|
|
131
|
+
drawBackground(grid, input.viewport, input.background.variant, input.background.gap);
|
|
132
|
+
return { grid, placed, anchors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function edgeDraw(edge: Edge, source: Anchor, target: Anchor, input: FrameInput): EdgeDraw {
|
|
136
|
+
const name = edge.type ?? input.defaultEdgeType ?? "smoothstep";
|
|
137
|
+
const type = input.edgeTypes?.[name] ?? (BUILTIN_STYLES.has(name) ? name : "smoothstep");
|
|
138
|
+
const base = {
|
|
139
|
+
id: edge.id,
|
|
140
|
+
source: source.cell,
|
|
141
|
+
sourcePosition: source.position,
|
|
142
|
+
target: target.cell,
|
|
143
|
+
targetPosition: target.position,
|
|
144
|
+
label: edge.label,
|
|
145
|
+
animated: edge.animated,
|
|
146
|
+
selected: edge.selected,
|
|
147
|
+
markerEnd: edge.markerEnd !== "none",
|
|
148
|
+
markerStart: edge.markerStart === "arrow",
|
|
149
|
+
color: edge.color,
|
|
150
|
+
};
|
|
151
|
+
if (typeof type === "function")
|
|
152
|
+
return { ...base, style: "step", points: type({ edge, ...base }) };
|
|
153
|
+
const style: EdgeStyle =
|
|
154
|
+
type === "smoothstep" || type === "step" || type === "straight" || type === "bezier"
|
|
155
|
+
? type
|
|
156
|
+
: "smoothstep";
|
|
157
|
+
// Without braille, curves fall back to the orthogonal path.
|
|
158
|
+
const curved = style === "bezier" || style === "straight";
|
|
159
|
+
return { ...base, style: curved && input.braille === false ? "smoothstep" : style };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type Hit =
|
|
163
|
+
| { kind: "handle"; anchor: Anchor }
|
|
164
|
+
| { kind: "node"; node: Node }
|
|
165
|
+
| { kind: "edge"; id: string }
|
|
166
|
+
| { kind: "pane" };
|
|
167
|
+
|
|
168
|
+
/** What is at canvas cell `at`: a source handle, a node (topmost first), an edge, or none. */
|
|
169
|
+
export function hitTest(frame: Frame, at: XY): Hit {
|
|
170
|
+
for (const list of frame.anchors.values())
|
|
171
|
+
for (const anchor of list)
|
|
172
|
+
if (anchor.type === "source" && anchor.cell.x === at.x && anchor.cell.y === at.y) {
|
|
173
|
+
const node = frame.placed.find((p) => p.node.id === anchor.nodeId)?.node;
|
|
174
|
+
if (node?.connectable !== false) return { kind: "handle", anchor };
|
|
175
|
+
}
|
|
176
|
+
for (let i = frame.placed.length - 1; i >= 0; i--) {
|
|
177
|
+
const p = frame.placed[i];
|
|
178
|
+
if (p && contains(p.rect, at)) return { kind: "node", node: p.node };
|
|
179
|
+
}
|
|
180
|
+
const owner = frame.grid.get(at.x, at.y)?.owner;
|
|
181
|
+
if (owner && owner !== CONNECTION_ID) return { kind: "edge", id: owner };
|
|
182
|
+
return { kind: "pane" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Where a connection dropped at `at` ends: a target handle, or a node's first target. */
|
|
186
|
+
export function dropTarget(frame: Frame, at: XY, source: Anchor): Anchor | undefined {
|
|
187
|
+
for (const list of frame.anchors.values())
|
|
188
|
+
for (const anchor of list)
|
|
189
|
+
if (
|
|
190
|
+
anchor.type === "target" &&
|
|
191
|
+
anchor.nodeId !== source.nodeId &&
|
|
192
|
+
anchor.cell.x === at.x &&
|
|
193
|
+
anchor.cell.y === at.y
|
|
194
|
+
)
|
|
195
|
+
return anchor;
|
|
196
|
+
for (let i = frame.placed.length - 1; i >= 0; i--) {
|
|
197
|
+
const p = frame.placed[i];
|
|
198
|
+
if (!p || p.node.id === source.nodeId || !contains(p.rect, at)) continue;
|
|
199
|
+
if (p.node.type === "group") continue;
|
|
200
|
+
return pickAnchor(frame.anchors.get(p.node.id) ?? [], "target", null);
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
package/src/geometry.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where things are on the canvas: flow coordinates (cells at zoom 1) to canvas cells,
|
|
3
|
+
* semantic zoom, node rectangles, handle anchors, fitting the view. Pure functions.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
Detail,
|
|
7
|
+
HandleSpec,
|
|
8
|
+
HandleType,
|
|
9
|
+
Node,
|
|
10
|
+
Position,
|
|
11
|
+
Rect,
|
|
12
|
+
Viewport,
|
|
13
|
+
XY,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** The zoom levels, from closest to farthest. Each draws nodes with less detail. */
|
|
17
|
+
const HALF = 0.5;
|
|
18
|
+
const QUARTER = 0.25;
|
|
19
|
+
export const ZOOMS = [1, HALF, QUARTER] as const;
|
|
20
|
+
const DETAILS: readonly Detail[] = ["full", "compact", "dot"];
|
|
21
|
+
const FIT_PADDING = 2;
|
|
22
|
+
/** A node that was never measured: its label in a bordered box. */
|
|
23
|
+
const BORDER_AND_PADDING = 4;
|
|
24
|
+
const DEFAULT_HEIGHT = 3;
|
|
25
|
+
const MIN_LABEL = 4;
|
|
26
|
+
|
|
27
|
+
export function detailFor(zoom: number): Detail {
|
|
28
|
+
const index = ZOOMS.findIndex((z) => zoom >= z);
|
|
29
|
+
return DETAILS[index < 0 ? DETAILS.length - 1 : index] ?? "dot";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The next zoom level in (`+1`) or out (`-1`), or the same one at either end. */
|
|
33
|
+
export function stepZoom(zoom: number, direction: 1 | -1): number {
|
|
34
|
+
const index = ZOOMS.findIndex((z) => zoom >= z);
|
|
35
|
+
const current = index < 0 ? ZOOMS.length - 1 : index;
|
|
36
|
+
const next = Math.min(ZOOMS.length - 1, Math.max(0, current - direction));
|
|
37
|
+
return ZOOMS[next] ?? zoom;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Zooms to `zoom` keeping the flow point under canvas cell `anchor` where it is. */
|
|
41
|
+
export function zoomAround(viewport: Viewport, zoom: number, anchor: XY): Viewport {
|
|
42
|
+
const flow = toFlow(anchor, viewport);
|
|
43
|
+
return {
|
|
44
|
+
x: Math.round(anchor.x - flow.x * zoom),
|
|
45
|
+
y: Math.round(anchor.y - flow.y * zoom),
|
|
46
|
+
zoom,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const toCanvas = (point: XY, viewport: Viewport): XY => ({
|
|
51
|
+
x: Math.round(point.x * viewport.zoom + viewport.x),
|
|
52
|
+
y: Math.round(point.y * viewport.zoom + viewport.y),
|
|
53
|
+
});
|
|
54
|
+
export const toFlow = (point: XY, viewport: Viewport): XY => ({
|
|
55
|
+
x: (point.x - viewport.x) / viewport.zoom,
|
|
56
|
+
y: (point.y - viewport.y) / viewport.zoom,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const labelOf = (node: Node) =>
|
|
60
|
+
typeof node.data.label === "string" ? node.data.label : node.id;
|
|
61
|
+
|
|
62
|
+
/** Where each node is in flow coordinates, its parents' positions added. */
|
|
63
|
+
export function absolutePositions(nodes: readonly Node[]): Map<string, XY> {
|
|
64
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
65
|
+
const positions = new Map<string, XY>();
|
|
66
|
+
const resolve = (node: Node, seen: Set<string>): XY => {
|
|
67
|
+
const known = positions.get(node.id);
|
|
68
|
+
if (known) return known;
|
|
69
|
+
const parent = node.parentId ? byId.get(node.parentId) : undefined;
|
|
70
|
+
// A cycle of parents is ignored rather than followed.
|
|
71
|
+
const origin =
|
|
72
|
+
parent && !seen.has(parent.id) ? resolve(parent, new Set(seen).add(node.id)) : { x: 0, y: 0 };
|
|
73
|
+
const position = { x: origin.x + node.position.x, y: origin.y + node.position.y };
|
|
74
|
+
positions.set(node.id, position);
|
|
75
|
+
return position;
|
|
76
|
+
};
|
|
77
|
+
for (const node of nodes) resolve(node, new Set([node.id]));
|
|
78
|
+
return positions;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type Size = { width: number; height: number };
|
|
82
|
+
/** A rendered size and the detail it was rendered at. */
|
|
83
|
+
export type Measured = Size & { detail: Detail };
|
|
84
|
+
|
|
85
|
+
/** The size a node takes at `detail` before it is measured. */
|
|
86
|
+
export function estimateSize(node: Node, detail: Detail, zoom = 1): Size {
|
|
87
|
+
if (node.type === "group" || (node.width !== undefined && node.height !== undefined)) {
|
|
88
|
+
const width = node.width ?? MIN_LABEL;
|
|
89
|
+
const height = node.height ?? DEFAULT_HEIGHT;
|
|
90
|
+
return detail === "full"
|
|
91
|
+
? { width, height }
|
|
92
|
+
: {
|
|
93
|
+
width: Math.max(2, Math.round(width * zoom)),
|
|
94
|
+
height: Math.max(1, Math.round(height * zoom)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const label = Math.max(MIN_LABEL, labelOf(node).length);
|
|
98
|
+
if (detail === "dot") return { width: 1, height: 1 };
|
|
99
|
+
if (detail === "compact") return { width: label + 2, height: 1 };
|
|
100
|
+
return { width: label + BORDER_AND_PADDING, height: DEFAULT_HEIGHT };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Placed = {
|
|
104
|
+
node: Node;
|
|
105
|
+
/** In canvas cells. */
|
|
106
|
+
rect: Rect;
|
|
107
|
+
/** Drawing order: groups under their children, the selection over the rest. */
|
|
108
|
+
z: number;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const GROUP_Z = -1;
|
|
112
|
+
const SELECTED_Z = 1000;
|
|
113
|
+
|
|
114
|
+
/** Each visible node's rectangle on the canvas, in drawing order. */
|
|
115
|
+
export function placeNodes(
|
|
116
|
+
nodes: readonly Node[],
|
|
117
|
+
viewport: Viewport,
|
|
118
|
+
measured: ReadonlyMap<string, Measured>,
|
|
119
|
+
): Placed[] {
|
|
120
|
+
const detail = detailFor(viewport.zoom);
|
|
121
|
+
const positions = absolutePositions(nodes);
|
|
122
|
+
const placed: Placed[] = [];
|
|
123
|
+
for (const node of nodes) {
|
|
124
|
+
if (node.hidden) continue;
|
|
125
|
+
const at = toCanvas(positions.get(node.id) ?? node.position, viewport);
|
|
126
|
+
const size = measured.get(node.id);
|
|
127
|
+
const { width, height } =
|
|
128
|
+
size && size.detail === detail ? size : estimateSize(node, detail, viewport.zoom);
|
|
129
|
+
const z =
|
|
130
|
+
(node.zIndex ?? 0) + (node.type === "group" ? GROUP_Z : 0) + (node.selected ? SELECTED_Z : 0);
|
|
131
|
+
placed.push({ node, rect: { x: at.x, y: at.y, width, height }, z });
|
|
132
|
+
}
|
|
133
|
+
// Stable: equal z keeps the application's order.
|
|
134
|
+
return placed.sort((a, b) => a.z - b.z);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The flow-coordinate bounds of `nodes` at full detail, or null when there are none. */
|
|
138
|
+
export function flowBounds(
|
|
139
|
+
nodes: readonly Node[],
|
|
140
|
+
measured: ReadonlyMap<string, Measured>,
|
|
141
|
+
): Rect | null {
|
|
142
|
+
const positions = absolutePositions(nodes);
|
|
143
|
+
let bounds: { x0: number; y0: number; x1: number; y1: number } | null = null;
|
|
144
|
+
for (const node of nodes) {
|
|
145
|
+
if (node.hidden) continue;
|
|
146
|
+
const at = positions.get(node.id) ?? node.position;
|
|
147
|
+
const size = measured.get(node.id);
|
|
148
|
+
const { width, height } = size?.detail === "full" ? size : estimateSize(node, "full");
|
|
149
|
+
const box = { x0: at.x, y0: at.y, x1: at.x + width, y1: at.y + height };
|
|
150
|
+
bounds = bounds
|
|
151
|
+
? {
|
|
152
|
+
x0: Math.min(bounds.x0, box.x0),
|
|
153
|
+
y0: Math.min(bounds.y0, box.y0),
|
|
154
|
+
x1: Math.max(bounds.x1, box.x1),
|
|
155
|
+
y1: Math.max(bounds.y1, box.y1),
|
|
156
|
+
}
|
|
157
|
+
: box;
|
|
158
|
+
}
|
|
159
|
+
return (
|
|
160
|
+
bounds && {
|
|
161
|
+
x: bounds.x0,
|
|
162
|
+
y: bounds.y0,
|
|
163
|
+
width: bounds.x1 - bounds.x0,
|
|
164
|
+
height: bounds.y1 - bounds.y0,
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The closest zoom level at which `bounds` fits `canvas`, centered. */
|
|
170
|
+
export function fitViewport(bounds: Rect | null, canvas: Size, padding = FIT_PADDING): Viewport {
|
|
171
|
+
if (!bounds) return { x: padding, y: padding, zoom: 1 };
|
|
172
|
+
const zoom =
|
|
173
|
+
ZOOMS.find(
|
|
174
|
+
(z) =>
|
|
175
|
+
bounds.width * z <= canvas.width - padding * 2 &&
|
|
176
|
+
bounds.height * z <= canvas.height - padding * 2,
|
|
177
|
+
) ?? ZOOMS[ZOOMS.length - 1];
|
|
178
|
+
const z = zoom ?? 1;
|
|
179
|
+
return {
|
|
180
|
+
x: Math.round((canvas.width - bounds.width * z) / 2 - bounds.x * z),
|
|
181
|
+
y: Math.round((canvas.height - bounds.height * z) / 2 - bounds.y * z),
|
|
182
|
+
zoom: z,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The smallest pan that brings `rect` (canvas cells) into `canvas`, `margin` cells in. */
|
|
187
|
+
export function panToShow(viewport: Viewport, rect: Rect, canvas: Size, margin = 1): Viewport {
|
|
188
|
+
const shift = (start: number, length: number, size: number) => {
|
|
189
|
+
if (length + margin * 2 > size) return -start + margin;
|
|
190
|
+
if (start < margin) return margin - start;
|
|
191
|
+
if (start + length > size - margin) return size - margin - (start + length);
|
|
192
|
+
return 0;
|
|
193
|
+
};
|
|
194
|
+
const dx = shift(rect.x, rect.width, canvas.width);
|
|
195
|
+
const dy = shift(rect.y, rect.height, canvas.height);
|
|
196
|
+
return dx === 0 && dy === 0 ? viewport : { ...viewport, x: viewport.x + dx, y: viewport.y + dy };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const contains = (rect: Rect, point: XY) =>
|
|
200
|
+
point.x >= rect.x &&
|
|
201
|
+
point.x < rect.x + rect.width &&
|
|
202
|
+
point.y >= rect.y &&
|
|
203
|
+
point.y < rect.y + rect.height;
|
|
204
|
+
|
|
205
|
+
export type Anchor = {
|
|
206
|
+
nodeId: string;
|
|
207
|
+
handle: string | null;
|
|
208
|
+
type: HandleType;
|
|
209
|
+
position: Position;
|
|
210
|
+
/** The cell just outside the node's border, where the edge starts or ends. */
|
|
211
|
+
cell: XY;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Each handle's anchor cell: `handles` as the node declared them, or one source and one
|
|
216
|
+
* target on the node's `sourcePosition`/`targetPosition`. Handles sharing a side are
|
|
217
|
+
* spread along it.
|
|
218
|
+
*/
|
|
219
|
+
export function anchorsOf(
|
|
220
|
+
placed: Placed,
|
|
221
|
+
handles: readonly HandleSpec[] | undefined,
|
|
222
|
+
detail: Detail,
|
|
223
|
+
): Anchor[] {
|
|
224
|
+
const { node, rect } = placed;
|
|
225
|
+
const specs: readonly HandleSpec[] =
|
|
226
|
+
handles && handles.length > 0
|
|
227
|
+
? handles
|
|
228
|
+
: [
|
|
229
|
+
...(node.type === "input" || node.type === "group"
|
|
230
|
+
? []
|
|
231
|
+
: [{ id: null, type: "target" as const, position: node.targetPosition ?? "left" }]),
|
|
232
|
+
...(node.type === "output" || node.type === "group"
|
|
233
|
+
? []
|
|
234
|
+
: [{ id: null, type: "source" as const, position: node.sourcePosition ?? "right" }]),
|
|
235
|
+
];
|
|
236
|
+
// At `dot` the node is one cell: every handle is on it, whatever its side.
|
|
237
|
+
const bySide = new Map<Position, HandleSpec[]>();
|
|
238
|
+
for (const spec of specs) bySide.set(spec.position, [...(bySide.get(spec.position) ?? []), spec]);
|
|
239
|
+
const anchors: Anchor[] = [];
|
|
240
|
+
for (const [position, list] of bySide)
|
|
241
|
+
list.forEach((spec, index) => {
|
|
242
|
+
const along = (length: number) =>
|
|
243
|
+
detail === "dot" ? 0 : Math.floor(((index + 1) * length) / (list.length + 1) - 1 / 2);
|
|
244
|
+
const midX = rect.x + Math.max(0, Math.min(rect.width - 1, along(rect.width)));
|
|
245
|
+
const midY = rect.y + Math.max(0, Math.min(rect.height - 1, along(rect.height)));
|
|
246
|
+
const cell =
|
|
247
|
+
position === "left"
|
|
248
|
+
? { x: rect.x - 1, y: midY }
|
|
249
|
+
: position === "right"
|
|
250
|
+
? { x: rect.x + rect.width, y: midY }
|
|
251
|
+
: position === "top"
|
|
252
|
+
? { x: midX, y: rect.y - 1 }
|
|
253
|
+
: { x: midX, y: rect.y + rect.height };
|
|
254
|
+
anchors.push({ nodeId: node.id, handle: spec.id, type: spec.type, position, cell });
|
|
255
|
+
});
|
|
256
|
+
return anchors;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The anchor an edge uses at one end: its named handle, else the first of that type. */
|
|
260
|
+
export function pickAnchor(
|
|
261
|
+
anchors: readonly Anchor[],
|
|
262
|
+
type: HandleType,
|
|
263
|
+
handle: string | null | undefined,
|
|
264
|
+
): Anchor | undefined {
|
|
265
|
+
const ofType = anchors.filter((a) => a.type === type);
|
|
266
|
+
return (handle ? ofType.find((a) => a.handle === handle) : undefined) ?? ofType[0];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const opposite: Record<Position, Position> = {
|
|
270
|
+
left: "right",
|
|
271
|
+
right: "left",
|
|
272
|
+
top: "bottom",
|
|
273
|
+
bottom: "top",
|
|
274
|
+
};
|