From 199237d2fb9d62263dffdc6325db1fd2905f34a3 Mon Sep 17 00:00:00 2001 From: Mikaela Grundin <9350337+zatine@users.noreply.github.com> Date: Fri, 6 Nov 2020 09:27:34 +0100 Subject: [PATCH] core: DependencyGraph component (#3236) * add dependency graph files * add stories for DependencyGraph * add changeset * add margin and border to storybook examples * support dark theme in default label * add transition to nodes, edges and labels * move @types to devDependencies * clean up DependencyGraph test * don't use default exports * change padding format * change marginX/Y to paddingX/Y * fix types export * fix storybook * move @types/dagre from devDependencies to dependencies --- .changeset/shaggy-bobcats-warn.md | 5 + packages/core/package.json | 8 + .../DependencyGraph/DefaultLabel.tsx | 35 ++ .../DependencyGraph/DefaultNode.tsx | 79 +++++ .../DependencyGraph.stories.tsx | 178 ++++++++++ .../DependencyGraph/DependencyGraph.test.tsx | 106 ++++++ .../DependencyGraph/DependencyGraph.tsx | 324 ++++++++++++++++++ .../components/DependencyGraph/Edge.test.tsx | 100 ++++++ .../src/components/DependencyGraph/Edge.tsx | 122 +++++++ .../components/DependencyGraph/Node.test.tsx | 79 +++++ .../src/components/DependencyGraph/Node.tsx | 76 ++++ .../components/DependencyGraph/constants.ts | 21 ++ .../src/components/DependencyGraph/index.ts | 20 ++ .../src/components/DependencyGraph/types.ts | 88 +++++ packages/core/src/components/index.ts | 1 + yarn.lock | 121 +++++++ 16 files changed, 1363 insertions(+) create mode 100644 .changeset/shaggy-bobcats-warn.md create mode 100644 packages/core/src/components/DependencyGraph/DefaultLabel.tsx create mode 100644 packages/core/src/components/DependencyGraph/DefaultNode.tsx create mode 100644 packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx create mode 100644 packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx create mode 100644 packages/core/src/components/DependencyGraph/DependencyGraph.tsx create mode 100644 packages/core/src/components/DependencyGraph/Edge.test.tsx create mode 100644 packages/core/src/components/DependencyGraph/Edge.tsx create mode 100644 packages/core/src/components/DependencyGraph/Node.test.tsx create mode 100644 packages/core/src/components/DependencyGraph/Node.tsx create mode 100644 packages/core/src/components/DependencyGraph/constants.ts create mode 100644 packages/core/src/components/DependencyGraph/index.ts create mode 100644 packages/core/src/components/DependencyGraph/types.ts diff --git a/.changeset/shaggy-bobcats-warn.md b/.changeset/shaggy-bobcats-warn.md new file mode 100644 index 0000000000..048e1a0ff6 --- /dev/null +++ b/.changeset/shaggy-bobcats-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': minor +--- + +New DependencyGraph component added to core package. diff --git a/packages/core/package.json b/packages/core/package.json index 43f48e0ece..0ddc77f1e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,10 +35,15 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/dagre": "^0.7.44", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", "classnames": "^2.2.6", "clsx": "^1.1.0", + "d3-selection": "^2.0.0", + "d3-shape": "^2.0.0", + "d3-zoom": "^2.0.0", + "dagre": "^0.8.5", "immer": "^7.0.9", "lodash": "^4.17.15", "material-table": "^1.69.1", @@ -63,6 +68,9 @@ "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/classnames": "^2.2.9", + "@types/d3-selection": "^2.0.0", + "@types/d3-shape": "^2.0.0", + "@types/d3-zoom": "^2.0.0", "@types/google-protobuf": "^3.7.2", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", diff --git a/packages/core/src/components/DependencyGraph/DefaultLabel.tsx b/packages/core/src/components/DependencyGraph/DefaultLabel.tsx new file mode 100644 index 0000000000..0679d1dae0 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/DefaultLabel.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import { BackstageTheme } from '@backstage/theme'; +import { RenderLabelProps } from './types'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + text: { + fill: theme.palette.textContrast, + }, +})); + +export function DefaultLabel({ edge: { label } }: RenderLabelProps) { + const classes = useStyles(); + return ( + + {label} + + ); +} diff --git a/packages/core/src/components/DependencyGraph/DefaultNode.tsx b/packages/core/src/components/DependencyGraph/DefaultNode.tsx new file mode 100644 index 0000000000..9656c860ae --- /dev/null +++ b/packages/core/src/components/DependencyGraph/DefaultNode.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { BackstageTheme } from '@backstage/theme'; +import { RenderNodeProps } from './types'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + node: { + fill: theme.palette.background.paper, + stroke: theme.palette.border, + }, + text: { + fill: theme.palette.textContrast, + }, +})); + +export function DefaultNode({ node: { id } }: RenderNodeProps) { + const classes = useStyles(); + const [width, setWidth] = React.useState(0); + const [height, setHeight] = React.useState(0); + const idRef = React.useRef(null); + + React.useLayoutEffect(() => { + // set the width to the length of the ID + if (idRef.current) { + let { + height: renderedHeight, + width: renderedWidth, + } = idRef.current.getBBox(); + renderedHeight = Math.round(renderedHeight); + renderedWidth = Math.round(renderedWidth); + + if (renderedHeight !== height || renderedWidth !== width) { + setWidth(renderedWidth); + setHeight(renderedHeight); + } + } + }, [width, height]); + + const padding = 10; + const paddedWidth = width + padding * 2; + const paddedHeight = height + padding * 2; + + return ( + + + + {id} + + + ); +} diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx new file mode 100644 index 0000000000..e39ecfc5da --- /dev/null +++ b/packages/core/src/components/DependencyGraph/DependencyGraph.stories.tsx @@ -0,0 +1,178 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { DependencyGraph } from './DependencyGraph'; +import { Direction, LabelPosition } from './types'; + +export default { + title: 'Data Display/DependencyGraph', + component: DependencyGraph, +}; + +const containerStyle = { width: '100%' }; +const graphStyle = { border: '1px solid grey' }; + +const exampleNodes = [ + { id: 'source' }, + { id: 'downstream' }, + { id: 'second-downstream' }, + { id: 'third-downstream' }, +]; + +const exampleEdges = [ + { from: 'source', to: 'downstream' }, + { from: 'downstream', to: 'second-downstream' }, + { from: 'downstream', to: 'third-downstream' }, +]; + +export const Default = () => ( +
+ +
+); + +export const BottomToTop = () => ( +
+ +
+); + +export const LeftToRight = () => ( +
+ +
+); + +export const RightToLeft = () => ( +
+ +
+); + +export const WithLabels = () => { + const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' })); + return ( +
+ +
+ ); +}; + +export const CustomNodes = () => { + const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine']; + const nodes = exampleNodes.map((node, index) => ({ + ...node, + description: 'Description text', + color: colors[index], + })); + return ( +
+ ( + + + + {props.node.id} + + + {props.node.description} + + + )} + /> +
+ ); +}; + +export const CustomLabels = () => { + const colors = ['pink', 'coral', 'aqua']; + const edges = exampleEdges.map((edge, index) => ({ + ...edge, + label: colors[index], + color: colors[index], + })); + return ( +
+ ( + + + + {props.edge.label} + + + )} + /> +
+ ); +}; diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx new file mode 100644 index 0000000000..526dc6d7dd --- /dev/null +++ b/packages/core/src/components/DependencyGraph/DependencyGraph.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { DependencyGraph } from './DependencyGraph'; +import { RenderLabelProps, RenderNodeProps } from './types'; +import { EDGE_TEST_ID, LABEL_TEST_ID, NODE_TEST_ID } from './constants'; + +describe('', () => { + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const edges = [ + { from: nodes[0].id, to: nodes[1].id }, + { from: nodes[1].id, to: nodes[2].id }, + ]; + + const CUSTOM_TEST_ID = 'custom-test-id'; + + it('renders each node and edge supplied', async () => { + const { getByText, queryAllByTestId, findAllByTestId } = render( + , + ); + const renderedNodes = await findAllByTestId(NODE_TEST_ID); + expect(renderedNodes).toHaveLength(3); + expect(getByText(nodes[0].id)).toBeInTheDocument(); + expect(getByText(nodes[1].id)).toBeInTheDocument(); + expect(getByText(nodes[2].id)).toBeInTheDocument(); + expect(queryAllByTestId(EDGE_TEST_ID)).toHaveLength(2); + expect(queryAllByTestId(LABEL_TEST_ID)).toHaveLength(0); + }); + + it('renders edge labels if present', async () => { + const labeledEdges = [ + { ...edges[0], label: 'first' }, + { ...edges[1], label: 'second' }, + ]; + const { getByText, getAllByTestId, findAllByTestId } = render( + , + ); + const renderedEdges = await findAllByTestId(EDGE_TEST_ID); + expect(renderedEdges).toHaveLength(2); + expect(getAllByTestId(LABEL_TEST_ID)).toHaveLength(2); + expect(getByText(labeledEdges[0].label)).toBeInTheDocument(); + expect(getByText(labeledEdges[1].label)).toBeInTheDocument(); + }); + + it('renders nodes according to renderNode prop', async () => { + const singleNode = [nodes[0]]; + + const renderNode = (props: RenderNodeProps) => ( + + {props.node.id} + + + ); + const { getByText, findByTestId, container } = render( + , + ); + const node = await findByTestId(CUSTOM_TEST_ID); + expect(node).toBeInTheDocument(); + expect(container.querySelector('circle')).toBeInTheDocument(); + expect(getByText(singleNode[0].id)).toBeInTheDocument(); + }); + + it('renders labels according to renderLabel prop', async () => { + const labeledEdge = [{ ...edges[0], label: 'label' }]; + + const renderLabel = (props: RenderLabelProps) => ( + + {props.edge.label} + + + ); + const { getByText, findByTestId, container } = render( + , + ); + const node = await findByTestId(CUSTOM_TEST_ID); + expect(node).toBeInTheDocument(); + expect(container.querySelector('circle')).toBeInTheDocument(); + expect(getByText(labeledEdge[0].label)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core/src/components/DependencyGraph/DependencyGraph.tsx new file mode 100644 index 0000000000..ced0bdf276 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/DependencyGraph.tsx @@ -0,0 +1,324 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import * as d3Zoom from 'd3-zoom'; +import * as d3Selection from 'd3-selection'; +import useTheme from '@material-ui/core/styles/useTheme'; +import dagre from 'dagre'; +import debounce from 'lodash/debounce'; +import { BackstageTheme } from '@backstage/theme'; +import { + DependencyEdge, + DependencyNode, + Direction, + Alignment, + Ranker, + RenderNodeFunction, + RenderLabelFunction, + GraphEdge, + GraphNode, + LabelPosition, +} from './types'; +import { Node } from './Node'; +import { Edge } from './Edge'; +import { ARROW_MARKER_ID } from './constants'; + +export type DependencyGraphProps = React.SVGProps & { + edges: DependencyEdge[]; + nodes: DependencyNode[]; + direction?: Direction; + align?: Alignment; + nodeMargin?: number; + edgeMargin?: number; + rankMargin?: number; + paddingX?: number; + paddingY?: number; + acyclicer?: 'greedy'; + ranker?: Ranker; + labelPosition?: LabelPosition; + labelOffset?: number; + edgeRanks?: number; + edgeWeight?: number; + renderNode?: RenderNodeFunction; + renderLabel?: RenderLabelFunction; + defs?: SVGDefsElement | SVGDefsElement[]; +}; + +const WORKSPACE_ID = 'workspace'; + +export function DependencyGraph({ + edges, + nodes, + renderNode, + direction = Direction.TOP_BOTTOM, + align, + nodeMargin = 50, + edgeMargin = 10, + rankMargin = 50, + paddingX = 0, + paddingY = 0, + acyclicer, + ranker = Ranker.NETWORK_SIMPLEX, + labelPosition = LabelPosition.RIGHT, + labelOffset = 10, + edgeRanks = 1, + edgeWeight = 1, + renderLabel, + defs, + ...svgProps +}: DependencyGraphProps) { + const theme: BackstageTheme = useTheme(); + const [containerWidth, setContainerWidth] = React.useState(100); + const [containerHeight, setContainerHeight] = React.useState(100); + + const graph = React.useRef>( + new dagre.graphlib.Graph(), + ); + const [graphWidth, setGraphWidth] = React.useState( + graph.current.graph()?.width || 0, + ); + const [graphHeight, setGraphHeight] = React.useState( + graph.current.graph()?.height || 0, + ); + const [graphNodes, setGraphNodes] = React.useState([]); + const [graphEdges, setGraphEdges] = React.useState([]); + + const maxWidth = Math.max(graphWidth, containerWidth); + const maxHeight = Math.max(graphHeight, containerHeight); + + const containerRef = React.useMemo( + () => + debounce((node: SVGSVGElement) => { + if (!node) { + return; + } + // Set up zooming + panning + const container = d3Selection.select(node); + const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID)); + const zoom = d3Zoom + .zoom() + .scaleExtent([1, 10]) + .on('zoom', event => { + event.transform.x = Math.min( + 0, + Math.max( + event.transform.x, + maxWidth - maxWidth * event.transform.k, + ), + ); + event.transform.y = Math.min( + 0, + Math.max( + event.transform.y, + maxHeight - maxHeight * event.transform.k, + ), + ); + workspace.attr('transform', event.transform); + }); + + container.call(zoom); + + const { + width: newContainerWidth, + height: newContainerHeight, + } = node.getBoundingClientRect(); + if (containerWidth !== newContainerWidth) { + setContainerWidth(newContainerWidth); + } + if (containerHeight !== newContainerHeight) { + setContainerHeight(newContainerHeight); + } + }, 100), + [containerHeight, containerWidth, maxWidth, maxHeight], + ); + + const setNodesAndEdges = React.useCallback(() => { + // Cleaning up lingering nodes and edges + const currentGraphNodes = graph.current.nodes(); + const currentGraphEdges = graph.current.edges(); + + currentGraphNodes.forEach(nodeId => { + const remainingNode = nodes.some(node => node.id === nodeId); + if (!remainingNode) { + graph.current.removeNode(nodeId); + } + }); + + currentGraphEdges.forEach(e => { + const remainingEdge = edges.some( + edge => edge.from === e.v && edge.to === e.w, + ); + if (!remainingEdge) { + graph.current.removeEdge(e.v, e.w); + } + }); + + // Adding/updating nodes and edges + nodes.forEach(node => { + const existingNode = graph.current + .nodes() + .find(nodeId => node.id === nodeId); + + if (existingNode) { + const { width, height, x, y } = graph.current.node(existingNode); + graph.current.setNode(existingNode, { ...node, width, height, x, y }); + } else { + graph.current.setNode(node.id, { ...node, width: 0, height: 0 }); + } + }); + + edges.forEach(e => { + graph.current.setEdge(e.from, e.to, { + ...e, + label: e.label, + width: 0, + height: 0, + labelpos: labelPosition, + labeloffset: labelOffset, + weight: edgeWeight, + minlen: edgeRanks, + }); + }); + }, [edges, nodes, labelPosition, labelOffset, edgeWeight, edgeRanks]); + + const updateGraph = React.useMemo( + () => + debounce( + () => { + dagre.layout(graph.current); + const { height, width } = graph.current.graph(); + const newHeight = Math.max(0, height || 0); + const newWidth = Math.max(0, width || 0); + setGraphWidth(newWidth); + setGraphHeight(newHeight); + + setGraphNodes(graph.current.nodes()); + setGraphEdges(graph.current.edges()); + }, + 250, + { leading: true }, + ), + [], + ); + + React.useEffect(() => { + graph.current.setGraph({ + rankdir: direction, + align, + nodesep: nodeMargin, + edgesep: edgeMargin, + ranksep: rankMargin, + marginx: paddingX, + marginy: paddingY, + acyclicer, + ranker, + }); + + setNodesAndEdges(); + updateGraph(); + + return updateGraph.cancel; + }, [ + acyclicer, + align, + direction, + edgeMargin, + paddingX, + paddingY, + nodeMargin, + rankMargin, + ranker, + setNodesAndEdges, + updateGraph, + ]); + + function setNode(id: string, node: DependencyNode) { + graph.current.setNode(id, node); + updateGraph(); + return graph.current; + } + + function setEdge(id: dagre.Edge, edge: DependencyEdge) { + graph.current.setEdge(id, edge); + updateGraph(); + return graph.current; + } + + return ( + + + + + + {defs} + + + + {graphEdges.map(e => { + const edge = graph.current.edge(e) as GraphEdge; + if (!edge) return null; + return ( + + ); + })} + {graphNodes.map((id: string) => { + const node = graph.current.node(id) as GraphNode; + if (!node) return null; + return ( + + ); + })} + + + + ); +} diff --git a/packages/core/src/components/DependencyGraph/Edge.test.tsx b/packages/core/src/components/DependencyGraph/Edge.test.tsx new file mode 100644 index 0000000000..b651c17bf0 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/Edge.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { Edge } from './Edge'; +import { RenderLabelProps } from './types'; + +const fromNode = 'node'; +const toNode = 'other-node'; + +const edge = { + from: fromNode, + to: toNode, +}; + +const id = { + v: fromNode, + w: toNode, +}; + +const setEdge = jest.fn(); +const renderElement = jest.fn((props: RenderLabelProps) => ( + {props.edge.label} +)); + +const minProps = { + points: [ + { x: 10, y: 20 }, + { x: 20, y: 20 }, + ], + id, + setEdge, + renderElement, + edge, +}; + +const label = 'label'; +const edgeWithLabel = { ...edge, label }; + +describe('', () => { + beforeEach(() => { + // jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement + Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + afterEach(jest.clearAllMocks); + + it('does not render the supplied label element if label is missing', () => { + const { container } = render(); + expect(container.getElementsByTagName('g')).toHaveLength(0); + }); + + it('renders the supplied label element if label is present', () => { + const { getByText } = render(); + expect(getByText(label)).toBeInTheDocument(); + }); + + it('passes down edge properties to the render method if label is present', () => { + const edgeWithRandomProp = { ...edge, label, randomProp: true }; + render( + , + ); + + expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp }); + }); + + it('calls setEdge with edge ID and actual label size after rendering', () => { + const { getByText } = render(); + expect(getByText(label)).toBeInTheDocument(); + + // Updates the edge in the graph + expect(setEdge).toHaveBeenCalledWith(id, { + height: 100, + width: 100, + ...edgeWithLabel, + }); + + // Does not pass down width/height to label + expect(renderElement).not.toHaveBeenCalledWith( + expect.objectContaining({ height: 100, width: 100 }), + ); + }); +}); diff --git a/packages/core/src/components/DependencyGraph/Edge.tsx b/packages/core/src/components/DependencyGraph/Edge.tsx new file mode 100644 index 0000000000..5e67aca63f --- /dev/null +++ b/packages/core/src/components/DependencyGraph/Edge.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import * as d3Shape from 'd3-shape'; +import isFinite from 'lodash/isFinite'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import { BackstageTheme } from '@backstage/theme'; +import { + GraphEdge, + RenderLabelProps, + RenderLabelFunction, + DependencyEdge, +} from './types'; +import { ARROW_MARKER_ID, EDGE_TEST_ID, LABEL_TEST_ID } from './constants'; +import { DefaultLabel } from './DefaultLabel'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + path: { + strokeWidth: 2, + stroke: theme.palette.textSubtle, + fill: 'none', + transition: `${theme.transitions.duration.shortest}ms`, + }, + label: { + transition: `${theme.transitions.duration.shortest}ms`, + }, +})); + +type EdgePoint = dagre.GraphEdge['points'][0]; + +export type EdgeComponentProps = { + id: dagre.Edge; + edge: GraphEdge; + render?: RenderLabelFunction; + setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>; +}; + +const renderDefault = (props: RenderLabelProps) => ; + +const createPath = d3Shape + .line() + .x(d => d.x) + .y(d => d.y) + .curve(d3Shape.curveMonotoneX); + +export function Edge({ + render = renderDefault, + setEdge, + id, + edge, +}: EdgeComponentProps) { + const { x = 0, y = 0, width, height, points, ...labelProps } = edge; + const classes = useStyles(); + + const labelRef = React.useRef(null); + + React.useLayoutEffect(() => { + // set the label width to the actual rendered width to properly layout graph + if (labelRef.current) { + let { + height: renderedHeight, + width: renderedWidth, + } = labelRef.current.getBBox(); + renderedHeight = Math.round(renderedHeight); + renderedWidth = Math.round(renderedWidth); + + if (renderedHeight !== height || renderedWidth !== width) { + setEdge(id, { + ...edge, + height: renderedHeight, + width: renderedWidth, + }); + } + } + }, [edge, height, width, setEdge, id]); + + let path: string = ''; + + if (points) { + const finitePoints = points.filter( + (point: EdgePoint) => isFinite(point.x) && isFinite(point.y), + ); + path = createPath(finitePoints) || ''; + } + + return ( + <> + {path && ( + + )} + {labelProps.label ? ( + + {render({ edge: labelProps })} + + ) : null} + + ); +} diff --git a/packages/core/src/components/DependencyGraph/Node.test.tsx b/packages/core/src/components/DependencyGraph/Node.test.tsx new file mode 100644 index 0000000000..9f9f7c693a --- /dev/null +++ b/packages/core/src/components/DependencyGraph/Node.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import dagre from 'dagre'; +import { render } from '@testing-library/react'; +import { Node } from './Node'; +import { RenderNodeProps } from './types'; + +const node = { id: 'abc' }; +const setNode = jest.fn(() => new dagre.graphlib.Graph()); +const renderElement = jest.fn((props: RenderNodeProps) => ( + {props.node.id} +)); + +const minProps = { + id: node.id, + node, + setNode, + render: renderElement, + x: 0, + y: 0, + width: 0, + height: 0, +}; + +describe('', () => { + beforeEach(() => { + // jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement + Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + afterEach(jest.clearAllMocks); + + it('renders the supplied element', () => { + const { getByText } = render(); + expect(getByText(minProps.id)).toBeInTheDocument(); + }); + + it('passes down node properties to the render method', () => { + const nodeWithRandomProp = { ...node, randomProp: true }; + render(); + + expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp }); + }); + + it('calls setNode with node ID and actual size after rendering', () => { + const { getByText } = render(); + expect(getByText(minProps.id)).toBeInTheDocument(); + + // Updates the node in the graph + expect(setNode).toHaveBeenCalledWith(node.id, { + height: 100, + width: 100, + ...node, + }); + + // Does not pass down width/height to node + expect(renderElement).not.toHaveBeenCalledWith( + expect.objectContaining({ height: 100, width: 100 }), + ); + }); +}); diff --git a/packages/core/src/components/DependencyGraph/Node.tsx b/packages/core/src/components/DependencyGraph/Node.tsx new file mode 100644 index 0000000000..4d64e31335 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/Node.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import { DefaultNode } from './DefaultNode'; +import { RenderNodeFunction, RenderNodeProps, GraphNode } from './types'; +import { NODE_TEST_ID } from './constants'; + +const useStyles = makeStyles(theme => ({ + node: { + transition: `${theme.transitions.duration.shortest}ms`, + }, +})); + +export type NodeComponentProps = { + node: GraphNode; + render?: RenderNodeFunction; + setNode: dagre.graphlib.Graph['setNode']; +}; + +const renderDefault = (props: RenderNodeProps) => ; + +export function Node({ + render = renderDefault, + setNode, + node, +}: NodeComponentProps) { + const { width, height, x = 0, y = 0, ...nodeProps } = node; + const classes = useStyles(); + const nodeRef = React.useRef(null); + + React.useLayoutEffect(() => { + // set the node width to the actual rendered width to properly layout graph + if (nodeRef.current) { + let { + height: renderedHeight, + width: renderedWidth, + } = nodeRef.current.getBBox(); + renderedHeight = Math.round(renderedHeight); + renderedWidth = Math.round(renderedWidth); + + if (renderedHeight !== height || renderedWidth !== width) { + setNode(node.id, { + ...node, + height: renderedHeight, + width: renderedWidth, + }); + } + } + }, [node, width, height, setNode]); + + return ( + + {render({ node: nodeProps })} + + ); +} diff --git a/packages/core/src/components/DependencyGraph/constants.ts b/packages/core/src/components/DependencyGraph/constants.ts new file mode 100644 index 0000000000..412a677f87 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const ARROW_MARKER_ID = 'arrow-marker'; + +export const NODE_TEST_ID = 'node'; +export const EDGE_TEST_ID = 'edge'; +export const LABEL_TEST_ID = 'label'; diff --git a/packages/core/src/components/DependencyGraph/index.ts b/packages/core/src/components/DependencyGraph/index.ts new file mode 100644 index 0000000000..9a4f0071e3 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as DependencyGraphTypes from './types'; + +export { DependencyGraph } from './DependencyGraph'; +export { DependencyGraphTypes }; diff --git a/packages/core/src/components/DependencyGraph/types.ts b/packages/core/src/components/DependencyGraph/types.ts new file mode 100644 index 0000000000..19173db4b3 --- /dev/null +++ b/packages/core/src/components/DependencyGraph/types.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import dagre from 'dagre'; + +type CustomType = { [customKey: string]: any }; + +/* Edges */ +export type DependencyEdge = T & { + from: string; + to: string; + label?: string; +}; + +export type GraphEdge = DependencyEdge & + dagre.GraphEdge & + EdgeProperties; + +export type RenderLabelProps = { edge: DependencyEdge }; + +export type RenderLabelFunction = ( + props: RenderLabelProps, +) => React.ReactNode; + +/* Nodes */ +export type DependencyNode = T & { + id: string; +}; + +export type GraphNode = dagre.Node>; + +export type RenderNodeProps = { node: DependencyNode }; + +export type RenderNodeFunction = ( + props: RenderNodeProps, +) => React.ReactNode; + +/* Based on: https://github.com/dagrejs/dagre/wiki#configuring-the-layout */ + +export type EdgeProperties = { + label?: string; + width?: number; + height?: number; + labeloffset?: number; + labelpos?: LabelPosition; + minlen?: number; + weight?: number; + [customKey: string]: any; +}; + +export enum Direction { + TOP_BOTTOM = 'TB', + BOTTOM_TOP = 'BT', + LEFT_RIGHT = 'LR', + RIGHT_LEFT = 'RL', +} + +export enum Alignment { + UP_LEFT = 'UL', + UP_RIGHT = 'UR', + DOWN_LEFT = 'DL', + DOWN_RIGHT = 'DR', +} + +export enum Ranker { + NETWORK_SIMPLEX = 'network-simplex', + TIGHT_TREE = 'tight-tree', + LONGEST_PATH = 'longest-path', +} + +export enum LabelPosition { + LEFT = 'l', + RIGHT = 'r', + CENTER = 'c', +} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 40c4ef0c11..bcc56debea 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -18,6 +18,7 @@ export * from './AlertDisplay'; export * from './Button'; export * from './CodeSnippet'; export * from './CopyTextButton'; +export * from './DependencyGraph'; export * from './DismissableBanner'; export * from './FeatureDiscovery'; export * from './HorizontalScrollGrid'; diff --git a/yarn.lock b/yarn.lock index 8ee1728b30..c265aecd6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4982,16 +4982,38 @@ dependencies: postcss "5 - 7" +"@types/d3-color@*": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.0.tgz#febdfadade56e215a4c3f612fe3000d92999f5d5" + integrity sha512-Bs0maTeU47rdZT+n42iQ0C4gnbnJlIDJkqHFtIsDx2tPPITDeoSdIrm+00UYXzegzArYC2GsG80eHNMwz08IAw== + "@types/d3-force@^1.2.1": version "1.2.1" resolved "https://registry.npmjs.org/@types/d3-force/-/d3-force-1.2.1.tgz#c28803ea36fe29788db69efa0ad6c2dc09544e83" integrity sha512-jqK+I36uz4kTBjyk39meed5y31Ab+tXYN/x1dn3nZEus9yOHCLc+VrcIYLc/aSQ0Y7tMPRlIhLetulME76EiiA== +"@types/d3-interpolate@*": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.0.tgz#325029216dc722c1c68c33ccda759f1209d35823" + integrity sha512-Wt1v2zTlEN8dSx8hhx6MoOhWQgTkz0Ukj7owAEIOF2QtI0e219paFX9rf/SLOr/UExWb1TcUzatU8zWwFby6gg== + dependencies: + "@types/d3-color" "*" + "@types/d3-path@*": version "1.0.8" resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79" integrity sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA== +"@types/d3-path@^1": + version "1.0.9" + resolved "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz#73526b150d14cd96e701597cbf346cfd1fd4a58c" + integrity sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ== + +"@types/d3-selection@*", "@types/d3-selection@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.0.tgz#59df94a8e47ed1050a337d4ffb4d4d213aa590a8" + integrity sha512-EF0lWZ4tg7oDFg4YQFlbOU3936e3a9UmoQ2IXlBy1+cv2c2Pv7knhKUzGlH5Hq2sF/KeDTH1amiRPey2rrLMQA== + "@types/d3-shape@*": version "1.3.2" resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz#a41d9d6b10d02e221696b240caf0b5d0f5a588ec" @@ -4999,6 +5021,26 @@ dependencies: "@types/d3-path" "*" +"@types/d3-shape@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.0.0.tgz#61aa065726f3c2641aedc59c3603475ab11aeb2f" + integrity sha512-NLzD02m5PiD1KLEDjLN+MtqEcFYn4ZL9+Rqc9ZwARK1cpKZXd91zBETbe6wpBB6Ia0D0VZbpmbW3+BsGPGnCpA== + dependencies: + "@types/d3-path" "^1" + +"@types/d3-zoom@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.0.tgz#ef8b87464e8ebc7c66b70f6383d1ae841e78e7fc" + integrity sha512-daL0PJm4yT0ISTGa7p2lHX0kvv9FO/IR1ooWbHR/7H4jpbaKiLux5FslyS/OvISPiJ5SXb4sOqYhO6fMB6hKRw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/dagre@^0.7.44": + version "0.7.44" + resolved "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.44.tgz#8f4b796b118ca29c132da7068fbc0d0351ee5851" + integrity sha512-N6HD+79w77ZVAaVO7JJDW5yJ9LAxM62FpgNGO9xEde+KVYjDRyhIMzfiErXpr1g0JPon9kwlBzoBK6s4fOww9Q== + "@types/diff@^4.0.2": version "4.0.2" resolved "https://registry.npmjs.org/@types/diff/-/diff-4.0.2.tgz#2e9bb89f9acc3ab0108f0f3dc4dbdcf2fff8a99c" @@ -9645,11 +9687,29 @@ d3-color@1: resolved "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a" integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q== +"d3-color@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" + integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== + "d3-dispatch@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== +d3-drag@2: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz#9eaf046ce9ed1c25c88661911c1d5a4d8eb7ea6d" + integrity sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w== + dependencies: + d3-dispatch "1 - 2" + d3-selection "2" + +"d3-ease@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz#fd1762bfca00dae4bacea504b1d628ff290ac563" + integrity sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ== + d3-force@^2.0.1: version "2.1.1" resolved "https://registry.npmjs.org/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" @@ -9671,11 +9731,23 @@ d3-interpolate@1, d3-interpolate@^1.3.0: dependencies: d3-color "1" +"d3-interpolate@1 - 2": + version "2.0.1" + resolved "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" + integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== + dependencies: + d3-color "1 - 2" + d3-path@1: version "1.0.9" resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== +"d3-path@1 - 2": + version "2.0.0" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz#55d86ac131a0548adae241eebfb56b4582dd09d8" + integrity sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA== + "d3-quadtree@1 - 2": version "2.0.0" resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" @@ -9693,6 +9765,11 @@ d3-scale@^2.1.0: d3-time "1" d3-time-format "2" +d3-selection@2, d3-selection@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz#94a11638ea2141b7565f883780dabc7ef6a61066" + integrity sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA== + d3-shape@^1.2.0: version "1.3.7" resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -9700,6 +9777,13 @@ d3-shape@^1.2.0: dependencies: d3-path "1" +d3-shape@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-2.0.0.tgz#2331b62fa784a2a1daac47a7233cfd69301381fd" + integrity sha512-djpGlA779ua+rImicYyyjnOjeubyhql1Jyn1HK0bTyawuH76UQRWXd+pftr67H6Fa8hSwetkgb/0id3agKWykw== + dependencies: + d3-path "1 - 2" + d3-time-format@2: version "2.3.0" resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz#107bdc028667788a8924ba040faf1fbccd5a7850" @@ -9717,6 +9801,28 @@ d3-time@1: resolved "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== +d3-transition@2: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz#366ef70c22ef88d1e34105f507516991a291c94c" + integrity sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog== + dependencies: + d3-color "1 - 2" + d3-dispatch "1 - 2" + d3-ease "1 - 2" + d3-interpolate "1 - 2" + d3-timer "1 - 2" + +d3-zoom@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz#f04d0afd05518becce879d04709c47ecd93fba54" + integrity sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw== + dependencies: + d3-dispatch "1 - 2" + d3-drag "2" + d3-interpolate "1 - 2" + d3-selection "2" + d3-transition "2" + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -9725,6 +9831,14 @@ d@1, d@^1.0.1: es5-ext "^0.10.50" type "^1.0.1" +dagre@^0.8.5: + version "0.8.5" + resolved "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz#ba30b0055dac12b6c1fcc247817442777d06afee" + integrity sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw== + dependencies: + graphlib "^2.1.8" + lodash "^4.17.15" + damerau-levenshtein@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" @@ -12568,6 +12682,13 @@ graphiql@^1.0.0-alpha.10: regenerator-runtime "^0.13.5" theme-ui "^0.3.1" +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + graphql-config@^3.0.2: version "3.0.3" resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.3.tgz#58907c65ed7d6e04132321450b60e57863ea9a5f"