Updated visualiser showing a flowchart style output
This commit is contained in:
parent
9d499e57d3
commit
f506f047b8
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user