Basic loopback visualisation
This commit is contained in:
parent
53d8b3ce2c
commit
64c601d8c8
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user