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
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': minor
|
||||
---
|
||||
|
||||
New DependencyGraph component added to core package.
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<text className={classes.text} textAnchor="middle">
|
||||
{label}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
@@ -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<SVGTextElement | null>(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 (
|
||||
<g>
|
||||
<rect
|
||||
className={classes.node}
|
||||
width={paddedWidth}
|
||||
height={paddedHeight}
|
||||
rx={10}
|
||||
/>
|
||||
<text
|
||||
ref={idRef}
|
||||
className={classes.text}
|
||||
y={paddedHeight / 2}
|
||||
x={paddedWidth / 2}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{id}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const BottomToTop = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.BOTTOM_TOP}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LeftToRight = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.LEFT_RIGHT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RightToLeft = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.RIGHT_LEFT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithLabels = () => {
|
||||
const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' }));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={edges}
|
||||
direction={Direction.LEFT_RIGHT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomNodes = () => {
|
||||
const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine'];
|
||||
const nodes = exampleNodes.map((node, index) => ({
|
||||
...node,
|
||||
description: 'Description text',
|
||||
color: colors[index],
|
||||
}));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={exampleEdges}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
renderNode={props => (
|
||||
<g>
|
||||
<rect width={200} height={100} rx={20} fill={props.node.color} />
|
||||
<text
|
||||
x={100}
|
||||
y={45}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="baseline"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{props.node.id}
|
||||
</text>
|
||||
<text
|
||||
x={100}
|
||||
y={55}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="hanging"
|
||||
>
|
||||
{props.node.description}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomLabels = () => {
|
||||
const colors = ['pink', 'coral', 'aqua'];
|
||||
const edges = exampleEdges.map((edge, index) => ({
|
||||
...edge,
|
||||
label: colors[index],
|
||||
color: colors[index],
|
||||
}));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={edges}
|
||||
labelPosition={LabelPosition.CENTER}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
renderLabel={props => (
|
||||
<g>
|
||||
<circle r={25} fill={props.edge.color} />
|
||||
<text x={0} y={0} textAnchor="middle" alignmentBaseline="middle">
|
||||
{props.edge.label}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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('<DependencyGraph />', () => {
|
||||
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(
|
||||
<DependencyGraph nodes={nodes} edges={edges} />,
|
||||
);
|
||||
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(
|
||||
<DependencyGraph nodes={nodes} edges={labeledEdges} />,
|
||||
);
|
||||
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) => (
|
||||
<g>
|
||||
<text>{props.node.id}</text>
|
||||
<circle data-testid={CUSTOM_TEST_ID} r={100} />
|
||||
</g>
|
||||
);
|
||||
const { getByText, findByTestId, container } = render(
|
||||
<DependencyGraph nodes={singleNode} edges={[]} renderNode={renderNode} />,
|
||||
);
|
||||
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) => (
|
||||
<g>
|
||||
<text>{props.edge.label}</text>
|
||||
<circle data-testid={CUSTOM_TEST_ID} r={100} />
|
||||
</g>
|
||||
);
|
||||
const { getByText, findByTestId, container } = render(
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={labeledEdge}
|
||||
renderLabel={renderLabel}
|
||||
/>,
|
||||
);
|
||||
const node = await findByTestId(CUSTOM_TEST_ID);
|
||||
expect(node).toBeInTheDocument();
|
||||
expect(container.querySelector('circle')).toBeInTheDocument();
|
||||
expect(getByText(labeledEdge[0].label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<SVGSVGElement> & {
|
||||
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<number>(100);
|
||||
const [containerHeight, setContainerHeight] = React.useState<number>(100);
|
||||
|
||||
const graph = React.useRef<dagre.graphlib.Graph<{}>>(
|
||||
new dagre.graphlib.Graph(),
|
||||
);
|
||||
const [graphWidth, setGraphWidth] = React.useState<number>(
|
||||
graph.current.graph()?.width || 0,
|
||||
);
|
||||
const [graphHeight, setGraphHeight] = React.useState<number>(
|
||||
graph.current.graph()?.height || 0,
|
||||
);
|
||||
const [graphNodes, setGraphNodes] = React.useState<string[]>([]);
|
||||
const [graphEdges, setGraphEdges] = React.useState<dagre.Edge[]>([]);
|
||||
|
||||
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<SVGSVGElement, null>(node);
|
||||
const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
|
||||
const zoom = d3Zoom
|
||||
.zoom<SVGSVGElement, null>()
|
||||
.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 (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
{...svgProps}
|
||||
width={maxWidth}
|
||||
height={maxHeight}
|
||||
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id={ARROW_MARKER_ID}
|
||||
viewBox="0 0 24 24"
|
||||
markerWidth="14"
|
||||
markerHeight="14"
|
||||
refX="16"
|
||||
refY="12"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path
|
||||
fill={theme.palette.textSubtle}
|
||||
d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"
|
||||
/>
|
||||
</marker>
|
||||
{defs}
|
||||
</defs>
|
||||
<g id={WORKSPACE_ID}>
|
||||
<svg
|
||||
width={graphWidth}
|
||||
height={graphHeight}
|
||||
y={maxHeight / 2 - graphHeight / 2}
|
||||
x={maxWidth / 2 - graphWidth / 2}
|
||||
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
|
||||
>
|
||||
{graphEdges.map(e => {
|
||||
const edge = graph.current.edge(e) as GraphEdge;
|
||||
if (!edge) return null;
|
||||
return (
|
||||
<Edge
|
||||
key={`${e.v}-${e.w}`}
|
||||
id={e}
|
||||
setEdge={setEdge}
|
||||
render={renderLabel}
|
||||
edge={edge}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graphNodes.map((id: string) => {
|
||||
const node = graph.current.node(id) as GraphNode;
|
||||
if (!node) return null;
|
||||
return (
|
||||
<Node
|
||||
key={id}
|
||||
setNode={setNode}
|
||||
render={renderNode}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<text>{props.edge.label}</text>
|
||||
));
|
||||
|
||||
const minProps = {
|
||||
points: [
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 20, y: 20 },
|
||||
],
|
||||
id,
|
||||
setEdge,
|
||||
renderElement,
|
||||
edge,
|
||||
};
|
||||
|
||||
const label = 'label';
|
||||
const edgeWithLabel = { ...edge, label };
|
||||
|
||||
describe('<Edge />', () => {
|
||||
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(<Edge {...minProps} />);
|
||||
expect(container.getElementsByTagName('g')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the supplied label element if label is present', () => {
|
||||
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes down edge properties to the render method if label is present', () => {
|
||||
const edgeWithRandomProp = { ...edge, label, randomProp: true };
|
||||
render(
|
||||
<Edge {...minProps} render={renderElement} edge={edgeWithRandomProp} />,
|
||||
);
|
||||
|
||||
expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
|
||||
});
|
||||
|
||||
it('calls setEdge with edge ID and actual label size after rendering', () => {
|
||||
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<T = any> = {
|
||||
id: dagre.Edge;
|
||||
edge: GraphEdge<T>;
|
||||
render?: RenderLabelFunction;
|
||||
setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
|
||||
};
|
||||
|
||||
const renderDefault = (props: RenderLabelProps) => <DefaultLabel {...props} />;
|
||||
|
||||
const createPath = d3Shape
|
||||
.line<EdgePoint>()
|
||||
.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<SVGGElement>(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 && (
|
||||
<path
|
||||
data-testid={EDGE_TEST_ID}
|
||||
className={classes.path}
|
||||
markerEnd={`url(#${ARROW_MARKER_ID})`}
|
||||
d={path}
|
||||
/>
|
||||
)}
|
||||
{labelProps.label ? (
|
||||
<g
|
||||
ref={labelRef}
|
||||
data-testid={LABEL_TEST_ID}
|
||||
className={classes.label}
|
||||
transform={`translate(${x},${y})`}
|
||||
>
|
||||
{render({ edge: labelProps })}
|
||||
</g>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<text>{props.node.id}</text>
|
||||
));
|
||||
|
||||
const minProps = {
|
||||
id: node.id,
|
||||
node,
|
||||
setNode,
|
||||
render: renderElement,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
describe('<Node />', () => {
|
||||
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(<Node {...minProps} />);
|
||||
expect(getByText(minProps.id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes down node properties to the render method', () => {
|
||||
const nodeWithRandomProp = { ...node, randomProp: true };
|
||||
render(<Node {...minProps} node={nodeWithRandomProp} />);
|
||||
|
||||
expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
|
||||
});
|
||||
|
||||
it('calls setNode with node ID and actual size after rendering', () => {
|
||||
const { getByText } = render(<Node {...minProps} />);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<T = any> = {
|
||||
node: GraphNode<T>;
|
||||
render?: RenderNodeFunction;
|
||||
setNode: dagre.graphlib.Graph['setNode'];
|
||||
};
|
||||
|
||||
const renderDefault = (props: RenderNodeProps) => <DefaultNode {...props} />;
|
||||
|
||||
export function Node({
|
||||
render = renderDefault,
|
||||
setNode,
|
||||
node,
|
||||
}: NodeComponentProps) {
|
||||
const { width, height, x = 0, y = 0, ...nodeProps } = node;
|
||||
const classes = useStyles();
|
||||
const nodeRef = React.useRef<SVGGElement | null>(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 (
|
||||
<g
|
||||
ref={nodeRef}
|
||||
data-testid={NODE_TEST_ID}
|
||||
className={classes.node}
|
||||
transform={`translate(${x - width / 2},${y - height / 2})`}
|
||||
>
|
||||
{render({ node: nodeProps })}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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 = CustomType> = T & {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type GraphEdge<T = CustomType> = DependencyEdge<T> &
|
||||
dagre.GraphEdge &
|
||||
EdgeProperties;
|
||||
|
||||
export type RenderLabelProps<T = CustomType> = { edge: DependencyEdge<T> };
|
||||
|
||||
export type RenderLabelFunction = (
|
||||
props: RenderLabelProps<any>,
|
||||
) => React.ReactNode;
|
||||
|
||||
/* Nodes */
|
||||
export type DependencyNode<T = CustomType> = T & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GraphNode<T = CustomType> = dagre.Node<DependencyNode<T>>;
|
||||
|
||||
export type RenderNodeProps<T = CustomType> = { node: DependencyNode<T> };
|
||||
|
||||
export type RenderNodeFunction = (
|
||||
props: RenderNodeProps<any>,
|
||||
) => 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',
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user