Updated visualiser showing a flowchart style output

This commit is contained in:
Colin Dawson 2026-03-13 00:48:10 +00:00
parent 9d499e57d3
commit f506f047b8

View File

@ -1,25 +1,489 @@
import React from "react";
import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService";
import templateVersionsService, {
CreateWorkflowTemplateVersion,
TaskMetadata,
} from "../services/WorkflowTemplateService";
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
import { sortTasksTopologically } from "./workflowGraphUtils";
import { useTranslation } from "react-i18next";
import { Namespaces } from "../../../../i18n/i18n";
interface VisualiserTabProps {
data: CreateWorkflowTemplateVersion;
taskValidation: Record<string, boolean>;
}
type WorkflowTask = CreateWorkflowTemplateVersion["tasks"][0];
type WorkflowEdge = {
sourceGuid: string;
targetGuid: string;
labels: string[];
sourceIndex: number;
sourceCount: number;
targetIndex: number;
targetCount: number;
};
function getOutcomeId(value: string): string {
return value.split(".").pop() ?? value;
}
function getOutcomeLinks(
task: WorkflowTask,
): Array<{ targetGuid: string; label: string }> {
const raw = task.config.outcomeActions as unknown;
if (Array.isArray(raw)) {
return raw
.map((entry) => {
const value = entry as Partial<{
outcome: string;
verdict: string;
task: string | null;
}>;
if (!value.task) {
return null;
}
return {
targetGuid: value.task,
label: value.outcome ?? value.verdict ?? "",
};
})
.filter(
(link): link is { targetGuid: string; label: string } =>
!!link && typeof link.targetGuid === "string",
);
}
if (raw && typeof raw === "object") {
return Object.entries(raw as Record<string, string | null>)
.filter(([, targetGuid]) => typeof targetGuid === "string")
.map(([label, targetGuid]) => ({
targetGuid: targetGuid as string,
label,
}));
}
return [];
}
function getOutcomeTargets(task: WorkflowTask): string[] {
return getOutcomeLinks(task)
.map((link) => link.targetGuid)
.filter(
(targetGuid, index, all) =>
typeof targetGuid === "string" && all.indexOf(targetGuid) === index,
);
}
function buildLevels(tasks: WorkflowTask[]): WorkflowTask[][] {
if (tasks.length === 0) {
return [];
}
const byGuid = new Map<string, WorkflowTask>();
const indexByGuid = new Map<string, number>();
const outgoing = new Map<string, Set<string>>();
const indegree = new Map<string, number>();
const levelByGuid = new Map<string, number>();
tasks.forEach((task, index) => {
const guid = task.config.guid as string;
byGuid.set(guid, task);
indexByGuid.set(guid, index);
outgoing.set(guid, new Set<string>());
indegree.set(guid, 0);
levelByGuid.set(guid, 0);
});
tasks.forEach((task) => {
const sourceGuid = task.config.guid as string;
const sourceTargets = outgoing.get(sourceGuid) as Set<string>;
getOutcomeTargets(task).forEach((targetGuid) => {
if (!byGuid.has(targetGuid) || targetGuid === sourceGuid) {
return;
}
if (sourceTargets.has(targetGuid)) {
return;
}
sourceTargets.add(targetGuid);
indegree.set(targetGuid, (indegree.get(targetGuid) ?? 0) + 1);
});
});
const queue: string[] = [];
tasks.forEach((task) => {
const guid = task.config.guid as string;
if ((indegree.get(guid) ?? 0) === 0) {
queue.push(guid);
}
});
queue.sort((a, b) => (indexByGuid.get(a) ?? 0) - (indexByGuid.get(b) ?? 0));
const visited = new Set<string>();
while (queue.length > 0) {
const guid = queue.shift() as string;
visited.add(guid);
const currentLevel = levelByGuid.get(guid) ?? 0;
(outgoing.get(guid) ?? new Set<string>()).forEach((targetGuid) => {
levelByGuid.set(
targetGuid,
Math.max(levelByGuid.get(targetGuid) ?? 0, currentLevel + 1),
);
const nextInDegree = (indegree.get(targetGuid) ?? 0) - 1;
indegree.set(targetGuid, nextInDegree);
if (nextInDegree === 0) {
queue.push(targetGuid);
}
});
queue.sort((a, b) => (indexByGuid.get(a) ?? 0) - (indexByGuid.get(b) ?? 0));
}
// Cycle fallback: place unresolved tasks by original order on next available level.
if (visited.size < tasks.length) {
let maxLevel = 0;
levelByGuid.forEach((level) => {
maxLevel = Math.max(maxLevel, level);
});
tasks.forEach((task) => {
const guid = task.config.guid as string;
if (!visited.has(guid)) {
maxLevel += 1;
levelByGuid.set(guid, maxLevel);
}
});
}
const grouped = new Map<number, WorkflowTask[]>();
tasks.forEach((task) => {
const guid = task.config.guid as string;
const level = levelByGuid.get(guid) ?? 0;
const list = grouped.get(level) ?? [];
list.push(task);
grouped.set(level, list);
});
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map((entry) => entry[1]);
}
function buildEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
const validGuids = new Set(tasks.map((task) => task.config.guid as string));
const grouped = new Map<
string,
{ sourceGuid: string; targetGuid: string; labels: Set<string> }
>();
tasks.forEach((task) => {
const sourceGuid = task.config.guid as string;
getOutcomeLinks(task).forEach(({ targetGuid, label }) => {
if (!validGuids.has(targetGuid) || targetGuid === sourceGuid) {
return;
}
const key = `${sourceGuid}->${targetGuid}`;
const existing = grouped.get(key);
if (existing) {
if (label) {
existing.labels.add(label);
}
return;
}
grouped.set(key, {
sourceGuid,
targetGuid,
labels: new Set(label ? [label] : []),
});
});
});
const edges = Array.from(grouped.values()).map((edge) => ({
sourceGuid: edge.sourceGuid,
targetGuid: edge.targetGuid,
labels: Array.from(edge.labels).sort((a, b) => a.localeCompare(b)),
sourceIndex: 0,
sourceCount: 1,
targetIndex: 0,
targetCount: 1,
}));
const bySource = new Map<string, WorkflowEdge[]>();
const byTarget = new Map<string, WorkflowEdge[]>();
edges.forEach((edge) => {
const sourceList = bySource.get(edge.sourceGuid) ?? [];
sourceList.push(edge);
bySource.set(edge.sourceGuid, sourceList);
const targetList = byTarget.get(edge.targetGuid) ?? [];
targetList.push(edge);
byTarget.set(edge.targetGuid, targetList);
});
bySource.forEach((sourceEdges) => {
sourceEdges.sort((a, b) => a.targetGuid.localeCompare(b.targetGuid));
sourceEdges.forEach((edge, index) => {
edge.sourceIndex = index;
edge.sourceCount = sourceEdges.length;
});
});
byTarget.forEach((targetEdges) => {
targetEdges.sort((a, b) => a.sourceGuid.localeCompare(b.sourceGuid));
targetEdges.forEach((edge, index) => {
edge.targetIndex = index;
edge.targetCount = targetEdges.length;
});
});
return edges;
}
const VisualiserTab: React.FC<VisualiserTabProps> = ({
data,
taskValidation,
}) => {
const tasks = data.tasks;
const { t: tEnum } = useTranslation(Namespaces.enumValues);
const [taskMetadata, setTaskMetadata] = React.useState<TaskMetadata[]>([]);
const tasks = React.useMemo(
() => sortTasksTopologically(data.tasks) as WorkflowTask[],
[data.tasks],
);
const taskByGuid = React.useMemo(() => {
const byGuid = new Map<string, WorkflowTask>();
tasks.forEach((task) => {
byGuid.set(task.config.guid as string, task);
});
return byGuid;
}, [tasks]);
const metadataByTaskType = React.useMemo(() => {
const map = new Map<string, TaskMetadata>();
taskMetadata.forEach((meta) => {
map.set(meta.taskType, meta);
});
return map;
}, [taskMetadata]);
React.useEffect(() => {
let mounted = true;
const loadTaskMetadata = async () => {
const meta = await templateVersionsService.getTaskMetadata("");
if (mounted) {
setTaskMetadata(meta);
}
};
loadTaskMetadata();
return () => {
mounted = false;
};
}, []);
const levels = React.useMemo(() => buildLevels(tasks), [tasks]);
const edges = React.useMemo(() => buildEdges(tasks), [tasks]);
const levelByGuid = React.useMemo(() => {
const levelMap = new Map<string, number>();
levels.forEach((level, levelIndex) => {
level.forEach((task) => {
levelMap.set(task.config.guid as string, levelIndex);
});
});
return levelMap;
}, [levels]);
const edgeLaneOffsetByKey = React.useMemo(() => {
const grouped = new Map<string, WorkflowEdge[]>();
edges.forEach((edge) => {
const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0;
const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0;
let routeType = "adjacent";
if (targetLevel - sourceLevel > 1) {
routeType = "down-skip";
} else if (targetLevel < sourceLevel) {
routeType = "upward";
}
const bucketKey = `${routeType}:${Math.min(sourceLevel, targetLevel)}:${Math.max(sourceLevel, targetLevel)}`;
const list = grouped.get(bucketKey) ?? [];
list.push(edge);
grouped.set(bucketKey, list);
});
const offsets = new Map<string, number>();
grouped.forEach((group) => {
group
.sort((a, b) => {
const aKey = `${a.sourceGuid}-${a.targetGuid}`;
const bKey = `${b.sourceGuid}-${b.targetGuid}`;
return aKey.localeCompare(bKey);
})
.forEach((edge, index) => {
const centeredIndex = index - (group.length - 1) / 2;
const edgeKey = `${edge.sourceGuid}->${edge.targetGuid}`;
offsets.set(edgeKey, centeredIndex * 2.2);
});
});
return offsets;
}, [edges, levelByGuid]);
const visualiserContainerRef = React.useRef<HTMLDivElement>(null);
const [nodePositions, setNodePositions] = React.useState<
Map<string, { x: number; y: number; width: number; height: number }>
>(new Map());
const mergedLeftIncomingByTarget = React.useMemo(() => {
const incomingCount = new Map<string, number>();
edges.forEach((edge) => {
const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0;
const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0;
if (targetLevel < sourceLevel) {
incomingCount.set(
edge.targetGuid,
(incomingCount.get(edge.targetGuid) ?? 0) + 1,
);
}
});
const sharedLaneByTarget = new Map<string, number>();
edges.forEach((edge) => {
if ((incomingCount.get(edge.targetGuid) ?? 0) <= 1) {
return;
}
const source = nodePositions.get(edge.sourceGuid);
const target = nodePositions.get(edge.targetGuid);
if (!source || !target) {
return;
}
const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0;
const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0;
if (targetLevel >= sourceLevel) {
return;
}
const sourceShift =
(edge.sourceIndex - (edge.sourceCount - 1) / 2) * 2.25;
const laneSpread =
edgeLaneOffsetByKey.get(`${edge.sourceGuid}->${edge.targetGuid}`) ?? 0;
const startX = source.x - source.width / 2;
const startY = source.y + sourceShift * 0.35 + laneSpread * 0.25;
const endX = target.x - target.width / 2;
const endY = target.y;
const verticalSpan = Math.abs(endY - startY);
const longRouteExtra =
Math.max(0, verticalSpan - 10) * 0.2 +
Math.abs(targetLevel - sourceLevel) * 1.2;
const candidateLaneX =
Math.min(startX, endX) -
6 -
Math.abs(sourceShift) +
-longRouteExtra +
laneSpread * 1.2;
const current = sharedLaneByTarget.get(edge.targetGuid);
sharedLaneByTarget.set(
edge.targetGuid,
current === undefined
? candidateLaneX
: Math.min(current, candidateLaneX),
);
});
return sharedLaneByTarget;
}, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]);
React.useEffect(() => {
const measurePositions = () => {
if (!visualiserContainerRef.current) {
return;
}
const containerRect =
visualiserContainerRef.current.getBoundingClientRect();
if (containerRect.width === 0 || containerRect.height === 0) {
return;
}
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;
}
const nodeRect = node.getBoundingClientRect();
const x =
((nodeRect.left - containerRect.left + nodeRect.width / 2) /
containerRect.width) *
100;
const y =
((nodeRect.top - containerRect.top + nodeRect.height / 2) /
containerRect.height) *
100;
const width = (nodeRect.width / containerRect.width) * 100;
const height = (nodeRect.height / containerRect.height) * 100;
positions.set(guid, { x, y, width, height });
});
setNodePositions(positions);
};
const timer = setTimeout(measurePositions, 50);
if (!visualiserContainerRef.current) {
return () => clearTimeout(timer);
}
const resizeObserver = new ResizeObserver(() => {
measurePositions();
});
resizeObserver.observe(visualiserContainerRef.current);
return () => {
clearTimeout(timer);
resizeObserver.disconnect();
};
}, [levels]);
return (
<div className="visualiser-root">
<div className="visualiser-container">
<div className="visualiser-flow">
<div className="visualiser-level">
<div className="visualiser-level-row">
{tasks.map((task) => (
<div className="visualiser-container" ref={visualiserContainerRef}>
<div className="visualiser-flow" style={{ gap: "56px" }}>
{levels.map((level, levelIndex) => (
<div key={`level-${levelIndex}`} className="visualiser-level">
<div
className="visualiser-level-row"
style={{
gridTemplateColumns: `repeat(${Math.max(level.length, 1)}, minmax(220px, 1fr))`,
gap: "40px",
}}
>
{level.map((task) => (
<div
key={task.config.guid as string}
className="visualiser-node"
@ -37,7 +501,193 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
))}
</div>
</div>
))}
</div>
<svg
aria-hidden="true"
className="visualiser-outcome-connectors"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<defs>
<marker
id="visualiser-arrow"
markerWidth="8"
markerHeight="8"
refX="7"
refY="4"
orient="auto"
>
<path
className="visualiser-outcome-connector-arrow"
d="M 0 0 L 8 4 L 0 8 z"
/>
</marker>
</defs>
{edges.map((edge) => {
const source = nodePositions.get(edge.sourceGuid);
const target = nodePositions.get(edge.targetGuid);
if (!source || !target) {
return null;
}
const sourceShift =
(edge.sourceIndex - (edge.sourceCount - 1) / 2) * 2.25;
const targetShift =
(edge.targetIndex - (edge.targetCount - 1) / 2) * 1.5;
const edgeKey = `${edge.sourceGuid}->${edge.targetGuid}`;
const laneSpread = edgeLaneOffsetByKey.get(edgeKey) ?? 0;
const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0;
const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0;
const levelDelta = targetLevel - sourceLevel;
const mergedLeftLaneX = mergedLeftIncomingByTarget.get(
edge.targetGuid,
);
const isMergedLeft =
levelDelta < 0 && mergedLeftLaneX !== undefined;
let path = "";
let labelX = 0;
let labelY = 0;
let labelAnchor: "start" | "middle" | "end" = "middle";
if (levelDelta > 1) {
// Multi-level downward jumps: route on the right side.
const startX = source.x + source.width / 2;
const startY = source.y + sourceShift * 0.35 + laneSpread * 0.25;
const endX = target.x + target.width / 2;
const endY = target.y + targetShift * 0.35 + laneSpread * 0.25;
const verticalSpan = Math.abs(endY - startY);
const longRouteExtra =
Math.max(0, verticalSpan - 10) * 0.2 + (levelDelta - 1) * 1.2;
const laneX =
Math.max(startX, endX) +
6 +
Math.abs(sourceShift) +
longRouteExtra +
laneSpread * 1.2;
path = `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY} L ${endX} ${endY}`;
labelX = startX + 0.9;
labelY = startY - 1.5 - Math.abs(laneSpread) * 0.12;
labelAnchor = "start";
} else if (levelDelta < 0) {
// Upward jumps: route on the left side.
const startX = source.x - source.width / 2;
const startY = source.y + sourceShift * 0.35 + laneSpread * 0.25;
const endX = target.x - target.width / 2;
const endY = isMergedLeft
? target.y
: target.y + targetShift * 0.35 + laneSpread * 0.25;
const verticalSpan = Math.abs(endY - startY);
const longRouteExtra =
Math.max(0, verticalSpan - 10) * 0.2 +
Math.abs(levelDelta) * 1.2;
const laneX =
mergedLeftLaneX ??
Math.min(startX, endX) -
6 -
Math.abs(sourceShift) +
-longRouteExtra +
laneSpread * 1.2;
path = isMergedLeft
? `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY}`
: `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY} L ${endX} ${endY}`;
labelX = startX - 0.9;
labelY = startY - 1.5 - Math.abs(laneSpread) * 0.12;
labelAnchor = "end";
} else {
// Adjacent forward flow: keep curved center connectors.
const startX = source.x;
const startY = source.y + source.height / 2;
const endX = target.x;
const endY = target.y - target.height / 2;
const controlOffset = Math.max(4, Math.abs(endY - startY) * 0.25);
const startControlX = startX + sourceShift + laneSpread;
const endControlX = endX + targetShift + laneSpread;
path = `M ${startX} ${startY} C ${startControlX} ${startY + controlOffset}, ${endControlX} ${endY - controlOffset}, ${endX} ${endY}`;
const t = 0.5;
labelX =
Math.pow(1 - t, 3) * startX +
3 * Math.pow(1 - t, 2) * t * startControlX +
3 * (1 - t) * Math.pow(t, 2) * endControlX +
Math.pow(t, 3) * endX;
labelY =
Math.pow(1 - t, 3) * startY +
3 * Math.pow(1 - t, 2) * t * (startY + controlOffset) +
3 * (1 - t) * Math.pow(t, 2) * (endY - controlOffset) +
Math.pow(t, 3) * endY;
labelAnchor = "middle";
}
const sourceTask = taskByGuid.get(edge.sourceGuid);
const sourceMetadata = sourceTask
? metadataByTaskType.get(sourceTask.type)
: undefined;
const label = edge.labels
.map((outcome) => {
if (!sourceMetadata) {
return tEnum(outcome);
}
const matchedOutcome = sourceMetadata.outcomes.find(
(candidate) =>
candidate === outcome ||
getOutcomeId(candidate) === outcome,
);
return tEnum(matchedOutcome ?? outcome);
})
.join(", ");
return (
<g key={`${edge.sourceGuid}-${edge.targetGuid}`}>
<path
className="visualiser-outcome-connector-line"
d={path}
markerEnd={
isMergedLeft ? undefined : "url(#visualiser-arrow)"
}
/>
{label && (
<text
x={labelX}
y={labelY - 1.25}
className="visualiser-outcome-connector-label"
textAnchor={labelAnchor}
dominantBaseline="middle"
>
{label}
</text>
)}
</g>
);
})}
{Array.from(mergedLeftIncomingByTarget.entries()).map(
([targetGuid, laneX]) => {
const target = nodePositions.get(targetGuid);
if (!target) {
return null;
}
const endX = target.x - target.width / 2;
const endY = target.y;
return (
<path
key={`merged-left-${targetGuid}`}
className="visualiser-outcome-connector-line"
d={`M ${laneX} ${endY} L ${endX} ${endY}`}
markerEnd="url(#visualiser-arrow)"
/>
);
},
)}
</svg>
</div>
</div>
);