More work on the visualiser
This commit is contained in:
parent
64dd818a62
commit
22c80e82f2
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user