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; padding: $spacePadding;
} }
.visualiser-flow { .visualiser-container {
position: relative;
width: min(100%, 760px); width: min(100%, 760px);
}
.visualiser-flow {
width: 100%;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
rgba($blue, 0.12), rgba($blue, 0.12),
@ -143,4 +148,53 @@
.visualiser-connector-branch-arrow { .visualiser-connector-branch-arrow {
fill: rgba($blue, 0.8); 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 levelRowRefs = useRef<(HTMLDivElement | null)[]>([]);
const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]); const [arrowPositions, setArrowPositions] = useState<ArrowPositions[]>([]);
const taskColumnMap = useRef<Map<string, number>>(new Map()); 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 // Calculate column range directly from taskColumnMap in render
const allColumns = Array.from(taskColumnMap.current.values()); const allColumns = Array.from(taskColumnMap.current.values());
@ -178,6 +182,42 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
setArrowPositions(positions); setArrowPositions(positions);
}, [tasks]); }, [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 getArrowPositionsForLevel = (levelIndex: number): number[] => {
const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex); const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex);
return found?.positions ?? []; return found?.positions ?? [];
@ -328,97 +368,219 @@ 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 ( return (
<div className="visualiser-root"> <div className="visualiser-root">
<div className="visualiser-flow"> <div className="visualiser-container" ref={visualiserContainerRef}>
<div className="visualiser-node"> <div className="visualiser-flow">
<div className="visualiser-node-content"> <div className="visualiser-node">
<span>Start</span> <div className="visualiser-node-content">
</div> <span>Start</span>
</div> </div>
{levels.length > 0 && </div>
renderConnector( {levels.length > 0 &&
levels[0].length, renderConnector(
getConnectorPositionsByColumn(levels[0]), levels[0].length,
undefined, getConnectorPositionsByColumn(levels[0]),
)} undefined,
{levels.map((level, index) => ( )}
<React.Fragment key={`level-${index}`}> {levels.map((level, index) => (
<div className="visualiser-level"> <React.Fragment key={`level-${index}`}>
<div <div className="visualiser-level">
className="visualiser-level-row" <div
ref={(el) => { className="visualiser-level-row"
levelRowRefs.current[index] = el; ref={(el) => {
}} levelRowRefs.current[index] = el;
style={ }}
{ style={
gridTemplateColumns: {
columnRange.max > columnRange.min gridTemplateColumns:
? `repeat(${ columnRange.max > columnRange.min
columnRange.max - columnRange.min + 1 ? `repeat(${
}, minmax(220px, 1fr))` columnRange.max - columnRange.min + 1
: "220px", }, minmax(220px, 1fr))`
} as React.CSSProperties : "220px",
} } as React.CSSProperties
> }
{level.map((task) => { >
const taskColumn = {level.map((task) => {
taskColumnMap.current.get(task.config.guid as string) ?? 0; const taskColumn =
const isRootTask = !( taskColumnMap.current.get(task.config.guid as string) ??
task.config.predecessors as string[] | undefined 0;
)?.length; const isRootTask = !(
const numColumns = columnRange.max - columnRange.min + 1; task.config.predecessors as string[] | undefined
)?.length;
let styleObj: React.CSSProperties = {}; const numColumns = columnRange.max - columnRange.min + 1;
if (isRootTask && numColumns > 1) { let styleObj: React.CSSProperties = {};
// Root tasks span all columns and center their content
styleObj = { if (isRootTask && numColumns > 1) {
gridColumn: `1 / -1`, // Root tasks span all columns and center their content
display: "flex", styleObj = {
justifyContent: "center", gridColumn: `1 / -1`,
}; display: "flex",
} else { justifyContent: "center",
// Child tasks positioned in their specific column };
const gridColumn = taskColumn - columnRange.min + 1; } else {
styleObj = { gridColumn }; // Child tasks positioned in their specific column
} const gridColumn = taskColumn - columnRange.min + 1;
styleObj = { gridColumn };
return ( }
<div
key={task.config.guid as string} return (
className="visualiser-node" <div
data-column={taskColumn} key={task.config.guid as string}
style={styleObj} className="visualiser-node"
> data-column={taskColumn}
<div className="visualiser-node-content"> style={styleObj}
<ValidationErrorIcon >
visible={ <div className="visualiser-node-content">
taskValidation[task.config.guid as string] === false <ValidationErrorIcon
} visible={
/> taskValidation[task.config.guid as string] ===
<span>{(task.config.name as string) || task.type}</span> false
</div> }
</div> />
); <span>
})} {(task.config.name as string) || task.type}
</div> </span>
</div>
</div>
);
})}
</div>
</div>
{index < levels.length - 1
? renderConnector(
levels[index + 1].length,
getConnectorPositionsByColumn(levels[index + 1]),
getConnectorPositionsByColumn(levels[index]),
)
: renderConnector(1, undefined, undefined)}
</React.Fragment>
))}
{levels.length === 0 && renderConnector(1)}
<div className="visualiser-node">
<div className="visualiser-node-content">
<span>End</span>
</div> </div>
{index < levels.length - 1
? renderConnector(
levels[index + 1].length,
getConnectorPositionsByColumn(levels[index + 1]),
getConnectorPositionsByColumn(levels[index]),
)
: renderConnector(1, undefined, undefined)}
</React.Fragment>
))}
{levels.length === 0 && renderConnector(1)}
<div className="visualiser-node">
<div className="visualiser-node-content">
<span>End</span>
</div> </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>
</div> </div>
); );