diff --git a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx index 58e7d5c..5618d5d 100644 --- a/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx +++ b/src/modules/manager/workflowTemplates/components/VisualisetTab.tsx @@ -409,6 +409,112 @@ const VisualiserTab: React.FC = ({ return sharedLaneByTarget; }, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]); + const mergedRightIncomingByTarget = React.useMemo(() => { + const forwardIncomingCount = new Map(); + + edges.forEach((edge) => { + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + if (targetLevel > sourceLevel) { + forwardIncomingCount.set( + edge.targetGuid, + (forwardIncomingCount.get(edge.targetGuid) ?? 0) + 1, + ); + } + }); + + const sharedLaneByTarget = new Map(); + + edges.forEach((edge) => { + const source = nodePositions.get(edge.sourceGuid); + const target = nodePositions.get(edge.targetGuid); + if (!source || !target) { + return; + } + + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + const levelDelta = targetLevel - sourceLevel; + if (levelDelta <= 0) { + return; + } + + const targetForwardCount = forwardIncomingCount.get(edge.targetGuid) ?? 0; + const shouldMergeAdjacent = levelDelta === 1 && targetForwardCount >= 3; + if (!(levelDelta > 1 || shouldMergeAdjacent)) { + return; + } + + const sourceShift = + (edge.sourceIndex - (edge.sourceCount - 1) / 2) * 2.25; + const laneSpread = + edgeLaneOffsetByKey.get(`${edge.sourceGuid}->${edge.targetGuid}`) ?? 0; + const isLongDownward = levelDelta > 1; + const startX = isLongDownward ? source.x + source.width / 2 : source.x; + const startY = isLongDownward + ? source.y + sourceShift * 0.35 + laneSpread * 0.25 + : source.y + source.height / 2; + const endX = target.x + target.width / 2; + const endY = isLongDownward + ? target.y + : target.y - target.height / 2 + target.height * 0.5; + const verticalSpan = Math.abs(endY - startY); + const longRouteExtra = + Math.max(0, verticalSpan - 10) * 0.2 + (levelDelta - 1) * 1.2; + const laneBaseOffset = isLongDownward ? 6 : 2.5; + const candidateLaneX = + Math.max(startX, endX) + + laneBaseOffset + + Math.abs(sourceShift) + + (isLongDownward ? longRouteExtra : 0) + + laneSpread * 1.2; + + const current = sharedLaneByTarget.get(edge.targetGuid); + sharedLaneByTarget.set( + edge.targetGuid, + current === undefined + ? candidateLaneX + : Math.max(current, candidateLaneX), + ); + }); + + return sharedLaneByTarget; + }, [edges, levelByGuid, edgeLaneOffsetByKey, nodePositions]); + const rightMergeEligibleByTarget = React.useMemo(() => { + const forwardIncomingCount = new Map(); + const longIncomingCount = new Map(); + + edges.forEach((edge) => { + const sourceLevel = levelByGuid.get(edge.sourceGuid) ?? 0; + const targetLevel = levelByGuid.get(edge.targetGuid) ?? 0; + if (targetLevel > sourceLevel) { + forwardIncomingCount.set( + edge.targetGuid, + (forwardIncomingCount.get(edge.targetGuid) ?? 0) + 1, + ); + + if (targetLevel - sourceLevel > 1) { + longIncomingCount.set( + edge.targetGuid, + (longIncomingCount.get(edge.targetGuid) ?? 0) + 1, + ); + } + } + }); + + const eligibility = new Map< + string, + { mergeLong: boolean; mergeAdjacent: boolean } + >(); + forwardIncomingCount.forEach((count, targetGuid) => { + eligibility.set(targetGuid, { + mergeLong: (longIncomingCount.get(targetGuid) ?? 0) >= 2, + mergeAdjacent: count >= 3, + }); + }); + + return eligibility; + }, [edges, levelByGuid]); const connectedByGuid = React.useMemo(() => { const map = new Map>(); @@ -597,29 +703,49 @@ const VisualiserTab: React.FC = ({ ); const isMergedLeft = levelDelta < 0 && mergedLeftLaneX !== undefined; + const mergedRightLaneX = mergedRightIncomingByTarget.get( + edge.targetGuid, + ); + const rightMergeEligibility = rightMergeEligibleByTarget.get( + edge.targetGuid, + ); + const isMergedRight = + mergedRightLaneX !== undefined && + ((levelDelta > 1 && rightMergeEligibility?.mergeLong) || + (levelDelta === 1 && rightMergeEligibility?.mergeAdjacent)); let path = ""; let labelX = 0; let labelY = 0; let labelAnchor: "start" | "middle" | "end" = "middle"; - if (levelDelta > 1) { + if (levelDelta > 1 || (levelDelta === 1 && isMergedRight)) { // Multi-level downward jumps: route on the right side. - const startX = source.x + source.width / 2; - const startY = source.y + sourceShift * 0.35 + laneSpread * 0.25; + const isLongDownward = levelDelta > 1; + const startX = isLongDownward + ? source.x + source.width / 2 + : source.x; + const startY = isLongDownward + ? source.y + sourceShift * 0.35 + laneSpread * 0.25 + : source.y + source.height / 2; const endX = target.x + target.width / 2; - const endY = target.y + targetShift * 0.35 + laneSpread * 0.25; + const endY = isLongDownward + ? target.y + targetShift * 0.35 + laneSpread * 0.25 + : target.y; const verticalSpan = Math.abs(endY - startY); const longRouteExtra = Math.max(0, verticalSpan - 10) * 0.2 + (levelDelta - 1) * 1.2; const laneX = + mergedRightLaneX ?? Math.max(startX, endX) + - 6 + - Math.abs(sourceShift) + - longRouteExtra + - laneSpread * 1.2; + 6 + + Math.abs(sourceShift) + + longRouteExtra + + laneSpread * 1.2; - path = `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY} L ${endX} ${endY}`; + path = isMergedRight + ? `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY}` + : `M ${startX} ${startY} L ${laneX} ${startY} L ${laneX} ${endY} L ${endX} ${endY}`; labelX = startX + 0.9; labelY = startY - 1.5 - Math.abs(laneSpread) * 0.12; labelAnchor = "start"; @@ -716,7 +842,9 @@ const VisualiserTab: React.FC = ({ className={`visualiser-outcome-connector-line${edgeIntensityClass}${edgeIsDim ? " is-dim" : ""}`} d={path} markerEnd={ - isMergedLeft ? undefined : "url(#visualiser-arrow)" + isMergedLeft || isMergedRight + ? undefined + : "url(#visualiser-arrow)" } /> {label && ( @@ -770,6 +898,43 @@ const VisualiserTab: React.FC = ({ ); }, )} + {Array.from(mergedRightIncomingByTarget.entries()).map( + ([targetGuid, laneX]) => { + const target = nodePositions.get(targetGuid); + if (!target) { + return null; + } + + const endX = target.x + target.width / 2; + const endY = target.y; + const mergedIsActive = + hoveredGuid !== null && + (hoveredGuid === targetGuid || + edges.some( + (edge) => + edge.targetGuid === targetGuid && + edge.sourceGuid === hoveredGuid && + (levelByGuid.get(edge.targetGuid) ?? 0) > + (levelByGuid.get(edge.sourceGuid) ?? 0), + )); + const mergedIsDim = hoveredGuid !== null && !mergedIsActive; + const mergedIntensityClass = + hoveredGuid === targetGuid + ? " is-active-incoming" + : mergedIsActive + ? " is-active-outgoing" + : ""; + + return ( + + ); + }, + )}