More work on the visualiser
This commit is contained in:
parent
64dd818a62
commit
22c80e82f2
@ -11,15 +11,7 @@
|
|||||||
|
|
||||||
.visualiser-flow {
|
.visualiser-flow {
|
||||||
width: 100%;
|
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;
|
padding: 20px 16px;
|
||||||
box-shadow: 0 10px 24px rgba($leftMenu-background, 0.15);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -35,7 +27,6 @@
|
|||||||
|
|
||||||
.visualiser-level-row {
|
.visualiser-level-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
@ -97,6 +88,11 @@
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visualiser-connector-spacer {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.visualiser-connector-branch-line {
|
.visualiser-connector-branch-line {
|
||||||
stroke: rgba($blue, 0.55);
|
stroke: rgba($blue, 0.55);
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
@ -108,16 +104,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@include color-mode(dark) {
|
@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 {
|
.visualiser-node {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
const [taskDOMPositions, setTaskDOMPositions] = useState<
|
const [taskDOMPositions, setTaskDOMPositions] = useState<
|
||||||
Map<string, { x: number; y: number; width: number; height: number }>
|
Map<string, { x: number; y: number; width: number; height: number }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
const [columnGap, setColumnGap] = useState<number>(80);
|
||||||
|
|
||||||
// Vertical spacing between stacked verdict labels in merged outcome connectors
|
// Vertical spacing between stacked verdict labels in merged outcome connectors
|
||||||
const VERDICT_LABEL_SPACING = 3;
|
const VERDICT_LABEL_SPACING = 3;
|
||||||
@ -35,6 +36,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
|
|
||||||
// Column layout constants
|
// Column layout constants
|
||||||
const COLUMN_MIN_WIDTH = 220;
|
const COLUMN_MIN_WIDTH = 220;
|
||||||
|
const BASE_COLUMN_GAP = 16;
|
||||||
|
const COLUMN_GAP_MARGIN = 35;
|
||||||
|
|
||||||
// Outcome connector curve constants
|
// Outcome connector curve constants
|
||||||
const CURVE_BASE_OFFSET = 20;
|
const CURVE_BASE_OFFSET = 20;
|
||||||
@ -177,11 +180,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
(s) => (s.config.guid as string) === guid,
|
(s) => (s.config.guid as string) === guid,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assign columns: spread siblings around parent column
|
// Assign columns: keep siblings together at or to the right of parent column
|
||||||
const startCol = Math.max(
|
const startCol = predColumn;
|
||||||
0,
|
|
||||||
predColumn - Math.floor(siblings.length / 2),
|
|
||||||
);
|
|
||||||
const column = startCol + siblingIndex;
|
const column = startCol + siblingIndex;
|
||||||
|
|
||||||
columnByGuid.set(guid, column);
|
columnByGuid.set(guid, column);
|
||||||
@ -246,49 +246,70 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Measure task DOM positions for outcome connectors
|
// Measure task DOM positions for outcome connectors
|
||||||
if (!visualiserContainerRef.current) return;
|
const measurePositions = () => {
|
||||||
|
if (!visualiserContainerRef.current) return;
|
||||||
|
|
||||||
const containerRect =
|
const containerRect =
|
||||||
visualiserContainerRef.current.getBoundingClientRect();
|
visualiserContainerRef.current.getBoundingClientRect();
|
||||||
const positions = new Map<
|
const positions = new Map<
|
||||||
string,
|
string,
|
||||||
{ x: number; y: number; width: number; height: number }
|
{ x: number; y: number; width: number; height: number }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const nodes =
|
const nodes =
|
||||||
visualiserContainerRef.current.querySelectorAll(".visualiser-node");
|
visualiserContainerRef.current.querySelectorAll(".visualiser-node");
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
const taskName = node.querySelector(".visualiser-node-content span");
|
const guid = node.getAttribute("data-guid");
|
||||||
if (!taskName) return;
|
if (!guid) return;
|
||||||
|
|
||||||
// Find task by name
|
// Find task by GUID
|
||||||
const task = tasks.find(
|
const task = tasks.find((t) => (t.config.guid as string) === guid);
|
||||||
(t) => (t.config.name as string) === taskName.textContent,
|
if (!task) return;
|
||||||
);
|
|
||||||
if (!task) return;
|
|
||||||
|
|
||||||
const nodeRect = node.getBoundingClientRect();
|
const nodeRect = node.getBoundingClientRect();
|
||||||
const relX =
|
const relX =
|
||||||
((nodeRect.left - containerRect.left + nodeRect.width / 2) /
|
((nodeRect.left - containerRect.left + nodeRect.width / 2) /
|
||||||
containerRect.width) *
|
containerRect.width) *
|
||||||
100;
|
100;
|
||||||
const relY =
|
const relY =
|
||||||
((nodeRect.top - containerRect.top + nodeRect.height / 2) /
|
((nodeRect.top - containerRect.top + nodeRect.height / 2) /
|
||||||
containerRect.height) *
|
containerRect.height) *
|
||||||
100;
|
100;
|
||||||
const widthPercent = (nodeRect.width / containerRect.width) * 100;
|
const widthPercent = (nodeRect.width / containerRect.width) * 100;
|
||||||
const heightPercent = (nodeRect.height / containerRect.height) * 100;
|
const heightPercent = (nodeRect.height / containerRect.height) * 100;
|
||||||
|
|
||||||
positions.set(task.config.guid as string, {
|
positions.set(task.config.guid as string, {
|
||||||
x: relX,
|
x: relX,
|
||||||
y: relY,
|
y: relY,
|
||||||
width: widthPercent,
|
width: widthPercent,
|
||||||
height: heightPercent,
|
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);
|
resizeObserver.observe(visualiserContainerRef.current);
|
||||||
}, [tasks, levels]);
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [tasks]);
|
||||||
|
|
||||||
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);
|
||||||
@ -754,6 +775,106 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
return connections;
|
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 rootTaskCount = tasks.filter((task) => {
|
||||||
const preds = task.config.predecessors as string[] | undefined;
|
const preds = task.config.predecessors as string[] | undefined;
|
||||||
return !preds || preds.length === 0;
|
return !preds || preds.length === 0;
|
||||||
@ -779,6 +900,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
columnRange.max - columnRange.min + 1
|
columnRange.max - columnRange.min + 1
|
||||||
}, minmax(${COLUMN_MIN_WIDTH}px, 1fr))`
|
}, minmax(${COLUMN_MIN_WIDTH}px, 1fr))`
|
||||||
: `${COLUMN_MIN_WIDTH}px`,
|
: `${COLUMN_MIN_WIDTH}px`,
|
||||||
|
gap: `${columnGap}px`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -837,6 +959,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
|
|||||||
key={task.config.guid as string}
|
key={task.config.guid as string}
|
||||||
className="visualiser-node"
|
className="visualiser-node"
|
||||||
data-column={taskColumn}
|
data-column={taskColumn}
|
||||||
|
data-guid={task.config.guid as string}
|
||||||
style={styleObj}
|
style={styleObj}
|
||||||
>
|
>
|
||||||
<div className="visualiser-node-content">
|
<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(
|
return Array.from(groups.entries()).map(
|
||||||
([predGuid, children]) => {
|
([predGuid, children]) => {
|
||||||
const predColumn = taskColumnMap.current.get(predGuid);
|
const predColumn = taskColumnMap.current.get(predGuid);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user