From c61c1cf1eb835a3215c04b2dc47a47619bb2b1da Mon Sep 17 00:00:00 2001 From: Preston Bao Date: Fri, 27 Feb 2026 13:01:53 -0800 Subject: [PATCH] fix(apollo-react): introduce sankey diagram zoom and scale --- .../src/pages/components/ApSankeyShowcase.tsx | 276 +++++------ packages/apollo-react/package.json | 4 + .../ApSankeyDiagram.test.tsx | 172 ++++++- .../ap-sankey-diagram/ApSankeyDiagram.tsx | 461 +++++++++++++----- .../ApSankeyDiagram.types.ts | 4 + pnpm-lock.yaml | 12 + 6 files changed, 665 insertions(+), 264 deletions(-) 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). - - -
-

- Left Alignment -

- -
- -
-

- 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 */}