webui/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx

590 lines
20 KiB
TypeScript

import React, { useRef, useState, useEffect } from "react";
import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService";
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
interface VisualiserTabProps {
data: CreateWorkflowTemplateVersion;
taskValidation: Record<string, boolean>;
}
interface ArrowPositions {
levelIndex: number;
positions: number[];
}
const VisualiserTab: React.FC<VisualiserTabProps> = ({
data,
taskValidation,
}) => {
const tasks = data.tasks;
const levels: CreateWorkflowTemplateVersion["tasks"][] = [];
const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]);
const taskColumnMap = useRef<Map<string, number>>(new Map());
const visualiserContainerRef = useRef<HTMLDivElement>(null);
const [taskDOMPositions, setTaskDOMPositions] = useState<
Map<string, { x: number; y: number }>
>(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,
};
if (tasks.length > 0) {
const byGuid = new Map<string, CreateWorkflowTemplateVersion["tasks"][0]>();
tasks.forEach((task) => {
byGuid.set(task.config.guid as string, task);
});
const levelByGuid = new Map<string, number>();
const columnByGuid = new Map<string, number>();
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) {
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<number, CreateWorkflowTemplateVersion["tasks"]>();
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 * 100);
});
if (nodePositions.length > 0) {
positions.push({ levelIndex, positions: nodePositions });
}
});
setArrowPositions(positions);
}, [tasks]);
useEffect(() => {
// Measure task DOM positions for outcome connectors
if (!visualiserContainerRef.current) return;
const containerRect =
visualiserContainerRef.current.getBoundingClientRect();
const positions = new Map<string, { x: number; y: number }>();
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 ?? [];
};
// Calculate connector positions based on grid columns
const getConnectorPositionsByColumn = (
levelTasks: CreateWorkflowTemplateVersion["tasks"][],
): number[] => {
const numColumns = columnRange.max - columnRange.min + 1;
if (numColumns <= 1) return [50];
return levelTasks.map((task) => {
const taskColumn =
taskColumnMap.current.get(task.config.guid as string) ?? 0;
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;
});
};
const renderConnector = (
count: number,
positions?: number[],
predecessorPositions?: number[],
) => {
if (count <= 1) {
// For a single child, use predecessor position if available
let startX = 50;
let endX = 50;
// 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 (
<div aria-hidden="true" className="visualiser-connector">
<div className="visualiser-connector-line" />
<div className="visualiser-connector-arrow" />
<div className="visualiser-connector-tail" />
</div>
);
}
// Otherwise render SVG with line from start to end (always, even if vertical)
return (
<svg
aria-hidden="true"
className="visualiser-connector-branch"
viewBox="0 0 100 40"
preserveAspectRatio="none"
>
<line
className="visualiser-connector-branch-line"
x1={startX}
y1="0"
x2={endX}
y2="40"
/>
<polygon
className="visualiser-connector-branch-arrow"
points={`${endX - 1.5},34 ${endX + 1.5},34 ${endX},40`}
/>
</svg>
);
}
// Use measured positions if available and valid, otherwise calculate evenly distributed
const displayPositions =
positions && positions.length > 0
? positions
: Array.from(
{ length: count },
(_, index) => ((index + 1) / (count + 1)) * 100,
);
// Guard against empty or invalid positions
if (!displayPositions || displayPositions.length === 0) {
return (
<div aria-hidden="true" className="visualiser-connector">
<div className="visualiser-connector-line" />
<div className="visualiser-connector-arrow" />
<div className="visualiser-connector-tail" />
</div>
);
}
const minX = Math.min(...displayPositions);
const maxX = Math.max(...displayPositions);
// Final safety check for Infinity
if (!isFinite(minX) || !isFinite(maxX)) {
return (
<div aria-hidden="true" className="visualiser-connector">
<div className="visualiser-connector-line" />
</div>
);
}
return (
<svg
aria-hidden="true"
className="visualiser-connector-branch"
viewBox="0 0 100 40"
preserveAspectRatio="none"
>
<line
className="visualiser-connector-branch-line"
x1="50"
y1="0"
x2="50"
y2="14"
/>
<line
className="visualiser-connector-branch-line"
x1={minX}
y1="14"
x2={maxX}
y2="14"
/>
{displayPositions.map((x) => (
<g key={x}>
<line
className="visualiser-connector-branch-line"
x1={x}
y1="14"
x2={x}
y2="28"
/>
<polygon
className="visualiser-connector-branch-arrow"
points={`${x - 1.5},28 ${x + 1.5},28 ${x},34`}
/>
</g>
))}
</svg>
);
};
// 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 (
<div className="visualiser-root">
<div className="visualiser-container" ref={visualiserContainerRef}>
<div className="visualiser-flow">
<div className="visualiser-node">
<div className="visualiser-node-content">
<span>Start</span>
</div>
</div>
{levels.length > 0 &&
renderConnector(
levels[0].length,
getConnectorPositionsByColumn(levels[0]),
undefined,
)}
{levels.map((level, index) => (
<React.Fragment key={`level-${index}`}>
<div className="visualiser-level">
<div
className="visualiser-level-row"
ref={(el) => {
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 (
<div
key={task.config.guid as string}
className="visualiser-node"
data-column={taskColumn}
style={styleObj}
>
<div className="visualiser-node-content">
<ValidationErrorIcon
visible={
taskValidation[task.config.guid as string] ===
false
}
/>
<span>
{(task.config.name as string) || task.type}
</span>
</div>
</div>
);
})}
</div>
</div>
{index < levels.length - 1
? renderConnector(
levels[index + 1].length,
getConnectorPositionsByColumn(levels[index + 1]),
getConnectorPositionsByColumn(levels[index]),
)
: renderConnector(1, undefined, undefined)}
</React.Fragment>
))}
{levels.length === 0 && renderConnector(1)}
<div className="visualiser-node">
<div className="visualiser-node-content">
<span>End</span>
</div>
</div>
</div>
{/* Outcome-based loop-back connectors overlay */}
<svg
aria-hidden="true"
className="visualiser-outcome-connectors"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{getOutcomeConnections().map((conn, idx) => {
const controlOffsetX = 20;
const controlOffsetY =
(Math.abs(conn.targetY - conn.sourceY) / 2) * 0.3;
// Calculate text position at the curve's midpoint (t=0.5 on Bezier curve)
const t = 0.5;
const p0 = { x: conn.sourceX, y: conn.sourceY };
const p1 = {
x: conn.sourceX - controlOffsetX,
y: conn.sourceY + controlOffsetY,
};
const p2 = {
x: conn.targetX - controlOffsetX,
y: conn.targetY - controlOffsetY,
};
const p3 = { x: conn.targetX, y: conn.targetY };
// Cubic Bezier curve formula at t
const textX =
Math.pow(1 - t, 3) * p0.x +
3 * Math.pow(1 - t, 2) * t * p1.x +
3 * (1 - t) * Math.pow(t, 2) * p2.x +
Math.pow(t, 3) * p3.x;
const textY =
Math.pow(1 - t, 3) * p0.y +
3 * Math.pow(1 - t, 2) * t * p1.y +
3 * (1 - t) * Math.pow(t, 2) * p2.y +
Math.pow(t, 3) * p3.y;
return (
<g
key={`outcome-${idx}`}
className="visualiser-outcome-connector"
>
<path
className="visualiser-outcome-connector-line"
d={`M ${conn.sourceX} ${conn.sourceY}
C ${conn.sourceX - controlOffsetX} ${conn.sourceY + controlOffsetY},
${conn.targetX - controlOffsetX} ${conn.targetY - controlOffsetY},
${conn.targetX} ${conn.targetY}`}
/>
<polygon
className="visualiser-outcome-connector-arrow"
points={`${conn.targetX - 1},${conn.targetY - 3} ${conn.targetX + 1},${conn.targetY - 3} ${conn.targetX},${conn.targetY}`}
/>
<text
x={textX}
y={textY}
className="visualiser-outcome-connector-label"
textAnchor="middle"
dominantBaseline="middle"
>
{conn.verdict}
</text>
</g>
);
})}
</svg>
</div>
</div>
);
};
export default VisualiserTab;