diff --git a/README.md b/README.md index 86d5c587..9420792e 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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 @@ -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. diff --git a/bin/cli.js b/bin/cli.js index d8f14cd7..245bf47f 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -23,6 +23,7 @@ program .option('-x, --exclude ', 'exclude modules using RegExp') .option('-j, --json', 'output as JSON') .option('-i, --image ', 'write graph to file as an image') + .option('-I, --interactive ', 'write graph to file as an interactive html page') .option('-l, --layout ', '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') @@ -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); diff --git a/lib/api.js b/lib/api.js index 76a989ee..68faa6e7 100644 --- a/lib/api.js +++ b/lib/api.js @@ -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 diff --git a/lib/graph.js b/lib/graph.js index 29032ea4..126af80d 100644 --- a/lib/graph.js +++ b/lib/graph.js @@ -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); @@ -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. @@ -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 diff --git a/lib/interactive/index.js b/lib/interactive/index.js new file mode 100644 index 00000000..4d11e55a --- /dev/null +++ b/lib/interactive/index.js @@ -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 ` + + + + Interactive Graph + + + +${content} + + + + `; +} + +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)); +}; diff --git a/lib/interactive/templates/scripts.js b/lib/interactive/templates/scripts.js new file mode 100644 index 00000000..03ff6a1f --- /dev/null +++ b/lib/interactive/templates/scripts.js @@ -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', ` + + +`); + +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()); diff --git a/lib/interactive/templates/styles.css b/lib/interactive/templates/styles.css new file mode 100644 index 00000000..24f48104 --- /dev/null +++ b/lib/interactive/templates/styles.css @@ -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; +}