More work on the visualiser

This commit is contained in:
Colin Dawson 2026-02-26 17:01:29 +00:00
parent 64dd818a62
commit 22c80e82f2
2 changed files with 193 additions and 59 deletions

View File

@ -11,15 +11,7 @@
.visualiser-flow {
width: 100%;
background: linear-gradient(
180deg,
rgba($blue, 0.12),
rgba($mode--light-bg, 0.9)
);
border: 1px solid rgba($blue, 0.25);
border-radius: 16px;
padding: 20px 16px;
box-shadow: 0 10px 24px rgba($leftMenu-background, 0.15);
display: flex;
flex-direction: column;
align-items: center;
@ -35,7 +27,6 @@
.visualiser-level-row {
display: grid;
gap: 16px;
align-items: center;
justify-items: center;
}
@ -97,6 +88,11 @@
height: 34px;
}
.visualiser-connector-spacer {
width: 100%;
height: 34px;
}
.visualiser-connector-branch-line {
stroke: rgba($blue, 0.55);
stroke-width: 2px;
@ -108,16 +104,6 @@
}
@include color-mode(dark) {
.visualiser-flow {
background: linear-gradient(
180deg,
rgba($blue, 0.18),
rgba($mode--dark-bg, 0.95)
);
border: 1px solid rgba($blue, 0.35);
box-shadow: 0 12px 28px rgba($leftMenu-background, 0.35);
}
.visualiser-node {
background: linear-gradient(
135deg,

View File

@ -25,6 +25,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const [taskDOMPositions, setTaskDOMPositions] = useState<
Map<string, { x: number; y: number; width: number; height: number }>
>(new Map());
const [columnGap, setColumnGap] = useState<number>(80);
// Vertical spacing between stacked verdict labels in merged outcome connectors
const VERDICT_LABEL_SPACING = 3;
@ -35,6 +36,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
// Column layout constants
const COLUMN_MIN_WIDTH = 220;
const BASE_COLUMN_GAP = 16;
const COLUMN_GAP_MARGIN = 35;
// Outcome connector curve constants
const CURVE_BASE_OFFSET = 20;
@ -177,11 +180,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
(s) => (s.config.guid as string) === guid,
);
// Assign columns: spread siblings around parent column
const startCol = Math.max(
0,
predColumn - Math.floor(siblings.length / 2),
);
// Assign columns: keep siblings together at or to the right of parent column
const startCol = predColumn;
const column = startCol + siblingIndex;
columnByGuid.set(guid, column);
@ -246,49 +246,70 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
useEffect(() => {
// Measure task DOM positions for outcome connectors
if (!visualiserContainerRef.current) return;
const measurePositions = () => {
if (!visualiserContainerRef.current) return;
const containerRect =
visualiserContainerRef.current.getBoundingClientRect();
const positions = new Map<
string,
{ x: number; y: number; width: number; height: number }
>();
const containerRect =
visualiserContainerRef.current.getBoundingClientRect();
const positions = new Map<
string,
{ x: number; y: number; width: number; height: number }
>();
const nodes =
visualiserContainerRef.current.querySelectorAll(".visualiser-node");
nodes.forEach((node) => {
const taskName = node.querySelector(".visualiser-node-content span");
if (!taskName) return;
const nodes =
visualiserContainerRef.current.querySelectorAll(".visualiser-node");
nodes.forEach((node) => {
const guid = node.getAttribute("data-guid");
if (!guid) return;
// Find task by name
const task = tasks.find(
(t) => (t.config.name as string) === taskName.textContent,
);
if (!task) return;
// Find task by GUID
const task = tasks.find((t) => (t.config.guid as string) === guid);
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;
const widthPercent = (nodeRect.width / containerRect.width) * 100;
const heightPercent = (nodeRect.height / containerRect.height) * 100;
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;
const widthPercent = (nodeRect.width / containerRect.width) * 100;
const heightPercent = (nodeRect.height / containerRect.height) * 100;
positions.set(task.config.guid as string, {
x: relX,
y: relY,
width: widthPercent,
height: heightPercent,
positions.set(task.config.guid as string, {
x: relX,
y: relY,
width: widthPercent,
height: heightPercent,
});
});
setTaskDOMPositions(positions);
};
// Initial measurement with a delay to ensure DOM and layout are ready
const timer = setTimeout(() => {
measurePositions();
}, 50);
// Re-measure when layout changes
if (!visualiserContainerRef.current) {
return () => clearTimeout(timer);
}
const resizeObserver = new ResizeObserver(() => {
measurePositions();
});
setTaskDOMPositions(positions);
}, [tasks, levels]);
resizeObserver.observe(visualiserContainerRef.current);
return () => {
clearTimeout(timer);
resizeObserver.disconnect();
};
}, [tasks]);
const getArrowPositionsForLevel = (levelIndex: number): number[] => {
const found = arrowPositions.find((ap) => ap.levelIndex === levelIndex);
@ -754,6 +775,106 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
return connections;
};
// Calculate required column gap based on outcome connections
const calculateRequiredColumnGap = (): number => {
// Get all outcome actions to determine max group size
const sourceGroups = new Map<string, number>();
const targetGroups = new Map<string, number>();
tasks.forEach((task) => {
const outcomes =
(task.config.outcomeActions as Array<{
verdict: string;
task: string | null;
}>) ?? [];
outcomes.forEach((outcome) => {
if (outcome.task) {
const sourceGuid = task.config.guid as string;
const targetGuid = outcome.task;
// Count outcomes from same source
sourceGroups.set(sourceGuid, (sourceGroups.get(sourceGuid) ?? 0) + 1);
// Count outcomes to same target
targetGroups.set(targetGuid, (targetGroups.get(targetGuid) ?? 0) + 1);
}
});
});
// Find maximum group size
const maxSourceGroup = Math.max(0, ...Array.from(sourceGroups.values()));
const maxTargetGroup = Math.max(0, ...Array.from(targetGroups.values()));
const maxGroupSize = Math.max(maxSourceGroup, maxTargetGroup);
// If no outcome connections, use base gap
if (maxGroupSize === 0) {
return BASE_COLUMN_GAP;
}
// Calculate max offset: half of (groupSize - 1) * offsetStep
const maxOffset = ((maxGroupSize - 1) * CURVE_OFFSET_STEP) / 2;
// Curves extend significantly - use larger multiplier to account for full curve trajectory
// For 2 curves (maxOffset ~7.5), this should provide ~100px of space
const requiredSpace =
CURVE_BASE_OFFSET + maxOffset * 15 + COLUMN_GAP_MARGIN;
return Math.max(BASE_COLUMN_GAP, requiredSpace);
};
// Update column gap when tasks change
useEffect(() => {
const newGap = calculateRequiredColumnGap();
setColumnGap(newGap);
}, [tasks]);
// Re-measure positions when gap changes to accommodate layout reflow
useEffect(() => {
const timer = setTimeout(() => {
if (!visualiserContainerRef.current) return;
const containerRect =
visualiserContainerRef.current.getBoundingClientRect();
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 task = tasks.find((t) => (t.config.guid as string) === guid);
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;
const widthPercent = (nodeRect.width / containerRect.width) * 100;
const heightPercent = (nodeRect.height / containerRect.height) * 100;
positions.set(task.config.guid as string, {
x: relX,
y: relY,
width: widthPercent,
height: heightPercent,
});
});
setTaskDOMPositions(positions);
}, 50);
return () => clearTimeout(timer);
}, [columnGap, tasks]);
const rootTaskCount = tasks.filter((task) => {
const preds = task.config.predecessors as string[] | undefined;
return !preds || preds.length === 0;
@ -779,6 +900,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
columnRange.max - columnRange.min + 1
}, minmax(${COLUMN_MIN_WIDTH}px, 1fr))`
: `${COLUMN_MIN_WIDTH}px`,
gap: `${columnGap}px`,
} as React.CSSProperties
}
>
@ -837,6 +959,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
key={task.config.guid as string}
className="visualiser-node"
data-column={taskColumn}
data-guid={task.config.guid as string}
style={styleObj}
>
<div className="visualiser-node-content">
@ -925,6 +1048,31 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
});
});
// If no connectors will be drawn but there are override tasks,
// render a spacer to maintain visual gap
if (groups.size === 0 && overrideTasks.size > 0) {
// Check if next level has predecessors in current level
const hasRelationship = nextLevel.some((task) => {
const preds =
(task.config.predecessors as string[]) ?? [];
return preds.some((predGuid) => {
return currentLevel.some(
(currentTask) =>
(currentTask.config.guid as string) === predGuid,
);
});
});
if (hasRelationship) {
return (
<div
aria-hidden="true"
className="visualiser-connector-spacer"
/>
);
}
}
return Array.from(groups.entries()).map(
([predGuid, children]) => {
const predColumn = taskColumnMap.current.get(predGuid);