Line data Source code
1 : import type * as core from '@ankoh/dashql-core';
2 :
3 : import { type DataflowEdge, DataflowEdgeKind, type DataflowGraph, type DataflowInput, type DataflowPort, type DataflowScript, type DataflowStatement } from './dataflow_graph.js';
4 :
5 : /// The space between an edge inside a box and the left border of the box, per statement that it spans
6 1 : const INTERNAL_EDGE_GUTTER = 3;
7 1 : const INTERNAL_EDGE_GUTTER_STEP = 2;
8 : /// The least horizontal distance between the ports of an edge to a target on the right
9 : const EDGE_STUB = 12;
10 : /// The least reach of the control points of a curve between rows at different heights
11 1 : const EDGE_CURVE = 40;
12 : /// The reach of the control points of the bends of an edge back to a target on the left
13 1 : const EDGE_LOOP = 48;
14 : /// The space around the badge on the straight trunk of a bus
15 : const EDGE_TRUNK_MARGIN = 8;
16 : export const EDGE_BADGE_HEIGHT = 15;
17 1 : const EDGE_BADGE_MIN_WIDTH = 25;
18 :
19 : export interface Rect {
20 : x: number;
21 : y: number;
22 : width: number;
23 : height: number;
24 : }
25 :
26 : /// A statement row of a box
27 : export interface DataflowSceneRow {
28 : statement: DataflowStatement;
29 : /// The top of the row on the canvas
30 : y: number;
31 : /// Do edges end or start at the row?
32 : input: boolean;
33 : output: boolean;
34 : }
35 :
36 : /// A script box
37 : export interface DataflowSceneBox extends Rect {
38 : script: DataflowScript;
39 : collapsed: boolean;
40 : rows: DataflowSceneRow[];
41 : /// The chart below the visualized statement
42 : chart: Rect | null;
43 : /// Does an edge start at the box? Collapsed boxes have one port per side.
44 : input: boolean;
45 : output: boolean;
46 : }
47 :
48 : /// The box of an input: a header with the name and one row with the value
49 : export interface DataflowSceneInput extends Rect {
50 : input: DataflowInput;
51 : /// The top of the row of the value on the canvas
52 : rowY: number;
53 : /// Does an edge start at the input?
54 : output: boolean;
55 : }
56 :
57 : /// An edge
58 : export interface DataflowSceneEdge {
59 : key: string;
60 : kind: DataflowEdgeKind;
61 : from: DataflowPort;
62 : to: DataflowPort;
63 : /// The input that an input edge starts at
64 : input: string | null;
65 : /// Do both ends lie in one box?
66 : internal: boolean;
67 : path: string;
68 : /// The name of the relation of a table edge, at the middle of the edge
69 : badge: { x: number; y: number; width: number; text: string } | null;
70 : }
71 :
72 : export interface DataflowScene {
73 : boxes: DataflowSceneBox[];
74 : inputs: DataflowSceneInput[];
75 : edges: DataflowSceneEdge[];
76 : /// The bounds of the boxes
77 : bounds: Rect;
78 : }
79 :
80 : /// The position and size of a box, from the layout
81 : export interface DataflowBoxPosition {
82 : x: number;
83 : y: number;
84 : width: number;
85 : height: number;
86 : rows: number[];
87 : }
88 :
89 : /// The size of a resized box, null for the defaults of the layout
90 : export interface DataflowBoxSize {
91 : width: number | null;
92 : chartHeight: number | null;
93 : }
94 :
95 : /// Where a box was, for the layout: saved in its `@flow` tag, or where the last layout or a drop put it
96 : export interface DataflowHint {
97 : x: number;
98 : y: number;
99 : /// Was the box just dropped here by the user?
100 : dropped?: boolean;
101 : }
102 :
103 : /// The inputs of the layout of a graph: a box per script, then a box of one row per input, and the edges between
104 : /// statements of different boxes. Boxes with hints keep their columns, their order in a column and their y.
105 1 : export function dataflowLayoutInput(
106 1 : graph: DataflowGraph,
107 1 : hint: (script: DataflowScript) => DataflowHint | null,
108 1 : chartStatement: (script: DataflowScript) => number | null,
109 1 : collapsed: (script: DataflowScript) => boolean,
110 1 : hintInput: (input: DataflowInput) => DataflowHint | null = () => null,
111 1 : size: (script: DataflowScript) => DataflowBoxSize | null = () => null,
112 1 : ): { boxes: core.DataflowLayoutBox[]; edges: core.DataflowLayoutEdge[] } {
113 1 : const hintOf = (h: DataflowHint | null) => (h == null ? {} : { hint: { x: h.x, y: h.y }, ...(h.dropped ? { dropped: true } : {}) });
114 1 : const index = new Map(graph.scripts.map((s, i) => [s.scriptKey, i]));
115 1 : const inputIndex = new Map(graph.inputs.map((input, i) => [input.name, graph.scripts.length + i]));
116 1 : const boxes: core.DataflowLayoutBox[] = graph.scripts.map(script => {
117 1 : const isCollapsed = collapsed(script);
118 1 : const sized = size(script);
119 1 : return {
120 1 : statements: script.statements.length,
121 1 : chartStatement: isCollapsed ? null : chartStatement(script),
122 1 : collapsed: isCollapsed,
123 1 : ...hintOf(hint(script)),
124 1 : ...(sized?.width != null ? { width: sized.width } : {}),
125 1 : ...(sized?.chartHeight != null ? { chartHeight: sized.chartHeight } : {}),
126 1 : };
127 1 : });
128 1 : for (const input of graph.inputs) {
129 1 : boxes.push({ statements: 1, input: true, ...hintOf(hintInput(input)) });
130 : }
131 1 : const edges = graph.edges.flatMap(edge => {
132 1 : const fromBox = edge.input != null ? inputIndex.get(edge.input) : index.get(edge.from.scriptKey);
133 1 : const toBox = index.get(edge.to.scriptKey);
134 1 : if (fromBox == null || toBox == null || fromBox === toBox) return [];
135 1 : return [{ fromBox, fromStatement: edge.from.statementId, toBox, toStatement: edge.to.statementId }];
136 1 : });
137 1 : return { boxes, edges };
138 1 : }
139 :
140 : /// The height of the chart of a laid out box: the space between the row of the chart and the next row, or the bottom
141 1 : export function dataflowChartHeight(p: DataflowBoxPosition, chartAt: number, config: core.DataflowLayoutConfig): number {
142 1 : const top = (p.rows[chartAt] ?? 0) + config.rowHeight + config.rowPadding;
143 1 : const bottom = p.rows[chartAt + 1] ?? p.height - config.rowPadding;
144 1 : return Math.max(0, bottom - top);
145 1 : }
146 :
147 : /// A laid out box with another width and chart height, the rows below the chart move with its bottom
148 1 : export function resizeDataflowBox(
149 1 : p: DataflowBoxPosition,
150 1 : chartAt: number | null,
151 1 : width: number,
152 1 : chartHeight: number | null,
153 1 : config: core.DataflowLayoutConfig,
154 1 : ): DataflowBoxPosition {
155 1 : if (chartAt == null || chartHeight == null) return { ...p, width };
156 1 : const delta = chartHeight - dataflowChartHeight(p, chartAt, config);
157 1 : return { ...p, width, height: p.height + delta, rows: p.rows.map((y, i) => (i > chartAt ? y + delta : y)) };
158 1 : }
159 :
160 : /// The vertical center of the port of a statement on the canvas
161 1 : function portY(box: DataflowSceneBox, statementId: number, config: core.DataflowLayoutConfig): number {
162 1 : const row = box.rows[statementId];
163 1 : if (box.collapsed || row == null) return box.y + config.headerHeight / 2;
164 1 : return row.y + config.rowHeight / 2;
165 1 : }
166 :
167 : /// The width of the frame that the ports of a box sit on: the statement rows, or the header of a collapsed box
168 1 : function portFrameWidth(box: EdgeEnd, config: core.DataflowLayoutConfig): number {
169 1 : return box.collapsed ? box.width : box.width - 2 * config.rowPadding;
170 1 : }
171 :
172 : /// An orthogonal path through points with rounded corners
173 1 : function roundedPath(points: [number, number][], radius: number): string {
174 1 : const out = [`M ${points[0][0]} ${points[0][1]}`];
175 1 : for (let i = 1; i < points.length - 1; ++i) {
176 1 : const [px, py] = points[i - 1];
177 1 : const [x, y] = points[i];
178 1 : const [nx, ny] = points[i + 1];
179 1 : const r = Math.min(radius, Math.hypot(x - px, y - py) / 2, Math.hypot(nx - x, ny - y) / 2);
180 1 : const before = [x - Math.sign(x - px) * r, y - Math.sign(y - py) * r];
181 1 : const after = [x + Math.sign(nx - x) * r, y + Math.sign(ny - y) * r];
182 1 : out.push(`L ${before[0]} ${before[1]}`, `Q ${x} ${y}, ${after[0]} ${after[1]}`);
183 1 : }
184 1 : const [lx, ly] = points[points.length - 1];
185 1 : out.push(`L ${lx} ${ly}`);
186 1 : return out.join(' ');
187 1 : }
188 :
189 : /// The part of a box that edges are routed around, an input is never collapsed
190 : interface EdgeEnd extends Rect {
191 : collapsed?: boolean;
192 : }
193 :
194 : /// The x of the output port of a box
195 1 : function outputX(from: EdgeEnd, config: core.DataflowLayoutConfig): number {
196 1 : return from.x + (from.width + portFrameWidth(from, config)) / 2;
197 1 : }
198 :
199 : /// The x of the input port of a box
200 1 : function inputX(to: EdgeEnd, config: core.DataflowLayoutConfig): number {
201 1 : return to.x + (to.width - portFrameWidth(to, config)) / 2;
202 1 : }
203 :
204 : /// The route of an edge from the output port of a row to the input port of a row of another box. A target to the
205 : /// right is reached with a cubic curve that leaves and enters its ports horizontally, after a straight trunk out of
206 : /// the source when the edge is part of a bus. A target to the left is reached with a loop around the boxes: a bend
207 : /// out of the source, a straight run back between the boxes or below both, and a bend into the target. Returns the
208 : /// path and the middle of the route.
209 1 : function routeEdge(
210 1 : from: EdgeEnd,
211 1 : fromY: number,
212 1 : to: EdgeEnd,
213 1 : toY: number,
214 1 : config: core.DataflowLayoutConfig,
215 1 : trunk: number | null = null,
216 1 : ): { path: string; middle: [number, number] } {
217 1 : const x1 = outputX(from, config);
218 1 : const x2 = inputX(to, config);
219 1 : if (x2 - x1 >= 2 * EDGE_STUB) {
220 1 : if (fromY === toY) return { path: `M ${x1} ${fromY} L ${x2} ${toY}`, middle: [(x1 + x2) / 2, fromY] };
221 1 : const xs = trunk != null && x2 - trunk >= 2 * EDGE_STUB ? trunk : x1;
222 1 : const d = Math.max((x2 - xs) / 2, Math.min(Math.abs(toY - fromY), EDGE_CURVE));
223 1 : const head = xs === x1 ? `M ${x1} ${fromY}` : `M ${x1} ${fromY} L ${xs} ${fromY}`;
224 : // A symmetric cubic passes through the middle of its ends
225 1 : return { path: `${head} C ${xs + d} ${fromY}, ${x2 - d} ${toY}, ${x2} ${toY}`, middle: [(xs + x2) / 2, (fromY + toY) / 2] };
226 1 : }
227 : // Back around the boxes: between them if there is space, below both otherwise
228 1 : const [fromBottom, toBottom] = [from.y + from.height, to.y + to.height];
229 1 : let cy: number;
230 1 : if (to.y - fromBottom >= 2 * EDGE_STUB) cy = (fromBottom + to.y) / 2;
231 0 : else if (from.y - toBottom >= 2 * EDGE_STUB) cy = (toBottom + from.y) / 2;
232 0 : else cy = Math.max(fromBottom, toBottom) + config.boxGap / 2;
233 : // The bends turn at the ports, or both at the middle when the target is slightly right of the source
234 1 : const [ox, ix] = x2 <= x1 ? [x1, x2] : [(x1 + x2) / 2, (x1 + x2) / 2];
235 1 : const path = [
236 1 : `M ${x1} ${fromY}`,
237 1 : `C ${x1 + EDGE_LOOP} ${fromY}, ${ox + EDGE_LOOP} ${cy}, ${ox} ${cy}`,
238 1 : `L ${ix} ${cy}`,
239 1 : `C ${ix - EDGE_LOOP} ${cy}, ${x2 - EDGE_LOOP} ${toY}, ${x2} ${toY}`,
240 1 : ].join(' ');
241 1 : return { path, middle: [(ox + ix) / 2, cy] };
242 1 : }
243 :
244 : /// The path of an edge between two rows of one box, along the left border of the rows inside the padding of the box
245 1 : function internalEdgePath(box: DataflowSceneBox, fromY: number, toY: number, span: number, config: core.DataflowLayoutConfig): string {
246 1 : const x = box.x + config.rowPadding;
247 1 : const gutter = Math.min(config.rowPadding - 1, INTERNAL_EDGE_GUTTER + INTERNAL_EDGE_GUTTER_STEP * Math.min(Math.max(span - 1, 0), 2));
248 1 : return roundedPath([[x, fromY], [x - gutter, fromY], [x - gutter, toY], [x, toY]], 3);
249 1 : }
250 :
251 : /// The key of the box of a script on the canvas
252 1 : export function nodeKeyOfScript(scriptKey: number): string {
253 1 : return `script:${scriptKey}`;
254 1 : }
255 :
256 : /// The key of the box of an input on the canvas
257 1 : export function nodeKeyOfInput(name: string): string {
258 1 : return `input:${name}`;
259 1 : }
260 :
261 : /// The key of the route of an edge between the rows of two boxes
262 1 : export function dataflowRouteKey(from: string, fromStatement: number, to: string, toStatement: number): string {
263 1 : return `${from}.${fromStatement}>${to}.${toStatement}`;
264 1 : }
265 :
266 : /// A route of the layout, with the boxes at its ends when it was laid out
267 : export interface DataflowLaidOutRoute {
268 : from: Rect;
269 : to: Rect;
270 : points: core.DataflowPoint[];
271 : }
272 :
273 : /// How the edges between boxes are routed: along the routes of the layout while their boxes stay where the layout put
274 : /// them and no other box is moved onto them, around the boxes by the router otherwise
275 : export interface DataflowRouting {
276 : /// The routes of the last layout that arranged every box, by `dataflowRouteKey`
277 : laidOut: ReadonlyMap<string, DataflowLaidOutRoute>;
278 : /// Routes edges around boxes, null for an edge without a way around them
279 : route: (boxes: Rect[], edges: core.DataflowRouteEdge[]) => (core.DataflowPoint[] | null)[];
280 : }
281 :
282 : /// The radius of the rounded corners of a route
283 1 : const ROUTE_CORNER_RADIUS = 8;
284 : /// A route steps up or down this little, or less, with one smooth bend instead of two corners
285 1 : const ROUTE_JOG = 24;
286 :
287 : /// The path of a polyline of horizontal and vertical segments with rounded corners. A short vertical step between two
288 : /// horizontal segments that run the same way is one smooth bend: two tight corners would read as a hop.
289 1 : function orthogonalPath(input: core.DataflowPoint[], radius: number): string {
290 : // Without repeated points and without points in the middle of a straight run
291 1 : const points: core.DataflowPoint[] = [];
292 1 : for (const p of input) {
293 1 : const last = points[points.length - 1];
294 1 : if (last != null && last[0] === p[0] && last[1] === p[1]) continue;
295 1 : const before = points[points.length - 2];
296 1 : if (before != null && last != null && ((before[0] === last[0] && last[0] === p[0]) || (before[1] === last[1] && last[1] === p[1]))) points.pop();
297 1 : points.push(p);
298 1 : }
299 1 : const out = [`M ${points[0][0]} ${points[0][1]}`];
300 1 : let i = 1;
301 1 : while (i < points.length - 1) {
302 1 : const [px, py] = points[i - 1];
303 1 : const [x, y] = points[i];
304 1 : const [nx, ny] = points[i + 1];
305 1 : const next = points[i + 2];
306 1 : if (next != null && py === y && x === nx && ny === next[1] && Math.abs(ny - y) <= ROUTE_JOG) {
307 1 : const [into, out_] = [Math.sign(x - px), Math.sign(next[0] - nx)];
308 1 : if (into !== 0 && into === out_) {
309 : // The bend reaches out on both sides of the step, as far as the segments before and after allow
310 1 : const reach = Math.min(Math.abs(x - px) / 2, Math.abs(next[0] - nx) / 2, Math.max(2 * radius, 3 * Math.abs(ny - y)));
311 1 : out.push(`L ${x - into * reach} ${y}`, `C ${x} ${y}, ${x} ${ny}, ${x + into * reach} ${ny}`);
312 1 : i += 2;
313 1 : continue;
314 1 : }
315 1 : }
316 1 : const r = Math.min(radius, Math.hypot(x - px, y - py) / 2, Math.hypot(nx - x, ny - y) / 2);
317 1 : out.push(`L ${x - Math.sign(x - px) * r} ${y - Math.sign(y - py) * r}`, `Q ${x} ${y}, ${x + Math.sign(nx - x) * r} ${y + Math.sign(ny - y) * r}`);
318 1 : i += 1;
319 1 : }
320 1 : const [lx, ly] = points[points.length - 1];
321 1 : out.push(`L ${lx} ${ly}`);
322 1 : return out.join(' ');
323 1 : }
324 :
325 1 : function sameRect(a: Rect, b: Rect): boolean {
326 1 : return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
327 1 : }
328 :
329 : /// Does a polyline of horizontal and vertical segments pass through the inside of a box?
330 1 : function crossesBox(points: core.DataflowPoint[], box: Rect): boolean {
331 1 : for (let i = 1; i < points.length; ++i) {
332 1 : const [x0, x1] = [Math.min(points[i - 1][0], points[i][0]), Math.max(points[i - 1][0], points[i][0])];
333 1 : const [y0, y1] = [Math.min(points[i - 1][1], points[i][1]), Math.max(points[i - 1][1], points[i][1])];
334 1 : if (x0 < box.x + box.width && x1 > box.x && y0 < box.y + box.height && y1 > box.y) return true;
335 1 : }
336 1 : return false;
337 1 : }
338 :
339 : /// Where the badge of a routed edge goes: in the middle of its first segment if it is horizontal and long enough
340 : /// and `first` is set, in the middle of its longest horizontal segment otherwise
341 1 : function routeBadgeAt(points: core.DataflowPoint[], width: number, first: boolean): [number, number] {
342 1 : const length = (i: number) => (points[i][1] === points[i + 1][1] ? Math.abs(points[i + 1][0] - points[i][0]) : -1);
343 1 : let at = 0;
344 1 : if (!(first && length(0) >= width + 2 * EDGE_TRUNK_MARGIN)) {
345 1 : for (let i = 1; i + 1 < points.length; ++i) if (length(i) > length(at)) at = i;
346 : }
347 1 : return [(points[at][0] + points[at + 1][0]) / 2, points[at][1]];
348 1 : }
349 :
350 : /// The width of a badge with a text
351 1 : function edgeBadgeWidth(text: string): number {
352 1 : return Math.max(EDGE_BADGE_MIN_WIDTH, text.length * 6 + 10);
353 1 : }
354 :
355 : /// The scene of a graph with the positions of its boxes, in the order of the scripts, then the inputs
356 1 : export function buildDataflowScene(
357 1 : graph: DataflowGraph,
358 1 : positions: DataflowBoxPosition[],
359 1 : chartStatement: (script: DataflowScript) => number | null,
360 1 : collapsed: (script: DataflowScript) => boolean,
361 1 : config: core.DataflowLayoutConfig,
362 : /// Routes the edges between boxes, the edges keep their curves without it or where it has no route
363 1 : routing: DataflowRouting | null = null,
364 1 : ): DataflowScene {
365 1 : const boxes: DataflowSceneBox[] = graph.scripts.map((script, index) => {
366 1 : const p = positions[index];
367 1 : const isCollapsed = collapsed(script);
368 1 : const rows = script.statements.map((statement, i) => ({ statement, y: p.y + (p.rows[i] ?? 0), input: false, output: false }));
369 1 : const chartAt = isCollapsed ? null : chartStatement(script);
370 1 : const chartRow = chartAt != null ? rows[chartAt] : undefined;
371 1 : const chart = chartAt == null || chartRow == null ? null : {
372 1 : x: p.x + config.rowPadding,
373 1 : y: chartRow.y + config.rowHeight + config.rowPadding,
374 1 : width: p.width - 2 * config.rowPadding,
375 1 : height: dataflowChartHeight(p, chartAt, config),
376 1 : };
377 1 : const height = isCollapsed ? config.headerHeight : p.height;
378 1 : return { script, collapsed: isCollapsed, x: p.x, y: p.y, width: p.width, height, rows, chart, input: false, output: false };
379 1 : });
380 1 : const boxOf = new Map(boxes.map(b => [b.script.scriptKey, b]));
381 1 : const inputs: DataflowSceneInput[] = graph.inputs.flatMap((input, i) => {
382 1 : const p = positions[graph.scripts.length + i];
383 1 : if (p == null) return [];
384 1 : return [{ input, x: p.x, y: p.y, width: p.width, height: p.height, rowY: p.y + (p.rows[0] ?? 0), output: false }];
385 1 : });
386 1 : const inputOf = new Map(inputs.map(i => [i.input.name, i]));
387 :
388 : // The table edges of a port with the same relation are a bus: they share a straight trunk out of the port with one
389 : // badge on it, when the nearest target on the right leaves room for it, and curve apart after it.
390 1 : const sourceOf = (edge: DataflowEdge): { key: string; end: EdgeEnd; y: number } | null => {
391 1 : if (edge.input != null) {
392 1 : const source = inputOf.get(edge.input);
393 1 : if (source == null) return null;
394 1 : return { key: `:${edge.input}`, end: source, y: source.rowY + config.rowHeight / 2 };
395 1 : }
396 1 : const from = boxOf.get(edge.from.scriptKey);
397 1 : if (from == null) return null;
398 1 : return { key: `${from.script.scriptKey}.${from.collapsed ? -1 : edge.from.statementId}`, end: from, y: portY(from, edge.from.statementId, config) };
399 1 : };
400 1 : const nearest = new Map<string, number>();
401 1 : const buses = new Map<string, number>();
402 1 : for (const edge of graph.edges) {
403 1 : const source = sourceOf(edge);
404 1 : const to = boxOf.get(edge.to.scriptKey);
405 1 : if (source == null || to == null || source.end === to) continue;
406 1 : const [x1, x2] = [outputX(source.end, config), inputX(to, config)];
407 1 : if (x2 - x1 >= 2 * EDGE_STUB) nearest.set(source.key, Math.min(nearest.get(source.key) ?? Infinity, x2));
408 1 : if (edge.kind === DataflowEdgeKind.Table) buses.set(`${source.key}:${edge.name}`, (buses.get(`${source.key}:${edge.name}`) ?? 0) + 1);
409 1 : }
410 1 : const trunkOf = (sourceKey: string, end: EdgeEnd, name: string): number | null => {
411 1 : if ((buses.get(`${sourceKey}:${name}`) ?? 0) < 2) return null;
412 1 : const x2 = nearest.get(sourceKey);
413 1 : const trunk = outputX(end, config) + edgeBadgeWidth(name) + 2 * EDGE_TRUNK_MARGIN;
414 1 : return x2 != null && x2 - trunk >= 2 * EDGE_STUB ? trunk : null;
415 1 : };
416 1 : const badged = new Set<string>();
417 :
418 : // Edges into collapsed boxes are merged, their statements are hidden
419 1 : const edges: DataflowSceneEdge[] = [];
420 1 : const merged = new Set<string>();
421 : // The edges between boxes that may be routed: the edge, the key of its route, its ends, and whether it is part of
422 : // a bus
423 1 : const routable: { edge: DataflowSceneEdge; key: string; from: EdgeEnd; fromY: number; to: EdgeEnd; toY: number; bus: boolean }[] = [];
424 1 : for (const edge of graph.edges) {
425 1 : const to = boxOf.get(edge.to.scriptKey);
426 1 : if (to == null) continue;
427 1 : if (edge.input != null) {
428 1 : const source = inputOf.get(edge.input);
429 1 : if (source == null) continue;
430 1 : const toStatement = to.collapsed ? -1 : edge.to.statementId;
431 1 : if (to.collapsed) {
432 0 : const key = `:${edge.input}>${to.script.scriptKey}.${toStatement}`;
433 0 : if (merged.has(key)) continue;
434 0 : merged.add(key);
435 0 : }
436 1 : const fromY = source.rowY + config.rowHeight / 2;
437 1 : const toY = portY(to, edge.to.statementId, config);
438 1 : const { path } = routeEdge(source, fromY, to, toY, config);
439 1 : source.output = true;
440 1 : const toRow = to.rows[edge.to.statementId];
441 1 : if (to.collapsed || toRow == null) to.input = true;
442 1 : else toRow.input = true;
443 1 : const sceneEdge: DataflowSceneEdge = { key: edge.key, kind: edge.kind, from: edge.from, to: edge.to, input: edge.input, internal: false, path, badge: null };
444 1 : edges.push(sceneEdge);
445 1 : const key = dataflowRouteKey(nodeKeyOfInput(edge.input), edge.from.statementId, nodeKeyOfScript(to.script.scriptKey), edge.to.statementId);
446 1 : routable.push({ edge: sceneEdge, key, from: source, fromY, to, toY, bus: false });
447 1 : continue;
448 1 : }
449 1 : const from = boxOf.get(edge.from.scriptKey);
450 1 : if (from == null) continue;
451 1 : const internal = from === to;
452 1 : if (internal && from.collapsed) continue;
453 1 : const fromStatement = from.collapsed ? -1 : edge.from.statementId;
454 1 : const toStatement = to.collapsed ? -1 : edge.to.statementId;
455 1 : if (from.collapsed || to.collapsed) {
456 1 : const key = `${from.script.scriptKey}.${fromStatement}>${to.script.scriptKey}.${toStatement}:${edge.kind}`;
457 1 : if (merged.has(key)) continue;
458 1 : merged.add(key);
459 1 : }
460 1 : const fromY = portY(from, edge.from.statementId, config);
461 1 : const toY = portY(to, edge.to.statementId, config);
462 1 : const sourceKey = `${from.script.scriptKey}.${fromStatement}`;
463 1 : const trunk = internal || edge.kind !== DataflowEdgeKind.Table ? null : trunkOf(sourceKey, from, edge.name);
464 1 : let path: string;
465 1 : let middle: [number, number] = [0, 0];
466 1 : if (internal) {
467 1 : path = internalEdgePath(from, fromY, toY, Math.abs(edge.to.statementId - edge.from.statementId), config);
468 : } else {
469 1 : ({ path, middle } = routeEdge(from, fromY, to, toY, config, trunk));
470 : }
471 : // Collapsed boxes have one port per side, rows have their own
472 1 : const fromRow = from.rows[edge.from.statementId];
473 1 : const toRow = to.rows[edge.to.statementId];
474 1 : if (from.collapsed || fromRow == null) from.output = true;
475 1 : else if (internal) fromRow.input = true;
476 1 : else fromRow.output = true;
477 1 : if (to.collapsed || toRow == null) to.input = true;
478 1 : else toRow.input = true;
479 1 : let badge: DataflowSceneEdge['badge'] = null;
480 1 : if (edge.kind === DataflowEdgeKind.Table && !internal && edge.name.length > 0) {
481 1 : const bus = `${sourceKey}:${edge.name}`;
482 1 : if (trunk != null) {
483 1 : if (!badged.has(bus)) {
484 1 : badged.add(bus);
485 1 : badge = { x: (outputX(from, config) + trunk) / 2, y: fromY, width: edgeBadgeWidth(edge.name), text: edge.name };
486 1 : }
487 : } else {
488 1 : badge = { x: middle[0], y: middle[1], width: edgeBadgeWidth(edge.name), text: edge.name };
489 : }
490 1 : }
491 1 : const sceneEdge: DataflowSceneEdge = { key: edge.key, kind: edge.kind, from: edge.from, to: edge.to, input: null, internal, path, badge };
492 1 : edges.push(sceneEdge);
493 1 : if (!internal) {
494 1 : const key = dataflowRouteKey(nodeKeyOfScript(from.script.scriptKey), edge.from.statementId, nodeKeyOfScript(to.script.scriptKey), edge.to.statementId);
495 1 : routable.push({ edge: sceneEdge, key, from, fromY, to, toY, bus: trunk != null });
496 1 : }
497 1 : }
498 :
499 : // Edges between boxes follow the routes of the layout while those still fit, the router routes the others around
500 : // the boxes. Routes are drawn with rounded corners from the port of the source row to the port of the target row.
501 1 : if (routing != null && routable.length > 0) {
502 1 : const obstacles: EdgeEnd[] = [...boxes, ...inputs];
503 1 : const pending: { r: (typeof routable)[number]; from: core.DataflowPoint; to: core.DataflowPoint }[] = [];
504 1 : const routed: { r: (typeof routable)[number]; points: core.DataflowPoint[] }[] = [];
505 1 : for (const r of routable) {
506 1 : const from: core.DataflowPoint = [r.from.x + r.from.width, r.fromY];
507 1 : const to: core.DataflowPoint = [r.to.x, r.toY];
508 1 : const laidOut = routing.laidOut.get(r.key);
509 1 : const fits = laidOut != null
510 1 : && sameRect(laidOut.from, r.from) && sameRect(laidOut.to, r.to)
511 1 : && laidOut.points[0][0] === from[0] && laidOut.points[0][1] === from[1]
512 1 : && laidOut.points[laidOut.points.length - 1][0] === to[0] && laidOut.points[laidOut.points.length - 1][1] === to[1]
513 1 : && !obstacles.some(b => b !== r.from && b !== r.to && crossesBox(laidOut.points, b));
514 1 : if (fits) routed.push({ r, points: laidOut.points });
515 1 : else pending.push({ r, from, to });
516 1 : }
517 1 : if (pending.length > 0) {
518 1 : const routes = routing.route(obstacles.map(b => ({ x: b.x, y: b.y, width: b.width, height: b.height })), pending.map(p => ({ from: p.from, to: p.to })));
519 1 : pending.forEach((p, i) => {
520 1 : const points = routes[i];
521 1 : if (points != null) routed.push({ r: p.r, points });
522 1 : });
523 1 : }
524 1 : const busBadged = new Set<string>();
525 1 : for (const { r, points } of routed) {
526 1 : const path: core.DataflowPoint[] = [[outputX(r.from, config), r.fromY], ...points, [inputX(r.to, config), r.toY]];
527 1 : r.edge.path = orthogonalPath(path, ROUTE_CORNER_RADIUS);
528 1 : if (r.edge.badge != null || (r.bus && r.edge.kind === DataflowEdgeKind.Table)) {
529 1 : const name = r.edge.badge?.text ?? '';
530 1 : if (r.bus) {
531 : // One badge per bus, on the first edge of the bus that is routed
532 0 : const bus = `${r.key.split('>')[0]}:${name}`;
533 0 : if (busBadged.has(bus) || name.length === 0) {
534 0 : r.edge.badge = null;
535 0 : continue;
536 0 : }
537 0 : busBadged.add(bus);
538 0 : }
539 1 : const width = edgeBadgeWidth(name);
540 1 : const [x, y] = routeBadgeAt(path, width, r.bus);
541 1 : r.edge.badge = { x, y, width, text: name };
542 1 : }
543 1 : }
544 1 : }
545 :
546 1 : let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
547 1 : for (const box of [...boxes, ...inputs]) {
548 1 : minX = Math.min(minX, box.x);
549 1 : minY = Math.min(minY, box.y);
550 1 : maxX = Math.max(maxX, box.x + box.width);
551 1 : maxY = Math.max(maxY, box.y + box.height);
552 1 : }
553 1 : const bounds = boxes.length + inputs.length === 0 ? { x: 0, y: 0, width: 0, height: 0 } : { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
554 1 : return { boxes, inputs, edges, bounds };
555 1 : }
556 :
557 : /// What is selected on the canvas: a box, or a statement of it, or an input
558 : export interface DataflowSelection {
559 : scriptKey: number;
560 : statementId: number | null;
561 : /// The selected input, the script key is `INPUT_PORT_SCRIPT_KEY` then
562 : input?: string;
563 : }
564 :
565 : /// The edges of a selection: of the selected statement, or of every statement of the selected box, or of the input
566 1 : export function selectedEdges(scene: DataflowScene, selection: DataflowSelection | null): DataflowSceneEdge[] {
567 1 : if (selection == null) return [];
568 1 : if (selection.input != null) return scene.edges.filter(edge => edge.input === selection.input);
569 1 : const touches = (port: DataflowPort) => port.scriptKey === selection.scriptKey
570 1 : && (selection.statementId == null || port.statementId === selection.statementId);
571 1 : return scene.edges.filter(edge => touches(edge.from) || touches(edge.to));
572 1 : }
573 :
574 : /// The transform that fits a scene into a viewport: centered, at most at its real size and at least at `minScale`
575 1 : export function fitDataflowScene(bounds: Rect, viewportWidth: number, viewportHeight: number, padding = 32, minScale = 0.25): { x: number; y: number; scale: number } {
576 1 : if (bounds.width <= 0 || bounds.height <= 0) return { x: padding, y: padding, scale: 1 };
577 1 : const scale = Math.max(minScale, Math.min(1, (viewportWidth - 2 * padding) / bounds.width, (viewportHeight - 2 * padding) / bounds.height));
578 1 : const x = (viewportWidth - bounds.width * scale) / 2 - bounds.x * scale;
579 1 : const y = Math.max(padding, (viewportHeight - bounds.height * scale) / 2) - bounds.y * scale;
580 1 : return { x, y, scale };
581 1 : }
582 :
|