@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
package/src/raster.ts
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edges drawn on the cell grid. Orthogonal paths (step, smoothstep, aligned straight
|
|
3
|
+
* edges) set four direction bits per cell; the bits of every edge in a cell are merged,
|
|
4
|
+
* so a fork reads `├`, two lines meeting `┼`, and a crossing is a `┼` too. Curves
|
|
5
|
+
* (bezier) and slanted straight lines are drawn in braille dots, two by four per cell.
|
|
6
|
+
* Pure: the canvas copies the grid into OpenTUI's buffer, tests print it.
|
|
7
|
+
*/
|
|
8
|
+
import type { Position, Rect, XY } from "./types.ts";
|
|
9
|
+
import { getStepPoints } from "./vendor/xyflow/smoothstep.ts";
|
|
10
|
+
import { getBezierEdgeCenter, getControlWithCurvature } from "./vendor/xyflow/bezier.ts";
|
|
11
|
+
|
|
12
|
+
export type Role = "edge" | "selected" | "animated" | "label" | "marker" | "background" | "handle";
|
|
13
|
+
export type Cell = {
|
|
14
|
+
char: string;
|
|
15
|
+
role: Role;
|
|
16
|
+
/** An explicit color, over the role's. */
|
|
17
|
+
color?: string;
|
|
18
|
+
/** The edge this cell belongs to, for hit testing. */
|
|
19
|
+
owner?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Direction bits of a box cell.
|
|
23
|
+
const N = 1;
|
|
24
|
+
const E = 2;
|
|
25
|
+
const S = 4;
|
|
26
|
+
const W = 8;
|
|
27
|
+
const BRAILLE_BASE = 0x2800;
|
|
28
|
+
const DOTS_X = 2;
|
|
29
|
+
const DOTS_Y = 4;
|
|
30
|
+
// Braille dots: 1-2-3 down the left column, 4-5-6 down the right, then 7 and 8 below.
|
|
31
|
+
const UPPER_ROWS = 3;
|
|
32
|
+
const LOWER_DOTS = 6;
|
|
33
|
+
const brailleBit = (column: number, row: number) =>
|
|
34
|
+
row < UPPER_ROWS ? 1 << (row + column * UPPER_ROWS) : 1 << (LOWER_DOTS + column);
|
|
35
|
+
/** Cells of an animated edge: one runner every this many. */
|
|
36
|
+
const RUNNER_EVERY = 4;
|
|
37
|
+
/** Bezier samples per cell of distance. */
|
|
38
|
+
const SAMPLES_PER_CELL = 6;
|
|
39
|
+
/** Curvature for backward bezier edges: xyflow's 0.25 is tuned for pixels. */
|
|
40
|
+
const CELL_CURVATURE = 0.06;
|
|
41
|
+
|
|
42
|
+
const SQUARE: Record<number, string> = {
|
|
43
|
+
[N]: "│",
|
|
44
|
+
[S]: "│",
|
|
45
|
+
[N | S]: "│",
|
|
46
|
+
[E]: "─",
|
|
47
|
+
[W]: "─",
|
|
48
|
+
[E | W]: "─",
|
|
49
|
+
[E | S]: "┌",
|
|
50
|
+
[W | S]: "┐",
|
|
51
|
+
[N | E]: "└",
|
|
52
|
+
[N | W]: "┘",
|
|
53
|
+
[N | E | S]: "├",
|
|
54
|
+
[N | W | S]: "┤",
|
|
55
|
+
[E | W | S]: "┬",
|
|
56
|
+
[N | E | W]: "┴",
|
|
57
|
+
[N | E | S | W]: "┼",
|
|
58
|
+
};
|
|
59
|
+
const ROUNDED: Record<number, string> = {
|
|
60
|
+
[E | S]: "╭",
|
|
61
|
+
[W | S]: "╮",
|
|
62
|
+
[N | E]: "╰",
|
|
63
|
+
[N | W]: "╯",
|
|
64
|
+
};
|
|
65
|
+
const LINE_ROLES: ReadonlySet<Role> = new Set(["edge", "selected", "animated"]);
|
|
66
|
+
const DASHED: Record<number, string> = { [E | W]: "╌", [N | S]: "╎" };
|
|
67
|
+
const RUNNER: Record<number, string> = { [E | W]: "━", [N | S]: "┃" };
|
|
68
|
+
|
|
69
|
+
/** An arrow pointing into a node, by the side of the node it enters. */
|
|
70
|
+
export const ARROW_INTO: Record<Position, string> = {
|
|
71
|
+
left: "▶",
|
|
72
|
+
right: "◀",
|
|
73
|
+
top: "▼",
|
|
74
|
+
bottom: "▲",
|
|
75
|
+
};
|
|
76
|
+
const towardNode: Record<Position, number> = { left: E, right: W, top: S, bottom: N };
|
|
77
|
+
|
|
78
|
+
export type EdgeStyle = "smoothstep" | "step" | "straight" | "bezier";
|
|
79
|
+
|
|
80
|
+
/** An edge, placed: both ends are anchor cells, just outside their nodes. */
|
|
81
|
+
export type EdgeDraw = {
|
|
82
|
+
id: string;
|
|
83
|
+
style: EdgeStyle;
|
|
84
|
+
source: XY;
|
|
85
|
+
sourcePosition: Position;
|
|
86
|
+
target: XY;
|
|
87
|
+
targetPosition: Position;
|
|
88
|
+
label?: string;
|
|
89
|
+
animated?: boolean;
|
|
90
|
+
selected?: boolean;
|
|
91
|
+
markerEnd?: boolean;
|
|
92
|
+
markerStart?: boolean;
|
|
93
|
+
color?: string;
|
|
94
|
+
/** For custom edge types: the path's corner points, drawn orthogonally. */
|
|
95
|
+
points?: readonly XY[];
|
|
96
|
+
/** Overrides the role its state gives it (the connection being made). */
|
|
97
|
+
role?: Role;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type BoxCell = {
|
|
101
|
+
mask: number;
|
|
102
|
+
rounded: boolean;
|
|
103
|
+
role: Role;
|
|
104
|
+
color?: string;
|
|
105
|
+
owner?: string;
|
|
106
|
+
/** Index along its edge, for the runner of an animated edge. */
|
|
107
|
+
index: number;
|
|
108
|
+
edges: number;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const mod = (a: number, n: number) => ((a % n) + n) % n;
|
|
112
|
+
|
|
113
|
+
type DotCell = { bits: number; role: Role; color?: string; owner?: string; index?: number };
|
|
114
|
+
|
|
115
|
+
export class Grid {
|
|
116
|
+
readonly width: number;
|
|
117
|
+
readonly height: number;
|
|
118
|
+
private readonly cells: (Cell | undefined)[];
|
|
119
|
+
private readonly boxes = new Map<number, BoxCell>();
|
|
120
|
+
private readonly dots = new Map<number, DotCell>();
|
|
121
|
+
|
|
122
|
+
constructor(width: number, height: number) {
|
|
123
|
+
this.width = Math.max(0, width);
|
|
124
|
+
this.height = Math.max(0, height);
|
|
125
|
+
this.cells = Array.from({ length: this.width * this.height }, () => undefined);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
inside(x: number, y: number) {
|
|
129
|
+
return x >= 0 && y >= 0 && x < this.width && y < this.height;
|
|
130
|
+
}
|
|
131
|
+
get(x: number, y: number): Cell | undefined {
|
|
132
|
+
return this.inside(x, y) ? this.cells[y * this.width + x] : undefined;
|
|
133
|
+
}
|
|
134
|
+
set(x: number, y: number, cell: Cell) {
|
|
135
|
+
if (this.inside(x, y)) this.cells[y * this.width + x] = cell;
|
|
136
|
+
}
|
|
137
|
+
/** Writes `text` from (x, y) on, clipped to the grid. */
|
|
138
|
+
text(x: number, y: number, text: string, cell: Omit<Cell, "char">) {
|
|
139
|
+
Array.from(text).forEach((char, i) => this.set(x + i, y, { ...cell, char }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Adds direction bits to a box cell; the last writer's role and color win. */
|
|
143
|
+
box(x: number, y: number, bits: number, style: Omit<BoxCell, "mask" | "edges">) {
|
|
144
|
+
if (!this.inside(x, y)) return;
|
|
145
|
+
const key = y * this.width + x;
|
|
146
|
+
const known = this.boxes.get(key);
|
|
147
|
+
this.boxes.set(key, {
|
|
148
|
+
...style,
|
|
149
|
+
mask: (known?.mask ?? 0) | bits,
|
|
150
|
+
rounded: style.rounded && (known?.rounded ?? true),
|
|
151
|
+
edges: (known?.edges ?? 0) + (known?.owner === style.owner ? 0 : 1),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Sets one braille dot at dot coordinates (two per cell across, four down). */
|
|
156
|
+
dot(dx: number, dy: number, style: Omit<DotCell, "bits">) {
|
|
157
|
+
const x = Math.floor(dx / DOTS_X);
|
|
158
|
+
const y = Math.floor(dy / DOTS_Y);
|
|
159
|
+
if (!this.inside(x, y) || dx < 0 || dy < 0) return;
|
|
160
|
+
const key = y * this.width + x;
|
|
161
|
+
const bit = brailleBit(dx - x * DOTS_X, dy - y * DOTS_Y);
|
|
162
|
+
const known = this.dots.get(key);
|
|
163
|
+
this.dots.set(key, { ...style, bits: (known?.bits ?? 0) | bit });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Turns direction bits and dots into characters. Box lines win over dots, and cells
|
|
168
|
+
* already set (markers, labels) win over both.
|
|
169
|
+
*/
|
|
170
|
+
resolve(phase = 0) {
|
|
171
|
+
for (const [key, d] of this.dots) {
|
|
172
|
+
if (this.cells[key]) continue;
|
|
173
|
+
// An animated curve: one cell in RUNNER_EVERY is dropped, and the gap moves.
|
|
174
|
+
if (
|
|
175
|
+
d.role === "animated" &&
|
|
176
|
+
d.index !== undefined &&
|
|
177
|
+
mod(d.index - phase, RUNNER_EVERY) === 0
|
|
178
|
+
)
|
|
179
|
+
continue;
|
|
180
|
+
this.cells[key] = {
|
|
181
|
+
char: String.fromCharCode(BRAILLE_BASE + d.bits),
|
|
182
|
+
role: d.role,
|
|
183
|
+
color: d.color,
|
|
184
|
+
owner: d.owner,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
for (const [key, b] of this.boxes) {
|
|
188
|
+
const known = this.cells[key];
|
|
189
|
+
if (known && !LINE_ROLES.has(known.role)) continue;
|
|
190
|
+
let char = (b.rounded ? ROUNDED[b.mask] : undefined) ?? SQUARE[b.mask] ?? "·";
|
|
191
|
+
if (b.role === "animated" && b.edges === 1) {
|
|
192
|
+
const runner = mod(b.index - phase, RUNNER_EVERY) === 0;
|
|
193
|
+
char = (runner ? RUNNER[b.mask] : DASHED[b.mask]) ?? char;
|
|
194
|
+
}
|
|
195
|
+
this.cells[key] = { char, role: b.role, color: b.color, owner: b.owner };
|
|
196
|
+
}
|
|
197
|
+
this.boxes.clear();
|
|
198
|
+
this.dots.clear();
|
|
199
|
+
return this;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Rows of text, trailing blanks trimmed: what tests compare. */
|
|
203
|
+
lines(): string[] {
|
|
204
|
+
return Array.from({ length: this.height }, (_, y) =>
|
|
205
|
+
Array.from({ length: this.width }, (_, x) => this.get(x, y)?.char ?? " ")
|
|
206
|
+
.join("")
|
|
207
|
+
.trimEnd(),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
/** Each cell that holds something, row by row. */
|
|
211
|
+
*entries(): Generator<[number, number, Cell]> {
|
|
212
|
+
for (let y = 0; y < this.height; y++)
|
|
213
|
+
for (let x = 0; x < this.width; x++) {
|
|
214
|
+
const cell = this.cells[y * this.width + x];
|
|
215
|
+
if (cell) yield [x, y, cell];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The cells from `a` to `b` on one axis, `a` excluded, `b` included. */
|
|
221
|
+
function* segment(a: XY, b: XY): Generator<XY> {
|
|
222
|
+
const dx = Math.sign(b.x - a.x);
|
|
223
|
+
const dy = Math.sign(b.y - a.y);
|
|
224
|
+
let { x, y } = a;
|
|
225
|
+
while (x !== b.x || y !== b.y) {
|
|
226
|
+
// A diagonal pair is walked horizontally then vertically.
|
|
227
|
+
if (x !== b.x) x += dx;
|
|
228
|
+
else y += dy;
|
|
229
|
+
yield { x, y };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const bitToward = (from: XY, to: XY) =>
|
|
234
|
+
to.x > from.x ? E : to.x < from.x ? W : to.y > from.y ? S : to.y < from.y ? N : 0;
|
|
235
|
+
|
|
236
|
+
/** Rounds corner points and makes each pair of consecutive points share an axis. */
|
|
237
|
+
export function orthogonal(points: readonly XY[]): XY[] {
|
|
238
|
+
const out: XY[] = [];
|
|
239
|
+
for (const p of points) {
|
|
240
|
+
const point = { x: Math.round(p.x), y: Math.round(p.y) };
|
|
241
|
+
const last = out.at(-1);
|
|
242
|
+
if (last && last.x === point.x && last.y === point.y) continue;
|
|
243
|
+
if (last && last.x !== point.x && last.y !== point.y) out.push({ x: point.x, y: last.y });
|
|
244
|
+
out.push(point);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The cells an orthogonal path goes through, in order, both ends included. */
|
|
250
|
+
export function pathCells(points: readonly XY[]): XY[] {
|
|
251
|
+
const corners = orthogonal(points);
|
|
252
|
+
const first = corners[0];
|
|
253
|
+
if (!first) return [];
|
|
254
|
+
const cells = [first];
|
|
255
|
+
for (let i = 1; i < corners.length; i++) {
|
|
256
|
+
const from = corners[i - 1];
|
|
257
|
+
const to = corners[i];
|
|
258
|
+
if (from && to) cells.push(...segment(from, to));
|
|
259
|
+
}
|
|
260
|
+
return cells;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The corner points of a step edge between two anchor cells. */
|
|
264
|
+
export function stepPoints(draw: EdgeDraw): { points: XY[]; label: XY } {
|
|
265
|
+
const { points, labelX, labelY } = getStepPoints({
|
|
266
|
+
source: draw.source,
|
|
267
|
+
sourcePosition: draw.sourcePosition,
|
|
268
|
+
target: draw.target,
|
|
269
|
+
targetPosition: draw.targetPosition,
|
|
270
|
+
offset: 1,
|
|
271
|
+
});
|
|
272
|
+
return { points, label: { x: Math.round(labelX), y: Math.round(labelY) } };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A cubic bezier's four points between two anchor cells, after xyflow's controls. */
|
|
276
|
+
export function bezierControls(draw: EdgeDraw): [XY, XY, XY, XY] {
|
|
277
|
+
const { source: s, target: t } = draw;
|
|
278
|
+
const [scx, scy] = getControlWithCurvature({
|
|
279
|
+
pos: draw.sourcePosition,
|
|
280
|
+
x1: s.x,
|
|
281
|
+
y1: s.y,
|
|
282
|
+
x2: t.x,
|
|
283
|
+
y2: t.y,
|
|
284
|
+
c: CELL_CURVATURE,
|
|
285
|
+
});
|
|
286
|
+
const [tcx, tcy] = getControlWithCurvature({
|
|
287
|
+
pos: draw.targetPosition,
|
|
288
|
+
x1: t.x,
|
|
289
|
+
y1: t.y,
|
|
290
|
+
x2: s.x,
|
|
291
|
+
y2: s.y,
|
|
292
|
+
c: CELL_CURVATURE,
|
|
293
|
+
});
|
|
294
|
+
return [s, { x: scx, y: scy }, { x: tcx, y: tcy }, t];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const CUBIC = 3;
|
|
298
|
+
const cubic = (a: number, b: number, c: number, d: number, t: number) => {
|
|
299
|
+
const u = 1 - t;
|
|
300
|
+
return u * u * u * a + CUBIC * u * u * t * b + CUBIC * u * t * t * c + t * t * t * d;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const roleOf = (draw: EdgeDraw): Role =>
|
|
304
|
+
draw.role ?? (draw.selected ? "selected" : draw.animated ? "animated" : "edge");
|
|
305
|
+
|
|
306
|
+
/** Draws one edge into `grid`; returns where its label goes. */
|
|
307
|
+
export function drawEdge(grid: Grid, draw: EdgeDraw): XY {
|
|
308
|
+
const role = roleOf(draw);
|
|
309
|
+
const style = { role, color: draw.color, owner: draw.id };
|
|
310
|
+
const aligned = draw.source.x === draw.target.x || draw.source.y === draw.target.y;
|
|
311
|
+
let label: XY;
|
|
312
|
+
if (draw.style === "bezier" || (draw.style === "straight" && !aligned)) {
|
|
313
|
+
const [p0, p1, p2, p3] =
|
|
314
|
+
draw.style === "bezier"
|
|
315
|
+
? bezierControls(draw)
|
|
316
|
+
: [draw.source, draw.source, draw.target, draw.target];
|
|
317
|
+
// Dot centers: a cell is 2 dots across and 4 down.
|
|
318
|
+
const toDot = (v: number, per: number) => v * per + (per - 1) / 2;
|
|
319
|
+
const length = Math.abs(p3.x - p0.x) + Math.abs(p3.y - p0.y) * 2 + 1;
|
|
320
|
+
const samples = Math.max(2, Math.ceil(length * SAMPLES_PER_CELL));
|
|
321
|
+
for (let i = 0; i <= samples; i++) {
|
|
322
|
+
const t = i / samples;
|
|
323
|
+
// A dash of dots every few cells on an animated edge, moving with the phase.
|
|
324
|
+
grid.dot(
|
|
325
|
+
Math.round(toDot(cubic(p0.x, p1.x, p2.x, p3.x, t), DOTS_X)),
|
|
326
|
+
Math.round(toDot(cubic(p0.y, p1.y, p2.y, p3.y, t), DOTS_Y)),
|
|
327
|
+
{ ...style, index: Math.floor(i / SAMPLES_PER_CELL) },
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const [cx, cy] = getBezierEdgeCenter({
|
|
331
|
+
sourceX: p0.x,
|
|
332
|
+
sourceY: p0.y,
|
|
333
|
+
targetX: p3.x,
|
|
334
|
+
targetY: p3.y,
|
|
335
|
+
sourceControlX: p1.x,
|
|
336
|
+
sourceControlY: p1.y,
|
|
337
|
+
targetControlX: p2.x,
|
|
338
|
+
targetControlY: p2.y,
|
|
339
|
+
});
|
|
340
|
+
label = { x: Math.round(cx), y: Math.round(cy) };
|
|
341
|
+
} else {
|
|
342
|
+
const step = draw.points || draw.style === "straight" ? undefined : stepPoints(draw);
|
|
343
|
+
const route = draw.points ?? step?.points ?? [draw.source, draw.target];
|
|
344
|
+
const cells = pathCells(route);
|
|
345
|
+
const rounded = draw.style === "smoothstep";
|
|
346
|
+
cells.forEach((cell, index) => {
|
|
347
|
+
const prev = cells[index - 1];
|
|
348
|
+
const next = cells[index + 1];
|
|
349
|
+
let bits = (prev ? bitToward(cell, prev) : 0) | (next ? bitToward(cell, next) : 0);
|
|
350
|
+
if (index === 0) bits |= towardNode[draw.sourcePosition];
|
|
351
|
+
if (index === cells.length - 1) bits |= towardNode[draw.targetPosition];
|
|
352
|
+
grid.box(cell.x, cell.y, bits, { rounded, index, ...style });
|
|
353
|
+
});
|
|
354
|
+
label = step?.label ?? cells[Math.floor(cells.length / 2)] ?? draw.source;
|
|
355
|
+
}
|
|
356
|
+
if (draw.markerEnd !== false)
|
|
357
|
+
grid.set(draw.target.x, draw.target.y, {
|
|
358
|
+
char: ARROW_INTO[draw.targetPosition],
|
|
359
|
+
role: draw.selected ? "selected" : "marker",
|
|
360
|
+
color: draw.color,
|
|
361
|
+
owner: draw.id,
|
|
362
|
+
});
|
|
363
|
+
if (draw.markerStart)
|
|
364
|
+
grid.set(draw.source.x, draw.source.y, {
|
|
365
|
+
char: ARROW_INTO[draw.sourcePosition],
|
|
366
|
+
role: draw.selected ? "selected" : "marker",
|
|
367
|
+
color: draw.color,
|
|
368
|
+
owner: draw.id,
|
|
369
|
+
});
|
|
370
|
+
return label;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** An edge's label, centered on `at`, over the lines. */
|
|
374
|
+
export function drawLabel(grid: Grid, at: XY, text: string, owner: string) {
|
|
375
|
+
const padded = ` ${text} `;
|
|
376
|
+
grid.text(at.x - Math.floor(padded.length / 2), at.y, padded, { role: "label", owner });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export type BackgroundVariant = "dots" | "lines" | "cross";
|
|
380
|
+
|
|
381
|
+
/** Background marks every `gap` flow cells, following the viewport, in the empty cells. */
|
|
382
|
+
export function drawBackground(
|
|
383
|
+
grid: Grid,
|
|
384
|
+
viewport: { x: number; y: number; zoom: number },
|
|
385
|
+
variant: BackgroundVariant,
|
|
386
|
+
gap: XY,
|
|
387
|
+
) {
|
|
388
|
+
const gx = Math.max(1, Math.round(gap.x * viewport.zoom));
|
|
389
|
+
const gy = Math.max(1, Math.round(gap.y * viewport.zoom));
|
|
390
|
+
for (let y = 0; y < grid.height; y++)
|
|
391
|
+
for (let x = 0; x < grid.width; x++) {
|
|
392
|
+
const onX = mod(x - viewport.x, gx) === 0;
|
|
393
|
+
const onY = mod(y - viewport.y, gy) === 0;
|
|
394
|
+
const char =
|
|
395
|
+
variant === "lines"
|
|
396
|
+
? onX && onY
|
|
397
|
+
? "┼"
|
|
398
|
+
: onX
|
|
399
|
+
? "│"
|
|
400
|
+
: onY
|
|
401
|
+
? "─"
|
|
402
|
+
: undefined
|
|
403
|
+
: onX && onY
|
|
404
|
+
? variant === "cross"
|
|
405
|
+
? "+"
|
|
406
|
+
: "·"
|
|
407
|
+
: undefined;
|
|
408
|
+
if (char && !grid.get(x, y)) grid.set(x, y, { char, role: "background" });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Draws `rects` and a viewport outline, in braille, scaled into `grid`. */
|
|
413
|
+
export function drawMiniMap(
|
|
414
|
+
grid: Grid,
|
|
415
|
+
world: Rect,
|
|
416
|
+
rects: readonly { rect: Rect; role: Role; color?: string }[],
|
|
417
|
+
view: Rect,
|
|
418
|
+
) {
|
|
419
|
+
const dotsW = grid.width * DOTS_X;
|
|
420
|
+
const dotsH = grid.height * DOTS_Y;
|
|
421
|
+
// A flow cell is one column by one row, and a dot half a column by a quarter row.
|
|
422
|
+
const scale = Math.min(dotsW / (world.width * DOTS_X), dotsH / (world.height * DOTS_Y));
|
|
423
|
+
const ox = (dotsW - world.width * DOTS_X * scale) / 2;
|
|
424
|
+
const oy = (dotsH - world.height * DOTS_Y * scale) / 2;
|
|
425
|
+
const toDots = (r: Rect) => ({
|
|
426
|
+
x0: Math.floor(ox + (r.x - world.x) * DOTS_X * scale),
|
|
427
|
+
y0: Math.floor(oy + (r.y - world.y) * DOTS_Y * scale),
|
|
428
|
+
x1: Math.ceil(ox + (r.x + r.width - world.x) * DOTS_X * scale) - 1,
|
|
429
|
+
y1: Math.ceil(oy + (r.y + r.height - world.y) * DOTS_Y * scale) - 1,
|
|
430
|
+
});
|
|
431
|
+
for (const { rect, role, color } of rects) {
|
|
432
|
+
const d = toDots(rect);
|
|
433
|
+
for (let y = d.y0; y <= Math.max(d.y0, d.y1); y++)
|
|
434
|
+
for (let x = d.x0; x <= Math.max(d.x0, d.x1); x++) grid.dot(x, y, { role, color });
|
|
435
|
+
}
|
|
436
|
+
const v = toDots(view);
|
|
437
|
+
const clampX = (x: number) => Math.max(0, Math.min(dotsW - 1, x));
|
|
438
|
+
const clampY = (y: number) => Math.max(0, Math.min(dotsH - 1, y));
|
|
439
|
+
for (let x = clampX(v.x0); x <= clampX(v.x1); x++) {
|
|
440
|
+
grid.dot(x, clampY(v.y0), { role: "selected" });
|
|
441
|
+
grid.dot(x, clampY(v.y1), { role: "selected" });
|
|
442
|
+
}
|
|
443
|
+
for (let y = clampY(v.y0); y <= clampY(v.y1); y++) {
|
|
444
|
+
grid.dot(clampX(v.x0), y, { role: "selected" });
|
|
445
|
+
grid.dot(clampX(v.x1), y, { role: "selected" });
|
|
446
|
+
}
|
|
447
|
+
grid.resolve();
|
|
448
|
+
/** Minimap cell → flow point, for a click that recenters the view. */
|
|
449
|
+
return (cell: XY): XY => ({
|
|
450
|
+
x: world.x + (cell.x * DOTS_X + 1 - ox) / (DOTS_X * scale),
|
|
451
|
+
y: world.y + (cell.y * DOTS_Y + DOTS_Y / 2 - oy) / (DOTS_Y * scale),
|
|
452
|
+
});
|
|
453
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one canvas knows beyond its props: the viewport, the canvas size, each node's
|
|
3
|
+
* measured size and declared handles, the background, a connection under way, and what
|
|
4
|
+
* the last frame placed where (for hit tests). Framework-free: `<Flow>` and the hooks
|
|
5
|
+
* read it with `useSyncExternalStore`.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
anchorsOf,
|
|
9
|
+
detailFor,
|
|
10
|
+
fitViewport,
|
|
11
|
+
flowBounds,
|
|
12
|
+
panToShow,
|
|
13
|
+
placeNodes,
|
|
14
|
+
stepZoom,
|
|
15
|
+
toFlow,
|
|
16
|
+
zoomAround,
|
|
17
|
+
type Anchor,
|
|
18
|
+
type Measured,
|
|
19
|
+
type Placed,
|
|
20
|
+
type Size,
|
|
21
|
+
} from "./geometry.ts";
|
|
22
|
+
import type { BackgroundVariant } from "./raster.ts";
|
|
23
|
+
import type {
|
|
24
|
+
Connection,
|
|
25
|
+
Detail,
|
|
26
|
+
Edge,
|
|
27
|
+
EdgeChange,
|
|
28
|
+
HandleSpec,
|
|
29
|
+
Node,
|
|
30
|
+
NodeChange,
|
|
31
|
+
Viewport,
|
|
32
|
+
XY,
|
|
33
|
+
} from "./types.ts";
|
|
34
|
+
|
|
35
|
+
export type BackgroundConfig = { variant: BackgroundVariant; gap: XY; color?: string };
|
|
36
|
+
|
|
37
|
+
/** A connection being made: by dragging from a handle, or with the keyboard. */
|
|
38
|
+
export type Connecting = {
|
|
39
|
+
source: Anchor;
|
|
40
|
+
/** The pointer, in canvas cells, while dragging. */
|
|
41
|
+
pointer?: XY;
|
|
42
|
+
/** The proposed target node, with the keyboard. */
|
|
43
|
+
target?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The changes the canvas itself proposes: never an addition or a replacement. */
|
|
47
|
+
export type CanvasNodeChange = Exclude<NodeChange, { type: "add" } | { type: "replace" }>;
|
|
48
|
+
export type CanvasEdgeChange = Exclude<EdgeChange, { type: "add" } | { type: "replace" }>;
|
|
49
|
+
|
|
50
|
+
export type Callbacks = {
|
|
51
|
+
onNodesChange?: (changes: CanvasNodeChange[]) => void;
|
|
52
|
+
onEdgesChange?: (changes: CanvasEdgeChange[]) => void;
|
|
53
|
+
onConnect?: (connection: Connection) => void;
|
|
54
|
+
onViewportChange?: (viewport: Viewport) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_VIEWPORT: Viewport = { x: 2, y: 1, zoom: 1 };
|
|
58
|
+
|
|
59
|
+
export class FlowStore {
|
|
60
|
+
viewport: Viewport;
|
|
61
|
+
size: Size = { width: 0, height: 0 };
|
|
62
|
+
nodes: readonly Node[] = [];
|
|
63
|
+
edges: readonly Edge[] = [];
|
|
64
|
+
readonly measured = new Map<string, Measured>();
|
|
65
|
+
/** The last size each node had at full detail: the view fits it at any zoom. */
|
|
66
|
+
readonly full = new Map<string, Measured>();
|
|
67
|
+
readonly handles = new Map<string, Map<string, HandleSpec>>();
|
|
68
|
+
background: BackgroundConfig | null = null;
|
|
69
|
+
connecting: Connecting | null = null;
|
|
70
|
+
callbacks: Callbacks = {};
|
|
71
|
+
/** Fit the view once the canvas has a size. */
|
|
72
|
+
private pendingFit: boolean;
|
|
73
|
+
private version = 0;
|
|
74
|
+
private readonly listeners = new Set<() => void>();
|
|
75
|
+
|
|
76
|
+
constructor(options: { viewport?: Viewport; fitView?: boolean } = {}) {
|
|
77
|
+
this.viewport = options.viewport ?? DEFAULT_VIEWPORT;
|
|
78
|
+
this.pendingFit = options.fitView ?? false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
subscribe = (listener: () => void) => {
|
|
82
|
+
this.listeners.add(listener);
|
|
83
|
+
return () => this.listeners.delete(listener);
|
|
84
|
+
};
|
|
85
|
+
getVersion = () => this.version;
|
|
86
|
+
private changed() {
|
|
87
|
+
this.version++;
|
|
88
|
+
for (const listener of this.listeners) listener();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get detail(): Detail {
|
|
92
|
+
return detailFor(this.viewport.zoom);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The latest props: served to hooks and event handlers, which run after the commit. */
|
|
96
|
+
sync(props: { nodes: readonly Node[]; edges: readonly Edge[]; callbacks: Callbacks }) {
|
|
97
|
+
this.callbacks = props.callbacks;
|
|
98
|
+
if (props.nodes === this.nodes && props.edges === this.edges) return;
|
|
99
|
+
this.nodes = props.nodes;
|
|
100
|
+
this.edges = props.edges;
|
|
101
|
+
this.changed();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setViewport(viewport: Viewport) {
|
|
105
|
+
const { x, y, zoom } = this.viewport;
|
|
106
|
+
if (viewport.x === x && viewport.y === y && viewport.zoom === zoom) return;
|
|
107
|
+
this.viewport = { x: Math.round(viewport.x), y: Math.round(viewport.y), zoom: viewport.zoom };
|
|
108
|
+
this.callbacks.onViewportChange?.(this.viewport);
|
|
109
|
+
this.changed();
|
|
110
|
+
}
|
|
111
|
+
panBy(dx: number, dy: number) {
|
|
112
|
+
this.setViewport({ ...this.viewport, x: this.viewport.x + dx, y: this.viewport.y + dy });
|
|
113
|
+
}
|
|
114
|
+
/** One zoom level in or out, around `anchor` (the canvas center by default). */
|
|
115
|
+
zoom(direction: 1 | -1, anchor?: XY) {
|
|
116
|
+
const zoom = stepZoom(this.viewport.zoom, direction);
|
|
117
|
+
if (zoom === this.viewport.zoom) return;
|
|
118
|
+
const center = anchor ?? {
|
|
119
|
+
x: Math.floor(this.size.width / 2),
|
|
120
|
+
y: Math.floor(this.size.height / 2),
|
|
121
|
+
};
|
|
122
|
+
this.setViewport(zoomAround(this.viewport, zoom, center));
|
|
123
|
+
}
|
|
124
|
+
fitView() {
|
|
125
|
+
if (this.size.width === 0 || this.size.height === 0) {
|
|
126
|
+
this.pendingFit = true;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.pendingFit = false;
|
|
130
|
+
this.setViewport(fitViewport(flowBounds(this.nodes, this.full), this.size));
|
|
131
|
+
}
|
|
132
|
+
/** Centers the view on a flow point, at `zoom` or the current one. */
|
|
133
|
+
setCenter(point: XY, zoom = this.viewport.zoom) {
|
|
134
|
+
this.setViewport({
|
|
135
|
+
x: Math.round(this.size.width / 2 - point.x * zoom),
|
|
136
|
+
y: Math.round(this.size.height / 2 - point.y * zoom),
|
|
137
|
+
zoom,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** Pans just enough for node `id` to be seen whole. */
|
|
141
|
+
reveal(id: string) {
|
|
142
|
+
const placed = this.place().find((p) => p.node.id === id);
|
|
143
|
+
if (placed) this.setViewport(panToShow(this.viewport, placed.rect, this.size));
|
|
144
|
+
}
|
|
145
|
+
setSize(size: Size) {
|
|
146
|
+
if (size.width === this.size.width && size.height === this.size.height) return;
|
|
147
|
+
this.size = size;
|
|
148
|
+
if (this.pendingFit) this.fitView();
|
|
149
|
+
this.changed();
|
|
150
|
+
}
|
|
151
|
+
measure(id: string, size: Measured) {
|
|
152
|
+
const known = this.measured.get(id);
|
|
153
|
+
if (
|
|
154
|
+
known &&
|
|
155
|
+
known.width === size.width &&
|
|
156
|
+
known.height === size.height &&
|
|
157
|
+
known.detail === size.detail
|
|
158
|
+
)
|
|
159
|
+
return;
|
|
160
|
+
this.measured.set(id, size);
|
|
161
|
+
if (size.detail === "full") this.full.set(id, size);
|
|
162
|
+
this.changed();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
addHandle(nodeId: string, key: string, spec: HandleSpec) {
|
|
166
|
+
const handles = this.handles.get(nodeId) ?? new Map<string, HandleSpec>();
|
|
167
|
+
handles.set(key, spec);
|
|
168
|
+
this.handles.set(nodeId, handles);
|
|
169
|
+
this.changed();
|
|
170
|
+
}
|
|
171
|
+
removeHandle(nodeId: string, key: string) {
|
|
172
|
+
const handles = this.handles.get(nodeId);
|
|
173
|
+
if (!handles?.delete(key)) return;
|
|
174
|
+
if (handles.size === 0) this.handles.delete(nodeId);
|
|
175
|
+
this.changed();
|
|
176
|
+
}
|
|
177
|
+
setBackground(background: BackgroundConfig | null) {
|
|
178
|
+
this.background = background;
|
|
179
|
+
this.changed();
|
|
180
|
+
}
|
|
181
|
+
setConnecting(connecting: Connecting | null) {
|
|
182
|
+
this.connecting = connecting;
|
|
183
|
+
this.changed();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The nodes on the canvas, in drawing order. */
|
|
187
|
+
place(): Placed[] {
|
|
188
|
+
return placeNodes(this.nodes, this.viewport, this.measured);
|
|
189
|
+
}
|
|
190
|
+
anchors(placed: Placed): Anchor[] {
|
|
191
|
+
return anchorsOf(placed, [...(this.handles.get(placed.node.id)?.values() ?? [])], this.detail);
|
|
192
|
+
}
|
|
193
|
+
/** A canvas cell as a flow point. */
|
|
194
|
+
toFlow(cell: XY): XY {
|
|
195
|
+
return toFlow(cell, this.viewport);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// What an interaction proposes: the application applies it (or not) to its own state.
|
|
199
|
+
proposeNodes(changes: CanvasNodeChange[]) {
|
|
200
|
+
if (changes.length > 0) this.callbacks.onNodesChange?.(changes);
|
|
201
|
+
}
|
|
202
|
+
proposeEdges(changes: CanvasEdgeChange[]) {
|
|
203
|
+
if (changes.length > 0) this.callbacks.onEdgesChange?.(changes);
|
|
204
|
+
}
|
|
205
|
+
/** Selects exactly `nodes` and `edges` (ids), deselecting the rest. */
|
|
206
|
+
select({ nodes = [], edges = [] }: { nodes?: readonly string[]; edges?: readonly string[] }) {
|
|
207
|
+
const n = new Set(nodes);
|
|
208
|
+
const e = new Set(edges);
|
|
209
|
+
this.proposeNodes(
|
|
210
|
+
this.nodes
|
|
211
|
+
.filter((node) => Boolean(node.selected) !== n.has(node.id))
|
|
212
|
+
.map((node) => ({ id: node.id, type: "select", selected: n.has(node.id) })),
|
|
213
|
+
);
|
|
214
|
+
this.proposeEdges(
|
|
215
|
+
this.edges
|
|
216
|
+
.filter((edge) => Boolean(edge.selected) !== e.has(edge.id))
|
|
217
|
+
.map((edge) => ({ id: edge.id, type: "select", selected: e.has(edge.id) })),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
/** Removes nodes (with their edges) and edges. */
|
|
221
|
+
deleteElements({
|
|
222
|
+
nodes = [],
|
|
223
|
+
edges = [],
|
|
224
|
+
}: {
|
|
225
|
+
nodes?: readonly string[];
|
|
226
|
+
edges?: readonly string[];
|
|
227
|
+
}) {
|
|
228
|
+
const gone = new Set(nodes);
|
|
229
|
+
// A group's children go with it.
|
|
230
|
+
for (const node of this.nodes) if (node.parentId && gone.has(node.parentId)) gone.add(node.id);
|
|
231
|
+
const edgeIds = new Set(edges);
|
|
232
|
+
for (const edge of this.edges)
|
|
233
|
+
if (gone.has(edge.source) || gone.has(edge.target)) edgeIds.add(edge.id);
|
|
234
|
+
this.proposeEdges([...edgeIds].map((id) => ({ id, type: "remove" })));
|
|
235
|
+
this.proposeNodes([...gone].map((id) => ({ id, type: "remove" })));
|
|
236
|
+
}
|
|
237
|
+
connect(connection: Connection) {
|
|
238
|
+
this.callbacks.onConnect?.(connection);
|
|
239
|
+
}
|
|
240
|
+
}
|