LCOV - code coverage report
Current view: top level - dashql-app/src/session/dataflow - dataflow_canvas.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 80.3 % 762 612
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import type * as core from '@ankoh/dashql-core';
       3              : import { select, zoom, zoomIdentity, type ZoomBehavior, type ZoomTransform } from 'd3';
       4              : 
       5              : import { ButtonGroup } from '../../ui/foundations/button_group.js';
       6              : import { Button, ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       7              : import { PlusIcon, SymbolIcon, ZoomInIcon, ZoomOutIcon } from '../../ui/foundations/symbol_icon.js';
       8              : import type { ConnectionState } from '../../connections/connection_state.js';
       9              : import { useQueryState } from '../../connections/query_executor.js';
      10              : import { VisualizationPanel } from '../../views/visualization/visualization_panel.js';
      11              : import { GraphPort } from '../../ui/graph/graph_port.js';
      12              : import * as graphStyles from '../../ui/graph/graph.module.css';
      13              : import type { ScriptData, SessionScripts } from '../scripts/session_scripts.js';
      14              : import type { ModifySessionScripts } from '../scripts/session_scripts_registry.js';
      15              : import { scriptDisplayName } from '../scripts/script_types.js';
      16              : import { checkSessionScriptReferences } from '../scripts/script_reference_reports.js';
      17              : import type { ScriptReferenceReport } from '../scripts/script_reference_check.js';
      18              : import type { RenameDraftReferences } from '../scripts/script_rename.js';
      19              : import { ScriptEntryHeader } from '../feed/script_entry_header.js';
      20              : import { deriveDataflowGraph, DataflowEdgeKind, INPUT_PORT_SCRIPT_KEY, statementTypeLabel, type DataflowInput, type DataflowScript } from './dataflow_graph.js';
      21              : import { SessionInputCard } from '../inputs/session_input_card.js';
      22              : import { useApplySessionInput } from '../inputs/use_apply_session_input.js';
      23              : import { deriveSessionInputs, type SessionInput } from '../inputs/session_inputs.js';
      24              : import {
      25              :     buildDataflowScene,
      26              :     dataflowRouteKey,
      27              :     nodeKeyOfInput,
      28              :     nodeKeyOfScript,
      29              :     type DataflowHint,
      30              :     type DataflowLaidOutRoute,
      31              :     type DataflowRouting,
      32              :     dataflowLayoutInput,
      33              :     EDGE_BADGE_HEIGHT,
      34              :     fitDataflowScene,
      35              :     resizeDataflowBox,
      36              :     selectedEdges,
      37              :     type DataflowBoxPosition,
      38              :     type DataflowBoxSize,
      39              :     type DataflowSceneBox,
      40              :     type DataflowSceneEdge,
      41              :     type DataflowSceneInput,
      42              :     type DataflowSelection,
      43              : } from './dataflow_scene.js';
      44              : import { createFlowScript, deleteFlowScript, persistFlowPlacement, removeFlowPositions } from './dataflow_source.js';
      45              : import { useDataflowModule } from './dataflow_module.js';
      46              : import { QueryStatusPlaceholder } from '../../views/card_placeholder.js';
      47              : import * as styles from './dataflow.module.css';
      48              : 
      49            1 : const FitIcon = SymbolIcon('graph_align');
      50              : 
      51              : /// The approximate width of a character of a statement label, labels are truncated to the box
      52            1 : const LABEL_CHAR_WIDTH = 8;
      53              : /// The border of the frame of a chart, see `.chart_body`
      54              : const CHART_BORDER = 1;
      55              : /// The resize handle in the bottom right corner of a box, and the step of a resize with the arrow keys
      56            1 : const RESIZE_HANDLE = 16;
      57            1 : const RESIZE_KEY_STEP = 16;
      58              : /// The canvas draws every box with its statements
      59            1 : const NEVER_COLLAPSED = () => false;
      60              : 
      61              : export interface DataflowCanvasDependencies {
      62              :     useQueryState: typeof useQueryState;
      63              :     VisualizationPanel: typeof VisualizationPanel;
      64              :     useDataflowModule: typeof useDataflowModule;
      65              :     ScriptEntryHeader: typeof ScriptEntryHeader;
      66              : }
      67              : 
      68            1 : const DEFAULT_DEPENDENCIES: DataflowCanvasDependencies = {
      69            1 :     useQueryState,
      70            1 :     VisualizationPanel,
      71            1 :     useDataflowModule,
      72            1 :     ScriptEntryHeader,
      73            1 : };
      74              : 
      75            1 : const NO_CONNECTIONS: ReadonlyMap<string, ConnectionState> = new Map();
      76              : 
      77              : interface Props {
      78              :     sessionScripts: SessionScripts;
      79              :     modifySessionScripts: ModifySessionScripts;
      80              :     /// The connections that the scripts run on, the headers of the boxes show and pick them
      81              :     scriptConnections?: ReadonlyMap<string, ConnectionState>;
      82              :     /// The function that renames the references in the draft of the composer
      83              :     draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
      84              :     /// Open the script in the editor dialog
      85              :     onInspect?: (scriptKey: number) => void;
      86              :     dependencies?: DataflowCanvasDependencies;
      87              : }
      88              : 
      89              : interface Position {
      90              :     x: number;
      91              :     y: number;
      92              : }
      93              : 
      94              : /// The size of a box during a resize: its width and the height of its chart, null without a chart
      95              : interface ResizedBox {
      96              :     width: number;
      97              :     chartHeight: number | null;
      98              : }
      99              : 
     100              : /// The input or the script of the key of a box (`nodeKeyOfInput`, `nodeKeyOfScript`)
     101            1 : function inputOfNodeKey(key: string): string | null {
     102            1 :     return key.startsWith('input:') ? key.slice('input:'.length) : null;
     103            1 : }
     104              : 
     105            1 : function scriptOfNodeKey(key: string): number {
     106            1 :     return Number(key.slice('script:'.length));
     107            1 : }
     108              : 
     109              : /// Snap a size to the grid, within its bounds
     110            1 : function snapSize(value: number, min: number, max: number, grid: number): number {
     111            1 :     return Math.min(max, Math.max(min, Math.round(value / grid) * grid));
     112            1 : }
     113              : 
     114            1 : function truncate(text: string, length: number): string {
     115            1 :     return text.length <= length ? text : `${text.slice(0, length - 1)}…`;
     116            1 : }
     117              : 
     118              : /// The dataflow of a session: every script is a box of its statements, edges show the flow of data between them.
     119              : /// The core lays the boxes out with ELK and routes the edges. Every layout starts from where the boxes were, so
     120              : /// boxes keep their columns, their order in a column and their y while the scripts are edited. A dropped or resized
     121              : /// box makes the others move as little as possible, then every box is saved in the `@flow` tag of its script, so a
     122              : /// reload shows the same layout. Arrange lays out every box anew.
     123            1 : export function DataflowCanvas(props: Props): React.ReactElement {
     124            1 :     const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
     125              :     // The layout is a wasm module of its own, loaded with the first canvas
     126            1 :     const { layout, error } = dependencies.useDataflowModule();
     127            1 :     if (layout == null) {
     128            1 :         return (
     129            1 :             <div className={styles.viewport} data-testid="dataflow-loading">
     130            1 :                 <div className={styles.empty}>{error != null ? `Eclipse Layout Kernel failed to load: ${error.message}` : 'Loading Eclipse Layout Kernel…'}</div>
     131            1 :             </div>
     132              :         );
     133              :     }
     134            1 :     return <DataflowCanvasView {...props} dependencies={dependencies} layoutModule={layout} />;
     135            1 : }
     136              : 
     137            1 : function DataflowCanvasView(props: Props & { dependencies: DataflowCanvasDependencies; layoutModule: core.DashQLDataflow }): React.ReactElement {
     138            1 :     const { sessionScripts, modifySessionScripts, dependencies, layoutModule } = props;
     139              :     // The sizes of the canvas, set by the layout
     140            1 :     const config = layoutModule.config;
     141            1 :     const viewportRef = React.useRef<HTMLDivElement | null>(null);
     142            1 :     const svgRef = React.useRef<SVGSVGElement | null>(null);
     143            1 :     const sceneRef = React.useRef<SVGGElement | null>(null);
     144            1 :     const zoomRef = React.useRef<ZoomBehavior<SVGSVGElement, unknown> | null>(null);
     145            1 :     const transformRef = React.useRef<ZoomTransform>(zoomIdentity);
     146            1 :     const [selection, setSelection] = React.useState<DataflowSelection | null>(null);
     147            1 :     const [hover, setHover] = React.useState<DataflowSelection | null>(null);
     148            1 :     const [drag, setDrag] = React.useState<{ node: string; position: Position } | null>(null);
     149            1 :     const [resize, setResize] = React.useState<{ scriptKey: number; size: ResizedBox } | null>(null);
     150            1 :     const [arrangeRevision, setArrangeRevision] = React.useState(0);
     151              : 
     152              :     // Where the layout placed the boxes without `@flow` tags, by script key. The boxes keep these positions while
     153              :     // the session changes, so that only new boxes are placed. A box that a growing neighbour would overlap is placed
     154              :     // again, a tag always wins.
     155            1 :     const placedRef = React.useRef(new Map<number, Position>());
     156              :     // Where the layout or a drag placed the inputs, by name. No script holds the position of an input.
     157            1 :     const placedInputsRef = React.useRef(new Map<string, Position>());
     158              :     // A drop or a resize that waits for its layout: the dropped box and where it was dropped, or the resized script
     159              :     // and its size. The layout after it is saved.
     160            1 :     const commitRef = React.useRef<{ node: string; position: Position | null; size: ResizedBox | null } | null>(null);
     161              :     // Arrange lays out every box anew
     162            1 :     const arrangingRef = React.useRef(false);
     163            1 :     const { apply: applyInput, literalOf, busyInputs } = useApplySessionInput(sessionScripts, modifySessionScripts);
     164              : 
     165            1 :     const graph = deriveDataflowGraph(sessionScripts);
     166            1 :     const scriptOf = React.useCallback((script: DataflowScript): ScriptData | undefined => sessionScripts.scripts[script.scriptKey], [sessionScripts.scripts]);
     167            1 :     const chartStatement = React.useCallback((script: DataflowScript) => {
     168            1 :         const statementId = scriptOf(script)?.annotations.visualizeQuery?.statementId;
     169            1 :         return statementId != null && statementId < script.statements.length ? statementId : null;
     170            1 :     }, [scriptOf]);
     171              : 
     172              :     // Every box is laid out from where it was: where the last layout put it, else where its `@flow` tag saved it. A
     173              :     // dropped box is laid out from where it was dropped, a resized one with its new size. ELK keeps the columns, the
     174              :     // order in a column and the y of the boxes, and routes the edges between them.
     175            1 :     const layout = React.useMemo((): { boxes: DataflowBoxPosition[]; laidOut: ReadonlyMap<string, DataflowLaidOutRoute> } => {
     176            1 :         const placed = placedRef.current;
     177            1 :         const placedInputs = placedInputsRef.current;
     178            1 :         const commit = commitRef.current;
     179            1 :         const arranging = arrangingRef.current;
     180            1 :         arrangingRef.current = false;
     181            1 :         const tagOf = (script: DataflowScript): Position | null => {
     182            1 :             if (arranging) return null;
     183            1 :             const flow = scriptOf(script)?.annotations.flowPosition;
     184            1 :             return flow?.x != null && flow.y != null ? { x: flow.x, y: flow.y } : null;
     185            1 :         };
     186            1 :         const sizeOf = (script: DataflowScript): DataflowBoxSize | null => {
     187            1 :             const flow = scriptOf(script)?.annotations.flowPosition;
     188            1 :             if (commit?.size != null && commit.node === nodeKeyOfScript(script.scriptKey)) {
     189            1 :                 return { width: commit.size.width, chartHeight: commit.size.chartHeight ?? flow?.height ?? null };
     190              :             }
     191            1 :             return flow != null ? { width: flow.width, chartHeight: flow.height } : null;
     192            1 :         };
     193            1 :         const hintOf = (key: string, last: Position | undefined, saved: Position | null): DataflowHint | null => {
     194            1 :             if (commit?.position != null && commit.node === key) return { ...commit.position, dropped: true };
     195            1 :             const at = last ?? saved;
     196            1 :             return at == null ? null : { x: at.x, y: at.y };
     197            1 :         };
     198            1 :         if (graph.scripts.length === 0) return { boxes: [], laidOut: new Map() };
     199            1 :         const keys = [...graph.scripts.map(script => nodeKeyOfScript(script.scriptKey)), ...graph.inputs.map(input => nodeKeyOfInput(input.name))];
     200            1 :         const input = dataflowLayoutInput(
     201            1 :             graph,
     202            1 :             script => hintOf(nodeKeyOfScript(script.scriptKey), placed.get(script.scriptKey), tagOf(script)),
     203            1 :             chartStatement,
     204            1 :             () => false,
     205            0 :             input => hintOf(nodeKeyOfInput(input.name), placedInputs.get(input.name), null),
     206            1 :             sizeOf,
     207            1 :         );
     208            1 :         const result = layoutModule.computeDataflowLayoutWithRoutes(input.boxes, input.edges);
     209            1 :         const boxes = result.boxes;
     210            1 :         const rect = (i: number) => ({ x: boxes[i].x, y: boxes[i].y, width: boxes[i].width, height: boxes[i].height });
     211            1 :         const laidOut = new Map(result.routes.map(r => [
     212            1 :             dataflowRouteKey(keys[r.fromBox], r.fromStatement, keys[r.toBox], r.toStatement),
     213            1 :             { from: rect(r.fromBox), to: rect(r.toBox), points: r.points },
     214            1 :         ]));
     215              :         // Remember where the boxes of analyzed scripts are, a script without statements is placed again once it has some
     216            1 :         placed.clear();
     217            1 :         graph.scripts.forEach((script, i) => {
     218            1 :             if (script.statements.length > 0) placed.set(script.scriptKey, { x: boxes[i].x, y: boxes[i].y });
     219            1 :         });
     220            1 :         placedInputs.clear();
     221            1 :         graph.inputs.forEach((input, i) => {
     222            0 :             const p = boxes[graph.scripts.length + i];
     223            0 :             if (p != null) placedInputs.set(input.name, { x: p.x, y: p.y });
     224            1 :         });
     225            1 :         return { boxes, laidOut };
     226            1 :     }, [graph, scriptOf, chartStatement, arrangeRevision, layoutModule]);
     227            1 :     const positions = layout.boxes;
     228              : 
     229              :     // After a drop or a resize, every script box is saved in its `@flow` tag: a reload lays out from there and shows
     230              :     // the same layout. Tags that already hold the position and the size stay.
     231            1 :     React.useEffect(() => {
     232            1 :         const commit = commitRef.current;
     233            1 :         if (commit == null) return;
     234            1 :         commitRef.current = null;
     235            1 :         graph.scripts.forEach((s, i) => {
     236            1 :             const script = sessionScripts.scripts[s.scriptKey];
     237            1 :             const p = positions[i];
     238            1 :             if (script == null || p == null || s.statements.length === 0) return;
     239            1 :             const flow = script.annotations.flowPosition;
     240            1 :             const resized = commit.size != null && commit.node === nodeKeyOfScript(s.scriptKey) ? commit.size : null;
     241            1 :             const width = resized != null ? resized.width : flow?.width ?? null;
     242            1 :             const height = resized != null ? resized.chartHeight ?? flow?.height ?? null : flow?.height ?? null;
     243            1 :             persistFlowPlacement(script, { x: p.x, y: p.y }, width, height, modifySessionScripts);
     244            1 :         });
     245            1 :     }, [positions]);
     246              : 
     247              :     // A dragged box follows the pointer, a resized box takes the size of the pointer. The other boxes make room once
     248              :     // the size is written.
     249            1 :     const shown = React.useMemo(() => {
     250            1 :         if (drag == null && resize == null) return positions;
     251            1 :         const out = positions.slice();
     252            1 :         if (drag != null) {
     253            1 :             const scriptIndex = graph.scripts.findIndex(s => nodeKeyOfScript(s.scriptKey) === drag.node);
     254            1 :             const inputIndex = graph.inputs.findIndex(i => nodeKeyOfInput(i.name) === drag.node);
     255            1 :             const index = scriptIndex >= 0 ? scriptIndex : inputIndex >= 0 ? graph.scripts.length + inputIndex : -1;
     256            1 :             if (index >= 0 && out[index] != null) out[index] = { ...out[index], x: drag.position.x, y: drag.position.y };
     257            1 :         }
     258            1 :         if (resize != null) {
     259            1 :             const index = graph.scripts.findIndex(s => s.scriptKey === resize.scriptKey);
     260            1 :             if (index >= 0 && out[index] != null) {
     261            1 :                 out[index] = resizeDataflowBox(out[index], chartStatement(graph.scripts[index]), resize.size.width, resize.size.chartHeight, config);
     262              :             }
     263            1 :         }
     264            1 :         return out;
     265            1 :     }, [positions, drag, resize, graph.scripts, graph.inputs, chartStatement]);
     266              :     // The edges between boxes go around the other boxes, routed by the core
     267              :     // Edges follow the routes of the layout, the edges of a dragged or resized box are routed around the boxes
     268            1 :     const routing: DataflowRouting = React.useMemo(
     269            1 :         () => ({ laidOut: layout.laidOut, route: (boxes, edges) => layoutModule.routeDataflowEdges(boxes, edges) }),
     270            1 :         [layout, layoutModule],
     271            1 :     );
     272            1 :     const scene = React.useMemo(() => buildDataflowScene(graph, shown, chartStatement, NEVER_COLLAPSED, config, routing), [graph, shown, chartStatement, routing]);
     273              :     // The edges of the selection and of the hovered box, a drag hides the ones of the hovered box
     274            1 :     const dragging = drag != null;
     275            1 :     const highlighted = React.useMemo(() => {
     276            1 :         const edges = new Set(selectedEdges(scene, selection));
     277            1 :         if (!dragging) for (const edge of selectedEdges(scene, hover)) edges.add(edge);
     278            1 :         return scene.edges.filter(edge => edges.has(edge));
     279            1 :     }, [scene, selection, hover, dragging]);
     280            1 :     const highlightedSet = React.useMemo(() => new Set(highlighted), [highlighted]);
     281              : 
     282              :     // Zoom and pan, the transform is applied to the DOM directly
     283            1 :     const fit = React.useCallback((animate = false) => {
     284            1 :         const viewport = viewportRef.current;
     285            1 :         const svg = svgRef.current;
     286            1 :         const behavior = zoomRef.current;
     287            1 :         if (viewport == null || svg == null || behavior == null) return;
     288            1 :         const { x, y, scale } = fitDataflowScene(scene.bounds, viewport.clientWidth, viewport.clientHeight);
     289            1 :         const target = zoomIdentity.translate(x, y).scale(scale);
     290            1 :         (animate ? select(svg).transition().duration(160) : select(svg)).call(behavior.transform as any, target);
     291            1 :     }, [scene.bounds]);
     292            1 :     React.useLayoutEffect(() => {
     293            1 :         const svg = svgRef.current;
     294            1 :         if (svg == null) return;
     295            1 :         const behavior = zoom<SVGSVGElement, unknown>()
     296            1 :             .scaleExtent([0.1, 2])
     297            1 :             .clickDistance(4)
     298              :             // A pinch on a touchpad is a wheel with ctrlKey and zooms the canvas over the rows and charts too
     299            1 :             .filter(event => (event.type === 'wheel' && event.ctrlKey) || (!event.ctrlKey && !event.button
     300            1 :                 && !(event.target instanceof Element && event.target.closest('button, [role="button"], foreignObject'))))
     301            1 :             .on('zoom', event => {
     302            1 :                 transformRef.current = event.transform;
     303            1 :                 sceneRef.current?.setAttribute('transform', event.transform.toString());
     304            1 :             });
     305            1 :         zoomRef.current = behavior;
     306            1 :         select(svg).call(behavior).on('dblclick.zoom', null);
     307            1 :         return () => {
     308            1 :             select(svg).on('.zoom', null);
     309            1 :             zoomRef.current = null;
     310            1 :         };
     311            1 :     }, []);
     312              :     // A pinch never zooms the page while the canvas is open, wherever the pointer is.
     313              :     // Chromium sends a pinch as a wheel with ctrlKey, Safari as gesture events.
     314            1 :     React.useEffect(() => {
     315            1 :         const prevent = (event: Event) => {
     316            1 :             if (event.type !== 'wheel' || (event as WheelEvent).ctrlKey) event.preventDefault();
     317            1 :         };
     318            1 :         const options = { capture: true, passive: false };
     319            1 :         document.addEventListener('wheel', prevent, options);
     320            1 :         document.addEventListener('gesturestart', prevent, options);
     321            1 :         document.addEventListener('gesturechange', prevent, options);
     322            1 :         return () => {
     323            1 :             document.removeEventListener('wheel', prevent, options);
     324            1 :             document.removeEventListener('gesturestart', prevent, options);
     325            1 :             document.removeEventListener('gesturechange', prevent, options);
     326            1 :         };
     327            1 :     }, []);
     328              :     // Fit once the first boxes are placed
     329            1 :     const fittedRef = React.useRef(false);
     330            1 :     React.useLayoutEffect(() => {
     331            1 :         if (fittedRef.current || scene.boxes.length === 0) return;
     332            1 :         fittedRef.current = true;
     333            1 :         fit();
     334            1 :     }, [fit, scene.boxes.length]);
     335            1 :     const zoomBy = React.useCallback((factor: number) => {
     336            0 :         const svg = svgRef.current;
     337            0 :         const behavior = zoomRef.current;
     338            0 :         if (svg != null && behavior != null) select(svg).transition().duration(120).call(behavior.scaleBy, factor);
     339            1 :     }, []);
     340              : 
     341              :     // Dragging a box by its header. Moves and the release are followed on the window, the pointer leaves the header.
     342            1 :     const dragRef = React.useRef<{ node: string; pointerId: number; startX: number; startY: number; origin: Position; position: Position | null } | null>(null);
     343            1 :     const finishDragRef = React.useRef<(event: PointerEvent, commit: boolean) => void>(() => {});
     344            1 :     finishDragRef.current = (event: PointerEvent, commit: boolean) => {
     345            1 :         const current = dragRef.current;
     346            1 :         if (current == null || current.pointerId !== event.pointerId) return;
     347            1 :         dragRef.current = null;
     348            1 :         setDrag(null);
     349            1 :         const input = inputOfNodeKey(current.node);
     350            1 :         if (input != null) {
     351              :             // An input keeps the position of the drag while the canvas is open
     352            0 :             if (current.position == null) {
     353            0 :                 if (commit) setSelection({ scriptKey: INPUT_PORT_SCRIPT_KEY, statementId: null, input });
     354            0 :             } else if (commit) {
     355            0 :                 commitRef.current = { node: current.node, position: current.position, size: null };
     356            0 :                 setArrangeRevision(r => r + 1);
     357            0 :             }
     358            0 :             return;
     359            0 :         }
     360            1 :         const scriptKey = scriptOfNodeKey(current.node);
     361            1 :         if (current.position == null) {
     362            0 :             if (commit) setSelection({ scriptKey, statementId: null });
     363            0 :             return;
     364            0 :         }
     365              :         // The layout starts from where the box was dropped, then every box is saved
     366            1 :         if (commit) {
     367            1 :             commitRef.current = { node: current.node, position: current.position, size: null };
     368            1 :             setArrangeRevision(r => r + 1);
     369            1 :         }
     370            1 :     };
     371            1 :     React.useEffect(() => {
     372            1 :         const move = (event: PointerEvent) => {
     373            1 :             const current = dragRef.current;
     374            1 :             if (current == null || current.pointerId !== event.pointerId) return;
     375            1 :             const k = transformRef.current.k;
     376            1 :             const dx = (event.clientX - current.startX) / k;
     377            1 :             const dy = (event.clientY - current.startY) / k;
     378            1 :             if (current.position == null && Math.hypot(dx * k, dy * k) < 4) return;
     379            1 :             const grid = config.grid;
     380            1 :             current.position = { x: Math.round((current.origin.x + dx) / grid) * grid, y: Math.round((current.origin.y + dy) / grid) * grid };
     381            1 :             setDrag({ node: current.node, position: current.position });
     382            1 :         };
     383            1 :         const up = (event: PointerEvent) => finishDragRef.current(event, true);
     384            1 :         const cancel = (event: PointerEvent) => finishDragRef.current(event, false);
     385            1 :         window.addEventListener('pointermove', move);
     386            1 :         window.addEventListener('pointerup', up);
     387            1 :         window.addEventListener('pointercancel', cancel);
     388            1 :         return () => {
     389            1 :             window.removeEventListener('pointermove', move);
     390            1 :             window.removeEventListener('pointerup', up);
     391            1 :             window.removeEventListener('pointercancel', cancel);
     392            1 :         };
     393            1 :     }, []);
     394            1 :     const onHandleDown = React.useCallback((event: React.PointerEvent, box: DataflowSceneBox) => {
     395            1 :         if (event.button !== 0) return;
     396            1 :         event.stopPropagation();
     397            1 :         dragRef.current = { node: nodeKeyOfScript(box.script.scriptKey), pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: { x: box.x, y: box.y }, position: null };
     398            1 :     }, []);
     399            1 :     const onInputHandleDown = React.useCallback((event: React.PointerEvent, box: DataflowSceneInput) => {
     400            0 :         if (event.button !== 0) return;
     401            0 :         event.stopPropagation();
     402            0 :         dragRef.current = { node: nodeKeyOfInput(box.input.name), pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin: { x: box.x, y: box.y }, position: null };
     403            1 :     }, []);
     404              : 
     405              :     // Resizing a box by the handle in its bottom right corner: the width of the box and the height of its chart, a box
     406              :     // without a chart only changes its width. The size is written into the `@flow` tag of the script on release.
     407            1 :     const resizeRef = React.useRef<{ scriptKey: number; pointerId: number; startX: number; startY: number; origin: ResizedBox; size: ResizedBox } | null>(null);
     408              :     // The layout makes room for the new size, then every box is saved with it
     409            1 :     const writeSize = React.useCallback((scriptKey: number, size: ResizedBox) => {
     410            1 :         commitRef.current = { node: nodeKeyOfScript(scriptKey), position: null, size };
     411            1 :         setArrangeRevision(r => r + 1);
     412            1 :     }, []);
     413            1 :     const finishResizeRef = React.useRef<(event: PointerEvent, commit: boolean) => void>(() => {});
     414            1 :     finishResizeRef.current = (event: PointerEvent, commit: boolean) => {
     415            1 :         const current = resizeRef.current;
     416            1 :         if (current == null || current.pointerId !== event.pointerId) return;
     417            1 :         resizeRef.current = null;
     418            1 :         setResize(null);
     419            1 :         const { size, origin } = current;
     420            1 :         if (commit && (size.width !== origin.width || size.chartHeight !== origin.chartHeight)) writeSize(current.scriptKey, size);
     421            1 :     };
     422            1 :     React.useEffect(() => {
     423            1 :         const move = (event: PointerEvent) => {
     424            1 :             const current = resizeRef.current;
     425            1 :             if (current == null || current.pointerId !== event.pointerId) return;
     426            1 :             const k = transformRef.current.k;
     427            1 :             const width = snapSize(current.origin.width + (event.clientX - current.startX) / k, config.minBoxWidth, config.maxBoxWidth, config.grid);
     428            1 :             const chartHeight = current.origin.chartHeight == null
     429            1 :                 ? null
     430            0 :                 : snapSize(current.origin.chartHeight + (event.clientY - current.startY) / k, config.minChartHeight, config.maxChartHeight, config.grid);
     431            1 :             if (width === current.size.width && chartHeight === current.size.chartHeight) return;
     432            1 :             current.size = { width, chartHeight };
     433            1 :             setResize({ scriptKey: current.scriptKey, size: current.size });
     434            1 :         };
     435            1 :         const up = (event: PointerEvent) => finishResizeRef.current(event, true);
     436            1 :         const cancel = (event: PointerEvent) => finishResizeRef.current(event, false);
     437            1 :         window.addEventListener('pointermove', move);
     438            1 :         window.addEventListener('pointerup', up);
     439            1 :         window.addEventListener('pointercancel', cancel);
     440            1 :         return () => {
     441            1 :             window.removeEventListener('pointermove', move);
     442            1 :             window.removeEventListener('pointerup', up);
     443            1 :             window.removeEventListener('pointercancel', cancel);
     444            1 :         };
     445            1 :     }, []);
     446            1 :     const onResizeDown = React.useCallback((event: React.PointerEvent, box: DataflowSceneBox) => {
     447            1 :         if (event.button !== 0) return;
     448            1 :         event.preventDefault();
     449            1 :         event.stopPropagation();
     450            1 :         const origin = { width: box.width, chartHeight: box.chart?.height ?? null };
     451            1 :         resizeRef.current = { scriptKey: box.script.scriptKey, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, origin, size: origin };
     452            1 :     }, []);
     453            1 :     const onResizeKey = React.useCallback((event: React.KeyboardEvent, box: DataflowSceneBox) => {
     454            1 :         let width = box.width;
     455            1 :         let chartHeight = box.chart?.height ?? null;
     456            1 :         if (event.key === 'ArrowLeft') width -= RESIZE_KEY_STEP;
     457            1 :         else if (event.key === 'ArrowRight') width += RESIZE_KEY_STEP;
     458            0 :         else if (event.key === 'ArrowUp' && chartHeight != null) chartHeight -= RESIZE_KEY_STEP;
     459            0 :         else if (event.key === 'ArrowDown' && chartHeight != null) chartHeight += RESIZE_KEY_STEP;
     460            0 :         else return;
     461            1 :         event.preventDefault();
     462            1 :         event.stopPropagation();
     463            1 :         const size = {
     464            1 :             width: snapSize(width, config.minBoxWidth, config.maxBoxWidth, config.grid),
     465            1 :             chartHeight: chartHeight == null ? null : snapSize(chartHeight, config.minChartHeight, config.maxChartHeight, config.grid),
     466            1 :         };
     467              :         // A box at a bound of its size stays as it is
     468            1 :         if (size.width === box.width && size.chartHeight === (box.chart?.height ?? null)) return;
     469            1 :         writeSize(box.script.scriptKey, size);
     470            1 :     }, [writeSize]);
     471              : 
     472              :     // Creating and removing scripts
     473            1 :     const deleteScript = React.useCallback((script: ScriptData) => deleteFlowScript(script, modifySessionScripts), [modifySessionScripts]);
     474              :     // The headers of the boxes show the diagnostics of the script references like the feed
     475            1 :     const scriptReferences = React.useMemo(() => checkSessionScriptReferences(sessionScripts), [sessionScripts]);
     476            1 :     const quoteIdentifier = React.useCallback((name: string) => sessionScripts.instance.quoteIdentifier(name), [sessionScripts.instance]);
     477            1 :     const createAt = React.useCallback((position: Position | null, producer: ScriptData | null) => {
     478            1 :         void createFlowScript(position, producer, quoteIdentifier, modifySessionScripts);
     479            1 :     }, [quoteIdentifier, modifySessionScripts]);
     480              :     // The layout places a script that reads a box right of it, in a free slot
     481            1 :     const createDownstream = React.useCallback((box: DataflowSceneBox) => {
     482            1 :         createAt(null, sessionScripts.scripts[box.script.scriptKey] ?? null);
     483            1 :     }, [sessionScripts.scripts, createAt]);
     484            1 :     const createNew = React.useCallback(() => createAt(null, null), [createAt]);
     485            1 :     const onCanvasDoubleClick = React.useCallback((event: React.MouseEvent<SVGSVGElement>) => {
     486            1 :         if (event.target !== svgRef.current) return;
     487            0 :         const rect = svgRef.current.getBoundingClientRect();
     488            0 :         const [x, y] = transformRef.current.invert([event.clientX - rect.left, event.clientY - rect.top]);
     489            0 :         const grid = config.grid;
     490            0 :         createAt({ x: Math.round(x / grid) * grid, y: Math.round(y / grid) * grid }, null);
     491            1 :     }, [createAt]);
     492            1 :     const arrange = React.useCallback(() => {
     493            1 :         placedRef.current.clear();
     494            1 :         placedInputsRef.current.clear();
     495              :         // The tags are removed with the next analysis, the next layout ignores them already
     496            1 :         arrangingRef.current = true;
     497            1 :         const scripts = graph.scripts.map(scriptOf).filter((s): s is ScriptData => s != null);
     498            1 :         removeFlowPositions(scripts, modifySessionScripts);
     499            1 :         setArrangeRevision(r => r + 1);
     500            1 :         fittedRef.current = false;
     501            1 :     }, [graph.scripts, scriptOf, modifySessionScripts]);
     502              : 
     503            1 :     const onKeyDown = React.useCallback((event: React.KeyboardEvent) => {
     504            0 :         if (event.target !== viewportRef.current) return;
     505            0 :         if (event.key === 'Escape') setSelection(null);
     506            0 :         else if (event.key === '+' || event.key === '=') zoomBy(1.25);
     507            0 :         else if (event.key === '-') zoomBy(0.8);
     508            0 :         else if (event.key === 'f') fit(true);
     509            0 :         else return;
     510            0 :         event.preventDefault();
     511            0 :         event.stopPropagation();
     512            1 :     }, [fit, zoomBy]);
     513              : 
     514            1 :     const internal = scene.edges.filter(e => e.internal);
     515            1 :     const between = scene.edges.filter(e => !e.internal);
     516            1 :     const solid = (e: DataflowSceneEdge) => e.kind === DataflowEdgeKind.Inline || e.kind === DataflowEdgeKind.Table || e.kind === DataflowEdgeKind.Input;
     517            1 :     const renderHighlight = (edge: DataflowSceneEdge) => (
     518            1 :         <path
     519            1 :             key={edge.key}
     520            1 :             className={solid(edge) || edge.internal ? graphStyles.edgeHighlight : graphStyles.crossEdgeHighlight}
     521            1 :             d={edge.path}
     522            1 :             style={solid(edge) || edge.internal ? { strokeWidth: 2 } : undefined}
     523            1 :             data-dataflow-edge-highlight={edge.key}
     524            1 :         />
     525              :     );
     526            1 :     const renderBadge = (edge: DataflowSceneEdge) => edge.badge == null ? null : (
     527            1 :         <g
     528            1 :             key={edge.key}
     529            1 :             className={graphStyles.edgeBadge}
     530            1 :             transform={`translate(${edge.badge.x}, ${edge.badge.y})`}
     531            1 :             data-highlighted={highlightedSet.has(edge) || undefined}
     532            1 :             data-dataflow-badge={edge.key}
     533              :         >
     534            1 :             <rect
     535            1 :                 className={styles.badge}
     536            1 :                 x={-edge.badge.width / 2}
     537            1 :                 y={-EDGE_BADGE_HEIGHT / 2}
     538            1 :                 width={edge.badge.width}
     539            1 :                 height={EDGE_BADGE_HEIGHT}
     540            1 :                 rx={EDGE_BADGE_HEIGHT / 2}
     541            1 :             />
     542            1 :             <text className={styles.badge_text}>{edge.badge.text}</text>
     543            1 :         </g>
     544              :     );
     545              :     // The inputs as the feed and the dashboard show them, with their warnings
     546            1 :     const sessionInputs = deriveSessionInputs(sessionScripts);
     547            1 :     return (
     548            1 :         <div
     549            1 :             ref={viewportRef}
     550            1 :             className={styles.viewport}
     551            1 :             role="region"
     552            1 :             aria-label={`Dataflow of ${scene.boxes.length} scripts`}
     553            1 :             tabIndex={0}
     554            1 :             onKeyDown={onKeyDown}
     555            1 :             data-testid="dataflow-canvas"
     556            1 :             data-electron-drag-region="false"
     557              :         >
     558            1 :             <svg
     559            1 :                 ref={svgRef}
     560            1 :                 className={styles.svg}
     561            1 :                 onClick={event => { if (event.target === svgRef.current) setSelection(null); }}
     562            1 :                 onDoubleClick={onCanvasDoubleClick}
     563              :             >
     564            1 :                 <g ref={sceneRef}>
     565              :                     {/* The outlines of the boxes, below every edge */}
     566            1 :                     <g>
     567            1 :                         {scene.boxes.map(box => (
     568            1 :                             <rect
     569            1 :                                 key={box.script.scriptKey}
     570            1 :                                 className={styles.card}
     571            1 :                                 data-dataflow-card={box.script.scriptKey}
     572              :                                 // A selected box outlines the whole card, not its header
     573            1 :                                 data-selected={selection?.scriptKey === box.script.scriptKey && selection.statementId == null && selection.input == null}
     574            1 :                                 x={box.x}
     575            1 :                                 y={box.y}
     576            1 :                                 width={box.width}
     577            1 :                                 height={box.height}
     578            1 :                                 rx={8}
     579            1 :                                 ry={8}
     580            1 :                             />
     581            1 :                         ))}
     582            1 :                         {scene.inputs.map(box => (
     583            0 :                             <rect
     584            0 :                                 key={`input:${box.input.name}`}
     585            0 :                                 className={styles.card}
     586            0 :                                 data-selected={selection?.input === box.input.name}
     587            0 :                                 x={box.x}
     588            0 :                                 y={box.y}
     589            0 :                                 width={box.width}
     590            0 :                                 height={box.height}
     591            0 :                                 rx={8}
     592            0 :                                 ry={8}
     593            0 :                             />
     594            1 :                         ))}
     595            1 :                     </g>
     596              :                     {/* The edges between boxes run on the outlines up to their ports, the layout routes them around the boxes */}
     597            1 :                     <g aria-hidden="true">
     598            1 :                         {between.filter(solid).map(edge => (
     599            1 :                             <path key={edge.key} className={graphStyles.edge} d={edge.path} style={{ strokeWidth: 2 }} data-dataflow-edge={edge.key} data-kind={edge.kind} />
     600            1 :                         ))}
     601            1 :                     </g>
     602            1 :                     <g aria-hidden="true">
     603            1 :                         {between.filter(e => !solid(e)).map(edge => (
     604            0 :                             <path key={edge.key} className={graphStyles.crossEdge} d={edge.path} data-dataflow-edge={edge.key} data-kind={edge.kind} />
     605            1 :                         ))}
     606            1 :                     </g>
     607            1 :                     <g className={graphStyles.edgeBadgeLayer}>
     608            1 :                         {between.filter(e => !highlightedSet.has(e)).map(renderBadge)}
     609            1 :                     </g>
     610              :                     {/* The edges between the rows of a box run inside its padding */}
     611            1 :                     <g aria-hidden="true">
     612            1 :                         {internal.map(edge => (
     613            1 :                             <path key={edge.key} className={graphStyles.edge} d={edge.path} style={{ strokeWidth: 2 }} data-dataflow-edge={edge.key} data-kind={edge.kind} />
     614            1 :                         ))}
     615            1 :                     </g>
     616            1 :                     <g>
     617            1 :                         {scene.boxes.map(box => (
     618            1 :                             <DataflowBox
     619            1 :                                 config={config}
     620            1 :                                 key={box.script.scriptKey}
     621            1 :                                 box={box}
     622            1 :                                 sessionScripts={sessionScripts}
     623            1 :                                 modifySessionScripts={modifySessionScripts}
     624            1 :                                 scriptConnections={props.scriptConnections ?? NO_CONNECTIONS}
     625            1 :                                 scriptReferences={scriptReferences.get(box.script.scriptKey) ?? null}
     626            1 :                                 draftRenameRef={props.draftRenameRef}
     627            1 :                                 script={sessionScripts.scripts[box.script.scriptKey]}
     628            1 :                                 selection={selection?.scriptKey === box.script.scriptKey ? selection : null}
     629            1 :                                 dragging={drag?.node === nodeKeyOfScript(box.script.scriptKey)}
     630            1 :                                 resizing={resize?.scriptKey === box.script.scriptKey}
     631            1 :                                 dependencies={dependencies}
     632            1 :                                 onHandleDown={onHandleDown}
     633            1 :                                 onResizeDown={onResizeDown}
     634            1 :                                 onResizeKey={onResizeKey}
     635            1 :                                 onSelect={setSelection}
     636            1 :                                 onHover={setHover}
     637            1 :                                 onInspect={props.onInspect}
     638            1 :                                 onDelete={deleteScript}
     639            1 :                                 onCreateDownstream={createDownstream}
     640            1 :                             />
     641            1 :                         ))}
     642            1 :                         {scene.inputs.map(box => (
     643            0 :                             <DataflowInputBox
     644            0 :                                 config={config}
     645            0 :                                 key={box.input.name}
     646            0 :                                 box={box}
     647            0 :                                 input={sessionInputs.find(i => i.name === box.input.name) ?? null}
     648            0 :                                 selected={selection?.input === box.input.name}
     649            0 :                                 dragging={drag?.node === nodeKeyOfInput(box.input.name)}
     650            0 :                                 busy={busyInputs.has(box.input.name)}
     651            0 :                                 onHandleDown={onInputHandleDown}
     652            0 :                                 onSelect={setSelection}
     653            0 :                                 onHover={setHover}
     654            0 :                                 literalOf={literalOf}
     655            0 :                                 onCommit={applyInput}
     656            0 :                             />
     657            1 :                         ))}
     658            1 :                     </g>
     659              :                     {/* The highlighted edges and their badges are drawn on top of the boxes */}
     660            1 :                     <g className={graphStyles.edgeHighlightLayer} aria-hidden="true" data-dataflow-highlight-layer>
     661            1 :                         {highlighted.map(renderHighlight)}
     662            1 :                         {highlighted.filter(e => !e.internal).map(renderBadge)}
     663            1 :                     </g>
     664              :                     {/* The ports are drawn on top of the highlighted edges that end in them */}
     665            1 :                     <g aria-hidden="true" data-dataflow-port-layer>
     666            1 :                         {scene.boxes.map(box => <DataflowBoxPorts key={box.script.scriptKey} box={box} highlighted={highlighted} config={config} />)}
     667            1 :                         {scene.inputs.map(box => (
     668            0 :                             <DataflowInputPorts
     669            0 :                                 key={box.input.name}
     670            0 :                                 box={box}
     671            0 :                                 config={config}
     672            0 :                                 highlighted={highlighted.length > 0 && highlighted.some(e => e.input === box.input.name)}
     673            0 :                             />
     674            1 :                         ))}
     675            1 :                     </g>
     676            1 :                 </g>
     677            1 :             </svg>
     678            1 :             {scene.boxes.length === 0 && (
     679            0 :                 <div className={styles.empty}>The scripts of the session appear here, double-click to add one.</div>
     680              :             )}
     681            1 :             <div className={styles.toolbar}>
     682            1 :                 <Button size={ButtonSize.Small} variant={ButtonVariant.Default} leadingVisual={PlusIcon} onClick={createNew} data-testid="dataflow-new-script">New script</Button>
     683            1 :                 <Button size={ButtonSize.Small} variant={ButtonVariant.Default} onClick={arrange} data-testid="dataflow-arrange">Arrange</Button>
     684            1 :             </div>
     685            1 :             <ButtonGroup className={styles.controls} aria-label="Dataflow zoom controls">
     686            1 :                 <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Zoom in" onClick={() => zoomBy(1.25)}><ZoomInIcon size={12} /></IconButton>
     687            1 :                 <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Zoom out" onClick={() => zoomBy(0.8)}><ZoomOutIcon size={12} /></IconButton>
     688            1 :                 <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Fit dataflow" onClick={() => fit(true)}><FitIcon size={12} /></IconButton>
     689            1 :             </ButtonGroup>
     690            1 :         </div>
     691              :     );
     692            1 : }
     693              : 
     694              : interface BoxProps {
     695              :     box: DataflowSceneBox;
     696              :     config: core.DataflowLayoutConfig;
     697              :     sessionScripts: SessionScripts;
     698              :     modifySessionScripts: ModifySessionScripts;
     699              :     scriptConnections: ReadonlyMap<string, ConnectionState>;
     700              :     scriptReferences: ScriptReferenceReport | null;
     701              :     draftRenameRef?: React.RefObject<RenameDraftReferences | null>;
     702              :     script: ScriptData | undefined;
     703              :     selection: DataflowSelection | null;
     704              :     dragging: boolean;
     705              :     resizing: boolean;
     706              :     dependencies: DataflowCanvasDependencies;
     707              :     onHandleDown: (event: React.PointerEvent, box: DataflowSceneBox) => void;
     708              :     onResizeDown: (event: React.PointerEvent, box: DataflowSceneBox) => void;
     709              :     onResizeKey: (event: React.KeyboardEvent, box: DataflowSceneBox) => void;
     710              :     onSelect: (selection: DataflowSelection) => void;
     711              :     /// The pointer enters a box with its selection or leaves it with null
     712              :     onHover: (selection: DataflowSelection | null) => void;
     713              :     onInspect?: (scriptKey: number) => void;
     714              :     onDelete: (script: ScriptData) => void;
     715              :     onCreateDownstream: (box: DataflowSceneBox) => void;
     716              : }
     717              : 
     718              : /// The ports of a script box: one per side of a collapsed box, the ports of the statement rows otherwise
     719            1 : function DataflowBoxPorts(props: { box: DataflowSceneBox; highlighted: DataflowSceneEdge[]; config: core.DataflowLayoutConfig }): React.ReactElement {
     720            1 :     const { box } = props;
     721            1 :     const config = props.config;
     722            1 :     const scriptKey = box.script.scriptKey;
     723            1 :     const touches = (statementId: number | null, side: 'from' | 'to') => props.highlighted.some(edge => {
     724            1 :         const port = edge[side];
     725            1 :         return port.scriptKey === scriptKey && (statementId == null || port.statementId === statementId);
     726            1 :     });
     727            1 :     if (box.collapsed) {
     728            0 :         const y = box.y + config.headerHeight / 2;
     729            0 :         return (
     730            0 :             <g data-dataflow-ports={scriptKey}>
     731            0 :                 {box.input && <GraphPort x={box.x} y={y} side="left" highlighted={touches(null, 'to')} data-dataflow-port="input" />}
     732            0 :                 {box.output && <GraphPort x={box.x + box.width} y={y} side="right" highlighted={touches(null, 'from')} data-dataflow-port="output" />}
     733            0 :             </g>
     734              :         );
     735            0 :     }
     736            1 :     return (
     737            1 :         <g data-dataflow-ports={scriptKey}>
     738            1 :             {box.rows.map(row => {
     739            1 :                 const id = row.statement.statementId;
     740            1 :                 const y = row.y + config.rowHeight / 2;
     741            1 :                 return (
     742            1 :                     <React.Fragment key={id}>
     743            1 :                         {row.input && <GraphPort x={box.x + config.rowPadding} y={y} side="left" highlighted={touches(id, 'to')} data-dataflow-port="input" />}
     744            1 :                         {row.output && <GraphPort x={box.x + box.width - config.rowPadding} y={y} side="right" highlighted={touches(id, 'from')} data-dataflow-port="output" />}
     745            1 :                     </React.Fragment>
     746              :                 );
     747            1 :             })}
     748            1 :         </g>
     749              :     );
     750            1 : }
     751              : 
     752              : /// The output port of an input box, on its value row
     753            0 : function DataflowInputPorts(props: { box: DataflowSceneInput; highlighted: boolean; config: core.DataflowLayoutConfig }): React.ReactElement | null {
     754            0 :     const { box } = props;
     755            0 :     const config = props.config;
     756            0 :     if (!box.output) return null;
     757            0 :     const y = box.rowY + config.rowHeight / 2;
     758            0 :     const x = box.x + box.width - config.rowPadding;
     759            0 :     return <GraphPort x={x} y={y} side="right" highlighted={props.highlighted} data-dataflow-port="output" />;
     760            0 : }
     761              : 
     762              : /// The controls in the header of a box, a pointer down on them does not drag the box
     763            1 : const HEADER_CONTROLS = 'button, input, textarea, select, a, [role="button"], [role="menuitem"], [contenteditable]';
     764              : 
     765              : /// Is a pointer event on the header itself, not on one of its controls or in a popover that the header portals?
     766            1 : function onHeaderItself(event: React.SyntheticEvent): boolean {
     767            1 :     const target = event.target as Element;
     768            1 :     return event.currentTarget.contains(target) && target.closest(HEADER_CONTROLS) == null;
     769            1 : }
     770              : 
     771              : /// A script box: the header of the feed entry of the script, the statement rows, and the chart. The ports are drawn
     772              : /// by `DataflowBoxPorts` in a layer of their own.
     773            1 : function DataflowBox(props: BoxProps): React.ReactElement {
     774            1 :     const { box, script } = props;
     775            1 :     const config = props.config;
     776            1 :     const sessionId = props.sessionScripts.sessionId;
     777            1 :     const query = props.dependencies.useQueryState(sessionId, script?.latestQueryId ?? null);
     778            1 :     const name = scriptDisplayName(box.script.fileName);
     779            1 :     const scriptKey = box.script.scriptKey;
     780            1 :     const terminal = box.rows.length - 1;
     781            1 :     const result = query?.resultTable;
     782              :     // A new script reads the whole script, so the add button sits at the vertical middle of the box
     783            1 :     const addY = box.y + box.height / 2;
     784            1 :     const labelChars = (badgeWidth: number) => Math.max(4, Math.floor((box.width - 2 * config.rowPadding - 16 - badgeWidth - 8) / LABEL_CHAR_WIDTH));
     785              :     // The box has no editor, the edit button of the header opens the script in the editor dialog
     786            1 :     const onInspect = props.onInspect;
     787            1 :     const edit = React.useCallback(() => onInspect?.(scriptKey), [onInspect, scriptKey]);
     788            1 :     return (
     789            1 :         <g
     790            1 :             className={styles.box}
     791            1 :             data-dataflow-box={scriptKey}
     792            1 :             data-selected={props.selection != null && props.selection.statementId == null}
     793            1 :             data-dragging={props.dragging || undefined}
     794            1 :             data-resizing={props.resizing || undefined}
     795            1 :             onPointerEnter={() => props.onHover({ scriptKey, statementId: null })}
     796            1 :             onPointerLeave={() => props.onHover(null)}
     797              :         >
     798            1 :             <rect className={styles.hover_hit} x={box.x} y={box.y} width={box.width} height={box.height} rx={8} ry={8} />
     799            1 :             <g
     800            1 :                 className={styles.header}
     801            1 :                 data-dataflow-handle
     802            1 :                 role="button"
     803            1 :                 tabIndex={0}
     804            1 :                 aria-label={`${name}, ${box.rows.length} statements`}
     805            1 :                 onPointerDown={event => props.onHandleDown(event, box)}
     806            1 :                 onDoubleClick={event => { event.stopPropagation(); props.onInspect?.(scriptKey); }}
     807            1 :                 onKeyDown={event => {
     808            0 :                     if (event.key === 'Enter') props.onInspect?.(scriptKey);
     809            0 :                     else if (event.key === ' ') props.onSelect({ scriptKey, statementId: null });
     810            0 :                     else return;
     811            0 :                     event.preventDefault();
     812            0 :                 }}
     813              :             >
     814            1 :                 <rect x={box.x} y={box.y} width={box.width} height={config.headerHeight} rx={8} ry={8} className={styles.header_hit} />
     815            1 :             </g>
     816              :             {/* The header of the feed entry, a drag on its free space moves the box */}
     817            1 :             <foreignObject
     818            1 :                 x={box.x}
     819            1 :                 y={box.y}
     820            1 :                 width={box.width}
     821            1 :                 height={config.headerHeight}
     822            1 :                 className={styles.header_object}
     823              :             >
     824            1 :                 <div
     825            1 :                     className={styles.header_body}
     826            1 :                     data-dataflow-header={scriptKey}
     827            1 :                     onPointerDown={event => { if (onHeaderItself(event)) props.onHandleDown(event, box); }}
     828            1 :                     onDoubleClick={event => {
     829            1 :                         if (!onHeaderItself(event)) return;
     830            1 :                         event.stopPropagation();
     831            1 :                         props.onInspect?.(scriptKey);
     832            1 :                     }}
     833              :                 >
     834            1 :                     <props.dependencies.ScriptEntryHeader
     835            1 :                         sessionScripts={props.sessionScripts}
     836            1 :                         modifySessionScripts={props.modifySessionScripts}
     837            1 :                         scriptKey={scriptKey}
     838            1 :                         scriptConnections={props.scriptConnections}
     839            1 :                         scriptReferences={props.scriptReferences}
     840            1 :                         draftRenameRef={props.draftRenameRef}
     841            1 :                         onDelete={props.onDelete}
     842            1 :                         onEdit={onInspect != null ? edit : undefined}
     843              :                         // The snippets show in the editor dialog, the box has no room for them
     844            1 :                         snippets={false}
     845            1 :                         className={styles.entry_header}
     846            1 :                     />
     847            1 :                 </div>
     848            1 :             </foreignObject>
     849            1 :             {!box.collapsed && box.rows.map(row => {
     850            1 :                 const id = row.statement.statementId;
     851            1 :                 const selected = props.selection?.statementId === id;
     852            1 :                 const badge = statementTypeLabel(row.statement.statementType);
     853            1 :                 const badgeWidth = badge.length * 6 + 10;
     854            1 :                 return (
     855            1 :                     <g
     856            1 :                         key={id}
     857            1 :                         className={styles.statement}
     858            1 :                         data-dataflow-statement={`${scriptKey}.${id}`}
     859            1 :                         data-selected={selected}
     860            1 :                         role="button"
     861            1 :                         tabIndex={0}
     862            1 :                         aria-label={`${badge} ${row.statement.label}`}
     863            1 :                         onClick={event => { event.stopPropagation(); props.onSelect({ scriptKey, statementId: id }); }}
     864            1 :                         onDoubleClick={event => { event.stopPropagation(); props.onInspect?.(scriptKey); }}
     865            1 :                         onKeyDown={event => {
     866            0 :                             if (event.key !== 'Enter' && event.key !== ' ') return;
     867            0 :                             event.preventDefault();
     868            0 :                             props.onSelect({ scriptKey, statementId: id });
     869            0 :                         }}
     870              :                     >
     871            1 :                         <rect className={graphStyles.node_frame} x={box.x + config.rowPadding} y={row.y} width={box.width - 2 * config.rowPadding} height={config.rowHeight} rx={6} ry={6} />
     872            1 :                         <g className={styles.type_badge} transform={`translate(${box.x + config.rowPadding + 8}, ${row.y + config.rowHeight / 2})`}>
     873            1 :                             <rect x={0} y={-EDGE_BADGE_HEIGHT / 2} width={badgeWidth} height={EDGE_BADGE_HEIGHT} rx={EDGE_BADGE_HEIGHT / 2} />
     874            1 :                             <text x={badgeWidth / 2}>{badge}</text>
     875            1 :                         </g>
     876            1 :                         <text className={styles.label} x={box.x + config.rowPadding + 16 + badgeWidth} y={row.y + config.rowHeight / 2}>
     877            1 :                             {truncate(row.statement.label, labelChars(badgeWidth))}
     878            1 :                         </text>
     879            1 :                     </g>
     880              :                 );
     881            1 :             })}
     882            1 :             {box.chart != null && script?.annotations.visualizeQuery != null && (
     883            0 :                 <foreignObject x={box.chart.x} y={box.chart.y} width={box.chart.width} height={box.chart.height} className={styles.chart}>
     884            0 :                     <div className={styles.chart_body}>
     885            0 :                         {query?.resultTable != null
     886            0 :                             ? <props.dependencies.VisualizationPanel
     887            0 :                                 query={query}
     888            0 :                                 visualizeQuery={script.annotations.visualizeQuery}
     889            0 :                                 width={box.chart.width - 2 * CHART_BORDER}
     890            0 :                                 height={box.chart.height - 2 * CHART_BORDER}
     891            0 :                                 preview
     892            0 :                             />
     893            0 :                             : <QueryStatusPlaceholder query={query ?? null} />}
     894            0 :                     </div>
     895            0 :                 </foreignObject>
     896              :             )}
     897            1 :             {!box.collapsed && box.chart == null && result != null && terminal >= 0 && (
     898            1 :                 <text className={styles.summary} x={box.x + box.width - config.rowPadding - 10} y={box.rows[terminal].y + config.rowHeight / 2}>
     899            1 :                     {`${result.numRows} × ${result.numCols}`}
     900            1 :                 </text>
     901              :             )}
     902            1 :             <g
     903            1 :                 className={styles.add}
     904            1 :                 role="button"
     905            1 :                 tabIndex={0}
     906            1 :                 aria-label={`Add a script that reads ${name}`}
     907            1 :                 data-dataflow-action="add"
     908            1 :                 transform={`translate(${box.x + box.width + 14}, ${addY})`}
     909            1 :                 onPointerDown={event => event.stopPropagation()}
     910            1 :                 onClick={event => { event.stopPropagation(); props.onCreateDownstream(box); }}
     911            1 :                 onKeyDown={event => {
     912            0 :                     if (event.key !== 'Enter' && event.key !== ' ') return;
     913            0 :                     event.preventDefault();
     914            0 :                     props.onCreateDownstream(box);
     915            0 :                 }}
     916              :             >
     917            1 :                 <circle r={8} />
     918            1 :                 <path d="M -4 0 L 4 0 M 0 -4 L 0 4" />
     919            1 :             </g>
     920            1 :             {!box.collapsed && (
     921            1 :                 <g
     922            1 :                     className={styles.resize_handle}
     923            1 :                     role="button"
     924            1 :                     tabIndex={0}
     925            1 :                     aria-label={box.chart != null
     926            0 :                         ? `Resize ${name}. Current width ${box.width}, chart height ${box.chart.height}`
     927            1 :                         : `Resize ${name}. Current width ${box.width}`}
     928            1 :                     data-dataflow-resize={scriptKey}
     929            1 :                     data-chart={box.chart != null || undefined}
     930            1 :                     transform={`translate(${box.x + box.width - RESIZE_HANDLE}, ${box.y + box.height - RESIZE_HANDLE})`}
     931            1 :                     onPointerDown={event => props.onResizeDown(event, box)}
     932            1 :                     onClick={event => event.stopPropagation()}
     933            1 :                     onKeyDown={event => props.onResizeKey(event, box)}
     934              :                 >
     935            1 :                     <rect width={RESIZE_HANDLE} height={RESIZE_HANDLE} className={styles.resize_hit} />
     936            1 :                     <path d="M 12 6 V 12 H 6 Z" />
     937            1 :                 </g>
     938              :             )}
     939            1 :         </g>
     940              :     );
     941            1 : }
     942              : 
     943              : interface InputBoxProps {
     944              :     box: DataflowSceneInput;
     945              :     config: core.DataflowLayoutConfig;
     946              :     /// The input of the session, null while the inputs follow the graph
     947              :     input: SessionInput | null;
     948              :     selected: boolean;
     949              :     dragging: boolean;
     950              :     busy: boolean;
     951              :     onHandleDown: (event: React.PointerEvent, box: DataflowSceneInput) => void;
     952              :     onSelect: (selection: DataflowSelection) => void;
     953              :     /// The pointer enters a box with its selection or leaves it with null
     954              :     onHover: (selection: DataflowSelection | null) => void;
     955              :     literalOf: (input: SessionInput, value: string) => { literal: string } | { error: string };
     956              :     onCommit: (name: string, literal: string) => void;
     957              : }
     958              : 
     959              : /// The box of an input: the header with the name and the type, and the value in its row. Enter in the value writes it
     960              : /// and runs the scripts that use the input.
     961            0 : function DataflowInputBox(props: InputBoxProps): React.ReactElement {
     962            0 :     const { box } = props;
     963            0 :     const config = props.config;
     964            0 :     const input: DataflowInput = box.input;
     965            0 :     const name = input.name;
     966            0 :     const select = () => props.onSelect({ scriptKey: INPUT_PORT_SCRIPT_KEY, statementId: null, input: name });
     967            0 :     return (
     968            0 :         <g
     969            0 :             className={styles.box}
     970            0 :             data-dataflow-input={name}
     971            0 :             data-selected={props.selected}
     972            0 :             data-dragging={props.dragging || undefined}
     973            0 :             onPointerEnter={() => props.onHover({ scriptKey: INPUT_PORT_SCRIPT_KEY, statementId: null, input: name })}
     974            0 :             onPointerLeave={() => props.onHover(null)}
     975              :         >
     976            0 :             <rect className={styles.hover_hit} x={box.x} y={box.y} width={box.width} height={box.height} rx={8} ry={8} />
     977            0 :             <g
     978            0 :                 className={styles.header}
     979            0 :                 data-dataflow-handle
     980            0 :                 role="button"
     981            0 :                 tabIndex={0}
     982            0 :                 aria-label={`Input :${name}, ${input.type.sql}`}
     983            0 :                 onPointerDown={event => props.onHandleDown(event, box)}
     984            0 :                 onKeyDown={event => {
     985            0 :                     if (event.key !== ' ' && event.key !== 'Enter') return;
     986            0 :                     event.preventDefault();
     987            0 :                     select();
     988            0 :                 }}
     989              :             >
     990            0 :                 <rect x={box.x} y={box.y} width={box.width} height={config.headerHeight} rx={8} ry={8} className={styles.header_hit} />
     991            0 :                 <text className={styles.title} x={box.x + 12} y={box.y + config.headerHeight / 2}>{truncate(`:${name}`, 16)}</text>
     992            0 :                 <text className={styles.input_type} x={box.x + box.width - 12} y={box.y + config.headerHeight / 2} data-inferred={!input.type.cast || undefined}>
     993            0 :                     {truncate(input.type.sql, 14)}
     994            0 :                 </text>
     995            0 :             </g>
     996            0 :             {props.input != null && (
     997            0 :                 <foreignObject x={box.x + config.rowPadding} y={box.rowY} width={box.width - 2 * config.rowPadding} height={config.rowHeight} className={styles.input_row}>
     998            0 :                     <SessionInputCard input={props.input} variant="block" busy={props.busy} literalOf={props.literalOf} onCommit={props.onCommit} />
     999            0 :                 </foreignObject>
    1000              :             )}
    1001            0 :         </g>
    1002              :     );
    1003            0 : }
        

Generated by: LCOV version 2.0-1