diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index e624347..3461fd1 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect } from "react"; +import React from "react"; import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService"; import ValidationErrorIcon from "../../../../components/validationErrorIcon"; @@ -7,1224 +7,37 @@ interface VisualiserTabProps { taskValidation: Record; } -interface ArrowPositions { - levelIndex: number; - positions: number[]; -} - const VisualiserTab: React.FC = ({ data, taskValidation, }) => { const tasks = data.tasks; - const levels: CreateWorkflowTemplateVersion["tasks"][] = []; - 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()); - const [columnGap, setColumnGap] = useState(80); - - // 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; - const BASE_COLUMN_GAP = 16; - const COLUMN_GAP_MARGIN = 35; - - // 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 = { - min: allColumns.length > 0 ? Math.min(...allColumns) : 0, - max: allColumns.length > 0 ? Math.max(...allColumns) : 0, - }; - - if (tasks.length > 0) { - const byGuid = new Map(); - tasks.forEach((task) => { - byGuid.set(task.config.guid as string, task); - }); - - const levelByGuid = new Map(); - const columnByGuid = new Map(); - - const getLevel = (task: CreateWorkflowTemplateVersion["tasks"][0]) => { - const guid = task.config.guid as string; - const cached = levelByGuid.get(guid); - if (cached !== undefined) { - return cached; - } - - const predecessors = task.config.predecessors as string[] | undefined; - if (!predecessors || predecessors.length === 0) { - levelByGuid.set(guid, 1); - return 1; - } - - const predecessorLevels = predecessors.map((pred) => { - const predTask = byGuid.get(pred); - if (!predTask) { - return 1; - } - return getLevel(predTask); - }); - - const nextLevel = Math.max(...predecessorLevels, 0) + 1; - levelByGuid.set(guid, nextLevel); - return nextLevel; - }; - - // First pass: calculate levels for all tasks - tasks.forEach((task) => { - getLevel(task); - }); - - // Second pass: assign columns based on predecessor-child relationships - const getColumn = ( - task: CreateWorkflowTemplateVersion["tasks"][0], - ): number => { - const guid = task.config.guid as string; - const cached = columnByGuid.get(guid); - if (cached !== undefined) { - return cached; - } - - const predecessors = task.config.predecessors as string[] | undefined; - if (!predecessors || predecessors.length === 0) { - // Spread root tasks across columns when there are multiple starts - const rootTasks = tasks.filter((t) => { - const preds = t.config.predecessors as string[] | undefined; - return !preds || preds.length === 0; - }); - - rootTasks.sort((a, b) => { - const nameA = (a.config.name as string) || a.type || ""; - const nameB = (b.config.name as string) || b.type || ""; - return nameA.localeCompare(nameB); - }); - - const rootIndex = rootTasks.findIndex( - (t) => (t.config.guid as string) === guid, - ); - - const column = Math.max(0, rootIndex); - columnByGuid.set(guid, column); - return column; - } - - // If multiple predecessors, position task at the average of their columns - if (predecessors.length > 1) { - const predColumns = predecessors - .map((predGuid) => { - const predTask = byGuid.get(predGuid); - return predTask ? getColumn(predTask) : 0; - }) - .filter((col) => col !== undefined); - - if (predColumns.length > 0) { - const avgColumn = - predColumns.reduce((sum, col) => sum + col, 0) / predColumns.length; - const column = Math.round(avgColumn); - columnByGuid.set(guid, column); - return column; - } - } - - // Get predecessor column and find which child index this is - const predTask = byGuid.get(predecessors[0]); - if (!predTask) { - columnByGuid.set(guid, 0); - return 0; - } - - const predColumn = getColumn(predTask); - - // Find all siblings (tasks with same predecessor) - const siblings = tasks.filter((t) => { - const preds = t.config.predecessors as string[] | undefined; - return preds && preds[0] === predecessors[0]; - }); - - // Sort siblings by name for consistent ordering - siblings.sort((a, b) => { - const nameA = (a.config.name as string) || a.type || ""; - const nameB = (b.config.name as string) || b.type || ""; - return nameA.localeCompare(nameB); - }); - - const siblingIndex = siblings.findIndex( - (s) => (s.config.guid as string) === guid, - ); - - // Assign columns: keep siblings together at or to the right of parent column - const startCol = predColumn; - const column = startCol + siblingIndex; - - columnByGuid.set(guid, column); - return column; - }; - - // Calculate columns for all tasks - tasks.forEach((task) => { - const col = getColumn(task); - taskColumnMap.current.set(task.config.guid as string, col); - }); - - const levelsMap = new Map(); - tasks.forEach((task) => { - const level = getLevel(task); - const list = levelsMap.get(level) ?? []; - list.push(task); - levelsMap.set(level, list); - }); - - // Sort tasks within each level by column - [...levelsMap.entries()] - .sort((a, b) => a[0] - b[0]) - .forEach((entry) => { - entry[1].sort((a, b) => { - const colA = taskColumnMap.current.get(a.config.guid as string) ?? 0; - const colB = taskColumnMap.current.get(b.config.guid as string) ?? 0; - return colA - colB; - }); - levels.push(entry[1]); - }); - } - - useEffect(() => { - // Measure actual positions of task nodes and calculate arrow positions - const positions: ArrowPositions[] = []; - - levelRowRefs.current.forEach((levelRow, levelIndex) => { - if (!levelRow) return; - - const levelRect = levelRow.getBoundingClientRect(); - const nodes = levelRow.querySelectorAll(".visualiser-node"); - const nodePositions: number[] = []; - - nodes.forEach((node) => { - const nodeRect = node.getBoundingClientRect(); - // Calculate center of node relative to level row - const relativeCenter = - (nodeRect.left - levelRect.left + nodeRect.width / 2) / - levelRect.width; - // Convert to SVG coordinates (0-100) - nodePositions.push(relativeCenter * SVG_COORDINATE_MAX); - }); - - if (nodePositions.length > 0) { - positions.push({ levelIndex, positions: nodePositions }); - } - }); - - setArrowPositions(positions); - }, [tasks]); - - useEffect(() => { - // Measure task DOM positions for outcome connectors - 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 nodes = - visualiserContainerRef.current.querySelectorAll(".visualiser-node"); - nodes.forEach((node) => { - const guid = node.getAttribute("data-guid"); - if (!guid) 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; - - 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(); - }); - - resizeObserver.observe(visualiserContainerRef.current); - - return () => { - clearTimeout(timer); - resizeObserver.disconnect(); - }; - }, [tasks]); - - const getArrowPositionsForLevel = (levelIndex: number): number[] => { - const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex); - return found?.positions ?? []; - }; - - const getColumnPercent = (column: number): number => { - const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return SVG_CENTER_POSITION; - const gridCol = column - columnRange.min; - return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX; - }; - - // Calculate connector positions based on grid columns - const getConnectorPositionsByColumn = ( - levelTasks: CreateWorkflowTemplateVersion["tasks"][], - ): number[] => { - const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return [SVG_CENTER_POSITION]; - - return levelTasks.map((task) => { - const predecessors = (task.config.predecessors as string[]) ?? []; - const taskColumn = - taskColumnMap.current.get(task.config.guid as string) ?? 0; - - if (predecessors.length > 1) { - const predColumns = predecessors - .map((predGuid) => taskColumnMap.current.get(predGuid)) - .filter((col) => col !== undefined) as number[]; - - if (predColumns.length > 0) { - const avgPredColumn = - 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) * 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) * SVG_COORDINATE_MAX; - }); - }; - - const getPredecessorPositionsByColumn = ( - levelTasks: CreateWorkflowTemplateVersion["tasks"][], - excludeTasks?: Set, - ): number[] => { - const numColumns = columnRange.max - columnRange.min + 1; - if (numColumns <= 1) return [SVG_CENTER_POSITION]; - - const predColumns = new Set(); - - levelTasks.forEach((task) => { - const preds = (task.config.predecessors as string[]) ?? []; - preds.forEach((predGuid) => { - // Skip excluded tasks (e.g., those with override) - if (excludeTasks && excludeTasks.has(predGuid)) return; - - const predColumn = taskColumnMap.current.get(predGuid); - if (predColumn !== undefined) { - predColumns.add(predColumn); - } - }); - }); - - return Array.from(predColumns).map((predColumn) => - getColumnPercent(predColumn), - ); - }; - - const renderConnector = ( - count: number, - positions?: number[], - predecessorPositions?: number[], - currentLevelTasks?: CreateWorkflowTemplateVersion["tasks"][], - ) => { - // Check if any task in this level has multiple predecessors from different columns - const hasMultiplePredecessorColumns = - currentLevelTasks && - currentLevelTasks.some((task) => { - const preds = (task.config.predecessors as string[]) ?? []; - if (preds.length <= 1) return false; - - const predColumns = preds - .map((predGuid) => taskColumnMap.current.get(predGuid)) - .filter((col) => col !== undefined) as number[]; - - return new Set(predColumns).size > 1; // Multiple different columns - }); - - // If we have a merging scenario (multiple predecessors converging) - if ( - hasMultiplePredecessorColumns && - predecessorPositions && - predecessorPositions.length > 1 && - positions - ) { - const minPredX = Math.min(...predecessorPositions); - const maxPredX = Math.max(...predecessorPositions); - - return ( - - ); - } - - if (count <= 1) { - // For a single child, use predecessor position if available - 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) { - endX = positions[0]; - - if (predecessorPositions && predecessorPositions.length > 0) { - // Find the closest predecessor position to the child position - const closest = predecessorPositions.reduce((prev, curr) => - Math.abs(curr - endX) < Math.abs(prev - endX) ? curr : prev, - ); - startX = closest; - } - } - - // If start and end are the same, use simple vertical connector - if (!predecessorPositions || predecessorPositions.length === 0) { - return ( -