diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index 9155981..58b879a 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -26,6 +26,33 @@ const VisualiserTab: React.FC = ({ Map >(new Map()); + // Vertical spacing between stacked verdict labels in merged outcome connectors + const VERDICT_LABEL_SPACING = 3; + + // SVG coordinate constants + const SVG_CENTER_POSITION = 50; + const SVG_COORDINATE_MAX = 100; + + // Column layout constants + const COLUMN_MIN_WIDTH = 220; + + // Outcome connector curve constants + const CURVE_BASE_OFFSET = 20; + const CURVE_OFFSET_MULTIPLIER = 0.3; + const CURVE_CONTROL_OFFSET_FACTOR = 0.3; + const CURVE_OFFSET_STEP = 15; + + // Bezier curve position constants + const BEZIER_LABEL_POSITION = 0.5; // Midpoint for label placement + const BEZIER_ARROW_POSITION = 0.3; // Position along curve for directional arrow + const BEZIER_ARROW_TANGENT_DELTA = 0.01; // Small offset for tangent calculation + + // Arrow dimensions + const ARROW_HALF_WIDTH = 1.5; + const DIRECTIONAL_ARROW_SIZE = 3; + const END_ARROW_OFFSET = 3; + const END_ARROW_HALF_WIDTH = 1; + // Calculate column range directly from taskColumnMap in render const allColumns = Array.from(taskColumnMap.current.values()); const columnRange = { @@ -206,7 +233,7 @@ const VisualiserTab: React.FC = ({ (nodeRect.left - levelRect.left + nodeRect.width / 2) / levelRect.width; // Convert to SVG coordinates (0-100) - nodePositions.push(relativeCenter * 100); + nodePositions.push(relativeCenter * SVG_COORDINATE_MAX); }); if (nodePositions.length > 0) { @@ -270,9 +297,9 @@ const VisualiserTab: React.FC = ({ const getColumnPercent = (column: number): number => { const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return 50; + if (numColumns <= 1) return SVG_CENTER_POSITION; const gridCol = column - columnRange.min; - return ((gridCol + 0.5) / numColumns) * 100; + return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX; }; // Calculate connector positions based on grid columns @@ -280,7 +307,7 @@ const VisualiserTab: React.FC = ({ levelTasks: CreateWorkflowTemplateVersion["tasks"][], ): number[] => { const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return [50]; + if (numColumns <= 1) return [SVG_CENTER_POSITION]; return levelTasks.map((task) => { const predecessors = (task.config.predecessors as string[]) ?? []; @@ -297,13 +324,13 @@ const VisualiserTab: React.FC = ({ predColumns.reduce((sum, col) => sum + col, 0) / predColumns.length; const gridCol = avgPredColumn - columnRange.min; // Map averaged column to percentage for centered merges - return ((gridCol + 0.5) / numColumns) * 100; + return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX; } } const gridCol = taskColumn - columnRange.min; // Map grid column to percentage (e.g., 2 columns: col 0 = 25%, col 1 = 75%) - return ((gridCol + 0.5) / numColumns) * 100; + return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX; }); }; @@ -312,7 +339,7 @@ const VisualiserTab: React.FC = ({ excludeTasks?: Set, ): number[] => { const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return [50]; + if (numColumns <= 1) return [SVG_CENTER_POSITION]; const predColumns = new Set(); @@ -402,7 +429,7 @@ const VisualiserTab: React.FC = ({ /> ))} @@ -412,8 +439,8 @@ const VisualiserTab: React.FC = ({ if (count <= 1) { // For a single child, use predecessor position if available - let startX = 50; - let endX = 50; + let startX = SVG_CENTER_POSITION; + let endX = SVG_CENTER_POSITION; // If single child with predecessor positions, connect from the appropriate predecessor if (positions && positions.length === 1) { @@ -475,7 +502,7 @@ const VisualiserTab: React.FC = ({ /> ); @@ -487,7 +514,7 @@ const VisualiserTab: React.FC = ({ ? positions : Array.from( { length: count }, - (_, index) => ((index + 1) / (count + 1)) * 100, + (_, index) => ((index + 1) / (count + 1)) * SVG_COORDINATE_MAX, ); // Guard against empty or invalid positions @@ -516,7 +543,7 @@ const VisualiserTab: React.FC = ({ const topX = predecessorPositions && predecessorPositions.length > 0 ? predecessorPositions[0] - : 50; + : SVG_CENTER_POSITION; return ( = ({ /> ))} @@ -562,7 +589,7 @@ const VisualiserTab: React.FC = ({ const getOutcomeConnections = (): Array<{ sourceGuid: string; targetGuid: string; - verdict: string; + verdicts: string[]; sourceX: number; targetX: number; sourceY: number; @@ -576,7 +603,7 @@ const VisualiserTab: React.FC = ({ const connections: Array<{ sourceGuid: string; targetGuid: string; - verdict: string; + verdicts: string[]; sourceX: number; targetX: number; sourceY: number; @@ -588,6 +615,21 @@ const VisualiserTab: React.FC = ({ curveOffset: number; }> = []; + // First, collect all outcome connections temporarily + const tempConnections: Array<{ + sourceGuid: string; + targetGuid: string; + verdict: string; + sourceX: number; + targetX: number; + sourceY: number; + targetY: number; + sourceWidth: number; + sourceHeight: number; + targetWidth: number; + targetHeight: number; + }> = []; + tasks.forEach((task) => { const outcomes = (task.config.outcomeActions as Array<{ @@ -601,7 +643,7 @@ const VisualiserTab: React.FC = ({ const targetPos = taskDOMPositions.get(outcome.task); if (sourcePos && targetPos) { - connections.push({ + tempConnections.push({ sourceGuid: task.config.guid as string, targetGuid: outcome.task, verdict: outcome.verdict, @@ -613,13 +655,40 @@ const VisualiserTab: React.FC = ({ sourceHeight: sourcePos.height, targetWidth: targetPos.width, targetHeight: targetPos.height, - curveOffset: 0, // Will be calculated below }); } } }); }); + // Group by source+target pair to combine connections with same start and end + const connectionGroups = new Map(); + tempConnections.forEach((conn) => { + const key = `${conn.sourceGuid}→${conn.targetGuid}`; + const group = connectionGroups.get(key) ?? []; + group.push(conn); + connectionGroups.set(key, group); + }); + + // Merge connections with same source and target + connectionGroups.forEach((group) => { + const first = group[0]; + connections.push({ + sourceGuid: first.sourceGuid, + targetGuid: first.targetGuid, + verdicts: group.map((c) => c.verdict), + sourceX: first.sourceX, + sourceY: first.sourceY, + targetX: first.targetX, + targetY: first.targetY, + sourceWidth: first.sourceWidth, + sourceHeight: first.sourceHeight, + targetWidth: first.targetWidth, + targetHeight: first.targetHeight, + curveOffset: 0, // Will be calculated below + }); + }); + // Detect overlapping connections to the same target and offset them const targetGroups = new Map(); connections.forEach((conn) => { @@ -635,7 +704,7 @@ const VisualiserTab: React.FC = ({ group.forEach((conn, idx) => { // Spread offsets: -10, 0, 10 for 3 connections, etc. const totalOffsets = group.length; - const offsetStep = 15; + const offsetStep = CURVE_OFFSET_STEP; const baseOffset = -((totalOffsets - 1) * offsetStep) / 2; conn.curveOffset = baseOffset + idx * offsetStep; }); @@ -668,8 +737,8 @@ const VisualiserTab: React.FC = ({ columnRange.max > columnRange.min ? `repeat(${ columnRange.max - columnRange.min + 1 - }, minmax(220px, 1fr))` - : "220px", + }, minmax(${COLUMN_MIN_WIDTH}px, 1fr))` + : `${COLUMN_MIN_WIDTH}px`, } as React.CSSProperties } > @@ -858,15 +927,18 @@ const VisualiserTab: React.FC = ({ ? conn.targetX + conn.targetWidth / 2 // right edge for rightward curves : conn.targetX - conn.targetWidth / 2; // left edge for leftward curves - const baseControlOffsetX = 20 + Math.abs(conn.curveOffset) * 0.3; + const baseControlOffsetX = + CURVE_BASE_OFFSET + + Math.abs(conn.curveOffset) * CURVE_OFFSET_MULTIPLIER; const controlOffsetY = - (Math.abs(conn.targetY - conn.sourceY) / 2) * 0.3; + (Math.abs(conn.targetY - conn.sourceY) / 2) * + CURVE_CONTROL_OFFSET_FACTOR; // Apply horizontal offset to spread overlapping curves const offsetX = conn.curveOffset; // Calculate text position at the curve's midpoint (t=0.5 on Bezier curve) - const t = 0.5; + const t = BEZIER_LABEL_POSITION; const p0 = { x: sourceEdgeX, y: conn.sourceY }; const p1 = { x: sourceEdgeX + baseControlOffsetX * curveDirection + offsetX, @@ -891,7 +963,7 @@ const VisualiserTab: React.FC = ({ Math.pow(t, 3) * p3.y; // Calculate arrow position at t=0.3 for directional indicator - const tArrow = 0.3; + const tArrow = BEZIER_ARROW_POSITION; const arrowX = Math.pow(1 - tArrow, 3) * p0.x + 3 * Math.pow(1 - tArrow, 2) * tArrow * p1.x + @@ -904,7 +976,8 @@ const VisualiserTab: React.FC = ({ Math.pow(tArrow, 3) * p3.y; // Calculate tangent for arrow rotation - const tArrowDelta = 0.31; + const tArrowDelta = + BEZIER_ARROW_POSITION + BEZIER_ARROW_TANGENT_DELTA; const arrowX2 = Math.pow(1 - tArrowDelta, 3) * p0.x + 3 * Math.pow(1 - tArrowDelta, 2) * tArrowDelta * p1.x + @@ -934,23 +1007,32 @@ const VisualiserTab: React.FC = ({ {/* Directional arrow along curve */} {/* End arrow */} - - {conn.verdict} - + {/* Render verdict labels - multiple if merged */} + {conn.verdicts.map((verdict, vIdx) => { + const labelOffsetY = + (vIdx - (conn.verdicts.length - 1) / 2) * + VERDICT_LABEL_SPACING; + return ( + + {verdict} + + ); + })} ); })}