Basic loopback visualisation

This commit is contained in:
Colin Dawson 2026-02-25 23:31:06 +00:00
parent 53d8b3ce2c
commit 64c601d8c8
2 changed files with 303 additions and 87 deletions

View File

@ -4,8 +4,13 @@
padding: $spacePadding;
}
.visualiser-flow {
.visualiser-container {
position: relative;
width: min(100%, 760px);
}
.visualiser-flow {
width: 100%;
background: linear-gradient(
180deg,
rgba($blue, 0.12),
@ -143,4 +148,53 @@
.visualiser-connector-branch-arrow {
fill: rgba($blue, 0.8);
}
.visualiser-outcome-connector-line {
stroke: rgba($blue, 0.65);
}
.visualiser-outcome-connector-arrow {
fill: rgba($blue, 0.65);
}
}
.visualiser-outcome-connectors {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.visualiser-outcome-connector-line {
fill: none;
stroke: rgba($blue, 0.45);
stroke-width: 2px;
stroke-dasharray: 4, 4;
vector-effect: non-scaling-stroke;
}
.visualiser-outcome-connector-arrow {
fill: rgba($blue, 0.45);
}
.visualiser-outcome-connector-label {
font-size: 2px;
font-weight: 600;
fill: $blue;
paint-order: stroke fill;
stroke: $mode--light-bg;
stroke-width: 1px;
stroke-linecap: round;
stroke-linejoin: round;
user-select: none;
}
@include color-mode(dark) {
.visualiser-outcome-connector-label {
fill: lighten($blue, 20%);
stroke: $mode--dark-bg;
}
}

View File

@ -21,6 +21,10 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
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());
@ -178,6 +182,42 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
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 ?? [];
@ -328,8 +368,59 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
);
};
// 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">
@ -363,7 +454,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
>
{level.map((task) => {
const taskColumn =
taskColumnMap.current.get(task.config.guid as string) ?? 0;
taskColumnMap.current.get(task.config.guid as string) ??
0;
const isRootTask = !(
task.config.predecessors as string[] | undefined
)?.length;
@ -394,10 +486,13 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
<div className="visualiser-node-content">
<ValidationErrorIcon
visible={
taskValidation[task.config.guid as string] === false
taskValidation[task.config.guid as string] ===
false
}
/>
<span>{(task.config.name as string) || task.type}</span>
<span>
{(task.config.name as string) || task.type}
</span>
</div>
</div>
);
@ -420,6 +515,73 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
</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>
);
};