feat(dep-graph): add experimental collapse edges option (#9004)

This commit is contained in:
Philip Fulcher 2022-02-16 14:12:13 -07:00 committed by GitHub
parent 2f78f29483
commit 31d51c36fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 544 additions and 24 deletions

View File

@ -0,0 +1,12 @@
{
"fileServerFolder": ".",
"fixturesFolder": "./src/fixtures",
"integrationFolder": "./src/release-integration",
"modifyObstructiveCode": false,
"pluginsFile": "./src/plugins/index",
"supportFile": "./src/support/index.ts",
"video": true,
"videosFolder": "../../dist/cypress/dep-graph/client-e2e/videos",
"screenshotsFolder": "../../dist/cypress/dep-graph/client-e2e/screenshots",
"chromeWebSecurity": false
}

View File

@ -21,6 +21,15 @@
"baseUrl": "http://localhost:4200"
}
},
"e2e-release-disabled": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "dep-graph/client-e2e/cypress-release.json",
"tsConfig": "dep-graph/client-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-client:serve-for-e2e:release",
"baseUrl": "http://localhost:4200"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],

View File

@ -68,7 +68,7 @@ describe('dep-graph-client', () => {
describe('selecting a different project', () => {
it('should change the available projects', () => {
getProjectItems().should('have.length', 55);
getProjectItems().should('have.length', 53);
cy.get('[data-cy=project-select]').select('Ocean', { force: true });
getProjectItems().should('have.length', 124);
});
@ -77,14 +77,14 @@ describe('dep-graph-client', () => {
describe('select all button', () => {
it('should check all project items', () => {
getSelectAllButton().scrollIntoView().click({ force: true });
getCheckedProjectItems().should('have.length', 55);
getCheckedProjectItems().should('have.length', 53);
});
});
describe('deselect all button', () => {
it('should uncheck all project items', () => {
getDeselectAllButton().click();
getUncheckedProjectItems().should('have.length', 55);
getUncheckedProjectItems().should('have.length', 53);
getSelectProjectsMessage().should('be.visible');
});
});
@ -156,7 +156,7 @@ describe('dep-graph-client', () => {
cy.get('[data-project="nx-dev"]').prev('button').click({ force: true });
getUnfocusProjectButton().click();
getUncheckedProjectItems().should('have.length', 55);
getUncheckedProjectItems().should('have.length', 53);
});
});
@ -263,6 +263,6 @@ describe('loading dep-graph client with url params', () => {
// wait for first graph to finish loading
cy.wait('@getGraph');
getCheckedProjectItems().should('have.length', 55);
getCheckedProjectItems().should('have.length', 53);
});
});

View File

@ -0,0 +1,18 @@
describe('dep-graph-client release', () => {
beforeEach(() => {
cy.intercept('/assets/graphs/*').as('getGraph');
cy.visit('/');
// wait for first graph to finish loading
cy.wait('@getGraph');
});
it('should not display experimental features', () => {
cy.get('experimental-features').should('not.exist');
});
it('should not display the debugger', () => {
cy.get('debugger-panel').should('not.exist');
});
});

View File

@ -97,6 +97,12 @@
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts watch",
"nx serve-base dep-graph-client"
]
},
"release": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts release",
"nx serve-base dep-graph-client"
]
}
}
},
@ -117,6 +123,13 @@
"nx serve-base dep-graph-client"
],
"readyWhen": "No issues found."
},
"release": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts release",
"nx serve-base dep-graph-client"
],
"readyWhen": "No issues found."
}
}
}

View File

