chore(docs): add docs graph component (#12929)

This commit is contained in:
Philip Fulcher 2022-11-02 09:23:08 -06:00 committed by GitHub
parent d103cd9d51
commit b23d8e911b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1121 additions and 180 deletions

View File

@ -150,3 +150,77 @@ src="https://www.youtube.com/embed/rNImFxo9gYs"
title="Nx Console Run UI Form"
width="100%" /%}
```
#### Graph
Embed an Nx Graph visualization that can be panned by the user.
````markdown
{% graph height="450px" %}
```json
{
"projects": [
{
"type": "app",
"name": "app-changed",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib2",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib3",
"data": {
"tags": ["scope:cart"]
}
}
],
"groupByFolder": false,
"workspaceLayout": {
"appsDir": "apps",
"libsDir": "libs"
},
"dependencies": {
"app-changed": [
{
"target": "lib",
"source": "app-changed",
"type": "direct"
}
],
"lib": [
{
"target": "lib2",
"source": "lib",
"type": "implicit"
},
{
"target": "lib3",
"source": "lib",
"type": "direct"
}
],
"lib2": [],
"lib3": []
},
"affectedProjectIds": []
}
```
{% /graph %}
````

View File

@ -1,6 +1,9 @@
# Explore the Graph
For Nx to run tasks quickly and correctly, it creates a graph of the dependencies between all the projects in the repository. Exploring this graph visually can be useful to understand why Nx is behaving in a certain way and to get a high level view of your code architecture.
For Nx to run tasks quickly and correctly, it creates a graph of the dependencies between all the projects in the
repository. Exploring this graph visually can be useful
to understand why Nx is behaving in a certain way and to get a
high level view of your code architecture.
To launch the project graph visualization run:
@ -8,12 +11,16 @@ To launch the project graph visualization run:
nx graph
```
This will open a browser window with an interactive representation of the project graph of your current codebase. Viewing the entire graph can be unmanageable even for smaller repositories, so there are several ways to narrow the focus of the visualization down to the most useful part of the graph at the moment.
This will open a browser window with an interactive representation of the project graph of your current codebase.
Viewing the entire graph can be unmanageable even for smaller repositories, so there are several ways to narrow the
focus of the visualization down to the most useful part of the graph at the moment.
1. Focus on a specific project and then use the proximity and group by folder controls to modify the graph around that project.
1. Focus on a specific project and then use the proximity and group by folder controls to modify the graph around that
project.
2. Use the search bar to find all projects with names that contain a certain string.
3. Manually hide or show projects in the sidebar
Once the graph is displayed, you can click on an individual dependency link to find out what specific file(s) created that dependency.
Once the graph is displayed, you can click on an individual dependency link to find out what specific file(s) created
that dependency.
![Project Graph screenshot](../images/project-graph.png)

View File

