diff --git a/apps/react-playground/src/pages/components/ApSankeyShowcase.tsx b/apps/react-playground/src/pages/components/ApSankeyShowcase.tsx
index 188e2f82e..4a47440ae 100644
--- a/apps/react-playground/src/pages/components/ApSankeyShowcase.tsx
+++ b/apps/react-playground/src/pages/components/ApSankeyShowcase.tsx
@@ -41,21 +41,43 @@ export function ApSankeyShowcase() {
- Job Flow with Metadata
+ Agent Trace Flow with Metadata
- Complex flow showing agent job execution paths. Hover over links to
- see metadata like total jobs, latency, and agent units.
+ Complex multi-stage trace flow showing agent job execution paths
+ through LLM calls, tool invocations, guardrails, and final
+ outcomes. Hover over links to see metadata. Use zoom controls to
+ navigate.
-
- Node Alignment Options
-
- Control how nodes are positioned horizontally: left, right, center, or
- justify (default).
-
-
-
-
-
-
- Center Alignment
-
-
-
-
-
-
- Right Alignment
-
-
-
-
-
Node Sizing
diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json
index 6e972fbe0..d566dabe5 100644
--- a/packages/apollo-react/package.json
+++ b/packages/apollo-react/package.json
@@ -195,6 +195,8 @@
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"debounce": "^3.0.0",
"html-to-image": "^1.11.11",
@@ -239,6 +241,8 @@
"@types/d3-sankey": "^0.12.4",
"@types/d3-scale": "^4.0.8",
"@types/d3-scale-chromatic": "^3.0.3",
+ "@types/d3-selection": "^3.0.11",
+ "@types/d3-zoom": "^3.0.8",
"@types/lodash": "^4.17.21",
"@types/luxon": "^3.7.1",
"@types/mdast": "^4.0.4",
diff --git a/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.test.tsx b/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.test.tsx
index 03311515e..676b4b918 100644
--- a/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.test.tsx
+++ b/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.test.tsx
@@ -1,7 +1,7 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
-import { ApSankeyDiagram } from './ApSankeyDiagram';
+import { ApSankeyDiagram, computeSankeyDimensions } from './ApSankeyDiagram';
import type { SankeyData } from './ApSankeyDiagram.types';
describe('ApSankeyDiagram', () => {
@@ -19,7 +19,7 @@ describe('ApSankeyDiagram', () => {
it('should render without crashing', () => {
const { container } = render( );
- const sankeyContainer = container.querySelector('[role="img"][aria-label="Sankey diagram"]');
+ const sankeyContainer = container.querySelector('[role="figure"][aria-label="Sankey diagram"]');
expect(sankeyContainer).toBeInTheDocument();
});
@@ -63,7 +63,171 @@ describe('ApSankeyDiagram', () => {
it('should handle empty data gracefully', () => {
const emptyData: SankeyData = { nodes: [], links: [] };
const { container } = render( );
- const sankeyContainer = container.querySelector('[role="img"][aria-label="Sankey diagram"]');
+ const sankeyContainer = container.querySelector('[role="figure"][aria-label="Sankey diagram"]');
expect(sankeyContainer).toBeInTheDocument();
});
+
+ it('should accept minNodeHeight and minColumnWidth props without error', () => {
+ const { container } = render(
+
+ );
+ const sankeyContainer = container.querySelector('[role="figure"][aria-label="Sankey diagram"]');
+ expect(sankeyContainer).toBeInTheDocument();
+ });
+});
+
+describe('Zoom and pan', () => {
+ const mockData: SankeyData = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ ],
+ links: [{ source: 'a', target: 'b', value: 10 }],
+ };
+
+ it('should render zoom controls, zoom group, and correct gradient coordinates', () => {
+ const { container } = render( );
+
+ // Zoom buttons with accessible labels
+ const zoomIn = screen.getByLabelText('Zoom in');
+ const zoomOut = screen.getByLabelText('Zoom out');
+ const fitToView = screen.getByLabelText('Fit to view');
+ expect(zoomIn).toBeInTheDocument();
+ expect(zoomOut).toBeInTheDocument();
+ expect(fitToView).toBeInTheDocument();
+
+ // Buttons are interactive — clicking does not throw
+ fireEvent.click(zoomIn);
+ fireEvent.click(zoomOut);
+ fireEvent.click(fitToView);
+
+ // SVG contains a zoom group
+ const svg = container.querySelector('svg');
+ expect(svg?.querySelector('g')).toBeInTheDocument();
+
+ // Gradients use userSpaceOnUse with numeric pixel coordinates, not percentages
+ const gradient = container.querySelector('linearGradient');
+ expect(gradient?.getAttribute('gradientUnits')).toBe('userSpaceOnUse');
+ expect(gradient?.getAttribute('x1')).not.toContain('%');
+ expect(gradient?.getAttribute('x2')).not.toContain('%');
+ expect(Number(gradient?.getAttribute('x2'))).toBeGreaterThan(Number(gradient?.getAttribute('x1')));
+ });
+});
+
+describe('computeSankeyDimensions', () => {
+ const margins = { left: 5, right: 120, top: 5, bottom: 40 };
+
+ it('should return zero dimensions for empty data', () => {
+ const result = computeSankeyDimensions(
+ { nodes: [], links: [] },
+ 16, // nodePadding
+ 24, // minNodeHeight
+ 200, // minColumnWidth
+ margins.left,
+ margins.right,
+ margins.top,
+ margins.bottom,
+ );
+ expect(result).toEqual({ minWidth: 0, minHeight: 0 });
+ });
+
+ it('should compute correct dimensions for a 3-node chain', () => {
+ const data: SankeyData = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ { id: 'c', label: 'C' },
+ ],
+ links: [
+ { source: 'a', target: 'b', value: 10 },
+ { source: 'b', target: 'c', value: 10 },
+ ],
+ };
+
+ const result = computeSankeyDimensions(
+ data,
+ 16, // nodePadding
+ 24, // minNodeHeight
+ 200, // minColumnWidth
+ margins.left,
+ margins.right,
+ margins.top,
+ margins.bottom,
+ );
+
+ // 3 columns × 200 + 5 + 120 = 725
+ expect(result.minWidth).toBe(725);
+ // 1 node per column max → 1 × (24 + 16) - 16 + 5 + 40 = 69
+ expect(result.minHeight).toBe(69);
+ });
+
+ it('should compute correct dimensions for a diamond graph', () => {
+ const data: SankeyData = {
+ nodes: [
+ { id: 'a', label: 'A' },
+ { id: 'b', label: 'B' },
+ { id: 'c', label: 'C' },
+ { id: 'd', label: 'D' },
+ ],
+ links: [
+ { source: 'a', target: 'b', value: 5 },
+ { source: 'a', target: 'c', value: 5 },
+ { source: 'b', target: 'd', value: 5 },
+ { source: 'c', target: 'd', value: 5 },
+ ],
+ };
+
+ const result = computeSankeyDimensions(
+ data,
+ 16,
+ 24,
+ 200,
+ margins.left,
+ margins.right,
+ margins.top,
+ margins.bottom,
+ );
+
+ // 3 columns (a=0, b/c=1, d=2) × 200 + 5 + 120 = 725
+ expect(result.minWidth).toBe(725);
+ // 2 nodes in middle column → 2 × (24 + 16) - 16 + 5 + 40 = 109
+ expect(result.minHeight).toBe(109);
+ });
+
+ it('should scale height with many nodes per column', () => {
+ // Fan-out: 1 source → 5 targets
+ const data: SankeyData = {
+ nodes: [
+ { id: 'src', label: 'Source' },
+ { id: 't1', label: 'T1' },
+ { id: 't2', label: 'T2' },
+ { id: 't3', label: 'T3' },
+ { id: 't4', label: 'T4' },
+ { id: 't5', label: 'T5' },
+ ],
+ links: [
+ { source: 'src', target: 't1', value: 1 },
+ { source: 'src', target: 't2', value: 1 },
+ { source: 'src', target: 't3', value: 1 },
+ { source: 'src', target: 't4', value: 1 },
+ { source: 'src', target: 't5', value: 1 },
+ ],
+ };
+
+ const result = computeSankeyDimensions(
+ data,
+ 16,
+ 24,
+ 200,
+ margins.left,
+ margins.right,
+ margins.top,
+ margins.bottom,
+ );
+
+ // 2 columns × 200 + 5 + 120 = 525
+ expect(result.minWidth).toBe(525);
+ // 5 nodes in second column → 5 × (24 + 16) - 16 + 5 + 40 = 229
+ expect(result.minHeight).toBe(229);
+ });
});
diff --git a/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.tsx b/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.tsx
index 94f9124e3..bbf49d4fa 100644
--- a/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.tsx
+++ b/packages/apollo-react/src/material/components/ap-sankey-diagram/ApSankeyDiagram.tsx
@@ -1,4 +1,7 @@
-import { Paper, Popper } from '@mui/material';
+import AddIcon from '@mui/icons-material/Add';
+import FitScreenIcon from '@mui/icons-material/FitScreen';
+import RemoveIcon from '@mui/icons-material/Remove';
+import { IconButton, Paper, Popper } from '@mui/material';
import { styled } from '@mui/material/styles';
import token from '@uipath/apollo-core';
import {
@@ -13,6 +16,8 @@ import {
sankeyRight,
} from 'd3-sankey';
import { schemeTableau10 } from 'd3-scale-chromatic';
+import { select } from 'd3-selection';
+import { zoom as d3Zoom, type ZoomBehavior, type ZoomTransform, zoomIdentity } from 'd3-zoom';
import React, {
useCallback,
useEffect,
@@ -22,7 +27,12 @@ import React, {
useState,
} from 'react';
-import type { ApSankeyDiagramProps, SankeyLink, SankeyNode } from './ApSankeyDiagram.types';
+import type {
+ ApSankeyDiagramProps,
+ SankeyData,
+ SankeyLink,
+ SankeyNode,
+} from './ApSankeyDiagram.types';
const SankeyContainer = styled('div')({
position: 'relative',
@@ -33,8 +43,6 @@ const SankeyContainer = styled('div')({
'& svg': {
display: 'block',
- width: '100%',
- height: '100%',
},
});
@@ -117,6 +125,18 @@ const TooltipValue = styled('span')({
fontWeight: token.FontFamily.FontWeightSemibold,
});
+const ZoomControls = styled('div')({
+ position: 'absolute',
+ bottom: token.Spacing.SpacingM,
+ right: token.Spacing.SpacingM,
+ display: 'flex',
+ gap: '1px',
+ backgroundColor: token.Colors.ColorGray100,
+ borderRadius: token.Border.BorderRadiusM,
+ overflow: 'hidden',
+ boxShadow: token.Shadow.ShadowDp2,
+});
+
/**
* ApSankeyDiagram component for visualizing flow data
*
@@ -155,7 +175,77 @@ interface ExtendedSankeyLink extends D3SankeyLink, Sanke
// Layout margins — small left (no labels go left), larger right (for last-column labels)
const DIAGRAM_MARGIN_LEFT = 5;
const DIAGRAM_MARGIN_RIGHT = 120;
-const DIAGRAM_MARGIN_VERTICAL = 5;
+const DIAGRAM_MARGIN_TOP = 5;
+const DIAGRAM_MARGIN_BOTTOM = 40;
+const ZOOM_SCALE_EXTENT_MIN = 0.5;
+const ZOOM_SCALE_EXTENT_MAX = 2;
+
+/**
+ * Compute minimum SVG dimensions for a Sankey dataset by assigning nodes to
+ * columns via forward BFS depth, then counting nodes per column.
+ * Returns { minWidth, minHeight }.
+ */
+export function computeSankeyDimensions(
+ data: SankeyData,
+ nodePadding: number,
+ minNodeHeight: number,
+ minColumnWidth: number,
+ marginLeft: number,
+ marginRight: number,
+ marginTop: number,
+ marginBottom: number
+): { minWidth: number; minHeight: number } {
+ if (!data || data.nodes.length === 0) return { minWidth: 0, minHeight: 0 };
+
+ // Build adjacency lists
+ const outgoing = new Map();
+ const incoming = new Map();
+ for (const node of data.nodes) {
+ outgoing.set(node.id, []);
+ incoming.set(node.id, []);
+ }
+ for (const link of data.links) {
+ outgoing.get(link.source)?.push(link.target);
+ incoming.get(link.target)?.push(link.source);
+ }
+
+ // Forward BFS — compute depth (max distance from sources)
+ const depth = new Map();
+ const sources = data.nodes.filter((n) => (incoming.get(n.id)?.length ?? 0) === 0);
+ const queue: string[] = [];
+ for (const s of sources) {
+ depth.set(s.id, 0);
+ queue.push(s.id);
+ }
+ // Guard against cycles: in the worst-case DAG every edge can enqueue once
+ const maxIterations = data.nodes.length * data.links.length + data.nodes.length;
+ let iterations = 0;
+ while (queue.length > 0 && iterations++ < maxIterations) {
+ const id = queue.shift()!;
+ const d = depth.get(id)!;
+ for (const target of outgoing.get(id) ?? []) {
+ if (!depth.has(target) || depth.get(target)! < d + 1) {
+ depth.set(target, d + 1);
+ queue.push(target);
+ }
+ }
+ }
+
+ // Count nodes per column (column = depth)
+ const columnCounts = new Map();
+ for (const d of depth.values()) {
+ columnCounts.set(d, (columnCounts.get(d) ?? 0) + 1);
+ }
+
+ const columnCount = columnCounts.size;
+ const maxNodesPerColumn = Math.max(0, ...columnCounts.values());
+
+ const minWidth = columnCount * minColumnWidth + marginLeft + marginRight;
+ const minHeight =
+ maxNodesPerColumn * (minNodeHeight + nodePadding) - nodePadding + marginTop + marginBottom;
+
+ return { minWidth, minHeight };
+};
// Truncate text to fit within a pixel width, measured via canvas
const truncateToFit = (text: string, maxWidth: number, ctx: CanvasRenderingContext2D): string => {
@@ -191,10 +281,16 @@ export const ApSankeyDiagram = React.forwardRef(null);
const svgRef = useRef(null);
+ const zoomGroupRef = useRef(null);
+ const zoomBehaviorRef = useRef | null>(null);
+ const zoomTransformRef = useRef(zoomIdentity);
+ const hasUserZoomedRef = useRef(false);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
const [selectedLinkIndex, setSelectedLinkIndex] = useState(null);
@@ -224,6 +320,24 @@ export const ApSankeyDiagram = React.forwardRef {
+ if (!data || data.nodes.length === 0) return { minWidth: 0, minHeight: 0 };
+ return computeSankeyDimensions(
+ data,
+ nodePadding,
+ minNodeHeight,
+ minColumnWidth,
+ DIAGRAM_MARGIN_LEFT,
+ DIAGRAM_MARGIN_RIGHT,
+ DIAGRAM_MARGIN_TOP,
+ DIAGRAM_MARGIN_BOTTOM
+ );
+ }, [data, nodePadding, minNodeHeight, minColumnWidth]);
+
+ const svgWidth = Math.max(actualWidth, minWidth);
+ const svgHeight = Math.max(actualHeight, minHeight);
+
// Create a color map for nodes
const nodeColorMap = useMemo(() => {
const map = new Map();
@@ -258,8 +372,8 @@ export const ApSankeyDiagram = React.forwardRef {
@@ -315,6 +429,8 @@ export const ApSankeyDiagram = React.forwardRef {
+ const svg = svgRef.current;
+ const zoomBehavior = zoomBehaviorRef.current;
+ if (!svg || !zoomBehavior || svgWidth === 0 || svgHeight === 0) return;
+
+ hasUserZoomedRef.current = false;
+
+ const cw = containerWidth || 1;
+ const ch = containerHeight || 1;
+ const scale = Math.min(cw / svgWidth, ch / svgHeight) * 0.95;
+ const tx = (cw - svgWidth * scale) / 2;
+ const ty = (ch - svgHeight * scale) / 2;
+ const t = zoomIdentity.translate(tx, ty).scale(scale);
+
+ select(svg)
+ .transition()
+ .duration(300)
+ .call(zoomBehavior.transform, t);
+ }, [containerWidth, containerHeight, svgWidth, svgHeight]);
+
+ // Attach d3-zoom behavior
+ useEffect(() => {
+ const svg = svgRef.current;
+ const zoomGroup = zoomGroupRef.current;
+ if (!svg || !zoomGroup) return;
+
+ const zoomBehavior = d3Zoom()
+ .scaleExtent([ZOOM_SCALE_EXTENT_MIN, ZOOM_SCALE_EXTENT_MAX])
+ .filter((event: Event) => {
+ // Require Ctrl/Cmd for wheel zoom so normal scroll isn't hijacked
+ if (event.type === 'wheel') {
+ return (event as WheelEvent).ctrlKey || (event as WheelEvent).metaKey;
+ }
+ // Allow drag-to-pan and touch gestures; exclude right-click
+ return !(event as MouseEvent).button;
+ })
+ .on('zoom', (event) => {
+ const t: ZoomTransform = event.transform;
+ zoomTransformRef.current = t;
+ zoomGroup.setAttribute('transform', `translate(${t.x},${t.y}) scale(${t.k})`);
+ if (event.sourceEvent) {
+ // User-initiated events (wheel, drag) have a sourceEvent; programmatic ones don't
+ hasUserZoomedRef.current = true;
+ }
+ });
+
+ zoomBehaviorRef.current = zoomBehavior;
+ select(svg).call(zoomBehavior);
+
+ return () => {
+ select(svg).on('.zoom', null);
+ zoomBehaviorRef.current = null;
+ };
+ }, []);
+
+ // Fit-to-view on mount and data changes; skip on resize if user has manually zoomed
+ useEffect(() => {
+ if (!sankeyGraph || containerWidth === 0 || containerHeight === 0) return;
+ if (hasUserZoomedRef.current) return;
+ fitToView();
+ }, [sankeyGraph, containerWidth, containerHeight, fitToView]);
+
+ // Zoom control handlers
+ const handleZoomIn = useCallback(() => {
+ const svg = svgRef.current;
+ const zoomBehavior = zoomBehaviorRef.current;
+ if (!svg || !zoomBehavior) return;
+ select(svg)
+ .transition()
+ .duration(200)
+ .call(zoomBehavior.scaleBy, 1.3);
+ }, []);
+
+ const handleZoomOut = useCallback(() => {
+ const svg = svgRef.current;
+ const zoomBehavior = zoomBehaviorRef.current;
+ if (!svg || !zoomBehavior) return;
+ select(svg)
+ .transition()
+ .duration(200)
+ .call(zoomBehavior.scaleBy, 1 / 1.3);
+ }, []);
+
+ // Handle link hover — accounts for zoom transform
const handleLinkMouseEnter = useCallback(
(index: number, centerX: number, centerY: number) => {
// Only update if hovering over a different link
if (selectedLinkIndex === index) return;
- // Create a virtual anchor element at the link's center
+ // Create a virtual anchor element at the link's center, adjusted for zoom
const svgRect = svgRef.current?.getBoundingClientRect();
if (svgRect) {
+ const t = zoomTransformRef.current;
+ const screenX = svgRect.left + centerX * t.k + t.x;
+ const screenY = svgRect.top + centerY * t.k + t.y;
const virtualAnchor = {
getBoundingClientRect: () => ({
- x: svgRect.left + centerX,
- y: svgRect.top + centerY,
- left: svgRect.left + centerX,
- top: svgRect.top + centerY,
- right: svgRect.left + centerX,
- bottom: svgRect.top + centerY,
+ x: screenX,
+ y: screenY,
+ left: screenX,
+ top: screenY,
+ right: screenX,
+ bottom: screenY,
width: 0,
height: 0,
toJSON: () => {},
@@ -438,119 +640,134 @@ export const ApSankeyDiagram = React.forwardRef
-
- {/* Define gradients for links */}
-
- {linkPaths.map((linkData) => (
-
-
-
-
- ))}
-
-
- {/* Render links */}
-
- {linkPaths.map((linkData, index) => {
- // Check if this link is connected to the hovered node
- const isConnectedToHoveredNode = connectedLinkIndices.has(index);
-
- // Determine opacity based on hover state
- let opacity = 0.6;
- if (selectedLinkIndex === index) {
- opacity = 1;
- } else if (hoveredNodeId) {
- opacity = isConnectedToHoveredNode ? 1 : 0.3;
- }
-
- return (
-
- handleLinkMouseEnter(index, linkData.centerX, linkData.centerY)
- }
- onMouseLeave={handleLinkMouseLeave}
- onClick={(e) => onLinkClick?.(linkData.originalLink, e)}
- aria-label={`Link from ${linkData.sourceLabel} to ${linkData.targetLabel}`}
- />
- );
- })}
-
+
+ {/* Zoom group — all content transforms together */}
+
+
+ {linkPaths.map((linkData) => (
+
+
+
+
+ ))}
+
+
+ {/* Render links */}
+
+ {linkPaths.map((linkData, index) => {
+ // Check if this link is connected to the hovered node
+ const isConnectedToHoveredNode = connectedLinkIndices.has(index);
+
+ // Determine opacity based on hover state
+ let opacity = 0.6;
+ if (selectedLinkIndex === index) {
+ opacity = 1;
+ } else if (hoveredNodeId) {
+ opacity = isConnectedToHoveredNode ? 1 : 0.3;
+ }
- {/* Render nodes */}
-
- {nodeData.map((nodeItem) => {
- // Determine node opacity based on hover state
- const nodeOpacity = hoveredNodeId
- ? hoveredNodeId === nodeItem.node.id
- ? 1
- : 0.4
- : 1;
-
- return (
-
- {/* Node rectangle */}
- handleNodeMouseEnter(nodeItem.node.id)}
- onMouseLeave={handleNodeMouseLeave}
- onClick={(e) =>
- onNodeClick?.(
- nodeItem.node as SankeyNode,
- e as React.MouseEvent
- )
+ return (
+
+ handleLinkMouseEnter(index, linkData.centerX, linkData.centerY)
}
+ onMouseLeave={handleLinkMouseLeave}
+ onClick={(e) => onLinkClick?.(linkData.originalLink, e)}
+ aria-label={`Link from ${linkData.sourceLabel} to ${linkData.targetLabel}`}
/>
+ );
+ })}
+
+
+ {/* Render nodes */}
+
+ {nodeData.map((nodeItem) => {
+ // Determine node opacity based on hover state
+ const nodeOpacity = hoveredNodeId
+ ? hoveredNodeId === nodeItem.node.id
+ ? 1
+ : 0.4
+ : 1;
- {/* Node label */}
-
- {nodeItem.fullLabel}
- {nodeItem.displayLabel}
-
-
- {/* Node value */}
- {nodeItem.value > 0 && (
-
+ {/* Node rectangle */}
+ handleNodeMouseEnter(nodeItem.node.id)}
+ onMouseLeave={handleNodeMouseLeave}
+ onClick={(e) =>
+ onNodeClick?.(
+ nodeItem.node as SankeyNode,
+ e as React.MouseEvent
+ )
+ }
+ />
+
+ {/* Node label */}
+
- {nodeItem.value}
-
- )}
-
- );
- })}
+ {nodeItem.fullLabel}
+ {nodeItem.displayLabel}
+
+
+ {/* Node value */}
+ {nodeItem.value > 0 && (
+
+ {nodeItem.value}
+
+ )}
+
+ );
+ })}
+
+ {/* Zoom controls */}
+
+
+
+
+
+
+
+
+
+
+
+
{/* Render tooltip using Popper for smart positioning */}