diff --git a/src/ui/src/components/chart/graph-utils.ts b/src/ui/src/components/chart/graph-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..53d57db735f75ae74cf431f1cf2f45515ace1a5d --- /dev/null +++ b/src/ui/src/components/chart/graph-utils.ts @@ -0,0 +1,32 @@ +import * as d3 from 'd3'; + +export function overflows(parent: DOMRect, child: DOMRect): boolean { + return child.height > parent.height || child.width > parent.width; +} + +export function scaleToFit(parent: DOMRect, child: DOMRect): number { + if (!overflows(parent, child)) { + // Don't scale the child if it doesn't overflow. + return 1; + } + return Math.min(parent.height / child.height, parent.width / child.width); +} + +export function fitDirection(parent: DOMRect, child: DOMRect): 'x' | 'y' { + return parent.width / child.width > parent.height / child.height ? 'y' : 'x'; +} + +export function centerFit(parent: DOMRect, child: DOMRect): d3.ZoomTransform { + if (!overflows(parent, child)) { + return d3.zoomIdentity.translate(parent.width / 2 - child.width / 2, parent.height / 2 - child.height / 2); + } + + const scale = scaleToFit(parent, child); + const direction = fitDirection(parent, child); + const translate = { + x: direction === 'x' ? 0 : parent.width / 2 - child.width * scale / 2, + y: direction === 'y' ? 0 : parent.height / 2 - child.height * scale / 2, + }; + console.log({ translate, parent, child }); + return d3.zoomIdentity.translate(translate.x, translate.y).scale(scale); +} diff --git a/src/ui/src/components/chart/graph.scss b/src/ui/src/components/chart/graph.scss index 2759f4af4ef6e4361443669012d5d0aaf9ad9846..e5d64b62c8365b4b5cb7ff710203f927a335e5e3 100644 --- a/src/ui/src/components/chart/graph.scss +++ b/src/ui/src/components/chart/graph.scss @@ -1,14 +1,14 @@ @import "../../theme.scss"; -#pixie-graph { - overflow: auto; +.pixie-graph-root { + height: 100%; + width: 100%; display: flex; - padding: 16px; + flex-direction: row; } -#pixie-graph svg { - margin: auto; - overflow: visible; +.pixie-graph-svg { + flex: 1; } svg .cluster rect { diff --git a/src/ui/src/components/chart/graph.tsx b/src/ui/src/components/chart/graph.tsx index 5d6801e0611753a55a9a1d2560a77c94305f1ea6..a1941e73da24def9bc01e30585c0fc7e459061ed 100644 --- a/src/ui/src/components/chart/graph.tsx +++ b/src/ui/src/components/chart/graph.tsx @@ -3,9 +3,10 @@ import './graph.scss'; import { WidgetDisplay } from 'containers/live/vis'; import * as d3 from 'd3'; import * as dagreD3 from 'dagre-d3'; -import * as dot from 'graphlib-dot'; +import * as graphlibDot from 'graphlib-dot'; import * as React from 'react'; -import { AutoSizer } from 'react-virtualized'; + +import { centerFit } from './graph-utils'; interface AdjacencyList { toColumn: string; @@ -38,54 +39,64 @@ interface GraphProps { fromCol?: string; } -export class Graph extends React.Component<GraphProps, {}> { - err: string; - constructor(props) { - super(props); - this.err = ''; - } +export const Graph = (props: GraphProps) => { + const { dot, toCol, fromCol, data } = props; + const [err, setErr] = React.useState(''); + const svgRef = React.useRef<SVGSVGElement>(null); + const ref = React.useRef({ + svgGroup: null, + renderer: null, + zoom: null, + baseSvg: null, + }); - dataToGraph = () => { + const dataToGraph = () => { const graph = new dagreD3.graphlib.Graph() .setGraph({}) .setDefaultEdgeLabel(() => ({})); - this.props.data.forEach((rb) => { + data.forEach((rb) => { // Filter out empty columns, because this will cause dagre to crash. - if (this.props.toCol !== '' && this.props.fromCol !== '') { - graph.setNode(rb[this.props.toCol], { label: rb[this.props.toCol] }); - graph.setNode(rb[this.props.fromCol], { label: rb[this.props.fromCol] }); - graph.setEdge(rb[this.props.fromCol], rb[this.props.toCol]); + if (toCol !== '' && fromCol !== '') { + graph.setNode(rb[toCol], { label: rb[toCol] }); + graph.setNode(rb[fromCol], { label: rb[fromCol] }); + graph.setEdge(rb[fromCol], rb[toCol]); } }); return graph; - } + }; - componentDidMount = () => { - const graph = this.props.dot ? dot.read(this.props.dot) : this.dataToGraph(); - const render = new dagreD3.render(); - const svg = d3.select<SVGGraphicsElement, any>('#pixie-graph svg'); - const svgGroup = svg.append('g'); + // Do this once to setup the component. + React.useLayoutEffect(() => { + const baseSvg = d3.select<SVGGraphicsElement, any>(svgRef.current); + const svgGroup = baseSvg.append('g'); + const zoom = d3.zoom().on('zoom', () => svgGroup.attr('transform', d3.event.transform)); + const renderer = new dagreD3.render(); + baseSvg.call(zoom); + ref.current = { svgGroup, baseSvg, zoom, renderer }; + }, []); + + React.useEffect(() => { + const graph = dot ? graphlibDot.read(dot) : dataToGraph(); + const { baseSvg, svgGroup, renderer, zoom } = ref.current; try { - render(svgGroup, graph); + renderer(svgGroup, graph); + setErr(''); } catch (error) { - this.err = 'Error rendering graph. Graph may display incorrectly.'; + setErr('Error rendering graph. Graph may display incorrectly.'); } - const bbox = svg.node().getBBox(); - svg.style('width', bbox.width) - .style('height', bbox.height); - } + // Center the graph + const rootBbox = svgRef.current.getBoundingClientRect(); + const groupBbox = svgGroup.node().getBBox(); - render() { - return ( - <AutoSizer>{({ height, width }) => { - return (<div id='pixie-graph' style={{ height, width }}> - <svg /> - {this.err !== '' ? <p>{this.err}</p> : null} - </div>); - }} - </AutoSizer> - ); - } -} + baseSvg.call(zoom.transform, centerFit(rootBbox, groupBbox)); + }, [dot, data]); + + return ( + <div className='pixie-graph-root'> + <svg className='pixie-graph-svg' ref={svgRef} /> + {err !== '' ? <p>{err}</p> : null} + </div> + ); +};