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 : }
|