@ -17,7 +17,7 @@ export const DebuggerPanel = memo(function ({
}: DebuggerPanelProps) {
return (
<div
id="debugger-panel"
data-cy="debugger-panel"
className="
flex-column
flex

View File

@ -0,0 +1,18 @@
import { useEnvironmentConfig } from './hooks/use-environment-config';
function ExperimentalFeature(props) {
const environment = useEnvironmentConfig();
const showExperimentalFeatures =
environment.appConfig.showExperimentalFeatures;
return showExperimentalFeatures ? (
<div data-cy="experimental-features" className="bg-purple-200 pb-2">
<h3 className="mt-4 cursor-text px-4 py-2 text-sm font-semibold uppercase tracking-wide text-gray-900 lg:text-xs ">
Experimental Features
</h3>
{props.children}
</div>
) : null;
}
export default ExperimentalFeature;

View File

@ -22,6 +22,7 @@ export interface Environment {
export interface AppConfig {
showDebugger: boolean;
showExperimentalFeatures: boolean;
projectGraphs: ProjectGraphList[];
defaultProjectGraph: string;
}

View File

@ -23,6 +23,7 @@ export const initialContext: DepGraphContext = {
searchDepth: 1,
searchDepthEnabled: false,
groupByFolder: false,
collapseEdges: false,
workspaceLayout: {
libsDir: '',
appsDir: '',
@ -66,6 +67,7 @@ export const depGraphMachine = Machine<
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
collapseEdges: ctx.collapseEdges,
}),
{
to: (context) => context.graphActor,
@ -112,6 +114,39 @@ export const depGraphMachine = Machine<
focusProject: {
target: 'focused',
},
setCollapseEdges: {
actions: [
'setCollapseEdges',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
collapseEdges: ctx.collapseEdges,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graphActor,
}
),
send(
(ctx, event) => {
if (event.type !== 'setCollapseEdges') return;
return {
type: 'notifyRouteCollapseEdges',
collapseEdges: event.collapseEdges,
};
},
{
to: (context) => context.routeSetterActor,
}
),
],
},
setGroupByFolder: {
actions: [
'setGroupByFolder',
@ -123,6 +158,7 @@ export const depGraphMachine = Machine<
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
collapseEdges: ctx.collapseEdges,
selectedProjects: ctx.selectedProjects,
}),
{
@ -183,6 +219,11 @@ export const depGraphMachine = Machine<
ctx.groupByFolder = event.groupByFolder;
}),
setCollapseEdges: assign((ctx, event) => {
if (event.type !== 'setCollapseEdges') return;
ctx.collapseEdges = event.collapseEdges;
}),
incrementSearchDepth: assign((ctx) => {
ctx.searchDepthEnabled = true;
ctx.searchDepth = ctx.searchDepth + 1;

View File

@ -24,6 +24,7 @@ export class GraphService {
private renderGraph: cy.Core;
private openTooltip: Instance = null;
private collapseEdges = false;
constructor(
private tooltipService: GraphTooltipService,
@ -52,7 +53,8 @@ export class GraphService {
event.groupByFolder,
event.workspaceLayout,
event.dependencies,
event.affectedProjects
event.affectedProjects,
event.collapseEdges
);
break;
@ -62,7 +64,8 @@ export class GraphService {
event.groupByFolder,
event.workspaceLayout,
event.dependencies,
event.affectedProjects
event.affectedProjects,
event.collapseEdges
);
this.setShownProjects(
event.selectedProjects.length > 0
@ -112,9 +115,11 @@ export class GraphService {
};
if (this.renderGraph) {
this.renderGraph
.elements()
.sort((a, b) => a.id().localeCompare(b.id()))
const elements = this.renderGraph.elements().sort((a, b) => {
return a.id().localeCompare(b.id());
});
elements
.layout({
name: 'dagre',
nodeDimensionsIncludeLabels: true,
@ -125,6 +130,80 @@ export class GraphService {
} as CytoscapeDagreConfig)
.run();
if (this.collapseEdges) {
this.renderGraph.remove(this.renderGraph.edges());
elements.edges().forEach((edge) => {
const sourceNode = edge.source();
const targetNode = edge.target();
if (
sourceNode.parent().first().id() ===
targetNode.parent().first().id()
) {
this.renderGraph.add(edge);
} else {
let sourceAncestors, targetAncestors;
const commonAncestors = edge.connectedNodes().commonAncestors();
if (commonAncestors.length > 0) {
sourceAncestors = sourceNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
targetAncestors = targetNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
} else {
sourceAncestors = sourceNode.ancestors();
targetAncestors = targetNode.ancestors();
}
let sourceId, targetId;
if (sourceAncestors.length > 0 && targetAncestors.length === 0) {
sourceId = sourceAncestors.last().id();
targetId = targetNode.id();
} else if (
targetAncestors.length > 0 &&
sourceAncestors.length === 0
) {
sourceId = sourceNode.id();
targetId = targetAncestors.last().id();
} else {
sourceId = sourceAncestors.last().id();
targetId = targetAncestors.last().id();
}
if (sourceId !== undefined && targetId !== undefined) {
const edgeId = `${sourceId}|${targetId}`;
if (this.renderGraph.$id(edgeId).length === 0) {
const ancestorEdge: cy.EdgeDefinition = {
group: 'edges',
data: {
id: edgeId,
source: sourceId,
target: targetId,
},
};
this.renderGraph.add(ancestorEdge);
}
} else {
console.log(`Couldn't figure out how to draw edge ${edge.id()}`);
console.log(
'source ancestors',
sourceAncestors.map((anc) => anc.id())
);
console.log(
'target ancestors',
targetAncestors.map((anc) => anc.id())
);
}
}
});
}
this.renderGraph.fit().center().resize();
selectedProjectNames = this.renderGraph
@ -284,12 +363,14 @@ export class GraphService {
if (!!currentFocusedProjectName) {
this.renderGraph.$id(currentFocusedProjectName).addClass('focused');
}
this.renderGraph.on('zoom', () => {
if (this.openTooltip) {
this.openTooltip.hide();
this.openTooltip = null;
}
});
this.listenForProjectNodeClicks();
this.listenForProjectNodeHovers();
}
@ -330,8 +411,10 @@ export class GraphService {
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
affectedProjectIds: string[],
collapseEdges: boolean
) {
this.collapseEdges = collapseEdges;
this.tooltipService.hideAll();
this.generateCytoscapeLayout(

View File

@ -35,6 +35,7 @@ export type DepGraphUIEvents =
| { type: 'deselectAll' }
| { type: 'selectAffected' }
| { type: 'setGroupByFolder'; groupByFolder: boolean }
| { type: 'setCollapseEdges'; collapseEdges: boolean }
| { type: 'setIncludeProjectsByPath'; includeProjectsByPath: boolean }
| { type: 'incrementSearchDepth' }
| { type: 'decrementSearchDepth' }
@ -73,6 +74,7 @@ export type GraphRenderEvents =
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
}
| {
type: 'notifyGraphUpdateGraph';
@ -84,6 +86,7 @@ export type GraphRenderEvents =
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
selectedProjects: string[];
}
| {
@ -124,6 +127,10 @@ export type RouteEvents =
type: 'notifyRouteGroupByFolder';
groupByFolder: boolean;
}
| {
type: 'notifyRouteCollapseEdges';
collapseEdges: boolean;
}
| {
type: 'notifyRouteSearchDepth';
searchDepthEnabled: boolean;
@ -154,6 +161,7 @@ export interface DepGraphContext {
searchDepth: number;
searchDepthEnabled: boolean;
groupByFolder: boolean;
collapseEdges: boolean;
workspaceLayout: {
libsDir: string;
appsDir: string;

View File

@ -21,11 +21,10 @@ function parseSearchParamsToEvents(searchParams: string): DepGraphUIEvents[] {
case 'groupByFolder':
events.push({ type: 'setGroupByFolder', groupByFolder: true });
break;
case 'collapseEdges':
events.push({ type: 'setCollapseEdges', collapseEdges: true });
break;
case 'searchDepth':
// events.push({
// type: 'setSearchDepthEnabled',
// searchDepthEnabled: true,
// });
events.push({
type: 'setSearchDepth',
searchDepth: parseInt(value),

View File

@ -3,7 +3,12 @@ import { createBrowserHistory } from 'history';
import { Machine } from 'xstate';
import { RouteEvents } from './interfaces';
type ParamKeys = 'focus' | 'groupByFolder' | 'searchDepth' | 'select';
type ParamKeys =
| 'focus'
| 'groupByFolder'
| 'searchDepth'
| 'select'
| 'collapseEdges';
type ParamRecord = Record<ParamKeys, string | null>;
function reduceParamRecordToQueryString(params: ParamRecord): string {
@ -25,6 +30,7 @@ export const createRouteMachine = () => {
const paramRecord: ParamRecord = {
focus: params.get('focus'),
groupByFolder: params.get('groupByFolder'),
collapseEdges: params.get('collapseEdges'),
searchDepth: params.get('searchDepth'),
select: params.get('select'),
};
@ -48,6 +54,7 @@ export const createRouteMachine = () => {
groupByFolder: null,
searchDepth: null,
select: null,
collapseEdges: null,
},
},
always: {
@ -98,6 +105,11 @@ export const createRouteMachine = () => {
ctx.params.groupByFolder = event.groupByFolder ? 'true' : null;
}),
},
notifyRouteCollapseEdges: {
actions: assign((ctx, event) => {
ctx.params.collapseEdges = event.collapseEdges ? 'true' : null;
}),
},
notifyRouteSearchDepth: {
actions: assign((ctx, event) => {
ctx.params.searchDepth = event.searchDepthEnabled

View File

@ -39,6 +39,9 @@ export const includePathSelector: DepGraphSelector<boolean> = (state) =>
export const groupByFolderSelector: DepGraphSelector<boolean> = (state) =>
state.context.groupByFolder;
export const collapseEdgesSelector: DepGraphSelector<boolean> = (state) =>
state.context.collapseEdges;
export const textFilterSelector: DepGraphSelector<string> = (state) =>
state.context.textFilter;

View File

@ -0,0 +1,41 @@
import { memo } from 'react';
export interface CollapseEdgesPanelProps {
collapseEdges: boolean;
collapseEdgesChanged: (checked: boolean) => void;
}
export const CollapseEdgesPanel = memo(
({ collapseEdges, collapseEdgesChanged }: CollapseEdgesPanelProps) => {
return (
<div className="px-4">
<div className="flex items-start">
<div className="flex h-5 items-center">
<input
id="collapseEdges"
name="collapseEdges"
value="collapseEdges"
type="checkbox"
className="h-4 w-4 rounded border-gray-300"
onChange={(event) => collapseEdgesChanged(event.target.checked)}
checked={collapseEdges}
></input>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="collapseEdges"
className="cursor-pointer font-medium text-gray-700"
>
Collapse edges
</label>
<p className="text-gray-500">
Display edges between groups rather than libraries
</p>
</div>
</div>
</div>
);
}
);
export default CollapseEdgesPanel;

View File

@ -1,7 +1,9 @@
import { useCallback } from 'react';
import ExperimentalFeature from '../experimental-feature';
import { useDepGraphService } from '../hooks/use-dep-graph';
import { useDepGraphSelector } from '../hooks/use-dep-graph-selector';
import {
collapseEdgesSelector,
focusedProjectNameSelector,
groupByFolderSelector,
hasAffectedProjectsSelector,
@ -9,6 +11,7 @@ import {
searchDepthSelector,
textFilterSelector,
} from '../machines/selectors';
import CollapseEdgesPanel from './collapse-edges-panel';
import FocusedProjectPanel from './focused-project-panel';
import GroupByFolderPanel from './group-by-folder-panel';
import ProjectList from './project-list';
@ -24,6 +27,7 @@ export function Sidebar() {
const textFilter = useDepGraphSelector(textFilterSelector);
const hasAffectedProjects = useDepGraphSelector(hasAffectedProjectsSelector);
const groupByFolder = useDepGraphSelector(groupByFolderSelector);
const collapseEdges = useDepGraphSelector(collapseEdgesSelector);
function resetFocus() {
depGraphService.send({ type: 'unfocusProject' });
@ -52,6 +56,10 @@ export function Sidebar() {
depGraphService.send({ type: 'setGroupByFolder', groupByFolder: checked });
}
function collapseEdgesChanged(checked: boolean) {
depGraphService.send({ type: 'setCollapseEdges', collapseEdges: checked });
}
function incrementDepthFilter() {
depGraphService.send({ type: 'incrementSearchDepth' });
}
@ -174,6 +182,13 @@ export function Sidebar() {
incrementDepthFilter={incrementDepthFilter}
decrementDepthFilter={decrementDepthFilter}
></SearchDepth>
<ExperimentalFeature>
<CollapseEdgesPanel
collapseEdges={collapseEdges}
collapseEdgesChanged={collapseEdgesChanged}
></CollapseEdgesPanel>
</ExperimentalFeature>
</div>
<ProjectList></ProjectList>

View File

@ -5,6 +5,7 @@ window.useXstateInspect = false;
window.appConfig = {
showDebugger: true,
showExperimentalFeatures: true,
projectGraphs: [
{
id: 'nx',
@ -41,6 +42,11 @@ window.appConfig = {
label: 'Affected',
url: 'assets/graphs/affected.json',
},
{
id: 'collapsing-edges-testing',
label: 'Collapsing Edges',
url: 'assets/graphs/collapsing-edges-testing.json',
},
],
defaultProjectGraph: 'nx',
};

View File

@ -0,0 +1,17 @@
window.exclude = [];
window.watch = false;
window.environment = 'release';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: false,
projectGraphs: [
{
id: 'local',
label: 'local',
url: 'assets/graphs/nx-examples.json',
},
],
defaultProjectGraph: 'local',
};

View File

@ -5,6 +5,7 @@ window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: true,
projectGraphs: [
{
id: 'local',

View File

@ -0,0 +1,222 @@
{
"hash": "1c2b69586aa096dc5e42eb252d0b5bfb94f20dc969a1e7b6f381a3b13add6928",
"layout": {
"appsDir": "apps",
"libsDir": "libs"
},
"projects": [
{
"name": "web",
"type": "app",
"data": {
"tags": [],
"root": "apps/app1"
}
},
{
"name": "admin",
"type": "app",
"data": {
"tags": [],
"root": "apps/app2"
}
},
{
"name": "core-util-auth",
"type": "lib",
"data": {
"tags": [],
"root": "core/util-auth"
}
},
{
"name": "web-feature-home-page",
"type": "lib",
"data": {
"tags": [],
"root": "web/feature-homepage"
}
},
{
"name": "web-feature-search",
"type": "lib",
"data": {
"tags": [],
"root": "web/feature-search"
}
},
{
"name": "web-data-access",
"type": "lib",
"data": {
"tags": [],
"root": "web/feature-search"
}
},
{
"name": "admin-feature-users",
"type": "lib",
"data": {
"tags": [],
"root": "admin/feature-users"
}
},
{
"name": "admin-feature-billing",
"type": "lib",
"data": {
"tags": [],
"root": "admin/feature-billing"
}
},
{
"name": "admin-data-access",
"type": "lib",
"data": {
"tags": [],
"root": "admin/data-access"
}
},
{
"name": "shared-components-ui-button",
"type": "lib",
"data": {
"tags": [],
"root": "shared/components/ui-button"
}
},
{
"name": "shared-components-ui-form",
"type": "lib",
"data": {
"tags": [],
"root": "shared/components/ui-form"
}
},
{
"name": "shared-util",
"type": "lib",
"data": {
"tags": [],
"root": "shared/util"
}
}
],
"dependencies": {
"web": [
{
"type": "dynamic",
"source": "web",
"target": "web-feature-home-page"
},
{
"type": "dynamic",
"source": "web",
"target": "web-feature-search"
},
{
"type": "static",
"source": "web",
"target": "core-util-auth"
}
],
"admin": [
{
"type": "dynamic",
"source": "admin",
"target": "admin-feature-users"
},
{
"type": "dynamic",
"source": "admin",
"target": "admin-feature-billing"
},
{
"type": "static",
"source": "admin",
"target": "core-util-auth"
}
],
"web-feature-home-page": [
{
"type": "static",
"source": "web-feature-home-page",
"target": "web-data-access"
},
{
"type": "static",
"source": "web-feature-home-page",
"target": "shared-components-ui-button"
}
],
"web-feature-search": [
{
"type": "static",
"source": "web-feature-search",
"target": "web-data-access"
},
{
"type": "static",
"source": "web-feature-search",
"target": "shared-components-ui-button"
},
{
"type": "static",
"source": "web-feature-search",
"target": "shared-components-ui-form"
}
],
"web-data-access": [
{
"type": "static",
"source": "web-data-access",
"target": "core-util-auth"
}
],
"admin-feature-users": [
{
"type": "static",
"source": "admin-feature-users",
"target": "admin-data-access"
},
{
"type": "static",
"source": "admin-feature-users",
"target": "shared-components-ui-button"
}
],
"admin-feature-billing": [
{
"type": "static",
"source": "admin-feature-billing",
"target": "admin-data-access"
},
{
"type": "static",
"source": "admin-feature-billing",
"target": "shared-components-ui-button"
}
],
"admin-data-access": [
{
"type": "static",
"source": "admin-data-access",
"target": "core-util-auth"
}
],
"core-util-auth": [],
"shared-components-ui-button": [],
"shared-components-ui-form": [
{
"type": "static",
"source": "shared-components-ui-form",
"target": "shared-util"
}
],
"shared-util": []
},
"affected": [],
"changes": {
"added": []
}
}

View File

@ -1,6 +1,6 @@
// nx-ignore-next-line
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { AppConfig } from './app/models';
import { AppConfig } from './app/interfaces';
export declare global {
export interface Window {

View File

@ -132,7 +132,7 @@
"css-minimizer-webpack-plugin": "^3.1.1",
"cypress": "^9.1.0",
"cytoscape": "^3.18.2",
"cytoscape-dagre": "^2.3.2",
"cytoscape-dagre": "^2.4.0",
"cytoscape-popper": "^2.0.0",
"cz-conventional-changelog": "^3.0.2",
"cz-customizable": "^6.2.0",

View File

@ -64,6 +64,7 @@ function buildEnvironmentJs(
window.appConfig = {
showDebugger: false,
showExperimentalFeatures: false,
projectGraphs: [
{
id: 'local',

View File

@ -1,7 +1,7 @@
import { copyFileSync } from 'fs';
import { argv } from 'yargs';
type Mode = 'dev' | 'watch';
type Mode = 'dev' | 'watch' | 'release';
const mode = argv._[0];
console.log(`Setting up graph for ${mode}`);

View File

@ -10342,10 +10342,10 @@ cypress@^9.1.0:
url "^0.11.0"
yauzl "^2.10.0"
cytoscape-dagre@^2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.3.2.tgz"
integrity sha512-dL9+RvGkatSlIdOKXiFwHpnpTo8ydFMqIYzZFkImJXNbDci3feyYxR46wFoaG9GFiWimc6XD9Lm0x29b1wvWpw==
cytoscape-dagre@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/cytoscape-dagre/-/cytoscape-dagre-2.4.0.tgz#abf145b1c675afe3b7d531166e6727dc39dc350d"
integrity sha512-jfOtKzKduCnruBs3YMHS9kqWjZKqvp6loSJwlotPO5pcU4wLUhkx7ZBIyW3VWZXa8wfkGxv/zhWoBxWtYrUxKQ==
dependencies:
dagre "^0.8.5"