@ -1,7 +1,7 @@
import Tag from './ui-components/tag';
export interface EdgeNodeTooltipProps {
type: 'static' | 'dynamic' | 'implicit';
type: string;
source: string;
target: string;
fileDependencies: Array<{ fileName: string }>;

View File

@ -1,11 +1,15 @@
import { getTooltipService } from '../tooltip-service';
import { GraphService } from './graph';
import { GraphService } from '@nrwl/graph/ui-graph';
import { selectValueByThemeStatic } from '../theme-resolver';
let graphService: GraphService;
export function getGraphService(): GraphService {
if (!graphService) {
graphService = new GraphService(getTooltipService(), 'cytoscape-graph');
const darkModeEnabled = selectValueByThemeStatic(true, false);
graphService = new GraphService(
'cytoscape-graph',
selectValueByThemeStatic('dark', 'light')
);
}
return graphService;

View File

@ -13,7 +13,7 @@ export function rankDirInit() {
export function rankDirResolver(rankDir: RankDir) {
currentRankDir = rankDir;
localStorage.setItem(localStorageRankDirKey, rankDir);
getGraphService().setRankDir(currentRankDir);
getGraphService().rankDir = currentRankDir;
}
export function selectValueByRankDirDynamic<T>(

View File

@ -1,28 +0,0 @@
import { NodeSingular } from 'cytoscape';
export class LabelWidthCalculator {
private cache: Map<string, number> = new Map<string, number>();
private ctx: CanvasRenderingContext2D;
calculateWidth(node: NodeSingular) {
if (!this.ctx) {
this.ctx = document.createElement('canvas').getContext('2d');
const fStyle = node.style('font-style');
const size = node.style('font-size');
const family = node.style('font-family');
const weight = node.style('font-weight');
this.ctx.font = fStyle + ' ' + weight + ' ' + size + ' ' + family;
}
const label = node.data('id');
if (this.cache.has(label)) {
return this.cache.get(label);
} else {
const width = this.ctx.measureText(node.data('id')).width;
this.cache.set(label, width);
return width;
}
}
}

View File

@ -36,7 +36,8 @@ export function themeResolver(theme: Theme) {
}
localStorage.setItem(localStorageThemeKey, theme);
getGraphService().evaluateStyles();
getGraphService().theme = currentTheme;
}
export function selectValueByThemeDynamic<T>(

View File

@ -1,10 +1,38 @@
import { getGraphService } from './machines/graph.service';
import { VirtualElement } from '@popperjs/core';
import { ProjectNodeToolTipProps } from './project-node-tooltip';
import { EdgeNodeTooltipProps } from './edge-tooltip';
import { GraphInteractionEvents, GraphService } from '@nrwl/graph/ui-graph';
export class GraphTooltipService {
private subscribers: Set<Function> = new Set();
constructor(graph: GraphService) {
graph.listen((event) => {
switch (event.type) {
case 'GraphRegenerated':
this.hideAll();
break;
case 'NodeClick':
this.openProjectNodeToolTip(event.ref, {
id: event.data.id,
tags: event.data.tags,
type: event.data.type,
});
break;
case 'EdgeClick':
this.openEdgeToolTip(event.ref, {
type: event.data.type,
target: event.data.target,
source: event.data.source,
fileDependencies: event.data.fileDependencies,
});
break;
}
});
}
currentTooltip:
| { ref: VirtualElement; type: 'node'; props: ProjectNodeToolTipProps }
| { ref: VirtualElement; type: 'edge'; props: EdgeNodeTooltipProps };
@ -41,7 +69,8 @@ let tooltipService: GraphTooltipService;
export function getTooltipService(): GraphTooltipService {
if (!tooltipService) {
tooltipService = new GraphTooltipService();
const graph = getGraphService();
tooltipService = new GraphTooltipService(graph);
}
return tooltipService;

12
graph/ui-graph/.babelrc Normal file
View File

@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,24 @@
const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core, builder: 'webpack5' },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};

View File

View File

@ -0,0 +1,27 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": ""
},
"files": [
"../../../node_modules/@nrwl/react/typings/styled-jsx.d.ts",
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"../**/*.spec.ts",
"../**/*.spec.js",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": [
"../src/**/*.stories.ts",
"../src/**/*.stories.js",
"../src/**/*.stories.jsx",
"../src/**/*.stories.tsx",
"../src/**/*.stories.mdx",
"*.js"
]
}

7
graph/ui-graph/README.md Normal file
View File

@ -0,0 +1,7 @@
# graph-ui-graph
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test graph-ui-graph` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,10 @@
/* eslint-disable */
export default {
displayName: 'graph-ui-graph',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/graph/ui-graph',
};

View File

@ -0,0 +1,55 @@
{
"name": "graph-ui-graph",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/ui-graph/src",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["graph/ui-graph/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "graph/ui-graph/jest.config.ts",
"passWithNoTests": true
}
},
"storybook": {
"executor": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"config": {
"configFolder": "graph/ui-graph/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/react",
"outputPath": "dist/storybook/graph-ui-graph",
"config": {
"configFolder": "graph/ui-graph/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@ -0,0 +1,3 @@
export * from './lib/nx-graph-viz';
export * from './lib/graph';
export * from './lib/graph-interaction-events';

View File

@ -0,0 +1,31 @@
import { VirtualElement } from '@popperjs/core';
import { NodeDataDefinition } from './util-cytoscape/project-node';
import { EdgeDataDefinition } from './util-cytoscape/edge';
interface NodeClickEvent {
type: 'NodeClick';
ref: VirtualElement;
id: string;
data: NodeDataDefinition;
}
interface EdgeClickEvent {
type: 'EdgeClick';
ref: VirtualElement;
id: string;
data: {
type: string;
source: string;
target: string;
fileDependencies: { fileName: string; target: string }[];
};
}
interface GraphRegeneratedEvent {
type: 'GraphRegenerated';
}
export type GraphInteractionEvents =
| NodeClickEvent
| EdgeClickEvent
| GraphRegeneratedEvent;

View File

@ -7,18 +7,19 @@ import type { VirtualElement } from '@popperjs/core';
import cy from 'cytoscape';
import cytoscapeDagre from 'cytoscape-dagre';
import popper from 'cytoscape-popper';
import { edgeStyles, nodeStyles } from '../styles-graph';
import { selectValueByRankDirStatic } from '../rankdir-resolver';
import { selectValueByThemeStatic } from '../theme-resolver';
import { GraphTooltipService } from '../tooltip-service';
import { edgeStyles, nodeStyles } from './styles-graph';
import {
CytoscapeDagreConfig,
ParentNode,
ProjectEdge,
ProjectNode,
} from '../util-cytoscape';
} from './util-cytoscape';
import { GraphPerfReport, GraphRenderEvents } from './interfaces';
import { getEnvironmentConfig } from '../hooks/use-environment-config';
import {
darkModeScratchKey,
switchValueByDarkMode,
} from './styles-graph/dark-mode';
import { GraphInteractionEvents } from './graph-interaction-events';
const cytoscapeDagreConfig = {
name: 'dagre',
@ -35,12 +36,72 @@ export class GraphService {
private collapseEdges = false;
private listeners = new Map<
number,
(event: GraphInteractionEvents) => void
>();
private _theme: 'light' | 'dark';
private _rankDir: 'TB' | 'LR' = 'TB';
constructor(
private tooltipService: GraphTooltipService,
private containerId: string
private container: string | HTMLElement,
theme: 'light' | 'dark',
private renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB'
) {
cy.use(cytoscapeDagre);
cy.use(popper);
this._theme = theme;
this._rankDir = rankDir;
}
get activeContainer() {
return typeof this.container === 'string'
? document.getElementById(this.container)
: this.container;
}
set theme(theme: 'light' | 'dark') {
this._theme = theme;
if (this.renderGraph) {
this.renderGraph.unmount();
const useDarkMode = theme === 'dark';
this.renderGraph.scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.elements().scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.mount(this.activeContainer);
}
}
set rankDir(rankDir: 'TB' | 'LR') {
this._rankDir = rankDir;
if (this.renderGraph) {
const elements = this.renderGraph.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
}
listen(callback: (event: GraphInteractionEvents) => void) {
const listenerId = this.listeners.size + 1;
this.listeners.set(listenerId, callback);
return () => {
this.listeners.delete(listenerId);
};
}
broadcast(event: GraphInteractionEvents) {
this.listeners.forEach((callback) => callback(event));
}
handleEvent(event: GraphRenderEvents): {
@ -51,9 +112,10 @@ export class GraphService {
if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') {
this.renderGraph.nodes('.focused').removeClass('focused');
this.renderGraph.unmount();
}
this.tooltipService.hideAll();
this.broadcast({ type: 'GraphRegenerated' });
switch (event.type) {
case 'notifyGraphInitGraph':
@ -141,7 +203,7 @@ export class GraphService {
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: selectValueByRankDirStatic('TB', 'LR') },
...{ rankDir: this._rankDir },
})
.run();
@ -219,9 +281,7 @@ export class GraphService {
});
}
const environmentConfig = getEnvironmentConfig();
if (environmentConfig.environment === 'nx-console') {
if (this.renderMode === 'nx-console') {
// when in the nx-console environment, adjust graph width and position to be to right of floating panel
// 175 is a magic number that represents the width of the floating panels divided in half plus some padding
this.renderGraph
@ -237,6 +297,13 @@ export class GraphService {
.nodes('[type!="dir"]')
.map((node) => node.id());
this.renderGraph.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph
.elements()
.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph.mount(this.activeContainer);
const renderTime = Date.now() - time;
perfReport = {
@ -440,13 +507,14 @@ export class GraphService {
this.renderGraph.destroy();
delete this.renderGraph;
}
const container = document.getElementById(this.containerId);
this.renderGraph = cy({
container: container,
headless: !container,
headless: this.activeContainer === null,
container: this.activeContainer,
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
panningEnabled: true,
userZoomingEnabled: this.renderMode !== 'nx-docs',
});
this.renderGraph.add(elements);
@ -456,7 +524,7 @@ export class GraphService {
}
this.renderGraph.on('zoom pan', () => {
this.tooltipService.hideAll();
this.broadcast({ type: 'GraphRegenerated' });
});
this.listenForProjectNodeClicks();
@ -504,7 +572,7 @@ export class GraphService {
collapseEdges: boolean
) {
this.collapseEdges = collapseEdges;
this.tooltipService.hideAll();
this.broadcast({ type: 'GraphRegenerated' });
this.generateCytoscapeLayout(
allProjects,
@ -609,10 +677,16 @@ export class GraphService {
let ref: VirtualElement = node.popperRef(); // used only for positioning
this.tooltipService.openProjectNodeToolTip(ref, {
this.broadcast({
type: 'NodeClick',
ref,
id: node.id(),
data: {
id: node.id(),
type: node.data('type'),
tags: node.data('tags'),
},
});
});
}
@ -622,20 +696,31 @@ export class GraphService {
const edge: cy.EdgeSingular = event.target;
let ref: VirtualElement = edge.popperRef(); // used only for positioning
this.tooltipService.openEdgeToolTip(ref, {
this.broadcast({
type: 'EdgeClick',
ref,
id: edge.id(),
data: {
type: edge.data('type'),
source: edge.source().id(),
target: edge.target().id(),
fileDependencies: edge
.source()
.data('files')
.filter((file) => file.deps && file.deps.includes(edge.target().id()))
.filter(
(file) => file.deps && file.deps.includes(edge.target().id())
)
.map((file) => {
return {
fileName: file.file.replace(`${edge.source().data('root')}/`, ''),
fileName: file.file.replace(
`${edge.source().data('root')}/`,
''
),
target: edge.target().id(),
};
}),
},
});
});
}
@ -671,28 +756,7 @@ export class GraphService {
}
getImage() {
const bg = selectValueByThemeStatic('#0F172A', '#FFFFFF');
const bg = switchValueByDarkMode(this.renderGraph, '#0F172A', '#FFFFFF');
return this.renderGraph.png({ bg, full: true });
}
evaluateStyles() {
if (this.renderGraph) {
const container = this.renderGraph.container();
this.renderGraph.unmount();
this.renderGraph.mount(container);
}
}
setRankDir(rankDir: 'TB' | 'LR') {
if (this.renderGraph) {
const elements = this.renderGraph.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
}
}

View File

@ -0,0 +1,219 @@
// nx-ignore-next-line
import type {
ProjectGraphDependency,
ProjectGraphProjectNode,
} from '@nrwl/devkit';
import { ActionObject, ActorRef, State, StateNodeConfig } from 'xstate';
// The hierarchical (recursive) schema for the states
export interface DepGraphSchema {
states: {
idle: {};
unselected: {};
focused: {};
textFiltered: {};
customSelected: {};
tracing: {};
};
}
export interface GraphPerfReport {
renderTime: number;
numNodes: number;
numEdges: number;
}
export type TracingAlgorithmType = 'shortest' | 'all';
// The events that the machine handles
export type DepGraphUIEvents =
| {
type: 'setSelectedProjectsFromGraph';
selectedProjectNames: string[];
perfReport: GraphPerfReport;
}
| { type: 'selectProject'; projectName: string }
| { type: 'deselectProject'; projectName: string }
| { type: 'selectAll' }
| { type: 'deselectAll' }
| { type: 'selectAffected' }
| { type: 'setGroupByFolder'; groupByFolder: boolean }
| { type: 'setTracingStart'; projectName: string }
| { type: 'setTracingEnd'; projectName: string }
| { type: 'clearTraceStart' }
| { type: 'clearTraceEnd' }
| { type: 'setTracingAlgorithm'; algorithm: TracingAlgorithmType }
| { type: 'setCollapseEdges'; collapseEdges: boolean }
| { type: 'setIncludeProjectsByPath'; includeProjectsByPath: boolean }
| { type: 'incrementSearchDepth' }
| { type: 'decrementSearchDepth' }
| { type: 'setSearchDepthEnabled'; searchDepthEnabled: boolean }
| { type: 'setSearchDepth'; searchDepth: number }
| { type: 'focusProject'; projectName: string }
| { type: 'unfocusProject' }
| { type: 'filterByText'; search: string }
| { type: 'clearTextFilter' }
| {
type: 'initGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
}
| {
type: 'updateGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
};
// The events that the graph actor handles
export type GraphRenderEvents =
| {
type: 'notifyGraphInitGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
}
| {
type: 'notifyGraphUpdateGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
selectedProjects: string[];
}
| {
type: 'notifyGraphFocusProject';
projectName: string;
searchDepth: number;
}
| {
type: 'notifyGraphShowProject';
projectName: string;
}
| {
type: 'notifyGraphHideProject';
projectName: string;
}
| {
type: 'notifyGraphShowAllProjects';
}
| {
type: 'notifyGraphHideAllProjects';
}
| {
type: 'notifyGraphShowAffectedProjects';
}
| {
type: 'notifyGraphFilterProjectsByText';
search: string;
includeProjectsByPath: boolean;
searchDepth: number;
}
| {
type: 'notifyGraphTracing';
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
export type RouteEvents =
| {
type: 'notifyRouteFocusProject';
focusedProject: string;
}
| {
type: 'notifyRouteGroupByFolder';
groupByFolder: boolean;
}
| {
type: 'notifyRouteCollapseEdges';
collapseEdges: boolean;
}
| {
type: 'notifyRouteSearchDepth';
searchDepthEnabled: boolean;
searchDepth: number;
}
| {
type: 'notifyRouteUnfocusProject';
}
| {
type: 'notifyRouteSelectAll';
}
| {
type: 'notifyRouteSelectAffected';
}
| { type: 'notifyRouteClearSelect' }
| {
type: 'notifyRouteTracing';
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
export type AllEvents = DepGraphUIEvents | GraphRenderEvents | RouteEvents;
// The context (extended state) of the machine
export interface DepGraphContext {
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
selectedProjects: string[];
focusedProject: string | null;
textFilter: string;
includePath: boolean;
searchDepth: number;
searchDepthEnabled: boolean;
groupByFolder: boolean;
collapseEdges: boolean;
workspaceLayout: {
libsDir: string;
appsDir: string;
};
graphActor: ActorRef<GraphRenderEvents>;
routeSetterActor: ActorRef<RouteEvents>;
routeListenerActor: ActorRef<DepGraphUIEvents>;
lastPerfReport: GraphPerfReport;
tracing: {
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
}
export type DepGraphStateNodeConfig = StateNodeConfig<
DepGraphContext,
{},
DepGraphUIEvents,
ActionObject<DepGraphContext, DepGraphUIEvents>
>;
export type DepGraphSend = (
event: DepGraphUIEvents | DepGraphUIEvents[]
) => void;
export type DepGraphState = State<
DepGraphContext,
DepGraphUIEvents,
any,
{
value: any;
context: DepGraphContext;
}
>;

View File

@ -0,0 +1,62 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { NxGraphViz } from './nx-graph-viz';
const Story: ComponentMeta<typeof NxGraphViz> = {
component: NxGraphViz,
title: 'NxGraphViz',
};
export default Story;
const Template: ComponentStory<typeof NxGraphViz> = (args) => (
<NxGraphViz {...args} />
);
export const Primary = Template.bind({});
Primary.args = {
projects: [
{
type: 'app',
name: 'app',
data: {
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib',
data: {
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib2',
data: {
root: 'libs/nested-scope/lib2',
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib3',
data: {
root: 'libs/nested-scope/lib3',
tags: ['scope:cart'],
},
},
],
groupByFolder: true,
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
dependencies: {
app: [{ target: 'lib', source: 'app', type: 'direct' }],
lib: [
{ target: 'lib2', source: 'lib', type: 'implicit' },
{ target: 'lib3', source: 'lib', type: 'direct' },
],
lib2: [],
lib3: [],
},
affectedProjectIds: [],
theme: 'light',
height: '450px',
};

View File

@ -0,0 +1,85 @@
/* nx-ignore-next-line */
import type {
ProjectGraphProjectNode,
ProjectGraphDependency,
} from 'nx/src/config/project-graph';
import { useEffect, useRef, useState } from 'react';
import { GraphService } from './graph';
type Theme = 'light' | 'dark' | 'system';
export interface GraphUiGraphProps {
projects: ProjectGraphProjectNode[];
groupByFolder: boolean;
workspaceLayout: { appsDir: string; libsDir: string };
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjectIds: string[];
theme: Theme;
height: string;
}
function resolveTheme(theme: Theme): 'dark' | 'light' {
if (theme !== 'system') {
return theme;
} else {
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
return darkMedia.matches ? 'dark' : 'light';
}
}
export function NxGraphViz({
projects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds,
theme,
height,
}: GraphUiGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [graph, setGraph] = useState<GraphService>(null);
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>();
const newlyResolvedTheme = resolveTheme(theme);
if (newlyResolvedTheme !== resolvedTheme) {
setResolvedTheme(newlyResolvedTheme);
if (graph) {
graph.theme = newlyResolvedTheme;
}
}
useEffect(() => {
if (containerRef.current !== null) {
import('./graph')
.then((module) => module.GraphService)
.then((GraphService) => {
const graph = new GraphService(
containerRef.current,
resolvedTheme,
'nx-docs',
'TB'
);
graph.handleEvent({
type: 'notifyGraphInitGraph',
projects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjects: affectedProjectIds,
collapseEdges: false,
});
graph.handleEvent({ type: 'notifyGraphShowAllProjects' });
setGraph(graph);
});
}
}, []);
return (
<div
ref={containerRef}
className="w-full"
style={{ width: '100%', height }}
></div>
);
}
export default NxGraphViz;

View File

@ -0,0 +1,15 @@
import { SingularData, Core } from 'cytoscape';
export const darkModeScratchKey = 'NX_GRAPH_DARK_MODE';
export function scratchHasDarkMode(element: SingularData | Core) {
return element.scratch(darkModeScratchKey) === true;
}
export function switchValueByDarkMode<T>(
element: SingularData | Core,
dark: T,
light: T
) {
return scratchHasDarkMode(element) ? dark : light;
}

View File

@ -1,42 +1,38 @@
import { Stylesheet } from 'cytoscape';
import { selectValueByThemeDynamic } from '../theme-resolver';
import { EdgeSingular, Stylesheet } from 'cytoscape';
import { NrwlPalette } from './palette';
import { switchValueByDarkMode } from './dark-mode';
const allEdges: Stylesheet = {
selector: 'edge',
style: {
width: '1px',
'line-color': selectValueByThemeDynamic(
NrwlPalette.slate_400,
NrwlPalette.slate_500
),
'text-outline-color': selectValueByThemeDynamic(
NrwlPalette.slate_400,
NrwlPalette.slate_500
),
'line-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
'text-outline-color': (node: EdgeSingular) =>
switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
'text-outline-width': '0px',
color: selectValueByThemeDynamic(
NrwlPalette.slate_400,
NrwlPalette.slate_500
),
color: (node: EdgeSingular) =>
switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
'curve-style': 'unbundled-bezier',
'target-arrow-shape': 'triangle',
'target-arrow-fill': 'filled',
'target-arrow-color': selectValueByThemeDynamic(
NrwlPalette.slate_400,
NrwlPalette.slate_500
),
'target-arrow-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
},
};
const affectedEdges: Stylesheet = {
selector: 'edge.affected',
style: {
'line-color': selectValueByThemeDynamic(
'line-color': (node) =>
switchValueByDarkMode(
node,
NrwlPalette.fuchsia_500,
NrwlPalette.pink_500
),
'target-arrow-color': selectValueByThemeDynamic(
'target-arrow-color': (node) =>
switchValueByDarkMode(
node,
NrwlPalette.fuchsia_500,
NrwlPalette.pink_500
),

View File

@ -0,0 +1,34 @@
import { NodeSingular } from 'cytoscape';
export class LabelWidthCalculator {
private cache = new Map<string, number>();
private ctx: CanvasRenderingContext2D;
constructor() {}
calculateWidth(node: NodeSingular): number {
if (!this.ctx) {
this.ctx = document
.createElement('canvas')
.getContext('2d') as CanvasRenderingContext2D;
}
const label = node.data('id');
const fStyle = node.style('font-style');
const size = node.style('font-size');
const family = node.style('font-family');
const weight = node.style('font-weight');
this.ctx.font = fStyle + ' ' + weight + ' ' + size + ' ' + family;
const cachedValue = this.cache.get(label);
if (cachedValue) {
return cachedValue;
} else {
const width = this.ctx.measureText(node.data('id')).width;
this.cache.set(label, width);
return width;
}
}
}

View File

@ -1,8 +1,8 @@
import { Stylesheet } from 'cytoscape';
import { selectValueByThemeDynamic } from '../theme-resolver';
import { NodeSingular, Stylesheet } from 'cytoscape';
import { FONTS } from './fonts';
import { NrwlPalette } from './palette';
import { LabelWidthCalculator } from './label-width';
import { switchValueByDarkMode } from './dark-mode';
const labelWidthCalculator = new LabelWidthCalculator();
@ -11,26 +11,19 @@ const allNodes: Stylesheet = {
style: {
'font-size': '32px',
'font-family': FONTS,
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.slate_600,
NrwlPalette.slate_200
),
backgroundColor: (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_600, NrwlPalette.slate_200),
'border-style': 'solid',
'border-color': selectValueByThemeDynamic(
NrwlPalette.slate_700,
NrwlPalette.slate_300
),
'border-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_300),
'border-width': '1px',
'text-halign': 'center',
'text-valign': 'center',
'padding-left': '16px',
color: selectValueByThemeDynamic(
NrwlPalette.slate_200,
NrwlPalette.slate_600
),
color: (node: NodeSingular) =>
switchValueByDarkMode(node, NrwlPalette.slate_200, NrwlPalette.slate_600),
label: 'data(id)',
// width: (node) => node.data('id').length * 16,
width: (node) => labelWidthCalculator.calculateWidth(node),
width: (node: NodeSingular) => labelWidthCalculator.calculateWidth(node),
'transition-property':
'background-color, border-color, line-color, target-arrow-color',
'transition-duration': 250,
@ -43,14 +36,10 @@ const focusedNodes: Stylesheet = {
selector: 'node.focused',
style: {
color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic(
NrwlPalette.slate_700,
NrwlPalette.slate_200
),
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.sky_500,
NrwlPalette.blue_500
),
'border-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_200),
backgroundColor: (node) =>
switchValueByDarkMode(node, NrwlPalette.sky_500, NrwlPalette.blue_500),
},
};
@ -58,11 +47,15 @@ const affectedNodes: Stylesheet = {
selector: 'node.affected',
style: {
color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic(
'border-color': (node) =>
switchValueByDarkMode(
node,
NrwlPalette.fuchsia_800,
NrwlPalette.pink_500
),
backgroundColor: selectValueByThemeDynamic(
backgroundColor: (node) =>
switchValueByDarkMode(
node,
NrwlPalette.fuchsia_700,
NrwlPalette.pink_400
),
@ -72,15 +65,11 @@ const affectedNodes: Stylesheet = {
const parentNodes: Stylesheet = {
selector: ':parent',
style: {
'background-opacity': selectValueByThemeDynamic(0.5, 0.8),
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.slate_700,
NrwlPalette.slate_50
),
'border-color': selectValueByThemeDynamic(
NrwlPalette.slate_500,
NrwlPalette.slate_400
),
'background-opacity': (node) => switchValueByDarkMode(node, 0.5, 0.8),
backgroundColor: (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_50),
'border-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.slate_500, NrwlPalette.slate_400),
'border-style': 'dashed',
'border-width': 2,
label: 'data(label)',
@ -95,14 +84,10 @@ const highlightedNodes: Stylesheet = {
selector: 'node.highlight',
style: {
color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic(
NrwlPalette.sky_600,
NrwlPalette.blue_600
),
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.sky_500,
NrwlPalette.blue_500
),
'border-color': (node) =>
switchValueByDarkMode(node, NrwlPalette.sky_600, NrwlPalette.blue_600),
backgroundColor: (node) =>
switchValueByDarkMode(node, NrwlPalette.sky_500, NrwlPalette.blue_500),
},
};

View File

@ -1,14 +1,14 @@
import * as cy from 'cytoscape';
import { EdgeSingular, Position, BaseLayoutOptions } from 'cytoscape';
export interface CytoscapeDagreConfig extends cy.BaseLayoutOptions {
export interface CytoscapeDagreConfig extends BaseLayoutOptions {
// dagre algo options, uses default value on undefined
nodeSep: number; // the separation between adjacent nodes in the same rank
edgeSep: number; // the separation between adjacent edges in the same rank
rankSep: number; // the separation between each rank in the layout
rankDir: 'TB' | 'LR'; // 'TB' for top to bottom flow, 'LR' for left to right,
ranker: 'network-simplex' | 'tight-tree' | 'longest-path'; // Type of algorithm to assign a rank to each node in the input graph. Possible values: 'network-simplex', 'tight-tree' or 'longest-path'
minLen: (edge: cy.EdgeSingular) => number; // number of ranks to keep between the source and target of the edge
edgeWeight: (edge: cy.EdgeSingular) => number; // higher weight edges are generally made shorter and straighter than lower weight edges
minLen: (edge: EdgeSingular) => number; // number of ranks to keep between the source and target of the edge
edgeWeight: (edge: EdgeSingular) => number; // higher weight edges are generally made shorter and straighter than lower weight edges
// general layout options
fit: boolean; // whether to fit to viewport
@ -22,7 +22,7 @@ export interface CytoscapeDagreConfig extends cy.BaseLayoutOptions {
boundingBox:
| { x1: number; y1: number; x2: number; y2: number }
| { x1: number; y1: number; w: number; h: number }; // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
transform: (node, pos) => cy.Position; // a function that applies a transform to the final node position
transform: (node, pos) => Position; // a function that applies a transform to the final node position
ready: () => void; // on layoutready
stop: () => void; // on layoutstop
}

View File

@ -2,6 +2,13 @@
import type { ProjectGraphDependency } from '@nrwl/devkit';
import * as cy from 'cytoscape';
export interface EdgeDataDefinition extends cy.NodeDataDefinition {
id: string;
source: string;
target: string;
type: 'static' | 'dynamic' | 'implicit';
}
export class ProjectEdge {
affected = false;

View File

@ -3,13 +3,13 @@ import type { ProjectGraphProjectNode } from '@nrwl/devkit';
import * as cy from 'cytoscape';
import { parseParentDirectoriesFromFilePath } from '../util';
interface NodeDataDefinition extends cy.NodeDataDefinition {
export interface NodeDataDefinition extends cy.NodeDataDefinition {
id: string;
type: string;
type: 'app' | 'lib' | 'e2e';
tags: string[];
}
interface Ancestor {
export interface Ancestor {
id: string;
parentId: string;
label: string;

View File

@ -0,0 +1,50 @@
// nx-ignore-next-line
import { ProjectGraphDependency } from '@nrwl/devkit';
export function trimBackSlash(value: string): string {
return value.replace(/\/$/, '');
}
export function parseParentDirectoriesFromFilePath(
path: string,
workspaceRoot: string
) {
const directories = path
.replace(workspaceRoot, '')
.split('/')
.filter((directory) => directory !== '');
// last directory is the project
directories.pop();
return directories;
}
export function hasPath(
dependencies: Record<string, ProjectGraphDependency[]>,
target: string,
node: string,
visited: string[],
currentSearchDepth: number,
maxSearchDepth: number = -1 // -1 indicates unlimited search depth
) {
if (target === node) return true;
if (maxSearchDepth === -1 || currentSearchDepth <= maxSearchDepth) {
for (let d of dependencies[node] || []) {
if (visited.indexOf(d.target) > -1) continue;
visited.push(d.target);
if (
hasPath(
dependencies,
target,
d.target,
visited,
currentSearchDepth + 1,
maxSearchDepth
)
)
return true;
}
}
return false;
}

View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./.storybook/tsconfig.json"
}
]
}

View File

@ -0,0 +1,28 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"],
"lib": ["DOM", "es2019"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"jest.config.ts",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}

View File

@ -14,6 +14,8 @@ import { Card, Cards } from './lib/tags/cards.component';
import { card, cards } from './lib/tags/cards.schema';
import { GithubRepository } from './lib/tags/github-repository.component';
import { githubRepository } from './lib/tags/github-repository.schema';
import { Graph } from './lib/tags/graph.component';
import { graph } from './lib/tags/graph.schema';
import { Iframe } from './lib/tags/iframe.component';
import { iframe } from './lib/tags/iframe.schema';
import { NxCloudSection } from './lib/tags/nx-cloud-section.component';
@ -44,6 +46,7 @@ export const getMarkdocCustomConfig = (
card,
cards,
'github-repository': githubRepository,
graph,
iframe,
'install-nx-console': installNxConsole,
'nx-cloud-section': nxCloudSection,
@ -62,6 +65,7 @@ export const getMarkdocCustomConfig = (
CustomLink,
Fence,
GithubRepository,
Graph,
Heading,
Iframe,
InstallNxConsole,

View File

@ -0,0 +1,62 @@
import dynamic from 'next/dynamic';
import { ReactNode } from 'react';
import { useTheme } from '@nrwl/nx-dev/ui-theme';
const wrapperClassNames =
'w-full place-content-center rounded-md my-6 ring-1 ring-slate-100 dark:ring-slate-700';
export function Graph({
height,
children,
}: {
height: string;
children: ReactNode;
}): JSX.Element {
const [theme] = useTheme();
if (!children || !children.hasOwnProperty('props')) {
return (
<div className={`${wrapperClassNames} p-4`}>
<p>No JSON provided for graph</p>
</div>
);
}
let parsedProps;
try {
parsedProps = JSON.parse(children?.props.children as any);
} catch {
return (
<div className={`${wrapperClassNames} p-4`}>
<p>Could not parse JSON for graph:</p>
<pre>{children?.props.children as any}</pre>
</div>
);
}
const NxGraphViz = dynamic(
() => import('@nrwl/graph/ui-graph').then((module) => module.NxGraphViz),
{
ssr: false,
loading: () => (
<div className={wrapperClassNames} style={{ height }}>
Loading...
</div>
),
}
);
return (
<div className={wrapperClassNames}>
<NxGraphViz
height={height}
groupByFolder={false}
theme={theme}
projects={parsedProps.projects}
workspaceLayout={parsedProps.workspaceLayout}
dependencies={parsedProps.dependencies}
affectedProjectIds={parsedProps.affectedProjectIds}
></NxGraphViz>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Schema } from '@markdoc/markdoc';
export const graph: Schema = {
render: 'Graph',
children: [],
attributes: {
height: {
type: 'String',
required: true,
},
},
};

View File

@ -2,7 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"]
"types": ["node"],
"lib": ["DOM", "es2019"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",

View File

@ -30,6 +30,7 @@
"@nrwl/eslint-plugin-nx": ["packages/eslint-plugin-nx/src"],
"@nrwl/expo": ["packages/expo"],
"@nrwl/express": ["packages/express"],
"@nrwl/graph/ui-graph": ["graph/ui-graph/src/index.ts"],
"@nrwl/jest": ["packages/jest"],
"@nrwl/jest/*": ["packages/jest/*"],
"@nrwl/js": ["packages/js/src"],

View File

@ -44,6 +44,7 @@
"expo": "packages/expo",
"express": "packages/express",
"graph-client": "graph/client",
"graph-ui-graph": "graph/ui-graph",
"jest": "packages/jest",
"js": "packages/js",
"linter": "packages/linter",