From 22c80e82f25ba5f78528adf75173987866b64a94 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Thu, 26 Feb 2026 17:01:29 +0000 Subject: [PATCH] More work on the visualiser --- src/Sass/visualiser.scss | 24 +- .../components/VisualisetTab.tsx | 228 +++++++++++++++--- 2 files changed, 193 insertions(+), 59 deletions(-) diff --git a/src/Sass/visualiser.scss b/src/Sass/visualiser.scss index 9b30c6c..2528f67 100644 --- a/src/Sass/visualiser.scss +++ b/src/Sass/visualiser.scss @@ -11,15 +11,7 @@ .visualiser-flow { width: 100%; - background: linear-gradient( - 180deg, - rgba($blue, 0.12), - rgba($mode--light-bg, 0.9) - ); - border: 1px solid rgba($blue, 0.25); - border-radius: 16px; padding: 20px 16px; - box-shadow: 0 10px 24px rgba($leftMenu-background, 0.15); display: flex; flex-direction: column; align-items: center; @@ -35,7 +27,6 @@ .visualiser-level-row { display: grid; - gap: 16px; align-items: center; justify-items: center; } @@ -97,6 +88,11 @@ height: 34px; } +.visualiser-connector-spacer { + width: 100%; + height: 34px; +} + .visualiser-connector-branch-line { stroke: rgba($blue, 0.55); stroke-width: 2px; @@ -108,16 +104,6 @@ } @include color-mode(dark) { - .visualiser-flow { - background: linear-gradient( - 180deg, - rgba($blue, 0.18), - rgba($mode--dark-bg, 0.95) - ); - border: 1px solid rgba($blue, 0.35); - box-shadow: 0 12px 28px rgba($leftMenu-background, 0.35); - } - .visualiser-node { background: linear-gradient( 135deg, diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index 7828218..e624347 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -25,6 +25,7 @@ const VisualiserTab: React.FC = ({ const [taskDOMPositions, setTaskDOMPositions] = useState< Map >(new Map()); + const [columnGap, setColumnGap] = useState(80); // Vertical spacing between stacked verdict labels in merged outcome connectors const VERDICT_LABEL_SPACING = 3; @@ -35,6 +36,8 @@ const VisualiserTab: React.FC = ({ // Column layout constants const COLUMN_MIN_WIDTH = 220; + const BASE_COLUMN_GAP = 16; + const COLUMN_GAP_MARGIN = 35; // Outcome connector curve constants const CURVE_BASE_OFFSET = 20; @@ -177,11 +180,8 @@ const VisualiserTab: React.FC = ({ (s) => (s.config.guid as string) === guid, ); - // Assign columns: spread siblings around parent column - const startCol = Math.max( - 0, - predColumn - Math.floor(siblings.length / 2), - ); + // Assign columns: keep siblings together at or to the right of parent column + const startCol = predColumn; const column = startCol + siblingIndex; columnByGuid.set(guid, column); @@ -246,49 +246,70 @@ const VisualiserTab: React.FC = ({ useEffect(() => { // Measure task DOM positions for outcome connectors - if (!visualiserContainerRef.current) return; + const measurePositions = () => { + if (!visualiserContainerRef.current) return; - const containerRect = - visualiserContainerRef.current.getBoundingClientRect(); - const positions = new Map< - string, - { x: number; y: number; width: number; height: number } - >(); + const containerRect = + visualiserContainerRef.current.getBoundingClientRect(); + const positions = new Map< + string, + { x: number; y: number; width: number; height: number } + >(); - const nodes = - visualiserContainerRef.current.querySelectorAll(".visualiser-node"); - nodes.forEach((node) => { - const taskName = node.querySelector(".visualiser-node-content span"); - if (!taskName) return; + const nodes = + visualiserContainerRef.current.querySelectorAll(".visualiser-node"); + nodes.forEach((node) => { + const guid = node.getAttribute("data-guid"); + if (!guid) return; - // Find task by name - const task = tasks.find( - (t) => (t.config.name as string) === taskName.textContent, - ); - if (!task) return; + // Find task by GUID + const task = tasks.find((t) => (t.config.guid as string) === guid); + 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; - const widthPercent = (nodeRect.width / containerRect.width) * 100; - const heightPercent = (nodeRect.height / containerRect.height) * 100; + 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; + const widthPercent = (nodeRect.width / containerRect.width) * 100; + const heightPercent = (nodeRect.height / containerRect.height) * 100; - positions.set(task.config.guid as string, { - x: relX, - y: relY, - width: widthPercent, - height: heightPercent, + positions.set(task.config.guid as string, { + x: relX, + y: relY, + width: widthPercent, + height: heightPercent, + }); }); + + setTaskDOMPositions(positions); + }; + + // Initial measurement with a delay to ensure DOM and layout are ready + const timer = setTimeout(() => { + measurePositions(); + }, 50); + + // Re-measure when layout changes + if (!visualiserContainerRef.current) { + return () => clearTimeout(timer); + } + + const resizeObserver = new ResizeObserver(() => { + measurePositions(); }); - setTaskDOMPositions(positions); - }, [tasks, levels]); + resizeObserver.observe(visualiserContainerRef.current); + + return () => { + clearTimeout(timer); + resizeObserver.disconnect(); + }; + }, [tasks]); const getArrowPositionsForLevel = (levelIndex: number): number[] => { const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex); @@ -754,6 +775,106 @@ const VisualiserTab: React.FC = ({ return connections; }; + // Calculate required column gap based on outcome connections + const calculateRequiredColumnGap = (): number => { + // Get all outcome actions to determine max group size + const sourceGroups = new Map(); + const targetGroups = new Map(); + + tasks.forEach((task) => { + const outcomes = + (task.config.outcomeActions as Array<{ + verdict: string; + task: string | null; + }>) ?? []; + + outcomes.forEach((outcome) => { + if (outcome.task) { + const sourceGuid = task.config.guid as string; + const targetGuid = outcome.task; + + // Count outcomes from same source + sourceGroups.set(sourceGuid, (sourceGroups.get(sourceGuid) ?? 0) + 1); + // Count outcomes to same target + targetGroups.set(targetGuid, (targetGroups.get(targetGuid) ?? 0) + 1); + } + }); + }); + + // Find maximum group size + const maxSourceGroup = Math.max(0, ...Array.from(sourceGroups.values())); + const maxTargetGroup = Math.max(0, ...Array.from(targetGroups.values())); + const maxGroupSize = Math.max(maxSourceGroup, maxTargetGroup); + + // If no outcome connections, use base gap + if (maxGroupSize === 0) { + return BASE_COLUMN_GAP; + } + + // Calculate max offset: half of (groupSize - 1) * offsetStep + const maxOffset = ((maxGroupSize - 1) * CURVE_OFFSET_STEP) / 2; + + // Curves extend significantly - use larger multiplier to account for full curve trajectory + // For 2 curves (maxOffset ~7.5), this should provide ~100px of space + const requiredSpace = + CURVE_BASE_OFFSET + maxOffset * 15 + COLUMN_GAP_MARGIN; + + return Math.max(BASE_COLUMN_GAP, requiredSpace); + }; + + // Update column gap when tasks change + useEffect(() => { + const newGap = calculateRequiredColumnGap(); + setColumnGap(newGap); + }, [tasks]); + + // Re-measure positions when gap changes to accommodate layout reflow + useEffect(() => { + const timer = setTimeout(() => { + if (!visualiserContainerRef.current) return; + + const containerRect = + visualiserContainerRef.current.getBoundingClientRect(); + const positions = new Map< + string, + { x: number; y: number; width: number; height: number } + >(); + + const nodes = + visualiserContainerRef.current.querySelectorAll(".visualiser-node"); + nodes.forEach((node) => { + const guid = node.getAttribute("data-guid"); + if (!guid) return; + + const task = tasks.find((t) => (t.config.guid as string) === guid); + 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; + const widthPercent = (nodeRect.width / containerRect.width) * 100; + const heightPercent = (nodeRect.height / containerRect.height) * 100; + + positions.set(task.config.guid as string, { + x: relX, + y: relY, + width: widthPercent, + height: heightPercent, + }); + }); + + setTaskDOMPositions(positions); + }, 50); + + return () => clearTimeout(timer); + }, [columnGap, tasks]); + const rootTaskCount = tasks.filter((task) => { const preds = task.config.predecessors as string[] | undefined; return !preds || preds.length === 0; @@ -779,6 +900,7 @@ const VisualiserTab: React.FC = ({ columnRange.max - columnRange.min + 1 }, minmax(${COLUMN_MIN_WIDTH}px, 1fr))` : `${COLUMN_MIN_WIDTH}px`, + gap: `${columnGap}px`, } as React.CSSProperties } > @@ -837,6 +959,7 @@ const VisualiserTab: React.FC = ({ key={task.config.guid as string} className="visualiser-node" data-column={taskColumn} + data-guid={task.config.guid as string} style={styleObj} >
@@ -925,6 +1048,31 @@ const VisualiserTab: React.FC = ({ }); }); + // If no connectors will be drawn but there are override tasks, + // render a spacer to maintain visual gap + if (groups.size === 0 && overrideTasks.size > 0) { + // Check if next level has predecessors in current level + const hasRelationship = nextLevel.some((task) => { + const preds = + (task.config.predecessors as string[]) ?? []; + return preds.some((predGuid) => { + return currentLevel.some( + (currentTask) => + (currentTask.config.guid as string) === predGuid, + ); + }); + }); + + if (hasRelationship) { + return ( +