From 64c601d8c84d05e3a893d5f47f6ceb6bccebc82c Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Wed, 25 Feb 2026 23:31:06 +0000 Subject: [PATCH] Basic loopback visualisation --- src/Sass/visualiser.scss | 56 ++- .../components/VisualisetTab.tsx | 334 +++++++++++++----- 2 files changed, 303 insertions(+), 87 deletions(-) diff --git a/src/Sass/visualiser.scss b/src/Sass/visualiser.scss index 596527f..9b30c6c 100644 --- a/src/Sass/visualiser.scss +++ b/src/Sass/visualiser.scss @@ -4,8 +4,13 @@ padding: $spacePadding; } -.visualiser-flow { +.visualiser-container { + position: relative; width: min(100%, 760px); +} + +.visualiser-flow { + width: 100%; background: linear-gradient( 180deg, rgba($blue, 0.12), @@ -143,4 +148,53 @@ .visualiser-connector-branch-arrow { fill: rgba($blue, 0.8); } + + .visualiser-outcome-connector-line { + stroke: rgba($blue, 0.65); + } + + .visualiser-outcome-connector-arrow { + fill: rgba($blue, 0.65); + } +} + +.visualiser-outcome-connectors { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} + +.visualiser-outcome-connector-line { + fill: none; + stroke: rgba($blue, 0.45); + stroke-width: 2px; + stroke-dasharray: 4, 4; + vector-effect: non-scaling-stroke; +} + +.visualiser-outcome-connector-arrow { + fill: rgba($blue, 0.45); +} + +.visualiser-outcome-connector-label { + font-size: 2px; + font-weight: 600; + fill: $blue; + paint-order: stroke fill; + stroke: $mode--light-bg; + stroke-width: 1px; + stroke-linecap: round; + stroke-linejoin: round; + user-select: none; +} + +@include color-mode(dark) { + .visualiser-outcome-connector-label { + fill: lighten($blue, 20%); + stroke: $mode--dark-bg; + } } diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index a3dc24c..debb0fc 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -21,6 +21,10 @@ const VisualiserTab: React.FC = ({ const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]); const [arrowPositions, setArrowPositions] = useState([]); const taskColumnMap = useRef>(new Map()); + const visualiserContainerRef = useRef(null); + const [taskDOMPositions, setTaskDOMPositions] = useState< + Map + >(new Map()); // Calculate column range directly from taskColumnMap in render const allColumns = Array.from(taskColumnMap.current.values()); @@ -178,6 +182,42 @@ const VisualiserTab: React.FC = ({ setArrowPositions(positions); }, [tasks]); + useEffect(() => { + // Measure task DOM positions for outcome connectors + if (!visualiserContainerRef.current) return; + + const containerRect = + visualiserContainerRef.current.getBoundingClientRect(); + const positions = new Map(); + + const nodes = + visualiserContainerRef.current.querySelectorAll(".visualiser-node"); + nodes.forEach((node) => { + const taskName = node.querySelector(".visualiser-node-content span"); + if (!taskName) return; + + // Find task by name + const task = tasks.find( + (t) => (t.config.name as string) === taskName.textContent, + ); + if (!task) return; + + const nodeRect = node.getBoundingClientRect(); + const relX = + ((nodeRect.left - containerRect.left + nodeRect.width / 2) / + containerRect.width) * + 100; + const relY = + ((nodeRect.top - containerRect.top + nodeRect.height / 2) / + containerRect.height) * + 100; + + positions.set(task.config.guid as string, { x: relX, y: relY }); + }); + + setTaskDOMPositions(positions); + }, [tasks, levels]); + const getArrowPositionsForLevel = (levelIndex: number): number[] => { const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex); return found?.positions ?? []; @@ -328,97 +368,219 @@ const VisualiserTab: React.FC = ({ ); }; + // Collect all outcome-based connections + const getOutcomeConnections = (): Array<{ + sourceGuid: string; + targetGuid: string; + verdict: string; + sourceX: number; + targetX: number; + sourceY: number; + targetY: number; + }> => { + const connections: Array<{ + sourceGuid: string; + targetGuid: string; + verdict: string; + sourceX: number; + targetX: number; + sourceY: number; + targetY: number; + }> = []; + + tasks.forEach((task) => { + const outcomes = + (task.config.outcomeActions as Array<{ + verdict: string; + task: string | null; + }>) ?? []; + + outcomes.forEach((outcome) => { + if (outcome.task) { + const sourcePos = taskDOMPositions.get(task.config.guid as string); + const targetPos = taskDOMPositions.get(outcome.task); + + if (sourcePos && targetPos) { + connections.push({ + sourceGuid: task.config.guid as string, + targetGuid: outcome.task, + verdict: outcome.verdict, + sourceX: sourcePos.x, + sourceY: sourcePos.y, + targetX: targetPos.x, + targetY: targetPos.y, + }); + } + } + }); + }); + + return connections; + }; + return (
-
-
-
- Start -
-
- {levels.length > 0 && - renderConnector( - levels[0].length, - getConnectorPositionsByColumn(levels[0]), - undefined, - )} - {levels.map((level, index) => ( - -
-
{ - levelRowRefs.current[index] = el; - }} - style={ - { - gridTemplateColumns: - columnRange.max > columnRange.min - ? `repeat(${ - columnRange.max - columnRange.min + 1 - }, minmax(220px, 1fr))` - : "220px", - } as React.CSSProperties - } - > - {level.map((task) => { - const taskColumn = - taskColumnMap.current.get(task.config.guid as string) ?? 0; - const isRootTask = !( - task.config.predecessors as string[] | undefined - )?.length; - const numColumns = columnRange.max - columnRange.min + 1; - - let styleObj: React.CSSProperties = {}; - - if (isRootTask && numColumns > 1) { - // Root tasks span all columns and center their content - styleObj = { - gridColumn: `1 / -1`, - display: "flex", - justifyContent: "center", - }; - } else { - // Child tasks positioned in their specific column - const gridColumn = taskColumn - columnRange.min + 1; - styleObj = { gridColumn }; - } - - return ( -
-
- - {(task.config.name as string) || task.type} -
-
- ); - })} -
+
+
+
+
+ Start +
+
+ {levels.length > 0 && + renderConnector( + levels[0].length, + getConnectorPositionsByColumn(levels[0]), + undefined, + )} + {levels.map((level, index) => ( + +
+
{ + levelRowRefs.current[index] = el; + }} + style={ + { + gridTemplateColumns: + columnRange.max > columnRange.min + ? `repeat(${ + columnRange.max - columnRange.min + 1 + }, minmax(220px, 1fr))` + : "220px", + } as React.CSSProperties + } + > + {level.map((task) => { + const taskColumn = + taskColumnMap.current.get(task.config.guid as string) ?? + 0; + const isRootTask = !( + task.config.predecessors as string[] | undefined + )?.length; + const numColumns = columnRange.max - columnRange.min + 1; + + let styleObj: React.CSSProperties = {}; + + if (isRootTask && numColumns > 1) { + // Root tasks span all columns and center their content + styleObj = { + gridColumn: `1 / -1`, + display: "flex", + justifyContent: "center", + }; + } else { + // Child tasks positioned in their specific column + const gridColumn = taskColumn - columnRange.min + 1; + styleObj = { gridColumn }; + } + + return ( +
+
+ + + {(task.config.name as string) || task.type} + +
+
+ ); + })} +
+
+ {index < levels.length - 1 + ? renderConnector( + levels[index + 1].length, + getConnectorPositionsByColumn(levels[index + 1]), + getConnectorPositionsByColumn(levels[index]), + ) + : renderConnector(1, undefined, undefined)} +
+ ))} + {levels.length === 0 && renderConnector(1)} +
+
+ End
- {index < levels.length - 1 - ? renderConnector( - levels[index + 1].length, - getConnectorPositionsByColumn(levels[index + 1]), - getConnectorPositionsByColumn(levels[index]), - ) - : renderConnector(1, undefined, undefined)} - - ))} - {levels.length === 0 && renderConnector(1)} -
-
- End
+ {/* Outcome-based loop-back connectors overlay */} +
);