From 40e931c842778172bc4482eccd8390422322043f Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Wed, 25 Feb 2026 22:47:00 +0000 Subject: [PATCH] working on the visualiser --- src/Sass/visualiser.scss | 39 +- .../components/VisualisetTab.tsx | 383 +++++++++++++++--- 2 files changed, 367 insertions(+), 55 deletions(-) diff --git a/src/Sass/visualiser.scss b/src/Sass/visualiser.scss index ba9bf92..596527f 100644 --- a/src/Sass/visualiser.scss +++ b/src/Sass/visualiser.scss @@ -5,7 +5,7 @@ } .visualiser-flow { - width: 320px; + width: min(100%, 760px); background: linear-gradient( 180deg, rgba($blue, 0.12), @@ -21,6 +21,20 @@ gap: 10px; } +.visualiser-level { + width: 100%; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.visualiser-level-row { + display: grid; + gap: 16px; + align-items: center; + justify-items: center; +} + .visualiser-node { width: 220px; padding: 12px 14px; @@ -73,6 +87,21 @@ background: rgba($blue, 0.2); } +.visualiser-connector-branch { + width: 100%; + height: 34px; +} + +.visualiser-connector-branch-line { + stroke: rgba($blue, 0.55); + stroke-width: 2px; + vector-effect: non-scaling-stroke; +} + +.visualiser-connector-branch-arrow { + fill: rgba($blue, 0.55); +} + @include color-mode(dark) { .visualiser-flow { background: linear-gradient( @@ -106,4 +135,12 @@ .visualiser-connector-tail { background: rgba($blue, 0.3); } + + .visualiser-connector-branch-line { + stroke: rgba($blue, 0.8); + } + + .visualiser-connector-branch-arrow { + fill: rgba($blue, 0.8); + } } diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index c84b98f..3bbb3c3 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef, useState, useEffect } from "react"; import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService"; import ValidationErrorIcon from "../../../../components/validationErrorIcon"; @@ -7,12 +7,37 @@ interface VisualiserTabProps { taskValidation: Record; } +interface ArrowPositions { + levelIndex: number; + positions: number[]; +} + const VisualiserTab: React.FC = ({ data, taskValidation, }) => { const tasks = data.tasks; - const orderedTasks: CreateWorkflowTemplateVersion["tasks"] = []; + const levels: CreateWorkflowTemplateVersion["tasks"][] = []; + const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]); + const [arrowPositions, setArrowPositions] = useState([]); + const taskColumnMap = useRef>(new Map()); + + // 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, + }; + + // Debug logging + if (allColumns.length > 0 && process.env.NODE_ENV === "development") { + const columnMap: Record = {}; + tasks.forEach((task) => { + columnMap[(task.config.name as string) || task.type] = + taskColumnMap.current.get(task.config.guid as string) ?? 0; + }); + console.log("Column assignments:", columnMap, "Range:", columnRange); + } if (tasks.length > 0) { const byGuid = new Map(); @@ -20,73 +45,323 @@ const VisualiserTab: React.FC = ({ byGuid.set(task.config.guid as string, task); }); - const startTask = tasks.find( - (task) => - !task.config.predecessors || - (task.config.predecessors as string[]).length === 0, - ); + const levelByGuid = new Map(); + const columnByGuid = new Map(); - if (startTask) { - const visited = new Set(); - let current: CreateWorkflowTemplateVersion["tasks"][0] | undefined = - startTask; - - while (current && !visited.has(current.config.guid as string)) { - orderedTasks.push(current); - visited.add(current.config.guid as string); - - const currentGuid = current.config.guid as string; - const nextTask = tasks.find((task) => { - const predecessors = task.config.predecessors as string[] | undefined; - return predecessors?.length === 1 && predecessors[0] === currentGuid; - }); - - current = nextTask; + 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) => { - if (!orderedTasks.includes(task)) { - orderedTasks.push(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) { + columnByGuid.set(guid, 0); + return 0; + } + + // 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: spread siblings around parent column + const startCol = Math.max( + 0, + predColumn - Math.floor(siblings.length / 2), + ); + 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]); + }); } - const nodes = [ - { key: "start", label: "Start", isTask: false }, - ...orderedTasks.map((task) => ({ - key: task.config.guid as string, - guid: task.config.guid as string, - label: (task.config.name as string) || task.type, - isTask: true, - })), - { key: "end", label: "End", isTask: false }, - ]; + 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 * 100); + }); + + if (nodePositions.length > 0) { + positions.push({ levelIndex, positions: nodePositions }); + } + }); + + setArrowPositions(positions); + }, [tasks]); + + const getArrowPositionsForLevel = (levelIndex: number): number[] => { + const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex); + return found?.positions ?? []; + }; + + const renderConnector = (count: number, positions?: number[]) => { + if (count <= 1) { + return ( +