Updated visualiser showing a flowchart style output
This commit is contained in:
parent
9d499e57d3
commit
f506f047b8
@ -1,43 +1,693 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { CreateWorkflowTemplateVersion } from "../services/WorkflowTemplateService";
|
import templateVersionsService, {
|
||||||
|
CreateWorkflowTemplateVersion,
|
||||||
|
TaskMetadata,
|
||||||
|
} from "../services/WorkflowTemplateService";
|
||||||
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
|
import ValidationErrorIcon from "../../../../components/validationErrorIcon";
|
||||||
|
import { sortTasksTopologically } from "./workflowGraphUtils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Namespaces } from "../../../../i18n/i18n";
|
||||||
|
|
||||||
interface VisualiserTabProps {
|
interface VisualiserTabProps {
|
||||||
data: CreateWorkflowTemplateVersion;
|
data: CreateWorkflowTemplateVersion;
|
||||||
taskValidation: Record<string, boolean>;
|
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> = ({
|
const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
||||||
data,
|
data,
|
||||||
taskValidation,
|
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 (
|
return (
|
||||||
<div className="visualiser-root">
|
<div className="visualiser-root">
|
||||||
<div className="visualiser-container">
|
<div className="visualiser-container" ref={visualiserContainerRef}>
|
||||||
<div className="visualiser-flow">
|
<div className="visualiser-flow" style={{ gap: "56px" }}>
|
||||||
<div className="visualiser-level">
|
{levels.map((level, levelIndex) => (
|
||||||
<div className="visualiser-level-row">
|
<div key={`level-${levelIndex}`} className="visualiser-level">
|
||||||
{tasks.map((task) => (
|
<div
|
||||||
<div
|
className="visualiser-level-row"
|
||||||
key={task.config.guid as string}
|
style={{
|
||||||
className="visualiser-node"
|
gridTemplateColumns: `repeat(${Math.max(level.length, 1)}, minmax(220px, 1fr))`,
|
||||||
data-guid={task.config.guid as string}
|
gap: "40px",
|
||||||
>
|
}}
|
||||||
<div className="visualiser-node-content">
|
>
|
||||||
<ValidationErrorIcon
|
{level.map((task) => (
|
||||||
visible={
|
<div
|
||||||
taskValidation[task.config.guid as string] === false
|
key={task.config.guid as string}
|
||||||
}
|
className="visualiser-node"
|
||||||
/>
|
data-guid={task.config.guid as string}
|
||||||
<span>{(task.config.name as string) || task.type}</span>
|
>
|
||||||
|
<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>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user