Broke out the magic numbers

This commit is contained in:
Colin Dawson 2026-02-26 12:15:49 +00:00
parent e2f5d2dc89
commit fcd0de35ea

View File

@ -26,6 +26,33 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
Map<string, { x: number; y: number; width: number; height: number }> Map<string, { x: number; y: number; width: number; height: number }>
>(new Map()); >(new Map());
// Vertical spacing between stacked verdict labels in merged outcome connectors
const VERDICT_LABEL_SPACING = 3;
// SVG coordinate constants
const SVG_CENTER_POSITION = 50;
const SVG_COORDINATE_MAX = 100;
// Column layout constants
const COLUMN_MIN_WIDTH = 220;
// Outcome connector curve constants
const CURVE_BASE_OFFSET = 20;
const CURVE_OFFSET_MULTIPLIER = 0.3;
const CURVE_CONTROL_OFFSET_FACTOR = 0.3;
const CURVE_OFFSET_STEP = 15;
// Bezier curve position constants
const BEZIER_LABEL_POSITION = 0.5; // Midpoint for label placement
const BEZIER_ARROW_POSITION = 0.3; // Position along curve for directional arrow
const BEZIER_ARROW_TANGENT_DELTA = 0.01; // Small offset for tangent calculation
// Arrow dimensions
const ARROW_HALF_WIDTH = 1.5;
const DIRECTIONAL_ARROW_SIZE = 3;
const END_ARROW_OFFSET = 3;
const END_ARROW_HALF_WIDTH = 1;
// 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());
const columnRange = { const columnRange = {
@ -206,7 +233,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
(nodeRect.left - levelRect.left + nodeRect.width / 2) / (nodeRect.left - levelRect.left + nodeRect.width / 2) /
levelRect.width; levelRect.width;
// Convert to SVG coordinates (0-100) // Convert to SVG coordinates (0-100)
nodePositions.push(relativeCenter * 100); nodePositions.push(relativeCenter * SVG_COORDINATE_MAX);
}); });
if (nodePositions.length > 0) { if (nodePositions.length > 0) {
@ -270,9 +297,9 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const getColumnPercent = (column: number): number => { const getColumnPercent = (column: number): number => {
const numColumns = columnRange.max - columnRange.min + 1; const numColumns = columnRange.max - columnRange.min + 1;
if (numColumns <= 1) return 50; if (numColumns <= 1) return SVG_CENTER_POSITION;
const gridCol = column - columnRange.min; const gridCol = column - columnRange.min;
return ((gridCol + 0.5) / numColumns) * 100; return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX;
}; };
// Calculate connector positions based on grid columns // Calculate connector positions based on grid columns
@ -280,7 +307,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
levelTasks: CreateWorkflowTemplateVersion["tasks"][], levelTasks: CreateWorkflowTemplateVersion["tasks"][],
): number[] => { ): number[] => {
const numColumns = columnRange.max - columnRange.min + 1; const numColumns = columnRange.max - columnRange.min + 1;
if (numColumns <= 1) return [50]; if (numColumns <= 1) return [SVG_CENTER_POSITION];
return levelTasks.map((task) => { return levelTasks.map((task) => {
const predecessors = (task.config.predecessors as string[]) ?? []; const predecessors = (task.config.predecessors as string[]) ?? [];
@ -297,13 +324,13 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
predColumns.reduce((sum, col) => sum + col, 0) / predColumns.length; predColumns.reduce((sum, col) => sum + col, 0) / predColumns.length;
const gridCol = avgPredColumn - columnRange.min; const gridCol = avgPredColumn - columnRange.min;
// Map averaged column to percentage for centered merges // Map averaged column to percentage for centered merges
return ((gridCol + 0.5) / numColumns) * 100; return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX;
} }
} }
const gridCol = taskColumn - columnRange.min; const gridCol = taskColumn - columnRange.min;
// Map grid column to percentage (e.g., 2 columns: col 0 = 25%, col 1 = 75%) // Map grid column to percentage (e.g., 2 columns: col 0 = 25%, col 1 = 75%)
return ((gridCol + 0.5) / numColumns) * 100; return ((gridCol + 0.5) / numColumns) * SVG_COORDINATE_MAX;
}); });
}; };
@ -312,7 +339,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
excludeTasks?: Set<string>, excludeTasks?: Set<string>,
): number[] => { ): number[] => {
const numColumns = columnRange.max - columnRange.min + 1; const numColumns = columnRange.max - columnRange.min + 1;
if (numColumns <= 1) return [50]; if (numColumns <= 1) return [SVG_CENTER_POSITION];
const predColumns = new Set<number>(); const predColumns = new Set<number>();
@ -402,7 +429,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
/> />
<polygon <polygon
className="visualiser-connector-branch-arrow" className="visualiser-connector-branch-arrow"
points={`${x - 1.5},28 ${x + 1.5},28 ${x},34`} points={`${x - ARROW_HALF_WIDTH},28 ${x + ARROW_HALF_WIDTH},28 ${x},34`}
/> />
</g> </g>
))} ))}
@ -412,8 +439,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
if (count <= 1) { if (count <= 1) {
// For a single child, use predecessor position if available // For a single child, use predecessor position if available
let startX = 50; let startX = SVG_CENTER_POSITION;
let endX = 50; let endX = SVG_CENTER_POSITION;
// If single child with predecessor positions, connect from the appropriate predecessor // If single child with predecessor positions, connect from the appropriate predecessor
if (positions && positions.length === 1) { if (positions && positions.length === 1) {
@ -475,7 +502,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
/> />
<polygon <polygon
className="visualiser-connector-branch-arrow" className="visualiser-connector-branch-arrow"
points={`${endX - 1.5},34 ${endX + 1.5},34 ${endX},40`} points={`${endX - ARROW_HALF_WIDTH},34 ${endX + ARROW_HALF_WIDTH},34 ${endX},40`}
/> />
</svg> </svg>
); );
@ -487,7 +514,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
? positions ? positions
: Array.from( : Array.from(
{ length: count }, { length: count },
(_, index) => ((index + 1) / (count + 1)) * 100, (_, index) => ((index + 1) / (count + 1)) * SVG_COORDINATE_MAX,
); );
// Guard against empty or invalid positions // Guard against empty or invalid positions
@ -516,7 +543,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const topX = const topX =
predecessorPositions && predecessorPositions.length > 0 predecessorPositions && predecessorPositions.length > 0
? predecessorPositions[0] ? predecessorPositions[0]
: 50; : SVG_CENTER_POSITION;
return ( return (
<svg <svg
@ -550,7 +577,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
/> />
<polygon <polygon
className="visualiser-connector-branch-arrow" className="visualiser-connector-branch-arrow"
points={`${x - 1.5},28 ${x + 1.5},28 ${x},34`} points={`${x - ARROW_HALF_WIDTH},28 ${x + ARROW_HALF_WIDTH},28 ${x},34`}
/> />
</g> </g>
))} ))}
@ -562,7 +589,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const getOutcomeConnections = (): Array<{ const getOutcomeConnections = (): Array<{
sourceGuid: string; sourceGuid: string;
targetGuid: string; targetGuid: string;
verdict: string; verdicts: string[];
sourceX: number; sourceX: number;
targetX: number; targetX: number;
sourceY: number; sourceY: number;
@ -576,7 +603,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const connections: Array<{ const connections: Array<{
sourceGuid: string; sourceGuid: string;
targetGuid: string; targetGuid: string;
verdict: string; verdicts: string[];
sourceX: number; sourceX: number;
targetX: number; targetX: number;
sourceY: number; sourceY: number;
@ -588,6 +615,21 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
curveOffset: number; curveOffset: number;
}> = []; }> = [];
// First, collect all outcome connections temporarily
const tempConnections: Array<{
sourceGuid: string;
targetGuid: string;
verdict: string;
sourceX: number;
targetX: number;
sourceY: number;
targetY: number;
sourceWidth: number;
sourceHeight: number;
targetWidth: number;
targetHeight: number;
}> = [];
tasks.forEach((task) => { tasks.forEach((task) => {
const outcomes = const outcomes =
(task.config.outcomeActions as Array<{ (task.config.outcomeActions as Array<{
@ -601,7 +643,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
const targetPos = taskDOMPositions.get(outcome.task); const targetPos = taskDOMPositions.get(outcome.task);
if (sourcePos && targetPos) { if (sourcePos && targetPos) {
connections.push({ tempConnections.push({
sourceGuid: task.config.guid as string, sourceGuid: task.config.guid as string,
targetGuid: outcome.task, targetGuid: outcome.task,
verdict: outcome.verdict, verdict: outcome.verdict,
@ -613,13 +655,40 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
sourceHeight: sourcePos.height, sourceHeight: sourcePos.height,
targetWidth: targetPos.width, targetWidth: targetPos.width,
targetHeight: targetPos.height, targetHeight: targetPos.height,
curveOffset: 0, // Will be calculated below
}); });
} }
} }
}); });
}); });
// Group by source+target pair to combine connections with same start and end
const connectionGroups = new Map<string, typeof tempConnections>();
tempConnections.forEach((conn) => {
const key = `${conn.sourceGuid}${conn.targetGuid}`;
const group = connectionGroups.get(key) ?? [];
group.push(conn);
connectionGroups.set(key, group);
});
// Merge connections with same source and target
connectionGroups.forEach((group) => {
const first = group[0];
connections.push({
sourceGuid: first.sourceGuid,
targetGuid: first.targetGuid,
verdicts: group.map((c) => c.verdict),
sourceX: first.sourceX,
sourceY: first.sourceY,
targetX: first.targetX,
targetY: first.targetY,
sourceWidth: first.sourceWidth,
sourceHeight: first.sourceHeight,
targetWidth: first.targetWidth,
targetHeight: first.targetHeight,
curveOffset: 0, // Will be calculated below
});
});
// Detect overlapping connections to the same target and offset them // Detect overlapping connections to the same target and offset them
const targetGroups = new Map<string, typeof connections>(); const targetGroups = new Map<string, typeof connections>();
connections.forEach((conn) => { connections.forEach((conn) => {
@ -635,7 +704,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
group.forEach((conn, idx) => { group.forEach((conn, idx) => {
// Spread offsets: -10, 0, 10 for 3 connections, etc. // Spread offsets: -10, 0, 10 for 3 connections, etc.
const totalOffsets = group.length; const totalOffsets = group.length;
const offsetStep = 15; const offsetStep = CURVE_OFFSET_STEP;
const baseOffset = -((totalOffsets - 1) * offsetStep) / 2; const baseOffset = -((totalOffsets - 1) * offsetStep) / 2;
conn.curveOffset = baseOffset + idx * offsetStep; conn.curveOffset = baseOffset + idx * offsetStep;
}); });
@ -668,8 +737,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
columnRange.max > columnRange.min columnRange.max > columnRange.min
? `repeat(${ ? `repeat(${
columnRange.max - columnRange.min + 1 columnRange.max - columnRange.min + 1
}, minmax(220px, 1fr))` }, minmax(${COLUMN_MIN_WIDTH}px, 1fr))`
: "220px", : `${COLUMN_MIN_WIDTH}px`,
} as React.CSSProperties } as React.CSSProperties
} }
> >
@ -858,15 +927,18 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
? conn.targetX + conn.targetWidth / 2 // right edge for rightward curves ? conn.targetX + conn.targetWidth / 2 // right edge for rightward curves
: conn.targetX - conn.targetWidth / 2; // left edge for leftward curves : conn.targetX - conn.targetWidth / 2; // left edge for leftward curves
const baseControlOffsetX = 20 + Math.abs(conn.curveOffset) * 0.3; const baseControlOffsetX =
CURVE_BASE_OFFSET +
Math.abs(conn.curveOffset) * CURVE_OFFSET_MULTIPLIER;
const controlOffsetY = const controlOffsetY =
(Math.abs(conn.targetY - conn.sourceY) / 2) * 0.3; (Math.abs(conn.targetY - conn.sourceY) / 2) *
CURVE_CONTROL_OFFSET_FACTOR;
// Apply horizontal offset to spread overlapping curves // Apply horizontal offset to spread overlapping curves
const offsetX = conn.curveOffset; const offsetX = conn.curveOffset;
// Calculate text position at the curve's midpoint (t=0.5 on Bezier curve) // Calculate text position at the curve's midpoint (t=0.5 on Bezier curve)
const t = 0.5; const t = BEZIER_LABEL_POSITION;
const p0 = { x: sourceEdgeX, y: conn.sourceY }; const p0 = { x: sourceEdgeX, y: conn.sourceY };
const p1 = { const p1 = {
x: sourceEdgeX + baseControlOffsetX * curveDirection + offsetX, x: sourceEdgeX + baseControlOffsetX * curveDirection + offsetX,
@ -891,7 +963,7 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
Math.pow(t, 3) * p3.y; Math.pow(t, 3) * p3.y;
// Calculate arrow position at t=0.3 for directional indicator // Calculate arrow position at t=0.3 for directional indicator
const tArrow = 0.3; const tArrow = BEZIER_ARROW_POSITION;
const arrowX = const arrowX =
Math.pow(1 - tArrow, 3) * p0.x + Math.pow(1 - tArrow, 3) * p0.x +
3 * Math.pow(1 - tArrow, 2) * tArrow * p1.x + 3 * Math.pow(1 - tArrow, 2) * tArrow * p1.x +
@ -904,7 +976,8 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
Math.pow(tArrow, 3) * p3.y; Math.pow(tArrow, 3) * p3.y;
// Calculate tangent for arrow rotation // Calculate tangent for arrow rotation
const tArrowDelta = 0.31; const tArrowDelta =
BEZIER_ARROW_POSITION + BEZIER_ARROW_TANGENT_DELTA;
const arrowX2 = const arrowX2 =
Math.pow(1 - tArrowDelta, 3) * p0.x + Math.pow(1 - tArrowDelta, 3) * p0.x +
3 * Math.pow(1 - tArrowDelta, 2) * tArrowDelta * p1.x + 3 * Math.pow(1 - tArrowDelta, 2) * tArrowDelta * p1.x +
@ -934,23 +1007,32 @@ const VisualiserTab: React.FC<VisualiserTabProps> = ({
{/* Directional arrow along curve */} {/* Directional arrow along curve */}
<polygon <polygon
className="visualiser-outcome-connector-arrow" className="visualiser-outcome-connector-arrow"
points={`-3,-1.5 -3,1.5 0,0`} points={`-${DIRECTIONAL_ARROW_SIZE},-${ARROW_HALF_WIDTH} -${DIRECTIONAL_ARROW_SIZE},${ARROW_HALF_WIDTH} 0,0`}
transform={`translate(${arrowX},${arrowY}) rotate(${angle})`} transform={`translate(${arrowX},${arrowY}) rotate(${angle})`}
/> />
{/* End arrow */} {/* End arrow */}
<polygon <polygon
className="visualiser-outcome-connector-arrow" className="visualiser-outcome-connector-arrow"
points={`${targetEdgeX - 1},${conn.targetY - 3} ${targetEdgeX + 1},${conn.targetY - 3} ${targetEdgeX},${conn.targetY}`} points={`${targetEdgeX - END_ARROW_HALF_WIDTH},${conn.targetY - END_ARROW_OFFSET} ${targetEdgeX + END_ARROW_HALF_WIDTH},${conn.targetY - END_ARROW_OFFSET} ${targetEdgeX},${conn.targetY}`}
/> />
<text {/* Render verdict labels - multiple if merged */}
x={textX} {conn.verdicts.map((verdict, vIdx) => {
y={textY} const labelOffsetY =
className="visualiser-outcome-connector-label" (vIdx - (conn.verdicts.length - 1) / 2) *
textAnchor="middle" VERDICT_LABEL_SPACING;
dominantBaseline="middle" return (
> <text
{conn.verdict} key={`verdict-${vIdx}`}
</text> x={textX}
y={textY + labelOffsetY}
className="visualiser-outcome-connector-label"
textAnchor="middle"
dominantBaseline="middle"
>
{verdict}
</text>
);
})}
</g> </g>
); );
})} })}