Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Interactive graph [as html page] #333

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ madge('path/to/app.js')
});
```

#### .interactive(pagePath: string, [circularOnly: boolean])

> Write the graph as an interactive html page to the given page path. Set `circularOnly` to only include circular dependencies. Returns a `Promise` resolved with a full path to the written page.

```javascript
const madge = require('madge');

madge('path/to/app.js')
.then((res) => res.interactive('path/to/page.html'))
.then((writtenPagePath) => {
console.log('Page written to ' + writtenPagePath);
});
```

#### .svg()

> Return a `Promise` resolved with the XML SVG representation of the dependency graph as a `Buffer`.
Expand Down Expand Up @@ -352,6 +366,12 @@ madge --image graph.svg path/src/app.js
madge --circular --image graph.svg path/src/app.js
```

> Save graph as an interactive html page (requires [Graphviz](#graphviz-optional))

```sh
madge --interactive graph.html path/src/app.js
```

> Save graph as a [DOT](http://en.wikipedia.org/wiki/DOT_language) file for further processing (requires [Graphviz](#graphviz-optional))

```sh
Expand All @@ -364,6 +384,16 @@ madge --dot path/src/app.js > graph.gv
madge --json path/src/app.js | tr '[a-z]' '[A-Z]' | madge --stdin
```

# Interactive Controls
`Left click` selects node(with edges) or edge. Other edges will be dimmed.

`Left click` out of context(node or edge) resets everything to the initial state.

`Right click` selects an additional node(with edges) or edge. Has no effect if there are no previously selected items.
Previously selected nodes or edges will be slightly dimmed(but will stay colored).

`Escape` resets everything to the initial state.

# Debugging

> To enable debugging output if you encounter problems, run madge with the `--debug` option then throw the result in a gist when creating issues on GitHub.
Expand Down
8 changes: 8 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ program
.option('-x, --exclude <regexp>', 'exclude modules using RegExp')
.option('-j, --json', 'output as JSON')
.option('-i, --image <file>', 'write graph to file as an image')
.option('-I, --interactive <file>', 'write graph to file as an interactive html page')
.option('-l, --layout <name>', 'layout engine to use for graph (dot/neato/fdp/sfdp/twopi/circo)')
.option('--orphans', 'show modules that no one is depending on')
.option('--leaves', 'show modules that have no dependencies')
Expand Down Expand Up @@ -251,6 +252,13 @@ function createOutputFromOptions(program, res) {
});
}

if (program.interactive) {
return res.interactive(program.interactive, program.circular).then((pagePath) => {
spinner.succeed(`${chalk.bold('Html page created at')} ${chalk.cyan.bold(pagePath)}`);
return res;
});
}

if (program.dot) {
return res.dot(program.circular).then((output) => {
process.stdout.write(output);
Expand Down
20 changes: 20 additions & 0 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,26 @@ class Madge {
);
}

/**
* Write dependency graph to interactive html page.
* @api public
* @param {String} pagePath
* @param {Boolean} circularOnly
* @return {Promise}
*/
interactive(pagePath, circularOnly) {
if (!pagePath) {
return Promise.reject(new Error('pagePath not provided'));
}

return graph.interactive(
circularOnly ? this.circularGraph() : this.obj(),
this.circular(),
pagePath,
this.config
);
}

/**
* Return Buffer with XML SVG representation of the dependency graph.
* @api public
Expand Down
22 changes: 20 additions & 2 deletions lib/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const path = require('path');
const {promisify} = require('util');
const graphviz = require('graphviz');
const interactive = require('./interactive');

const exec = promisify(require('child_process').execFile);
const writeFile = promisify(require('fs').writeFile);
Expand Down Expand Up @@ -121,14 +122,16 @@ function createGraph(modules, circular, config, options) {
* @param {Object} config
* @return {Promise}
*/
module.exports.svg = function (modules, circular, config) {
function svg(modules, circular, config) {
const options = createGraphvizOptions(config);

options.type = 'svg';

return checkGraphvizInstalled(config)
.then(() => createGraph(modules, circular, config, options));
};
}

module.exports.svg = svg;

/**
* Creates an image from the module dependency graph.
Expand All @@ -151,6 +154,21 @@ module.exports.image = function (modules, circular, imagePath, config) {
});
};

/**
* Creates an interactive html page from the module dependency graph.
* @param {Object} modules
* @param {Array} circular
* @param {String} pagePath
* @param {Object} config
* @return {Promise}
*/
module.exports.interactive = function (modules, circular, pagePath, config) {
return svg(modules, circular, config)
.then((svg) => interactive.generateInteractiveHtml(svg))
.then((page) => writeFile(pagePath, page))
.then(() => path.resolve(pagePath));
};

/**
* Return the module dependency graph as DOT output.
* @param {Object} modules
Expand Down
32 changes: 32 additions & 0 deletions lib/interactive/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';

const {resolve} = require('path');
const {promisify} = require('util');
const readFile = promisify(require('fs').readFile);

function toHtml(styles, content, scripts) {
return `<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>Interactive Graph</title>
<style>
${styles}
</style>
</head>
<body>
${content}
<script>
${scripts}
</script>
</body>
</html>
`;
}

module.exports.generateInteractiveHtml = function (svg) {
return Promise.all([
readFile(resolve(__dirname, './templates/styles.css')),
readFile(resolve(__dirname, './templates/scripts.js'))
]).then(([styles, scripts]) => toHtml(styles, svg, scripts));
};
136 changes: 136 additions & 0 deletions lib/interactive/templates/scripts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
'use strict';

/* eslint-disable no-undef */
const SELECTED = 'selected';
const DIMMED = 'dimmed';
const CURRENT = 'current';
const DUAL = 'dual';
const FROM = 'from';
const TO = 'to';

const getTitleFrom = (el) => {
const titleElement = el.querySelector('title');
return titleElement ? titleElement.textContent.trim() : null;
};
const getEdgeDirections = (edgeTitle = '') => {
const [from = '', to = ''] = edgeTitle.split('->');
return {from: from.trim(), to: to.trim()};
};

const resetAll = () => {
document.querySelectorAll(`.${DIMMED}`)
.forEach((edge) => edge.classList.remove(DIMMED));

document.querySelectorAll(`.${SELECTED}`)
.forEach((node) => {
node.classList.remove(SELECTED);
node.classList.remove(CURRENT);
node.classList.remove(TO);
node.classList.remove(FROM);
node.classList.remove(DUAL);
});
};

// prepend linear gradient:
const svgEl = document.querySelector('svg');
svgEl.insertAdjacentHTML('afterbegin', `<linearGradient id="dualDirection">
<stop offset="0%" stop-color="var(--color-from)" />
<stop offset="100%" stop-color="var(--color-to)" />
</linearGradient>`);

const nodes = document.querySelectorAll('.node');
const edges = document.querySelectorAll('.edge');

const nodeTitleToNodeMap = new Map();

for (const node of nodes) {
const title = getTitleFrom(node);
title && nodeTitleToNodeMap.set(title, node);
}

const edgesMap = new Map();

for (const edge of edges) {
const title = getTitleFrom(edge);
const {from, to} = getEdgeDirections(title);

let nodeList = [nodeTitleToNodeMap.get(from), nodeTitleToNodeMap.get(to)];
edgesMap.set(title, nodeList);

nodeList = edgesMap.get(from) || [];
nodeList.push(edge);
edgesMap.set(from, nodeList);

nodeList = edgesMap.get(to) || [];
nodeList.push(edge);
edgesMap.set(to, nodeList);
}

// select node or edge:
document.addEventListener('click', ({target}) => {
const closest = target.closest('.edge, .node');

if (!closest) {
return resetAll();
}

if (closest.classList.contains(SELECTED)) {
return;
}

const title = getTitleFrom(closest);

resetAll();
closest.classList.add(SELECTED);
(edgesMap.get(title) || []).forEach((edge) => {
edge.classList.add(SELECTED);
const {from} = getEdgeDirections(getTitleFrom(edge));
edge.classList.add(from === title ? FROM : TO);
});
document.querySelectorAll(`.edge:not(.${SELECTED})`)
.forEach((edge) => edge.classList.add(DIMMED));
});

// add node or edge to already selected ones:
document.addEventListener('contextmenu', (event) => {
event.preventDefault();
const hasSelected = Boolean(document.querySelector(`.${SELECTED}`));
if (!hasSelected) {
return;
}

const closest = event.target.closest('.edge, .node');

if (!closest || closest.classList.contains(SELECTED)) {
return;
}

document.querySelectorAll(`.${CURRENT}`)
.forEach((node) => node.classList.remove(CURRENT));

closest.classList.remove(DIMMED);
closest.classList.add(SELECTED);
closest.classList.add(CURRENT);

const title = getTitleFrom(closest);
(edgesMap.get(title) || []).forEach((edge) => {
edge.classList.remove(DIMMED);
edge.classList.add(SELECTED);
edge.classList.add(CURRENT);
const {from, to} = getEdgeDirections(getTitleFrom(edge));

if (edge.classList.contains(FROM) && to === title) {
edge.classList.remove(FROM);
edge.classList.add(DUAL);
} else if (edge.classList.contains(TO) && from === title) {
edge.classList.remove(TO);
edge.classList.add(DUAL);
} else {
edge.classList.add(from === title ? FROM : TO);
}
});
document.querySelectorAll(`.edge:not(.${CURRENT})`)
.forEach((edge) => edge.classList.add(DIMMED));
});

document.addEventListener('keydown', ({key}) => key === 'Escape' && resetAll());
58 changes: 58 additions & 0 deletions lib/interactive/templates/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
svg {
width: 100%;
height: 100%;
}

.node, .edge {
stroke-width: 1.5;
}

.node.selected path, .node.selected polygon {
stroke-width: 4.5;
}

.edge.selected path, .edge.selected polygon {
stroke: blue;
stroke-width: 4.5;
}

.edge.selected polygon {
fill: blue;
}

.edge.selected.from path, .edge.selected.from polygon {
stroke: yellow;
}

.edge.selected.from polygon, .edge.selected.dual polygon {
fill: yellow;
}

.edge.selected.dual path {
stroke: url(#dualDirection);
}

#dualDirection {
--color-from: yellow;
--color-to: blue;
}

.edge.dimmed path, .edge.dimmed polygon {
stroke-width: 1;
stroke-opacity: 0.3;
fill-opacity: 0.3;
}

.edge.selected.dimmed path, .edge.selected.dimmed polygon {
stroke-width: 1.8;
stroke-opacity: 0.55;
fill-opacity: 0.55;
}

.node:hover, .edge:hover {
cursor: pointer;
}

.edge:hover, .edge.dimmed path:hover, .edge.dimmed polygon:hover {
stroke-width: 10;
}