LCOV - code coverage report
Current view: top level - dashql-app/src/session/dataflow - dataflow_scene.ts Coverage Total Hit
Test: DashQL 915828b Lines: 96.6 % 380 367
Test Date: 2026-11-06 19:30:32

            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              : 
        

Generated by: LCOV version 2.0-1