working on the visualiser

This commit is contained in:
Colin Dawson 2026-02-25 22:47:00 +00:00
parent 5fb218908d
commit 40e931c842
2 changed files with 367 additions and 55 deletions

View File

@ -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);
}
}

View File

@ -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<string, boolean>;
}
interface ArrowPositions {
levelIndex: number;
positions: number[];
}
const VisualiserTab: React.FC<VisualiserTabProps> = ({
data,
taskValidation,
}) => {
const tasks = data.tasks;
const orderedTasks: CreateWorkflowTemplateVersion["tasks"] = [];
const levels: CreateWorkflowTemplateVersion["tasks"][] = [];
const levelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]);
const taskColumnMap = useRef<Map<string, 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,
};
// Debug logging
if (allColumns.length > 0 && process.env.NODE_ENV === "development") {
const columnMap: Record<string, number> = {};
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<string, CreateWorkflowTemplateVersion["tasks"][0]>();
@ -20,73 +45,323 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
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<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,
);
if (startTask) {
const visited = new Set<string>();
let current: CreateWorkflowTemplateVersion["tasks"][0] | undefined =
startTask;
// Assign columns: spread siblings around parent column
const startCol = Math.max(
0,
predColumn - Math.floor(siblings.length / 2),
);
const column = startCol + siblingIndex;
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;
}
}
columnByGuid.set(guid, column);
return column;
};
// Calculate columns for all tasks
tasks.forEach((task) => {
if (!orderedTasks.includes(task)) {
orderedTasks.push(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]);
});
}
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 (
<div className="visualiser-root">
<div className="visualiser-flow">
{nodes.map((node, index) => (
<React.Fragment key={node.key}>
<div className="visualiser-node">
<div className="visualiser-node-content">
{node.isTask && (
<ValidationErrorIcon
visible={taskValidation[node.guid as string] === false}
/>
)}
<span>{node.label}</span>
</div>
</div>
{index < nodes.length - 1 && (
<div aria-hidden="true" className="visualiser-connector">
<div className="visualiser-connector-line" />
<div className="visualiser-connector-arrow" />
<div className="visualiser-connector-tail" />
</div>
)}
);
}
// 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>
);
};
return (
<div className="visualiser-root">
<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, getArrowPositionsForLevel(0))}
{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,
getArrowPositionsForLevel(index + 1),
)
: renderConnector(1)}
</React.Fragment>
))}
{levels.length === 0 && renderConnector(1)}
<div className="visualiser-node">
<div className="visualiser-node-content">
<span>End</span>
</div>
</div>
</div>
</div>
);