diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..64cd600 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,6 @@ +;;; Directory Local Variables +;;; For more information see (info "(emacs) Directory Variables") + +((js2-mode + (flycheck-checker . javascript-standard))) + diff --git a/.gitignore b/.gitignore index 351b12d..3561da7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,57 +1,4 @@ -/lib - -.tern-port +/build /docs - -# Created by https://www.gitignore.io/api/node,linux - -### Node ### -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules -jspm_packages - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history - - -### Linux ### -*~ - -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* - -# KDE directory preferences -.directory - -# Linux trash folder which might appear on any partition or disk -.Trash-* - -/d3-sankey-diagram.js +/node_modules +.tern-port diff --git a/README.md b/README.md index 099d827..e49b865 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ See the **[demo](https://ricklupton.github.io/d3-sankey-diagram)** for examples of these. +d3-sankey-diagram versions v0.5 and up are based on d3 v4. + ## Installation Install using npm if you are using browserify or the like: @@ -25,17 +27,21 @@ Or download the [standalone bundle](https://github.com/ricklupton/d3-sankey-diag ## Usage ```js -var diagram = sankeyDiagram() - .width(1000) - .height(600) - .margins({ left: 100, right: 160, top: 10, bottom: 10 }) - .nodeTitle(function(d) { return d.data.title !== undefined ? d.data.title : d.id; }) - .linkTypeTitle(function(d) { return d.data.title; }) - .linkColor(function(d) { return d.data.color; }); +var layout = d3.sankey() + .extent([[100, 10], [840, 580]]); + +var linkTitle = d3.sankeyLinkTitle(function(d) { return d.title; }, + function(d) { return d.title; }, + d3.format('.3s')); + +var diagram = d3.sankeyDiagram() + .linkTitle(linkTitle) + .linkColor(function(d) { return d.color; }); d3.json('uk_energy.json', function(energy) { + layout.ordering(energy.order); d3.select('#sankey') - .datum(energy) + .datum(layout(energy)) .call(diagram); }); ``` @@ -45,11 +51,7 @@ Try more [live examples](https://ricklupton.github.io/d3-sankey-diagram). If you use the Jupyter notebook, try [ipysankeywidget](https://github.com/ricklupton/ipysankeywidget). -`d3-sankey-diagram` works both in node (using jsdom) and in the browser. To use -jsdom, transitions must be disabled using -```js -diagram.duration(null); -``` +`d3-sankey-diagram` works both in node (using jsdom) and in the browser. ## Documentation diff --git a/index.js b/index.js index 6d0254c..b342008 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,8 @@ -var sankeyDiagram = require('./lib/diagram').default; +export {default as sankey} from './src/sankey.js' +export {default as sankeyPositionJustified} from './src/sankeyLayout/verticalJustified.js' +export {default as sankeyPositionRelaxation} from './src/sankeyLayout/verticalRelaxation.js' -module.exports = sankeyDiagram; +export {default as sankeyLink} from './src/linkPath.js' +export {default as sankeyNode} from './src/node.js' + +export {default as sankeyDiagram, linkTitleGenerator as sankeyLinkTitle} from './src/diagram.js' diff --git a/package.json b/package.json index 4e25062..91ecd91 100644 --- a/package.json +++ b/package.json @@ -1,43 +1,54 @@ { "name": "d3-sankey-diagram", - "version": "0.4.4", - "description": "Sankey diagram d3 component", - "main": "index.js", - "directories": { - "test": "test" + "version": "0.5.0", + "description": "Sankey diagram d3 plugin", + "author": "Rick Lupton", + "keywords": [ + "d3", + "d3-module", + "sankey", + "diagram" + ], + "license": "MIT", + "main": "build/d3-sankey-diagram.js", + "jsnext:main": "index", + "homepage": "https://github.com/ricklupton/d3-sankey-diagram", + "repository": { + "type": "git", + "url": "https://github.com/ricklupton/d3-sankey-diagram.git" + }, + "scripts": { + "pretest": "rm -rf build && mkdir build && rollup -c", + "test": "tape -r ./test/buble-register -r reify 'test/**/*-test.js' && standard index.js src test", + "test:watch": "tape-watch -r ../test/buble-register -r reify 'test/**/*-test.js'", + "prepublishOnly": "npm run test && uglifyjs build/d3-sankey-diagram.js -c -m -o build/d3-sankey-diagram.min.js", + "postpublish": "zip -j build/d3-sankey-diagram.zip -- LICENSE README.md build/d3-sankey-diagram.js build/d3-sankey-diagram.min.js" }, "dependencies": { - "babel-runtime": "^6.9.2", - "d3": "~3.5.16", - "defined": "^1.0.0", - "graphlib": "~2.1.0", - "sankey-layout": "~0.2.5" + "d3-array": "^1.0.2", + "d3-collection": "^1.0.2", + "d3-dispatch": "^1.0.3", + "d3-format": "^1.1.1", + "d3-interpolate": "^1.1.3", + "d3-selection": "^1.0.3", + "d3-transition": "^1.0.4", + "graphlib": "~2.1.0" }, "devDependencies": { - "almost-equal": "~1.0.0", - "babel-cli": "~6.5.1", - "babel-core": "~6.5.2", - "babel-plugin-transform-runtime": "^6.9.0", - "babel-preset-es2015": "~6.5.0", - "babel-tape-runner": "^2.0.1", - "babelify": "~7.2.0", - "browserify": "~13.0.0", - "browserify-global-shim": "^1.0.3", - "defined": "~1.0.0", - "jsdoc": "~3.4.0", - "jsdom": "^9.4.1", - "tape": "^4.5.1", - "tape-run": "^2.1.3" - }, - "scripts": { - "prepublish": "npm run build && npm run bundle", - "build": "babel src -d lib", - "bundle": "browserify --standalone sankeyDiagram -t babelify -t [ browserify-global-shim --d3 d3 ] index.js > d3-sankey-diagram.js", - "test": "browserify -t babelify test/test-*.js | tape-run", - "test:node": "babel-tape-runner 'test/**/*.js'", - "jsdoc": "jsdoc --package package.json -r lib/ -d docs" - }, - "author": "Rick Lupton", - "repository": "https://github.com/ricklupton/d3-sankey-diagram", - "license": "MIT" + "almost-equal": "^1.1.0", + "babel-eslint": "^7.1.1", + "buble": "^0.15.2", + "d3-scale": "^1.0.6", + "faucet": "0.0.1", + "jsdom": "^11.1.0", + "reify": "^0.4.4", + "rollup": "0.27", + "rollup-plugin-buble": "^0.15.0", + "rollup-plugin-commonjs": "^7.0.0", + "rollup-plugin-node-resolve": "^2.0.0", + "standard": "^8.6.0", + "tape": "4", + "tape-watch": "^2.2.4", + "uglify-js": "2" + } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..c9e28ba --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,41 @@ +// Rollup plugins +import resolve from 'rollup-plugin-node-resolve' +import commonjs from 'rollup-plugin-commonjs' +import buble from 'rollup-plugin-buble' + +export default { + entry: 'index.js', + dest: 'build/d3-sankey-diagram.js', + format: 'umd', + moduleName: 'd3', + globals: { + 'd3-collection': 'd3', + 'd3-array': 'd3', + 'd3-selection': 'd3', + 'd3-transition': 'd3', + 'd3-dispatch': 'd3', + 'd3-format': 'd3', + 'd3-interpolate': 'd3' + }, + external: [ + 'd3-collection', + 'd3-array', + 'd3-selection', + 'd3-transition', + 'd3-dispatch', + 'd3-format', + 'd3-interpolate' + ], + plugins: [ + resolve({ + jsnext: true, + main: true + }), + commonjs({ + namedExports: { + 'node_modules/graphlib/index.js': ['Graph', 'alg'] + } + }), + buble() + ] +} diff --git a/src/assignRanks/.dir-locals.el b/src/assignRanks/.dir-locals.el new file mode 100644 index 0000000..319ecc9 --- /dev/null +++ b/src/assignRanks/.dir-locals.el @@ -0,0 +1,7 @@ +;;; Directory Local Variables +;;; For more information see (info "(emacs) Directory Variables") + +((js2-mode + (flycheck-javascript-standard-executable . "/home/rick/ownCloud/devel/d3-sankey-diagram/node_modules/.bin/standard")) + (flycheck-checker . javascript-standard))) + diff --git a/src/assignRanks/grouped-graph.js b/src/assignRanks/grouped-graph.js new file mode 100644 index 0000000..713d17d --- /dev/null +++ b/src/assignRanks/grouped-graph.js @@ -0,0 +1,92 @@ +import { Graph } from 'graphlib' +import { map } from 'd3-collection' +/** + * Create a new graph where nodes in the same rank set are merged into one node. + * + * Depends on the "backwards" attribute of the nodes in G, and the "delta" + * atribute of the edges. + * + */ +export default function groupedGraph (G, rankSets = []) { + // Not multigraph because this is only used for calculating ranks + const GG = new Graph({directed: true}) + if (G.nodes().length === 0) return GG + + // Make sure there is a minimum-rank set + rankSets = ensureSmin(G, rankSets) + + // Construct map of node ids to the set they are in, if any + const nodeSets = map() + var set + var id + var i + var j + for (i = 0; i < rankSets.length; ++i) { + set = rankSets[i] + if (!set.nodes || set.nodes.length === 0) continue + id = '' + i + for (j = 0; j < set.nodes.length; ++j) { + nodeSets.set(set.nodes[j], id) + } + GG.setNode(id, { type: set.type, nodes: set.nodes }) + } + + // use i to keep counting new ids + var nodes = G.nodes() + G.nodes().forEach(u => { + const d = G.node(u) + if (!nodeSets.has(u)) { + id = '' + (i++) + set = { type: 'same', nodes: [u] } + nodeSets.set(u, id) + GG.setNode(id, set) + } + }) + + // Add edges between nodes/groups + G.edges().forEach(e => { + const d = G.edge(e) + const sourceSet = nodeSets.get(e.v) + const targetSet = nodeSets.get(e.w) + + // Minimum edge length depends on direction of nodes: + // -> to -> : 1 + // -> to <- : 0 + // <- to -> : 0 (in opposite direction??) + // <- to <- : 1 in opposite direction + const edge = GG.edge(sourceSet, targetSet) || { delta: 0 } + if (sourceSet === targetSet) { + edge.delta = 0 + GG.setEdge(sourceSet, targetSet, edge) + } else if (G.node(e.v).backwards) { + edge.delta = Math.max(edge.delta, G.node(e.w).backwards ? 1 : 0) + GG.setEdge(targetSet, sourceSet, edge) + } else { + edge.delta = Math.max(edge.delta, G.node(e.w).backwards ? 0 : 1) + GG.setEdge(sourceSet, targetSet, edge) + } + }) + + return GG +} + +// export function linkDelta (nodeBackwards, link) { +// if (nodeBackwards(link.source)) { +// return nodeBackwards(link.target) ? 1 : 0 +// } else { +// return nodeBackwards(link.target) ? 0 : 1 +// } +// } + +function ensureSmin (G, rankSets) { + for (var i = 0; i < rankSets.length; ++i) { + if (rankSets[i].type === 'min') { + return rankSets // ok + } + } + + // find the first sourceSet node, or else use the first node + var sources = G.sources() + var n0 = sources.length ? sources[0] : G.nodes()[0] + return [{ type: 'min', nodes: [ n0 ] }].concat(rankSets) +} diff --git a/src/assignRanks/index.js b/src/assignRanks/index.js new file mode 100644 index 0000000..31d42e9 --- /dev/null +++ b/src/assignRanks/index.js @@ -0,0 +1,74 @@ +import groupedGraph from './grouped-graph' +import makeAcyclic from './make-acyclic' +import assignInitialRanks from './initial-ranks' +import { min } from 'd3-array' + +/** + * Assign ranks to the nodes in G, according to rankSets. + */ +export default function assignRanks (G, rankSets) { + // Group nodes together, and add additional edges from Smin to sources + const GG = groupedGraph(G, rankSets) + if (GG.nodeCount() === 0) return + + // Add additional edges from Smin to sources + addTemporaryEdges(GG) + + // Make the graph acyclic + makeAcyclic(GG, '0') + + // Assign the initial ranks + assignInitialRanks(GG) + + // XXX improve initial ranking... + moveSourcesRight(GG) + + // Apply calculated ranks to original graph + // const ranks = [] + GG.nodes().forEach(u => { + const groupedNode = GG.node(u) + // while (node.rank >= ranks.length) ranks.push([]) + groupedNode.nodes.forEach(v => { + G.node(v).rank = groupedNode.rank + }) + }) + // return ranks +} + +// export function nodeBackwards (link) { +// if (link.source.direction === 'l') { +// return link.target.direction === 'l' ? 1 : 0 +// } else { +// return link.target.direction === 'l' ? 0 : 1 +// } +// } + +function addTemporaryEdges (GG) { + // Add temporary edges between Smin and sources + GG.sources().forEach(u => { + if (u !== '0') { + GG.setEdge('0', u, { temp: true, delta: 0 }) + } + }) + + // XXX Should also add edges from sinks to Smax + + // G.nodes().forEach(u => { + // if (!nodeSets.has(u)) { + // GG. + // } + // }); +} + +function moveSourcesRight (GG) { + GG.edges().forEach(e => { + const edge = GG.edge(e) + if (edge.temp) moveRight(e.w) + }) + + function moveRight (v) { + const V = GG.node(v) + const rank = min(GG.outEdges(v), e => GG.node(e.w).rank - GG.edge(e).delta) + if (rank !== undefined) V.rank = rank + } +} diff --git a/src/assignRanks/initial-ranks.js b/src/assignRanks/initial-ranks.js new file mode 100644 index 0000000..b6b84a8 --- /dev/null +++ b/src/assignRanks/initial-ranks.js @@ -0,0 +1,53 @@ +import { set } from 'd3-collection' + +/** + * Take an acyclic graph and assign initial ranks to the nodes + */ +export default function assignInitialRanks (G) { + // Place nodes on queue when they have no unmarked in-edges. Initially, this + // means sources. + const queue = G.sources() + const seen = set() + const marked = set() + + // Mark any loops, since they don't affect rank assignment + G.edges().forEach(e => { + if (e.v === e.w) marked.add(edgeIdString(e)) + }) + + G.nodes().forEach(v => { + G.node(v).rank = 0 + }) + + while (queue.length > 0) { + const v = queue.shift() + seen.add(v) + + let V = G.node(v) + if (!V) G.setNode(v, (V = {})) + + // Set rank to minimum of incoming edges + V.rank = 0 + G.inEdges(v).forEach(e => { + const delta = G.edge(e).delta === undefined ? 1 : G.edge(e).delta + V.rank = Math.max(V.rank, G.node(e.v).rank + delta) + }) + + // Mark outgoing edges + G.outEdges(v).forEach(e => { + marked.add(edgeIdString(e)) + }) + + // Add nodes to queue when they have no unmarked in-edges. + G.nodes().forEach(n => { + if (queue.indexOf(n) < 0 && !seen.has(n) && + !G.inEdges(n).some(e => !marked.has(edgeIdString(e)))) { + queue.push(n) + } + }) + } +} + +function edgeIdString (e) { + return e.v + '\x01' + e.w + '\x01' + e.name +} diff --git a/src/assignRanks/make-acyclic.js b/src/assignRanks/make-acyclic.js new file mode 100644 index 0000000..4183738 --- /dev/null +++ b/src/assignRanks/make-acyclic.js @@ -0,0 +1,85 @@ +import { Graph } from 'graphlib' +import { set } from 'd3-collection' + +/** + * Reverse edges in G to make it acyclic + */ +export default function makeAcyclic (G, v0) { + const tree = findSpanningTree(G, v0) + + G.edges().forEach(e => { + const rel = nodeRelationship(tree, e.v, e.w) + if (rel < 0) { + const label = G.edge(e) || {} + label.reversed = true + G.removeEdge(e) + G.setEdge(e.w, e.v, label) + } + }) + + return G +} + +// find spanning tree, starting from the given node. +// return new graph where nodes have depth and thread +export function findSpanningTree (G, v0) { + const visited = set() + const tree = new Graph({directed: true}) + const thread = [] + + if (!G.hasNode(v0)) throw Error('node not in graph') + + doDfs(G, v0, visited, tree, thread) + G.nodes().forEach(u => { + if (!visited.has(u)) { + doDfs(G, u, visited, tree, thread) + } + }) + + thread.forEach((u, i) => { + tree.node(u).thread = (i + 1 < thread.length) ? thread[i + 1] : thread[0] + }) + + return tree +} + +/** + * Returns 1 if w is a descendent of v, -1 if v is a descendent of w, and 0 if + * they are unrelated. + */ +export function nodeRelationship (tree, v, w) { + const V = tree.node(v) + const W = tree.node(w) + if (V.depth < W.depth) { + let u = V.thread // next node + while (tree.node(u).depth > V.depth) { + if (u === w) return 1 + u = tree.node(u).thread + } + } else if (W.depth < V.depth) { + let u = W.thread // next node + while (tree.node(u).depth > W.depth) { + if (u === v) return -1 + u = tree.node(u).thread + } + } + return 0 +} + +function doDfs (G, v, visited, tree, thread, depth = 0) { + if (!visited.has(v)) { + visited.add(v) + thread.push(v) + tree.setNode(v, { depth: depth }) + + // It doesn't seem to cause a problem with letters as node ids, but numbers + // are sorted when using G.successors(). So use G.outEdges() instead. + const next = G.outEdges(v).map(e => e.w) + next.forEach((w, i) => { + if (!visited.has(w)) { + tree.setEdge(v, w, { delta: 1 }) + } + doDfs(G, w, visited, tree, thread, depth + 1) + }) + } +} diff --git a/src/diagram.js b/src/diagram.js index 4a5f20d..b8c830f 100644 --- a/src/diagram.js +++ b/src/diagram.js @@ -1,178 +1,218 @@ // The reusable SVG component for the sliced Sankey diagram -import { sankeyLayout } from 'sankey-layout'; +import sankeyLink from './linkPath.js' +import sankeyNode from './node.js' +import positionGroup from './positionGroup.js' + +import {select, event} from 'd3-selection' +import {transition} from 'd3-transition' +import {dispatch} from 'd3-dispatch' +import {format} from 'd3-format' +import {interpolate} from 'd3-interpolate' +import {map} from 'd3-collection' + +export function linkTitleGenerator (nodeTitle, typeTitle, fmt) { + return function (d) { + const parts = [] + const sourceTitle = nodeTitle(d.source) + const targetTitle = nodeTitle(d.target) + const matTitle = typeTitle(d) + + parts.push(`${sourceTitle} → ${targetTitle}`) + if (matTitle) parts.push(matTitle) + parts.push(fmt(d.value)) + return parts.join('\n') + } +} -import sankeyLink from './link'; -import sankeyNode from './node'; -import positionGroup from './positionGroup'; +export default function sankeyDiagram () { + let margin = {top: 0, right: 0, bottom: 0, left: 0} -import d3 from 'd3'; -import {Graph} from 'graphlib'; + let selectedNode = null + let selectedEdge = null + let groups = [] -export default function sankeyDiagram() { + const fmt = format('.3s') + const node = sankeyNode() + const link = sankeyLink() - let margin = {top: 100, right: 100, bottom: 100, left: 100}, - width = 500, - height = 500; + let linkColor = d => null + let linkTitle = linkTitleGenerator(node.nodeTitle(), d => d.type, fmt) - let duration = 500; + const listeners = dispatch('selectNode', 'selectGroup', 'selectLink') - let nodeCustom = (d => null), - linkCustom = (d => null), - linkTypeTitle = (d => d.data.type); + /* Main chart */ - let selectedNode = null, - selectedEdge = null; + function exports (context) { + const selection = context.selection ? context.selection() : context - const layout = sankeyLayout() - .whitespace(0.5) - .separation(nodeSeparation); + selection.each(function (G) { + // Create the skeleton, if it doesn't already exist + const svg = select(this) - const format = d3.format('.3s'); + let sankey = svg.selectAll('.sankey') + .data([{type: 'sankey'}]) - const node = sankeyNode(), - link = sankeyLink() - .linkTitle(linkTitle); + const sankeyEnter = sankey.enter() + .append('g') + .classed('sankey', true) - /* Main chart */ + sankeyEnter.append('g').classed('groups', true) + sankeyEnter.append('g').classed('links', true) // Links below nodes + sankeyEnter.append('g').classed('nodes', true) + sankeyEnter.append('g').classed('slice-titles', true) // Slice titles - var dispatch = d3.dispatch('selectNode', 'selectGroup', 'selectLink'); - function exports(_selection) { - _selection.each(function(datum) { + sankey = sankey.merge(sankeyEnter) - // Create the skeleton, if it doesn't already exist - const svg = d3.select(this).selectAll('svg').data([datum]); - createGroups(svg); - - // Update dimensions - svg.attr({width: width, height: height}); - svg.select('.sankey') - .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); - svg.select('.slice-titles') - .attr('transform', 'translate(' + margin.left + ',0)'); - - // Do Sankey layout - if (!datum) return; - layout.size([width - margin.left - margin.right, - height - margin.top - margin.bottom]); - layout(datum.links || [], datum.nodes || [], { - rankSets: datum.rankSets || [], - order: datum.order || null, - alignLinkTypes: datum.alignLinkTypes || false, - }); - - const links = layout.links(); - if (datum.overrideLinks) { - links.forEach(link => { - const override = datum.overrideLinks[link.id]; - if (override && override.r0 !== undefined) link.r0 = override.r0; - if (override && override.r1 !== undefined) link.r1 = override.r1; - }); - } + // Update margins + sankey + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') + // .select('.slice-titles') + // .attr('transform', 'translate(' + margin.left + ',0)') // Groups of nodes - const nodeMap = new Map(); - layout.nodes().forEach(n => nodeMap.set(n.id, n)); - const groups = (datum.groups || []).map(g => positionGroup(nodeMap, g)); + const nodeMap = map(G.nodes, n => n.id) + const groupsPositioned = (groups || []).map(g => positionGroup(nodeMap, g)) // Render - updateNodes(svg, layout.nodes()); - updateLinks(svg, layout.links()); - updateGroups(svg, groups); + updateNodes(sankey, context, G.nodes) + updateLinks(sankey, context, G.links) + updateGroups(svg, groupsPositioned) // updateSlices(svg, layout.slices(nodes)); // Events - svg.on('click', function() { - dispatch.selectNode.call(this, null); - dispatch.selectLink.call(this, null); - }); + svg.on('click', function () { + listeners.call('selectNode', this, null) + listeners.call('selectLink', this, null) + }) + }) + } - }); + function updateNodes (sankey, context, nodes) { + var nodeSel = sankey + .select('.nodes') + .selectAll('.node') + .data(nodes, d => d.id) + + // EXIT + nodeSel.exit().remove() + + nodeSel = nodeSel.merge( + nodeSel.enter() + .append('g') + .attr('class', 'node') + .call(node) + .on('click', selectNode)) + + if (context instanceof transition) { + nodeSel.transition(context) + .call(node) + } else { + nodeSel.call(node) + } } - function updateNodes(svg, nodes) { - var nodeSel = svg.select('.nodes').selectAll('.node') - .data(nodes, d => d.id); + function updateLinks (sankey, context, edges) { + var linkSel = sankey + .select('.links') + .selectAll('.link') + .data(edges, d => d.source.id + '-' + d.target.id + '-' + d.type) - nodeSel.enter() - .append('g') - .call(node) - .call(nodeCustom) - .classed('node', true) - .on('click', selectNode); + // EXIT - getTransition(nodeSel) - .call(node) - .call(nodeCustom); + linkSel.exit().remove() - nodeSel.exit().remove(); - } + // ENTER - function updateLinks(svg, edges) { - var linkSel = svg.select('.links').selectAll('path') - .data(edges, d => d.id); + var linkEnter = linkSel.enter() + .append('g') + .attr('class', 'link') + .on('click', selectLink) - linkSel.enter() - .append('path') - .call(linkCustom) - .classed('link', true) - .on('click', selectLink); + linkEnter.append('path') + .attr('d', link) + .style('fill', 'white') + .each(function (d) { this._current = d }) - // Update - getTransition(linkSel) - .call(link) - .call(linkCustom); + linkEnter.append('title') - linkSel.classed('selected', (d) => d.id === selectedEdge); - linkSel.sort(linkOrder); + // UPDATE - linkSel.exit().remove(); - } + linkSel = linkSel.merge(linkEnter) + if (context instanceof transition) { + linkSel + .transition(context) + .select('path') + .style('fill', linkColor) + .each(function (d) { + select(this) + .transition(context) + .attrTween('d', interpolateLink) + }) + } else { + linkSel + .select('path') + .style('fill', linkColor) + .attr('d', link) + } - function updateSlices(svg, slices) { - var slice = svg.select('.slice-titles').selectAll('.slice') - .data(slices, function(d) { return d.id; }); - - var textWidth = (slices.length > 1 ? - 0.9 * (slices[1].x - slices[0].x) : - null); - - slice.enter().append('g') - .attr('class', 'slice') - .append('foreignObject') - .attr('requiredFeatures', - 'http://www.w3.org/TR/SVG11/feature#Extensibility') - .attr('height', margin.top) - .attr('class', 'title') - .append('xhtml:div') - .style('text-align', 'center') - .style('word-wrap', 'break-word'); - // .text(pprop('sliceMetadata', 'title')); - - slice - .attr('transform', function(d) { - return 'translate(' + (d.x - textWidth / 2) + ',0)'; }) - .select('foreignObject') - .attr('width', textWidth) - .select('div'); - // .text(pprop('sliceMetadata', 'title')); - - slice.exit().remove(); - } + linkSel.select('title') + .text(linkTitle) - function updateGroups(svg, groups) { - const group = svg.select('.groups').selectAll('.group') - .data(groups); + linkSel.classed('selected', (d) => d.id === selectedEdge) + linkSel.sort(linkOrder) + } + // function updateSlices(svg, slices) { + // var slice = svg.select('.slice-titles').selectAll('.slice') + // .data(slices, function(d) { return d.id; }); + + // var textWidth = (slices.length > 1 ? + // 0.9 * (slices[1].x - slices[0].x) : + // null); + + // slice.enter().append('g') + // .attr('class', 'slice') + // .append('foreignObject') + // .attr('requiredFeatures', + // 'http://www.w3.org/TR/SVG11/feature#Extensibility') + // .attr('height', margin.top) + // .attr('class', 'title') + // .append('xhtml:div') + // .style('text-align', 'center') + // .style('word-wrap', 'break-word'); + // // .text(pprop('sliceMetadata', 'title')); + + // slice + // .attr('transform', function(d) { + // return 'translate(' + (d.x - textWidth / 2) + ',0)'; }) + // .select('foreignObject') + // .attr('width', textWidth) + // .select('div'); + // // .text(pprop('sliceMetadata', 'title')); + + // slice.exit().remove(); + // } + + function updateGroups (svg, groups) { + let group = svg.select('.groups').selectAll('.group') + .data(groups) + + // EXIT + group.exit().remove() + + // ENTER const enter = group.enter().append('g') .attr('class', 'group') - .on('click', selectGroup); + // .on('click', selectGroup); - enter.append('rect'); + enter.append('rect') enter.append('text') .attr('x', -10) - .attr('y', -25); + .attr('y', -25) + + group = group.merge(enter) group .style('display', d => d.title && d.nodes.length > 1 ? 'inline' : 'none') @@ -181,184 +221,119 @@ export default function sankeyDiagram() { .attr('x', -10) .attr('y', -20) .attr('width', d => d.rect.right - d.rect.left + 20) - .attr('height', d => d.rect.bottom - d.rect.top + 30); + .attr('height', d => d.rect.bottom - d.rect.top + 30) group.select('text') - .text(d => d.title); - - group.exit().remove(); + .text(d => d.title) } - function linkOrder(a, b) { - // var f = style('edges', 'zIndex'); - // return (f(a) || 0) - (f(b) || 0); - if (a.id === selectedEdge) return +1; - if (b.id === selectedEdge) return -1; - if (!a.source || a.target && a.target.direction === 'd') return -1; - if (!b.source || b.target && b.target.direction === 'd') return +1; - if (!a.target || a.source && a.source.direction === 'd') return -1; - if (!b.target || b.source && b.source.direction === 'd') return +1; - return a.dy - b.dy; + function interpolateLink (b) { + // XXX should limit radius better + b.points.forEach(function (p) { + if (p.ri > 1e3) p.ri = 1e3 + if (p.ro > 1e3) p.ro = 1e3 + }) + var interp = interpolate(linkGeom(this._current), b) + var that = this + return function (t) { + that._current = interp(t) + return link(that._current) + } } - function linkTitle(d) { - const parts = []; - const sourceTitle = node.nodeTitle()(d.source), - targetTitle = node.nodeTitle()(d.target), - matTitle = linkTypeTitle(d); - parts.push(`${sourceTitle} → ${targetTitle}`); - if (matTitle) parts.push(matTitle); - parts.push(format(d.value)); - return parts.join('\n'); + function linkGeom (l) { + return { + points: l.points, + dy: l.dy + } } - function selectLink(d) { - d3.event.stopPropagation(); - var el = d3.select(this)[0][0]; - dispatch.selectLink.call(el, d); + function linkOrder (a, b) { + if (a.id === selectedEdge) return +1 + if (b.id === selectedEdge) return -1 + if (!a.source || a.target && a.target.direction === 'd') return -1 + if (!b.source || b.target && b.target.direction === 'd') return +1 + if (!a.target || a.source && a.source.direction === 'd') return -1 + if (!b.target || b.source && b.source.direction === 'd') return +1 + return a.dy - b.dy } - function selectNode(d) { - d3.event.stopPropagation(); - var el = d3.select(this)[0][0]; - dispatch.selectNode.call(el, d); + function selectLink (d) { + event.stopPropagation() + var el = select(this).node() + listeners.call('selectLink', el, d) } - function selectGroup(d) { - d3.event.stopPropagation(); - var el = d3.select(this)[0][0]; - dispatch.selectGroup.call(el, d); + function selectNode (d) { + event.stopPropagation() + var el = select(this).node() + listeners.call('selectNode', el, d) } - function getTransition(sel) { - if (duration === null) { - return sel; - } else { - return sel.transition() - .ease('linear') - .duration(duration); - } - } + // function selectGroup(d) { + // d3.event.stopPropagation(); + // var el = d3.select(this)[0][0]; + // dispatch.selectGroup.call(el, d); + // } - /* Public API */ - exports.width = function(_x) { - if (!arguments.length) return width; - width = parseInt(_x, 10); - return this; - }; - - exports.height = function(_x) { - if (!arguments.length) return height; - height = parseInt(_x, 10); - return this; - }; - - exports.margins = function(_x) { - if (!arguments.length) return margin; + exports.margins = function (_x) { + if (!arguments.length) return margin margin = { top: _x.top === undefined ? margin.top : _x.top, left: _x.left === undefined ? margin.left : _x.left, bottom: _x.bottom === undefined ? margin.bottom : _x.bottom, - right: _x.right === undefined ? margin.right : _x.right, - }; - return this; - }; - - exports.duration = function(_x) { - if (!arguments.length) return duration; - duration = _x === null ? null : parseFloat(_x); - return this; - }; - - exports.linkValue = function(_x) { - if (!arguments.length) return layout.linkValue(); - layout.linkValue(_x); - return this; - }; - - // Node styles and title + right: _x.right === undefined ? margin.right : _x.right + } + return this + } - exports.nodeTitle = function(_x) { - if (!arguments.length) return node.nodeTitle(); - node.nodeTitle(_x); - return this; - }; + exports.groups = function (_x) { + if (!arguments.length) return groups + groups = _x + return this + } - exports.node = function(_x) { - if (!arguments.length) return nodeCustom; - nodeCustom = _x; - return this; - }; + // Node styles and title + exports.nodeTitle = function (_x) { + if (!arguments.length) return node.nodeTitle() + node.nodeTitle(_x) + linkTitle = linkTitleGenerator(_x, d => d.type, fmt) + return this + } // Link styles and titles - - exports.linkTypeTitle = function(_x) { - if (!arguments.length) return linkTypeTitle; - linkTypeTitle = d3.functor(_x); - return this; - }; - - exports.link = function(_x) { - if (!arguments.length) return linkCustom; - linkCustom = _x; - return this; - }; - - exports.scale = function(_x) { - if (!arguments.length) return layout.scale(); - layout.scale(_x); - return this; - }; - - exports.selectNode = function(_x) { - selectedNode = _x; - return this; - }; - - exports.selectLink = function(_x) { - selectedEdge = _x; - return this; - }; - - d3.rebind(exports, dispatch, 'on'); - return exports; -} - -function nodeSeparation(a, b, G) { - const a0 = G.inEdges(a).map(e => e.v), - b0 = G.inEdges(b).map(e => e.v), - a1 = G.outEdges(a).map(e => e.w), - b1 = G.outEdges(b).map(e => e.w); - let k = 0, n = 0, i; - - for (i = 0; i < b0.length; ++i) { - ++n; - if (a0.indexOf(b0[i]) !== -1) ++k; + exports.linkTitle = function (_x) { + if (!arguments.length) return linkTitle + linkTitle = _x + return this } - for (i = 0; i < a0.length; ++i) { - ++n; - if (b0.indexOf(a0[i]) !== -1) ++k; + + exports.linkColor = function (_x) { + if (!arguments.length) return linkColor + linkColor = _x + return this } - for (i = 0; i < b1.length; ++i) { - ++n; - if (a1.indexOf(b1[i]) !== -1) ++k; + + exports.linkMinWidth = function (_x) { + if (!arguments.length) return link.minWidth() + link.minWidth(_x) + return this } - for (i = 0; i < a1.length; ++i) { - ++n; - if (b1.indexOf(a1[i]) !== -1) ++k; + + exports.selectNode = function (_x) { + selectedNode = _x + return this } - if (n === 0) { return 1; } - return 1 - 0.6 * k / n; -} + exports.selectLink = function (_x) { + selectedEdge = _x + return this + } + exports.on = function () { + var value = listeners.on.apply(listeners, arguments) + return value === listeners ? exports : value + } -function createGroups(svg) { - const gEnter = svg.enter().append('svg') - .append('g') - .classed('sankey', true); - gEnter.append('g').classed('groups', true); - gEnter.append('g').classed('links', true); // Links below nodes - gEnter.append('g').classed('nodes', true); - gEnter.append('g').classed('slice-titles', true); // Slice titles + return exports } diff --git a/src/linkPath.js b/src/linkPath.js index 454f2a0..4bb039e 100644 --- a/src/linkPath.js +++ b/src/linkPath.js @@ -1,6 +1,16 @@ -import d3 from 'd3'; +import { interpolate } from 'd3-interpolate' + +// function defaultSegments (d) { +// return d.segments +// } + +function defaultMinWidth (d) { + return (d.dy === 0) ? 0 : 2 +} export default function sankeyLink() { + // var segments = defaultSegments + var minWidth = defaultMinWidth function radiusBounds(d) { var Dx = d.x1 - d.x0, @@ -11,8 +21,27 @@ export default function sankeyLink() { } function link(d) { + var path = '' + var seg + for (var i = 0; i < d.points.length - 1; ++i) { + seg = { + x0: d.points[i].x, + y0: d.points[i].y, + x1: d.points[i + 1].x, + y1: d.points[i + 1].y, + r0: d.points[i].ro, + r1: d.points[i + 1].ri, + d0: d.points[i].d, + d1: d.points[i + 1].d, + dy: d.dy + } + path += segmentPath(seg) + } + return path + } + + function segmentPath (d) { var dir = (d.d0 || 'r') + (d.d1 || 'r'); - //console.log(dir, d); if (d.source && d.source === d.target) { return selfLink(d); } @@ -30,7 +59,7 @@ export default function sankeyLink() { } // Minimum thickness 2px - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, x0 = d.x0, x1 = d.x1, y0 = d.y0, @@ -54,7 +83,7 @@ export default function sankeyLink() { dcy = (y1 - y0) - f * (r0 + r1), D = Math.sqrt(dcx*dcx + dcy*dcy); - const phi = -f * Math.acos((r0 + r1) / D), + const phi = -f * Math.acos(Math.min(1, (r0 + r1) / D)), psi = Math.atan2(dcy, dcx); let theta = Math.PI/2 + f * (psi + phi); @@ -76,11 +105,6 @@ export default function sankeyLink() { hc = h; } - // if (d.source.id === 'ImprovedGrass' && - // d.target.id === 'Domestic bioenergy') { - // console.log('link', r, d.r, d.Rmax); - // } - function arc(dir, r) { var f = ( dir * (y1-y0) > 0) ? 1 : 0, rr = (fx * dir * (y1-y0) > 0) ? (r + h) : (r - h); @@ -90,7 +114,9 @@ export default function sankeyLink() { } var path; - if (fx * (x2 - x3) < 0 || Math.abs(y1 - y0) > 4*h) { + // if (fx * (x2 - x3) < 0 || Math.abs(y1 - y0) > 4*h) { + // XXX this causes juddering during transitions + if (true) { path = ("M" + [x0, y0-h ] + " " + arc(+1, r0) + [x2+hs, y2-hc] + " " + "L" + [x3+hs, y3-hc] + " " + @@ -120,7 +146,7 @@ export default function sankeyLink() { } function selfLink(d) { - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, r = h*1.5, theta = 2 * Math.PI, x0 = d.x0, @@ -141,7 +167,7 @@ export default function sankeyLink() { function fbLink(d) { // Minimum thickness 2px - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, x0 = d.x0, x1 = d.x1, y0 = d.y0, @@ -182,7 +208,7 @@ export default function sankeyLink() { function fdLink(d) { // Minimum thickness 2px - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, x0 = d.x0, x1 = d.x1, y0 = d.y0, @@ -213,7 +239,7 @@ export default function sankeyLink() { function dfLink(d) { // Minimum thickness 2px - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, x0 = d.x0, x1 = d.x1, y0 = d.y0, @@ -244,7 +270,7 @@ export default function sankeyLink() { function bfLink(d) { // Minimum thickness 2px - var h = (d.dy === 0) ? 0 : Math.max(1, d.dy / 2), + var h = Math.max(minWidth(d), d.dy) / 2, x0 = d.x0, x1 = d.x1, y0 = d.y0, @@ -283,5 +309,18 @@ export default function sankeyLink() { "Z"); } + link.minWidth = function (x) { + if (arguments.length) { + minWidth = required(x) + return link + } + return minWidth + } + return link; } + +function required (f) { + if (typeof f !== 'function') throw new Error() + return f +} diff --git a/src/node.js b/src/node.js index 3f253cf..7fa69f9 100644 --- a/src/node.js +++ b/src/node.js @@ -1,279 +1,148 @@ -import d3 from 'd3'; +import { select, local } from 'd3-selection' +export default function () { + let nodeTitle = (d) => d.title !== undefined ? d.title : d.id + let nodeVisible = (d) => !!nodeTitle(d) -export default function() { - let nodeTitle = (d) => d.id, - nodeVisible = (d) => nodeTitle(d) ? true : false; + function sankeyNode (context) { + const selection = context.selection ? context.selection() : context - function sankeyNode(context) { - context.each(function(d) { - // transition is either the selection or the transition - const g = d3.select(this), - transition = d3.transition(g); - - const title = g.selectAll('title').data([0]), - text = g.selectAll('text').data([0]), - line = g.selectAll('line').data([0]), - clickTarget = g.selectAll('rect').data([0]); - - // Enter - title.enter().append('title'); - text.enter().append('text'); - line.enter().append('line'); - clickTarget.enter().append('rect') + if (selection.select('text').empty()) { + selection.append('title') + selection.append('text') + .attr('dy', '.35em') + selection.append('line') + .attr('x1', 0) + .attr('x2', 0) + selection.append('rect') .attr('x', -5) .attr('y', -5) + .attr('width', 10) .style('fill', 'none') .style('visibility', 'hidden') - .style('pointer-events', 'all'); + .style('pointer-events', 'all') - // Update - transition + selection .attr('transform', nodeTransform) - .style('display', function(d) { - if (d.dy === 0 || d.dummy || !nodeVisible(d)) { - return 'none'; - } else return 'inline'; - }); - - let {titleAbove, right} = titlePosition(d), - dy = (d.dy === 0) ? 0 : Math.max(1, d.dy); - - let x, y; - - // if (getNodeOffstage(d)) { - // // horizontal line - // transition.select('line') - // .attr('x1', 0) - // .attr('x2', 0) // don't show - // .attr('y1', 0) - // .attr('y2', 0); - - // transition.select('rect') - // .attr('height', 10) - // .attr('width', dy); - - // if (dy < 80) { - // transition.select('text') - // .attr('transform', - // 'translate(' + (dy/2) + ',' + - // (d.incoming.length ? 5 : -5) + ') ' + - // 'rotate(' + (d.incoming.length ? 90 : -90) + ')') - // .attr('text-anchor', 'start') - // .attr('dy', '.35em'); - // } else { - // transition.select('text') - // .attr('transform', 'translate(0,10)') - // .attr('text-anchor', 'start') - // .attr('dy', '.35em'); - // } - // } else { - - // vertical line - transition.select('line') - .attr('x1', 0) - .attr('x2', 0) - .attr('y1', titleAbove ? -5 : 0) - .attr('y2', dy); - - transition.select('rect') - .attr('width', 10) - .attr('height', dy + 5); - - y = titleAbove ? -10 : d.dy / 2; - x = (right ? 1 : -1) * (titleAbove ? 4 : -4); - transition.select('text') - .attr('transform', 'translate(' + x + ',' + y + ')') - .attr('text-anchor', right ? 'end' : 'start') - .attr('dy', '.35em'); - - // } - - let t, tOpacity; - if (false) { // }showIds) { - t = d => nodeTitle(d) || d.id; - tOpacity = d => nodeTitle(d) ? null : 0.1; - } else { - // don't cann nodeTitle on dummy nodes - t = d.data ? nodeTitle : (d => ''); - tOpacity = 1; - } - - g.select('title') - .text(t); - - g.select('text') - .text(t) - .style('opacity', tOpacity) - .call(wrap, 100); - }); - } - - sankeyNode.nodeVisible = function(x) { - if (!arguments.length) return nodeVisible; - nodeVisible = d3.functor(x); - return sankeyNode; - }; - - sankeyNode.nodeTitle = function(x) { - if (!arguments.length) return nodeTitle; - nodeTitle = d3.functor(x); - return sankeyNode; - }; - - return sankeyNode; -} + } + let title = selection.select('title') + let text = selection.select('text') + let line = selection.select('line') + let clickTarget = selection.select('rect') + + // Local var for title position of each node + const nodeLayout = local() + selection.each(function (d) { + const layoutData = titlePosition(d) + layoutData.dy = (d.y0 === d.y1) ? 0 : Math.max(1, d.y1 - d.y0) + nodeLayout.set(this, layoutData) + }) + + // Update un-transitioned + title + .text(nodeTitle) -function positionTitle(nodeSelection) { - nodeSelection.each(function(d) { - var node = d3.select(this), - transition = d3.transition(node), - titleAbove = false, - right = false, - dy = (d.dy === 0) ? 0 : Math.max(1, d.dy); + text + .attr('text-anchor', function (d) { return nodeLayout.get(this).right ? 'end' : 'start' }) + .text(nodeTitle) + .each(wrap, 100) - // If thin, and there's enough space, put above - if (d.spaceAbove > 20 && d.style !== 'type') { - titleAbove = true; - } else { - titleAbove = false; - if (d.outgoing.length == 1 && d.incoming.length > 1) { - right = false; - } else if (d.incoming.length == 1 && d.outgoing.length > 1) { - right = true; - } + // Are we in a transition? + if (context !== selection) { + text = text.transition(context) + line = line.transition(context) + clickTarget = clickTarget.transition(context) } - // Stick labels outside at edges - if (d.incoming.length === 0) { - right = true; - titleAbove = false; - } else if (d.outgoing.length === 0) { - right = false; - titleAbove = false; + // Update + context + .attr('transform', nodeTransform) + + line + .attr('y1', function (d) { return nodeLayout.get(this).titleAbove ? -5 : 0 }) + .attr('y2', function (d) { return nodeLayout.get(this).dy }) + .style('display', function (d) { + return (d.y0 === d.y1 || !nodeVisible(d)) ? 'none' : 'inline' + }) + + clickTarget + .attr('height', function (d) { return nodeLayout.get(this).dy + 5 }) + + text + .attr('transform', textTransform) + .style('display', function (d) { + return (d.y0 === d.y1 || !nodeVisible(d)) ? 'none' : 'inline' + }) + + function textTransform (d) { + const layout = nodeLayout.get(this) + const y = layout.titleAbove ? -10 : (d.y1 - d.y0) / 2 + const x = (layout.right ? 1 : -1) * (layout.titleAbove ? 4 : -4) + return 'translate(' + x + ',' + y + ')' } + } - var x, y; - - if (false) { // XXX }getNodeOffstage(d)) { - // horizontal line - transition.select('line') - .attr('x1', 0) - .attr('x2', 0) // don't show - .attr('y1', 0) - .attr('y2', 0); - - transition.select('rect') - .attr('height', 10) - .attr('width', dy); - - if (dy < 80) { - transition.select('text') - .attr('transform', - 'translate(' + (dy/2) + ',' + - (d.incoming.length ? 5 : -5) + ') ' + - 'rotate(' + (d.incoming.length ? 90 : -90) + ')') - .attr('text-anchor', 'start') - .attr('dy', '.35em'); - } else { - transition.select('text') - .attr('transform', 'translate(0,10)') - .attr('text-anchor', 'start') - .attr('dy', '.35em'); - } - } else { - // vertical line - transition.select('line') - .attr('x1', 0) - .attr('x2', 0) - .attr('y1', titleAbove ? -5 : 0) - .attr('y2', dy); - - transition.select('rect') - .attr('width', 10) - .attr('height', dy + 5); - - y = titleAbove ? -10 : d.dy / 2; - x = (right ? 1 : -1) * (titleAbove ? 4 : -4); - transition.select('text') - .attr('transform', 'translate(' + x + ',' + y + ')') - .attr('text-anchor', right ? 'end' : 'start') - .attr('dy', '.35em'); + sankeyNode.nodeVisible = function (x) { + if (arguments.length) { + nodeVisible = required(x) + return sankeyNode } + return nodeVisible + } - let metaTitle = pprop('nodeMetadata', 'title'), - nodeTitle, - titleOpacity; - - if (showIds) { - nodeTitle = d => metaTitle(d) || d.id; - titleOpacity = d => metaTitle(d) ? null : 0.1; - } else { - nodeTitle = metaTitle; - titleOpacity = 1; + sankeyNode.nodeTitle = function (x) { + if (arguments.length) { + nodeTitle = required(x) + return sankeyNode } + return nodeTitle + } - node.select('title') - .text(nodeTitle); - - node.select('text') - .text(nodeTitle) - .style('opacity', titleOpacity) - .call(wrap, 100); - }); - - - - return node; + return sankeyNode } -function nodeTransform(d) { - return 'translate(' + d.x + ',' + d.y + ')'; +function nodeTransform (d) { + return 'translate(' + d.x0 + ',' + d.y0 + ')' } - -function titlePosition(d) { - let titleAbove = false, - right = false; +function titlePosition (d) { + let titleAbove = false + let right = false // If thin, and there's enough space, put above if (d.spaceAbove > 20 && d.style !== 'type') { - titleAbove = true; + titleAbove = true } else { - titleAbove = false; - if (d.outgoing.length == 1 && d.incoming.length > 1) { - right = false; - } else if (d.incoming.length == 1 && d.outgoing.length > 1) { - right = true; - } + titleAbove = false } - // Stick labels outside at edges if (d.incoming.length === 0) { - right = true; - titleAbove = false; + right = true + titleAbove = false } else if (d.outgoing.length === 0) { - right = false; - titleAbove = false; + right = false + titleAbove = false } - return {titleAbove, right}; + return {titleAbove, right} } +function wrap (d, width) { + var text = select(this) + var lines = text.text().split(/\n/) + var lineHeight = 1.1 // ems + if (lines.length === 1) return + text.text(null) + lines.forEach(function (line, i) { + text.append('tspan') + .attr('x', 0) + .attr('dy', (i === 0 ? 0.7 - lines.length / 2 : 1) * lineHeight + 'em') + .text(line) + }) +} -function wrap(text, width) { - text.each(function() { - var text = d3.select(this), - lines = text.text().split(/\n/), - lineHeight = 1.1; // ems - if (lines.length === 1) { return; } - text.text(null); - lines.forEach(function(line, i) { - text.append("tspan") - .attr("x", 0) - .attr("dy", (i === 0 ? -lines.length/2 : 1) * lineHeight + 'em') - .text(line); - }); - }); +function required (f) { + if (typeof f !== 'function') throw new Error() + return f } diff --git a/src/positionGroup.js b/src/positionGroup.js index 698035d..06e3690 100644 --- a/src/positionGroup.js +++ b/src/positionGroup.js @@ -1,19 +1,20 @@ -export default function positionGroup(nodes, group) { +export default function positionGroup (nodes, group) { const rect = { top: Number.MAX_VALUE, left: Number.MAX_VALUE, bottom: 0, right: 0 - }; + } group.nodes.forEach(n => { - const node = nodes.get(n); - if (!node) return; - if (node.x < rect.left) rect.left = node.x; - if (node.x > rect.right) rect.right = node.x; - if (node.y < rect.top) rect.top = node.y; - if (node.y + node.dy > rect.bottom) rect.bottom = node.y + node.dy; - }); + const node = nodes.get(n) + if (!node) return + if (node.x0 < rect.left) rect.left = node.x0 + if (node.x1 > rect.right) rect.right = node.x1 + if (node.y0 < rect.top) rect.top = node.y0 + if (node.y1 > rect.bottom) rect.bottom = node.y1 + }) - return Object.assign({}, group, { rect }); + group.rect = rect + return group } diff --git a/src/sankey.js b/src/sankey.js new file mode 100644 index 0000000..6dee9e7 --- /dev/null +++ b/src/sankey.js @@ -0,0 +1,422 @@ +/** + */ + +import { sum } from 'd3-array' +import assignRanks from './assignRanks/index.js' +import sortNodes from './sortNodes/index.js' +import { addDummyNodes, removeDummyNodes } from './sortNodes/dummy-nodes.js' +import nestGraph from './sankeyLayout/nest-graph.js' +import positionHorizontally from './sankeyLayout/horizontal.js' +import positionVertically from './sankeyLayout/verticalJustified.js' +import prepareNodePorts from './sankeyLayout/prepare-subdivisions.js' +import orderLinks from './sankeyLayout/link-ordering.js' +import layoutLinks from './sankeyLayout/layout-links.js' +import { buildGraph } from './util.js' + +function defaultNodes (graph) { + return graph.nodes +} + +function defaultLinks (graph) { + return graph.links +} + +function defaultNodeId (d) { + return d.id +} + +function defaultNodeBackwards (d) { + return d.direction && d.direction.toLowerCase() === 'l' +} + +function defaultSourceId (d) { + // return typeof d.source === 'object' ? d.source.id : d.source + return { + id: typeof d.source === 'object' ? d.source.id : d.source, + port: typeof d.sourcePort === 'object' ? d.sourcePort.id : d.sourcePort + } +} + +function defaultTargetId (d) { + // return typeof d.target === 'object' ? d.target.id : d.target + return { + id: typeof d.target === 'object' ? d.target.id : d.target, + port: typeof d.targetPort === 'object' ? d.targetPort.id : d.targetPort + } +} + +function defaultLinkType (d) { + return d.type +} + +function defaultSortPorts (a, b) { + // XXX weighted sum + return a.id.localeCompare(b.id) +} + +// function defaultNodeSubdivisions + +export default function sankeyLayout () { + var nodes = defaultNodes + var links = defaultLinks + var nodeId = defaultNodeId + var nodeBackwards = defaultNodeBackwards + var sourceId = defaultSourceId + var targetId = defaultTargetId + var linkType = defaultLinkType + var ordering = null + var rankSets = [] + var maxIterations = 25 // XXX setter/getter + var nodePosition = null + var sortPorts = defaultSortPorts + + // extent + var x0 = 0 + var y0 = 0 + var x1 = 1 + var y1 = 1 + + // node width + var dx = 1 + + var scale = null + var linkValue = function (e) { return e.value } + var whitespace = 0.5 + var verticalLayout = positionVertically() + + function sankey () { + var graph = {nodes: nodes.apply(null, arguments), links: links.apply(null, arguments)} + var G = buildGraph(graph, nodeId, nodeBackwards, sourceId, targetId, linkType, linkValue) + + setNodeValues(G, linkValue) + + if (nodePosition) { + // hard-coded node positions + + G.nodes().forEach(u => { + const node = G.node(u) + const pos = nodePosition(node.data) + node.x0 = pos[0] + node.x1 = pos[0] + dx + node.y = pos[1] + }) + setWidths(G, scale) + } else { + // calculate node positions + + if (ordering !== null) { + applyOrdering(G, ordering) + } else { + assignRanks(G, rankSets) + sortNodes(G, maxIterations) + } + + addDummyNodes(G) + setNodeValues(G, linkValue) + if (ordering === null) { + // XXX sort nodes? + sortNodes(G, maxIterations) + } + + const nested = nestGraph(G.nodes().map(u => G.node(u))) + maybeScaleToFit(G, nested) + setWidths(G, scale) + + // position nodes + verticalLayout(nested, y1 - y0, whitespace) + positionHorizontally(G, x1 - x0, dx) + + // adjust origin + G.nodes().forEach(u => { + const node = G.node(u) + node.x0 += x0 + node.x1 += x0 + node.y += y0 + }) + } + + // sort & position links + prepareNodePorts(G, sortPorts) + orderLinks(G) + layoutLinks(G) + + removeDummyNodes(G) + addLinkEndpoints(G) + + copyResultsToGraph(G, graph) + + return graph + } + + sankey.update = function (graph, doOrderLinks) { + var G = buildGraph(graph, nodeId, nodeBackwards, sourceId, targetId, linkType, linkValue) + setNodeValues(G, linkValue) + const nested = nestGraph(G.nodes().map(u => G.node(u))) + maybeScaleToFit(G, nested) + setWidths(G, scale) + + prepareNodePorts(G, sortPorts) + orderLinks(G) + layoutLinks(G) + + // removeDummyNodes(G) + addLinkEndpoints(G) + + copyResultsToGraph(G, graph) + + return graph + } + // if (scale === null) sankey.scaleToFit(graph) + // // set node and edge sizes + // setNodeValues(graph, linkValue, scale) + // if (doOrderLinks) { + // orderLinks(graph) + // } + // layoutLinks(graph) + // return graph + // } + + sankey.nodes = function (x) { + if (arguments.length) { + nodes = required(x) + return sankey + } + return nodes + } + + sankey.links = function (x) { + if (arguments.length) { + links = required(x) + return sankey + } + return links + } + + sankey.nodeId = function (x) { + if (arguments.length) { + nodeId = required(x) + return sankey + } + return nodeId + } + + sankey.nodeBackwards = function (x) { + if (arguments.length) { + nodeBackwards = required(x) + return sankey + } + return nodeBackwards + } + + sankey.sourceId = function (x) { + if (arguments.length) { + sourceId = required(x) + return sankey + } + return sourceId + } + + sankey.targetId = function (x) { + if (arguments.length) { + targetId = required(x) + return sankey + } + return targetId + } + + sankey.linkType = function (x) { + if (arguments.length) { + linkType = required(x) + return sankey + } + return linkType + } + + sankey.sortPorts = function (x) { + if (arguments.length) { + sortPorts = required(x) + return sankey + } + return sortPorts + } + + // sankey.scaleToFit = function (graph) { + function maybeScaleToFit (G, nested) { + if (scale !== null) return + const maxValue = sum(nested.bandValues) + if (maxValue <= 0) { + scale = 1 + } else { + scale = (y1 - y0) / maxValue + if (whitespace !== 1) scale *= (1 - whitespace) + } + } + + sankey.ordering = function (x) { + if (!arguments.length) return ordering + ordering = x + return sankey + } + + sankey.rankSets = function (x) { + if (!arguments.length) return rankSets + rankSets = x + return sankey + } + + sankey.nodeWidth = function (x) { + if (!arguments.length) return dx + dx = x + return sankey + } + + sankey.nodePosition = function (x) { + if (!arguments.length) return nodePosition + nodePosition = x + return sankey + } + + sankey.size = function (x) { + if (!arguments.length) return [x1 - x0, y1 - y0] + x0 = y0 = 0 + x1 = +x[0] + y1 = +x[1] + return sankey + } + + sankey.extent = function (x) { + if (!arguments.length) return [[x0, y0], [x1, y1]] + x0 = +x[0][0] + y0 = +x[0][1] + x1 = +x[1][0] + y1 = +x[1][1] + return sankey + } + + sankey.whitespace = function (x) { + if (!arguments.length) return whitespace + whitespace = x + return sankey + } + + sankey.scale = function (x) { + if (!arguments.length) return scale + scale = x + return sankey + } + + sankey.linkValue = function (x) { + if (!arguments.length) return linkValue + linkValue = x + return sankey + } + + sankey.verticalLayout = function (x) { + if (!arguments.length) return verticalLayout + verticalLayout = required(x) + return sankey + } + + function applyOrdering (G, ordering) { + ordering.forEach((x, i) => { + x.forEach((u, j) => { + if (u.forEach) { + u.forEach((v, k) => { + const d = G.node(v) + if (d) { + d.rank = i + d.band = j + d.depth = k + } + }) + } else { + const d = G.node(u) + if (d) { + d.rank = i + // d.band = 0 + d.depth = j + } + } + }) + }) + } + + return sankey +} + +function setNodeValues (G, linkValue) { + G.nodes().forEach(u => { + const d = G.node(u) + const incoming = sum(G.inEdges(u), e => G.edge(e).value) + const outgoing = sum(G.outEdges(u), e => G.edge(e).value) + d.value = Math.max(incoming, outgoing) + }) +} + +function setWidths (G, scale) { + G.edges().forEach(e => { + const edge = G.edge(e) + edge.dy = edge.value * scale + }) + G.nodes().forEach(u => { + const node = G.node(u) + node.dy = node.value * scale + }) +} + +function required (f) { + if (typeof f !== 'function') throw new Error() + return f +} + +function addLinkEndpoints (G) { + G.edges().forEach(e => { + const edge = G.edge(e) + edge.points.unshift({x: edge.x0, y: edge.y0, ro: edge.r0, d: edge.d0}) + edge.points.push({x: edge.x1, y: edge.y1, ri: edge.r1, d: edge.d1}) + }) +} + +function copyResultsToGraph (G, graph) { + G.nodes().forEach(u => { + const node = G.node(u) + + // Build lists of edge data objects + node.data.incoming = [] + node.data.outgoing = [] + node.data.ports = node.ports + node.data.ports.forEach(port => { + port.incoming = [] + port.outgoing = [] + }) + + node.data.dy = node.dy + node.data.x0 = node.x0 + node.data.x1 = node.x1 + node.data.y0 = node.y + node.data.y1 = node.y + node.dy + node.data.rank = node.rank + node.data.band = node.band + node.data.depth = node.depth + node.data.value = node.value + node.data.spaceAbove = node.spaceAbove + node.data.spaceBelow = node.spaceBelow + }) + + G.edges().forEach(e => { + const edge = G.edge(e) + edge.data.source = G.node(e.v).data + edge.data.target = G.node(e.w).data + edge.data.sourcePort = edge.sourcePort + edge.data.targetPort = edge.targetPort + // console.log(edge) + edge.data.source.outgoing.push(edge.data) + edge.data.target.incoming.push(edge.data) + if (edge.data.sourcePort) edge.data.sourcePort.outgoing.push(edge.data) + if (edge.data.targetPort) edge.data.targetPort.incoming.push(edge.data) + // edge.data.value = edge.value + edge.data.dy = edge.dy + edge.data.points = edge.points || [] + // edge.data.id = `${e.v}-${e.w}-${e.name}` + }) +} diff --git a/src/sankeyLayout/horizontal.js b/src/sankeyLayout/horizontal.js new file mode 100644 index 0000000..aadcafa --- /dev/null +++ b/src/sankeyLayout/horizontal.js @@ -0,0 +1,47 @@ +import { max } from 'd3-array' + +// export function minEdgeDx (w, y0, y1) { +// console.log('mindx', w, y0, y1) +// const dy = y1 - y0 +// const ay = Math.abs(dy) - w // final sign doesn't matter +// const dx2 = w * w - ay * ay +// const dx = dx2 >= 0 ? Math.sqrt(dx2) : w +// return dx +// } + +export default function positionHorizontally (G, width, nodeWidth) { + // const minWidths = new Array(maxRank).fill(0) + // G.edges().forEach(e => { + // const r0 = G.node(e.v).rank || 0 + // minWidths[r0] = Math.max(minWidths[r0], minEdgeDx(G.edge(e).dy, G.node(e.v).y, G.node(e.w).y)) + // }) + // for (let i = 0; i < nested.length - 1; ++i) { + // minWidths[i] = 0 + // nested[i].forEach(band => { + // band.forEach(d => { + // // edges for dummy nodes, outgoing for real nodes + // (d.outgoing || d.edges).forEach(e => { + // minWidths[i] = Math.max(minWidths[i], minEdgeDx(e)) + // }) + // }) + // }) + // } + // const totalMinWidth = sum(minWidths) + // let dx + // if (totalMinWidth > width) { + // // allocate fairly + // dx = minWidths.map(w => width * w / totalMinWidth) + // } else { + // const spare = (width - totalMinWidth) / (nested.length - 1) + // dx = minWidths.map(w => w + spare) + // } + + const maxRank = max(G.nodes(), u => G.node(u).rank || 0) || 0 + const dx = (width - nodeWidth) / maxRank + + G.nodes().forEach(u => { + const node = G.node(u) + node.x0 = dx * (node.rank || 0) + node.x1 = node.x0 + nodeWidth + }) +} diff --git a/src/sankeyLayout/layout-links.js b/src/sankeyLayout/layout-links.js new file mode 100644 index 0000000..596bc59 --- /dev/null +++ b/src/sankeyLayout/layout-links.js @@ -0,0 +1,90 @@ +/** + * Edge positioning. + * + * @module link-positioning + */ + +import { findFirst, sweepCurvatureInwards } from './utils' + +/* + * Requires incoming and outgoing attributes on nodes + */ +export default function layoutLinks (G) { + setEdgeEndpoints(G) + setEdgeCurvatures(G) + return G +} + +function setEdgeEndpoints (G) { + G.nodes().forEach(u => { + const node = G.node(u) + node.ports.forEach(port => { + let sy = node.y + port.y + let ty = node.y + port.y + + port.outgoing.forEach(e => { + const link = G.edge(e) + // link.x0 = node.x1 + link.y0 = sy + link.dy / 2 + link.d0 = node.backwards ? 'l' : 'r' + link.dy = link.dy + sy += link.dy + }) + + port.incoming.forEach(e => { + const link = G.edge(e) + // link.x1 = node.x0 + link.y1 = ty + link.dy / 2 + link.d1 = node.backwards ? 'l' : 'r' + link.dy = link.dy + ty += link.dy + }) + }) + }) +} + +function setEdgeCurvatures (G) { + G.nodes().forEach(u => { + const node = G.node(u) + node.ports.forEach(port => { + setEdgeEndCurvatures(G, port.outgoing, 'r0') + setEdgeEndCurvatures(G, port.incoming, 'r1') + }) + }) +} + +function maximumRadiusOfCurvature (link) { + var Dx = link.x1 - link.x0 + var Dy = link.y1 - link.y0 + if (link.d0 !== link.d1) { + return Math.abs(Dy) / 2.1 + } else { + return (Dy !== 0) ? (Dx * Dx + Dy * Dy) / Math.abs(4 * Dy) : Infinity + } +} + +function setEdgeEndCurvatures (G, edges, rr) { + const links = edges.map(e => G.edge(e)) + + // initialise segments, find reversal of curvature + links.forEach(link => { + // const link = (i < 0) ? link.segments[link.segments.length + i] : link.segments[i] + link.Rmax = maximumRadiusOfCurvature(link) + link[rr] = Math.max(link.dy / 2, (link.d0 === link.d1 ? link.Rmax * 0.6 : (5 + link.dy / 2))) + }) + + let jmid = (rr === 'r0' + ? findFirst(links, f => f.y1 > f.y0) + : findFirst(links, f => f.y0 > f.y1)) + if (jmid === null) jmid = links.length + + // Set maximum radius down from middle + sweepCurvatureInwards(links.slice(jmid), rr) + + // Set maximum radius up from middle + if (jmid > 0) { + let links2 = [] + for (let j = jmid - 1; j >= 0; j--) links2.push(links[j]) + sweepCurvatureInwards(links2, rr) + } +} diff --git a/src/sankeyLayout/link-direction.js b/src/sankeyLayout/link-direction.js new file mode 100644 index 0000000..3586b33 --- /dev/null +++ b/src/sankeyLayout/link-direction.js @@ -0,0 +1,14 @@ +export default function linkDirection (G, e, head = true) { + if (e.v === e.w) { + // pretend self-links go downwards + return Math.PI / 2 * (head ? +1 : -1) + } else { + // const source = G.node(e.v) + // const target = G.node(e.w) + // return Math.atan2(target.y - source.y, + // target.x0 - source.x1) + const link = G.edge(e) + return Math.atan2(link.y1 - link.y0, + link.x1 - link.x0) + } +} diff --git a/src/sankeyLayout/link-ordering.js b/src/sankeyLayout/link-ordering.js new file mode 100644 index 0000000..c88d94e --- /dev/null +++ b/src/sankeyLayout/link-ordering.js @@ -0,0 +1,58 @@ +/** @module edge-ordering */ + +import linkDirection from './link-direction' + +/** + * Order the edges at all nodes. + */ +export default function orderEdges (G, opts) { + G.nodes().forEach(u => orderEdgesOne(G, u, opts)) +} + +/** + * Order the edges at the given node. + * The ports have already been setup and sorted. + */ +function orderEdgesOne (G, v) { + const node = G.node(v) + node.ports.forEach(port => { + port.incoming.sort(compareDirection(G, node, false)) + port.outgoing.sort(compareDirection(G, node, true)) + }) +} + +/** + * Sort links based on their endpoints & type + */ +function compareDirection (G, node, head = true) { + return function (a, b) { + var da = linkDirection(G, a, head) + var db = linkDirection(G, b, head) + var c = head ? 1 : -1 + + // links between same node, sort on type + if (a.v === b.v && a.w === b.w && Math.abs(da - db) < 1e-3) { + if (typeof a.name === 'number' && typeof b.name === 'number') { + return a.name - b.name + } else if (typeof a.name === 'string' && typeof b.name === 'string') { + return a.name.localeCompare(b.name) + } else { + return 0 + } + } + + // loops to same slice based on y-position + if (Math.abs(da - db) < 1e-3) { + if (a.w === b.w) { + return G.node(b.v).y - G.node(a.v).y + } else if (a.v === b.v) { + return G.node(b.w).y - G.node(a.w).y + } else { + return 0 + } + } + + // otherwise sort by direction + return c * (da - db) + } +} diff --git a/src/sankeyLayout/nest-graph.js b/src/sankeyLayout/nest-graph.js new file mode 100644 index 0000000..cc66d42 --- /dev/null +++ b/src/sankeyLayout/nest-graph.js @@ -0,0 +1,52 @@ +import { nest } from 'd3-collection' +import { sum, max } from 'd3-array' + +export default function nestGraph (nodes) { + const maxRank = max(nodes, d => d.rank || 0) || 0 + const maxBand = max(nodes, d => d.band || 0) || 0 + + // const nodes = graph.nodes().concat(graph.dummyNodes()) + + const nested = nest() + .key(d => d.rank || 0) + .key(d => d.band || 0) + .sortValues((a, b) => a.depth - b.depth) + .map(nodes) + + const result = new Array(maxRank + 1) + let rank + for (let i = 0; i <= maxRank; ++i) { + result[i] = new Array(maxBand + 1) + rank = nested.get(i) + if (rank) { + for (let j = 0; j <= maxBand; ++j) { + result[i][j] = rank.get(j) || [] + } + } else { + for (let j = 0; j <= maxBand; ++j) { + result[i][j] = [] + } + } + } + + result.bandValues = bandValues(result) + + return result +} + +export function bandValues (nested) { + if (nested.length === 0 || nested[0].length === 0) return [] + + const Nb = nested[0].length + const values = new Array(Nb) + for (let i = 0; i < Nb; i++) values[i] = 0 + + nested.forEach(rank => { + rank.forEach((band, j) => { + const total = sum(band, d => d.value) + values[j] = Math.max(values[j], total) + }) + }) + + return values +} diff --git a/src/sankeyLayout/prepare-subdivisions.js b/src/sankeyLayout/prepare-subdivisions.js new file mode 100644 index 0000000..12bf3ab --- /dev/null +++ b/src/sankeyLayout/prepare-subdivisions.js @@ -0,0 +1,56 @@ +import { map } from 'd3-collection' +import { sum } from 'd3-array' + +export default function prepareNodePorts (G, sortPorts) { + G.nodes().forEach(u => { + const node = G.node(u) + const ports = map() + function getOrSet (id, side) { + if (ports.has(id)) return ports.get(id) + const port = { id: id, node: node.data, side: side, incoming: [], outgoing: [] } + ports.set(id, port) + return port + } + + G.inEdges(u).forEach(e => { + const edge = G.edge(e) + const port = getOrSet(edge.targetPortId || 'in', node.direction !== 'l' ? 'west' : 'east') + port.incoming.push(e) + edge.targetPort = port + }) + G.outEdges(u).forEach(e => { + const edge = G.edge(e) + const port = getOrSet(edge.sourcePortId || 'out', node.direction !== 'l' ? 'east' : 'west') + port.outgoing.push(e) + edge.sourcePort = port + }) + + node.ports = ports.values() + node.ports.sort(sortPorts) + + // Set positions of ports, roughly -- so the other endpoints of links are + // known approximately when being sorted. + let y = {west: 0, east: 0} + let i = {west: 0, east: 0} + node.ports.forEach(port => { + port.y = y[port.side] + port.index = i[port.side] + port.dy = Math.max(sum(port.incoming, e => G.edge(e).dy), + sum(port.outgoing, e => G.edge(e).dy)) + const x = (port.side === 'west' ? node.x0 : node.x1) + + port.outgoing.forEach(e => { + const link = G.edge(e) + link.x0 = x + link.y0 = node.y + port.y + link.dy / 2 + }) + port.incoming.forEach(e => { + const link = G.edge(e) + link.x1 = x + link.y1 = node.y + port.y + link.dy / 2 + }) + y[port.side] += port.dy + i[port.side] += 1 + }) + }) +} diff --git a/src/sankeyLayout/utils.js b/src/sankeyLayout/utils.js new file mode 100644 index 0000000..73faf55 --- /dev/null +++ b/src/sankeyLayout/utils.js @@ -0,0 +1,38 @@ +export function findFirst(links, p) { + let jmid = null; + for (let j = 0; j < links.length; ++j) { + if (p(links[j])) { jmid = j; break; } + } + return jmid; +} + + +/** + * Adjust radii of curvature to avoid overlaps, as much as possible. + * @param links - the list of links, ordered from outside to inside of bend + * @param rr - "r0" or "r1", the side to work on + */ +export function sweepCurvatureInwards(links, rr) { + if (links.length === 0) return; + + // sweep from inside of curvature towards outside + let Rmin = 0, h; + for (let i = links.length - 1; i >= 0; --i) { + h = links[i].dy / 2; + if (links[i][rr] - h < Rmin) { // inner radius + links[i][rr] = Math.min(links[i].Rmax, Rmin + h); + } + Rmin = links[i][rr] + h; + } + + // sweep from outside of curvature towards centre + let Rmax = links[0].Rmax + links[0].dy / 2; + for (let i = 0; i < links.length; ++i) { + h = links[i].dy / 2; + if (links[i][rr] + h > Rmax) { // outer radius + links[i][rr] = Math.max(h, Rmax - h); + } + Rmax = links[i][rr] - h; + } + +} diff --git a/src/sankeyLayout/verticalJustified.js b/src/sankeyLayout/verticalJustified.js new file mode 100644 index 0000000..e3def01 --- /dev/null +++ b/src/sankeyLayout/verticalJustified.js @@ -0,0 +1,70 @@ +import { sum } from 'd3-array' + +function defaultSeparation (a, b) { + return 1 +} + +export default function positionNodesVertically () { + var separation = defaultSeparation + + function layout (nested, totalHeight, whitespace) { + nested.forEach(layer => { + let y = 0 + layer.forEach((band, j) => { + // Height of this band, based on fraction of value + const bandHeight = nested.bandValues[j] / sum(nested.bandValues) * totalHeight + + const margin = whitespace * bandHeight / 5 + const height = bandHeight - 2 * margin + const total = sum(band, d => d.dy) + const gaps = band.map((d, i) => { + if (!d.value) return 0 + return band[i + 1] ? separation(band[i], band[i + 1], layout) : 0 + }) + const space = Math.max(0, height - total) + const kg = sum(gaps) ? space / sum(gaps) : 0 + + const isFirst = true + const isLast = true // XXX bands + + let yy = y + margin + if (band.length === 1) { + // centre vertically + yy += (height - band[0].dy) / 2 + } + + let prevGap = isFirst ? Number.MAX_VALUE : 0 // edge of graph + band.forEach((node, i) => { + node.y = yy + node.spaceAbove = prevGap + node.spaceBelow = gaps[i] * kg + yy += node.dy + node.spaceBelow + prevGap = node.spaceBelow + + // XXX is this a good idea? + if (node.data && node.data.forceY !== undefined) { + node.y = margin + node.data.forceY * (height - node.dy) + } + }) + if (band.length > 0) { + band[band.length - 1].spaceBelow = isLast ? Number.MAX_VALUE : 0 // edge of graph + } + + y += bandHeight + }) + }) + } + + layout.separation = function (x) { + if (!arguments.length) return separation + separation = required(x) + return layout + } + + return layout +} + +function required (f) { + if (typeof f !== 'function') throw new Error() + return f +} diff --git a/src/sankeyLayout/verticalRelaxation.js b/src/sankeyLayout/verticalRelaxation.js new file mode 100644 index 0000000..090f756 --- /dev/null +++ b/src/sankeyLayout/verticalRelaxation.js @@ -0,0 +1,126 @@ +import { sum } from 'd3-array' + +export default function positionNodesVertically () { + var iterations = 25 + var nodePadding = 8 + + function layout (nested, height) { + initializeNodeDepth() + resolveCollisions() + for (var alpha = 1, i = iterations; i > 0; --i) { + relaxRightToLeft(alpha *= 0.99) + resolveCollisions() + relaxLeftToRight(alpha) + resolveCollisions() + } + + function initializeNodeDepth () { + nested.forEach(layer => { + let i = 0 + layer.forEach(band => { + // ignore bands for this layout + band.forEach(node => { + node.y = i++ + }) + }) + }) + } + + function relaxLeftToRight (alpha) { + nested.forEach(layer => { + layer.forEach(band => { + band.forEach(node => { + var edges = node.incoming || node.edges + if (edges.length) { + var y = sum(edges, weightedSource) / sum(edges, value) + node.y += (y - center(node)) * alpha + } + }) + }) + }) + + function weightedSource (link) { + return center(link.source) * link.value + } + } + + function relaxRightToLeft (alpha) { + nested.slice().reverse().forEach(layer => { + layer.forEach(band => { + band.forEach(node => { + var edges = node.outgoing || node.edges + if (edges.length) { + var y = sum(edges, weightedTarget) / sum(edges, value) + node.y += (y - center(node)) * alpha + } + }) + }) + }) + + function weightedTarget (link) { + return center(link.target) * link.value + } + } + + function resolveCollisions () { + nested.forEach(layer => { + layer.forEach(nodes => { + var node + var dy + var y0 = 0 + var n = nodes.length + var i + + // Push any overlapping nodes down. + nodes.sort(ascendingDepth) + for (i = 0; i < n; ++i) { + node = nodes[i] + dy = y0 - node.y + if (dy > 0) node.y += dy + y0 = node.y + node.dy + nodePadding + } + + // If the bottommost node goes outside the bounds, push it back up. + dy = y0 - nodePadding - height + if (dy > 0) { + y0 = node.y -= dy + + // Push any overlapping nodes back up. + for (i = n - 2; i >= 0; --i) { + node = nodes[i] + dy = node.y + node.dy + nodePadding - y0 + if (dy > 0) node.y -= dy + y0 = node.y + } + } + }) + }) + } + } + + layout.iterations = function (x) { + if (!arguments.length) return iterations + iterations = +x + return layout + } + + layout.padding = function (x) { + if (!arguments.length) return nodePadding + nodePadding = +x + return layout + } + + return layout +} + +function center (node) { + return node.y + node.dy / 2 +} + +function value (link) { + return link.value +} + +function ascendingDepth (a, b) { + return a.y - b.y +} diff --git a/src/sortNodes/count-crossings.js b/src/sortNodes/count-crossings.js new file mode 100644 index 0000000..cd2d99a --- /dev/null +++ b/src/sortNodes/count-crossings.js @@ -0,0 +1,142 @@ +/** @module node-ordering/count-crossings */ + +/** + * Count the total number of crossings between 2 layers. + * + * This is the sum of the countBetweenCrossings and countLoopCrossings. + * + * @param {Graph} G - The graph. + * @param {Array} orderA - List of node ids on left side. + * @param {Array} orderB - List of node ids on right side. + */ +export default function countCrossings (G, orderA, orderB) { + return ( + countBetweenCrossings(G, orderA, orderB) // + + // countLoopCrossings(G, orderA, orderB) + ) +} + +/** + * Count the number of crossings of edges passing between 2 layers. + * + * Algorithm from + * http://jgaa.info/accepted/2004/BarthMutzelJuenger2004.8.2.pdf + * + * @param {Graph} G - The graph. + * @param {Array} orderA - List of node ids on left side. + * @param {Array} orderB - List of node ids on right side. + */ +export function countBetweenCrossings (G, orderA, orderB) { + let north + let south + let q + + if (orderA.length > orderB.length) { + north = orderA + south = orderB + } else { + north = orderB + south = orderA + } + q = south.length + + // lexicographically sorted edges from north to south + let southSeq = [] + north.forEach(u => { + south.forEach((v, j) => { + if (G.hasEdge(u, v) || G.hasEdge(v, u)) southSeq.push(j) + }) + }) + + // build accumulator tree + let firstIndex = 1 + while (firstIndex < q) firstIndex *= 2 + const treeSize = 2 * firstIndex - 1 // number of tree nodes + firstIndex -= 1 // index of leftmost leaf + + let tree = new Array(treeSize) + for (let i = 0; i < treeSize; i++) tree[i] = 0 + + // count the crossings + let count = 0 + southSeq.forEach(k => { + let index = k + firstIndex + tree[index]++ + while (index > 0) { + if (index % 2) count += tree[index + 1] + index = Math.floor((index - 1) / 2) + tree[index]++ + } + }) + + return count +} + +/** + * Count the number of crossings from within-layer edges. + * + * @param {Graph} G - The graph. + * @param {Array} orderA - List of node ids on left side. + * @param {Array} orderB - List of node ids on right side. + */ +export function countLoopCrossings (G, orderA, orderB) { + // Count crossings from edges within orderA and within orderB. + // Only look for edges on the right of orderA (forward edges) + // and on the left of orderB (reverse edges) + + // how many edges pass across? + let crossA = orderA.map(d => 0) + let crossB = orderB.map(d => 0) + + orderA.forEach((u, i) => { + G.outEdges(u).forEach(e => { + if (e.v !== e.w && !G.edge(e).reverse) { + let index = orderA.indexOf(e.w) + if (index >= 0) { + if (i > index) { + let j = index + 1 + while (j < i) { + crossA[j++] += 1 + } + } else { + let j = i + 1 + while (j < index) { + crossA[j++] += 1 + } + } + } + } + }) + }) + + orderB.forEach((u, i) => { + G.outEdges(u).forEach(e => { + if (e.v !== e.w && G.edge(e).reverse) { + let index = orderB.indexOf(e.w) + if (index >= 0) { + if (i > index) { + let j = index + 1 + while (j < i) { + crossB[j++] += 1 + } + } else { + let j = i + 1 + while (j < index) { + crossB[j++] += 1 + } + } + } + } + }) + }) + + let count = 0 + orderA.forEach((u, i) => { + orderB.forEach((v, j) => { + const N = G.nodeEdges(u, v).length + count += N * (crossA[i] + crossB[j]) + }) + }) + + return count +} diff --git a/src/sortNodes/dummy-nodes.js b/src/sortNodes/dummy-nodes.js new file mode 100644 index 0000000..e0f44cf --- /dev/null +++ b/src/sortNodes/dummy-nodes.js @@ -0,0 +1,113 @@ +export function addDummyNodes (G) { + // Add edges & dummy nodes + if (typeof G.graph() !== 'object') G.setGraph({}) + G.graph().dummyChains = [] + G.edges().forEach(e => normaliseEdge(G, e)) +} + +// based on https://github.com/cpettitt/dagre/blob/master/lib/normalize.js +function normaliseEdge (G, e) { + const edge = G.edge(e) + const dummies = dummyNodes(G.node(e.v), G.node(e.w)) + if (dummies.length === 0) return + + G.removeEdge(e) + + let v = e.v + dummies.forEach((dummy, i) => { + const id = `__${e.v}_${e.w}_${i}` + if (!G.hasNode(id)) { + dummy.dummy = 'edge' + G.setNode(id, dummy) + if (i === 0) { + G.graph().dummyChains.push(id) + } + } + addDummyEdge(v, (v = id)) + }) + addDummyEdge(v, e.w) + + function addDummyEdge (v, w) { + const label = { points: [], value: edge.value, origEdge: e, origLabel: edge } + G.setEdge(v, w, label, e.name) + } +} + +export function removeDummyNodes (G) { + const chains = G.graph().dummyChains || [] + chains.forEach(v => { + let node = G.node(v) + let dummyEdges = G.inEdges(v).map(e => G.edge(e)) + + // Set dy and starting point of edge and add back to graph + dummyEdges.forEach(dummyEdge => { + dummyEdge.origLabel.dy = dummyEdge.dy + dummyEdge.origLabel.x0 = dummyEdge.x0 + dummyEdge.origLabel.y0 = dummyEdge.y0 + dummyEdge.origLabel.r0 = dummyEdge.r0 + dummyEdge.origLabel.d0 = dummyEdge.d0 + G.setEdge(dummyEdge.origEdge, dummyEdge.origLabel) + }) + let r1s = dummyEdges.map(dummyEdge => dummyEdge.r1) + + // Walk through chain + let w + while (node.dummy) { + dummyEdges = G.outEdges(v).map(e => G.edge(e)) + dummyEdges.forEach((dummyEdge, i) => { + dummyEdge.origLabel.points.push({ + x: (node.x0 + node.x1) / 2, + y: dummyEdge.y0, + d: dummyEdge.d0, + ro: dummyEdge.r0, + ri: r1s[i] // from last edge + }) + }) + r1s = dummyEdges.map(dummyEdge => dummyEdge.r1) + + // move on + w = G.successors(v)[0] + G.removeNode(v) + node = G.node(v = w) + } + + // Set ending point of edge + dummyEdges.forEach(dummyEdge => { + dummyEdge.origLabel.x1 = dummyEdge.x1 + dummyEdge.origLabel.y1 = dummyEdge.y1 + dummyEdge.origLabel.r1 = dummyEdge.r1 + dummyEdge.origLabel.d1 = dummyEdge.d1 + }) + }) +} + +export function dummyNodes (source, target) { + const dummyNodes = [] + let r = source.rank + + if (r + 1 <= target.rank) { + // add more to get forwards + if (source.backwards) { + dummyNodes.push({rank: r, backwards: false}) // turn around + } + while (++r < target.rank) { + dummyNodes.push({rank: r, backwards: false}) + } + if (target.backwards) { + dummyNodes.push({rank: r, backwards: false}) // turn around + } + } else if (r > target.rank) { + // add more to get backwards + if (!source.backwards) { + dummyNodes.push({rank: r, backwards: true}) // turn around + } + while (r-- > target.rank + 1) { + dummyNodes.push({rank: r, backwards: true}) + } + if (!target.backwards) { + dummyNodes.push({rank: r, backwards: true}) // turn around + } + } + + return dummyNodes +} diff --git a/src/sortNodes/index.js b/src/sortNodes/index.js new file mode 100644 index 0000000..ec7c3eb --- /dev/null +++ b/src/sortNodes/index.js @@ -0,0 +1,63 @@ +/** @module node-ordering */ + +import initialOrdering from './initial-ordering.js' +import swapNodes from './swap-nodes.js' +import countCrossings from './count-crossings.js' +import sortNodesOnce from './weighted-median-sort.js' + +/** + * Sorts the nodes in G, setting the `depth` attribute on each. + * + * @param {Graph} G - The graph. Nodes must have a `rank` attribute. + * + */ +export default function sortNodes (G, maxIterations = 25) { + let ranks = getRanks(G) + let order = initialOrdering(G, ranks) + let best = order + let i = 0 + + while (i++ < maxIterations) { + sortNodesOnce(G, order, (i % 2 === 0)) + swapNodes(G, order) + if (allCrossings(G, order) < allCrossings(G, best)) { + // console.log('improved', allCrossings(G, order), order); + best = copy(order) + } + } + + // Assign depth to nodes + // const depths = map() + best.forEach(nodes => { + nodes.forEach((u, i) => { + // depths.set(u, i) + G.node(u).depth = i + }) + }) +} + +function getRanks (G) { + const ranks = [] + G.nodes().forEach(u => { + const r = G.node(u).rank || 0 + while (r >= ranks.length) ranks.push([]) + ranks[r].push(u) + }) + return ranks +} + +function allCrossings (G, order) { + let count = 0 + for (let i = 0; i < order.length - 1; ++i) { + count += countCrossings(G, order[i], order[i + 1]) + } + return count +} + +function copy (order) { + let result = [] + order.forEach(rank => { + result.push(rank.map(d => d)) + }) + return result +} diff --git a/src/sortNodes/initial-ordering.js b/src/sortNodes/initial-ordering.js new file mode 100644 index 0000000..c67603d --- /dev/null +++ b/src/sortNodes/initial-ordering.js @@ -0,0 +1,24 @@ +import { alg } from 'graphlib' +import { map } from 'd3-collection' + +export default function initialOrdering (G, ranks) { + let order = [] + if (ranks.length === 0) return order + + // Start with sources & nodes in rank 0 + let start = G.sources() + let nodeRanks = map() + ranks.forEach((nodes, i) => { + order.push([]) + nodes.forEach(u => { + if (i === 0 && start.indexOf(u) < 0) start.push(u) + nodeRanks.set(u, i) + }) + }) + + alg.preorder(G, start).forEach(u => { + order[nodeRanks.get(u)].push(u) + }) + + return order +} diff --git a/src/sortNodes/median-value.js b/src/sortNodes/median-value.js new file mode 100644 index 0000000..53ba6e1 --- /dev/null +++ b/src/sortNodes/median-value.js @@ -0,0 +1,14 @@ +export default function medianValue (positions) { + const m = Math.floor(positions.length / 2) + if (positions.length === 0) { + return -1 + } else if (positions.length % 2 === 1) { + return positions[m] + } else if (positions.length === 2) { + return (positions[0] + positions[1]) / 2 + } else { + const left = positions[m - 1] - positions[0] + const right = positions[positions.length - 1] - positions[m] + return (positions[m - 1] * right + positions[m] * left) / (left + right) + } +} diff --git a/src/sortNodes/neighbour-positions.js b/src/sortNodes/neighbour-positions.js new file mode 100644 index 0000000..e8830f5 --- /dev/null +++ b/src/sortNodes/neighbour-positions.js @@ -0,0 +1,29 @@ +export default function neighbourPositions (G, order, i, j, u, includeLoops = false) { + // current rank i + // neighbour rank j + const thisRank = order[i] + const otherRank = order[j] + + const positions = [] + + // neighbouring positions on other rank + otherRank.forEach((n, i) => { + if (G.nodeEdges(n, u).length > 0) { + positions.push(i) + } + }) + + if (positions.length === 0 && includeLoops) { + // if no neighbours in other rank, look for loops to this rank + // XXX only on one side? + thisRank.forEach((n, i) => { + if (G.nodeEdges(n, u).length > 0) { + positions.push(i + 0.5) + } + }) + } + + positions.sort((a, b) => a - b) + + return positions +} diff --git a/src/sortNodes/sort-by-positions.js b/src/sortNodes/sort-by-positions.js new file mode 100644 index 0000000..8ecd574 --- /dev/null +++ b/src/sortNodes/sort-by-positions.js @@ -0,0 +1,44 @@ +import { map } from 'd3-collection' + +/** + * Sort arr according to order. -1 in order means stay in same position. + */ +export default function sortByPositions (arr, order) { + const origOrder = map(arr.map((d, i) => [d, i]), d => d[0]) + + // console.log('sorting', arr, order, origOrder) + for (let i = 1; i < arr.length; ++i) { + // console.group('start', i, arr[i]) + for (let k = i; k > 0; --k) { + let j = k - 1 + let a = order.get(arr[j]) + let b = order.get(arr[k]) + + // count back over any fixed positions (-1) + while ((a = order.get(arr[j])) === -1 && j > 0) j-- + + // console.log(j, k, arr[j], arr[k], a, b) + if (b === -1 || a === -1) { + // console.log('found -1', a, b, 'skipping', j, k) + break + } + + if (a === b) { + a = origOrder.get(arr[j]) + b = origOrder.get(arr[k]) + // console.log('a == b, switching to orig order', a, b) + } + + if (b >= a) { + // console.log('k > k -1, stopping') + break + } + // console.log('swapping', arr[k], arr[j]) + // swap arr[k], arr[j] + [arr[k], arr[j]] = [arr[j], arr[k]] + // console.log(arr) + } + // console.groupEnd() + } + // console.log('-->', arr) +} diff --git a/src/sortNodes/swap-nodes.js b/src/sortNodes/swap-nodes.js new file mode 100644 index 0000000..0f090fc --- /dev/null +++ b/src/sortNodes/swap-nodes.js @@ -0,0 +1,38 @@ +import countCrossings from './count-crossings' + +export default function swapNodes (G, order) { + let improved = true + while (improved) { + improved = false + for (let i = 0; i < order.length; ++i) { + for (let j = 0; j < order[i].length - 1; ++j) { + let count0 = allCrossings(G, order, i) + transpose(order[i], j, j + 1) + let count1 = allCrossings(G, order, i) + + if (count1 < count0) { + improved = true + } else { + transpose(order[i], j, j + 1) // put back + } + } + } + } +} + +function allCrossings (G, order, i) { + let count = 0 + if (i > 0) { + count += countCrossings(G, order[i - 1], order[i]) + } + if (i + 1 < order.length) { + count += countCrossings(G, order[i], order[i + 1]) + } + return count +} + +function transpose (list, i, j) { + const tmp = list[i] + list[i] = list[j] + list[j] = tmp +} diff --git a/src/sortNodes/weighted-median-sort.js b/src/sortNodes/weighted-median-sort.js new file mode 100644 index 0000000..a0ffaec --- /dev/null +++ b/src/sortNodes/weighted-median-sort.js @@ -0,0 +1,26 @@ +import { map } from 'd3-collection' +import medianValue from './median-value.js' +import neighbourPositions from './neighbour-positions.js' +import sortByPositions from './sort-by-positions.js' + +export default function sortNodes (G, order, sweepDirection = 1, includeLoops = false) { + if (sweepDirection > 0) { + for (let r = 1; r < order.length; ++r) { + let medians = map() + order[r].forEach(u => { + const neighbour = medianValue(neighbourPositions(G, order, r, r - 1, u, includeLoops)) + medians.set(u, neighbour) + }) + sortByPositions(order[r], medians) + } + } else { + for (let r = order.length - 2; r >= 0; --r) { + let medians = map() + order[r].forEach(u => { + const neighbour = medianValue(neighbourPositions(G, order, r, r + 1, u, includeLoops)) + medians.set(u, neighbour) + }) + sortByPositions(order[r], medians) + } + } +} diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..a426682 --- /dev/null +++ b/src/util.js @@ -0,0 +1,43 @@ +import { Graph } from 'graphlib' + +export function buildGraph (graph, nodeId, nodeBackwards, sourceId, targetId, linkType, linkValue) { + var G = new Graph({ directed: true, multigraph: true }) + graph.nodes.forEach(function (node, i) { + const id = nodeId(node, i) + if (G.hasNode(id)) throw new Error('duplicate: ' + id) + G.setNode(id, { + data: node, + index: i, + backwards: nodeBackwards(node, i), + // XXX don't need these now have nodePositions? + x0: node.x0, + x1: node.x1, + y: node.y0 + }) + }) + + graph.links.forEach(function (link, i) { + const v = idAndPort(sourceId(link, i)) + const w = idAndPort(targetId(link, i)) + var label = { + data: link, + sourcePortId: v.port, + targetPortId: w.port, + index: i, + points: [], + value: linkValue(link, i) + } + if (!G.hasNode(v.id)) throw new Error('missing: ' + v.id) + if (!G.hasNode(w.id)) throw new Error('missing: ' + w.id) + G.setEdge(v.id, w.id, label, linkType(link, i)) + }) + + G.setGraph({}) + + return G +} + +function idAndPort (x) { + if (typeof x === 'object') return x + return {id: x, port: undefined} +} diff --git a/test/buble-register.js b/test/buble-register.js new file mode 100644 index 0000000..d241179 --- /dev/null +++ b/test/buble-register.js @@ -0,0 +1,93 @@ +var fs = require( 'fs' ); +var path = require( 'path' ); +var crypto = require( 'crypto' ); +var homedir = require( 'os-homedir' ); +var buble = require( 'buble' ); + +var original = require.extensions[ '.js' ]; +var nodeModulesPattern = path.sep === '/' ? /\/node_modules\// : /\\node_modules\\/; + +var nodeVersion = /(?:0\.)?\d+/.exec( process.version )[0]; +var versions = [ '0.10', '0.12', '4', '5', '6' ]; + +if ( !~versions.indexOf( nodeVersion ) ) { + if ( +nodeVersion > 6 ) { + nodeVersion = '6'; + } else { + throw new Error( 'Unsupported version (' + nodeVersion + '). Please raise an issue at https://gitlab.com/Rich-Harris/buble/issues' ); + } +} + +var options = { + target: { + node: nodeVersion + }, + transforms: { + modules: false + } +}; + +function mkdirp ( dir ) { + var parent = path.dirname( dir ); + if ( dir === parent ) return; + mkdirp( parent ); + + try { + fs.mkdirSync( dir ); + } catch ( err ) { + if ( err.code !== 'EEXIST' ) throw err; + } +} + +var home = homedir(); +if ( home ) { + var cachedir = path.join( home, '.buble-cache', nodeVersion ); + mkdirp( cachedir ); + fs.writeFileSync( path.join( home, '.buble-cache/README.txt' ), 'These files enable a faster startup when using buble/register. You can safely delete this folder at any time. See https://buble.surge.sh/guide/ for more information.' ); +} + +var optionsStringified = JSON.stringify( options ); + +require.extensions[ '.js' ] = function ( m, filename ) { + if ( nodeModulesPattern.test( filename ) ) return original( m, filename ); + + var source = fs.readFileSync( filename, 'utf-8' ); + var hash = crypto.createHash( 'sha256' ); + hash.update( buble.VERSION ); + hash.update( optionsStringified ); + hash.update( source ); + var key = hash.digest( 'hex' ) + '.json'; + var cachepath = path.join( cachedir, key ); + + var compiled; + + if ( cachedir ) { + try { + compiled = JSON.parse( fs.readFileSync( cachepath, 'utf-8' ) ); + } catch ( err ) { + // noop + } + } + + if ( !compiled ) { + try { + compiled = buble.transform( source, options ); + + if ( cachedir ) { + fs.writeFileSync( cachepath, JSON.stringify( compiled ) ); + } + } catch ( err ) { + if ( err.snippet ) { + console.log( 'Error compiling ' + filename + ':\n---' ); + console.log( err.snippet ); + console.log( err.message ); + console.log( '' ) + process.exit( 1 ); + } + + throw err; + } + } + + m._compile( '"use strict";\n' + compiled.code, filename ); +}; diff --git a/test/test-positionGroups.js b/test/diagram-positionGroups-test.js similarity index 52% rename from test/test-positionGroups.js rename to test/diagram-positionGroups-test.js index eb4fc3e..8e999ce 100644 --- a/test/test-positionGroups.js +++ b/test/diagram-positionGroups-test.js @@ -5,71 +5,72 @@ import test from 'tape'; test('positionGroup()', t => { const nodes = new Map([ - ["a1", { - "dy": 75, - "y": 30, - "x": 0, - "id": "a1", - }], - ["b", { - "dy": 150, - "y": 75, - "x": 300, - "id": "b", - }], - ["a2", { - "dy": 75, - "y": 195, - "x": 0, - "id": "a2", - }] - ]); + { + id: 'a1', + x0: 0, + x1: 1, + y0: 30, + y1: 105 + }, { + id: 'b', + x0: 300, + x1: 301, + y0: 75, + y1: 225 + }, { + id: 'a2', + x0: 0, + x1: 1, + y0: 195, + y1: 270 + } + ].map(d => [d.id, d])) const group1 = { - "title": "Group", - "nodes": ["a1", "a2"] + 'title': 'Group', + 'nodes': ['a1', 'a2'] }; const group2 = { - "title": "B", - "nodes": ["b"] + 'title': 'B', + 'nodes': ['b'] }; const group3 = { - "title": "All", - "nodes": ["a1", "a2", "b"] + 'title': 'All', + 'nodes': ['a1', 'a2', 'b'] }; t.deepEqual(positionGroup(nodes, group1), { - title: "Group", - nodes: ["a1", "a2"], + title: 'Group', + nodes: ['a1', 'a2'], rect: { top: 30, left: 0, bottom: 195 + 75, - right: 0 + right: 1 } }, 'group1'); t.deepEqual(positionGroup(nodes, group2), { - title: "B", - nodes: ["b"], + title: 'B', + nodes: ['b'], rect: { top: 75, left: 300, bottom: 75 + 150, - right: 300 + right: 301 } }, 'group2'); t.deepEqual(positionGroup(nodes, group3), { - title: "All", - nodes: ["a1", "a2", "b"], + title: 'All', + nodes: ['a1', 'a2', 'b'], rect: { top: 30, left: 0, bottom: 195 + 75, - right: 300 + right: 301 } }, 'group3'); diff --git a/test/diagram-test.js b/test/diagram-test.js new file mode 100644 index 0000000..4b79140 --- /dev/null +++ b/test/diagram-test.js @@ -0,0 +1,232 @@ +import sankeyDiagram from '../src/diagram' +import sankey from '../src/sankey.js' + +import getBody from './get-document-body' +import { select } from 'd3-selection' +import { schemeCategory10, scaleOrdinal } from 'd3-scale' +import test from 'tape' + +test('diagram: renders something and updates', t => { + // prepare data + const s = sankey().size([600, 400]) + const graph = s(exampleBlastFurnace()) + + // diagram -- disable transitions + const diagram = sankeyDiagram() + const el = select(getBody()).append('div') + + el.datum(graph).call(diagram) + + t.equal(el.selectAll('.node').size(), graph.nodes.length, + 'right number of nodes') + + t.equal(el.selectAll('.link').size(), graph.links.length, + 'right number of links') + + // update + const h0 = getNodeHeights(el) + + graph.links.forEach(e => { e.value *= 1.1 }) + el.datum(s(graph)).call(diagram) + + const h1 = getNodeHeights(el) + + for (let i = 0; i < h0.length; ++i) { + t.ok(h1[i] > h0[i], 'height updates ' + i) + } + t.end() +}) + +function getNodeHeights (el) { + const h = [] + el.selectAll('.node').each(function () { + h.push(parseFloat(select(this).select('rect').attr('height'))) + }) + return h +} + +test('diagram: types', t => { + const graph = exampleLinkTypes() + sankey()(graph) + + const color = scaleOrdinal(schemeCategory10) + const diagram = sankeyDiagram() + + const el = render(graph, diagram) + + t.equal(el.selectAll('.node').size(), 4, + 'right number of nodes') + + t.equal(el.selectAll('.link').size(), 5, + 'right number of links') + + t.end() +}) + +test('diagram: types 2', t => { + const example = exampleLinkTypes2() + sankey()(example) + + const color = scaleOrdinal(schemeCategory10) + const diagram = sankeyDiagram(); + + const el = render(example, diagram) + + t.equal(el.selectAll('.node').size(), 5, + 'right number of nodes') + + t.equal(el.selectAll('.link').size(), 9, + 'right number of links') + + t.end() +}) + +// test('diagram: link attributes', t => { +// const links = [ +// {source: 'a', target: 'b', value: 2, type: 'x', +// color: 'red'}, +// ]; + +// function customLink(link) { +// link +// .attr('class', d => `link type-${d.data.type}`) +// .style('fill', d => d.data.color) +// .style('opacity', d => 1 / d.data.value); +// } + +// const diagram = sankeyDiagram() +// .nodeTitle(d => `Node ${d.id}`) +// .linkTypeTitle(d => `Type: ${d.data.type}`) +// .link(customLink); + +// const el = render({links}, diagram), +// link = el.selectAll('.link'); + +// t.deepEqual(d3.rgb(link.style('fill')), d3.rgb('red'), 'link color'); +// t.equal(link.style('opacity'), '0.5', 'link opacity'); +// t.equal(link.attr('class'), 'link type-x', 'link class'); +// t.equal(link.select('title').text(), +// 'Node a → Node b\nType: x\n2.00', 'link title'); + +// diagram +// .nodeTitle('node') +// .linkTypeTitle('z'); + +// const el2 = render({links}, diagram), +// link2 = el2.selectAll('.link'); + +// t.equal(link2.select('title').text(), +// 'node → node\nz\n2.00', 'link title (const)'); + +// t.end(); +// }); + +// test('diagram: node attributes', t => { +// const links = [ +// {source: 'a', target: 'b', value: 2} +// ]; + +// function customNode(node) { +// node +// .attr('class', 'node myclass'); +// } + +// // disable transitions +// const diagram = sankeyDiagram() +// const el = render({links}, diagram); + +// t.equal(el.selectAll('.node').attr('class'), 'node', 'node class before'); + +// diagram.node(customNode); +// el.call(diagram); + +// t.equal(el.selectAll('.node').attr('class'), 'node myclass', 'node class after'); + +// t.end(); +// }); + +function render (datum, diagram) { + const el = select(getBody()).append('div') + el.datum(datum).call(diagram) + return el +} + +function exampleBlastFurnace () { + // Simplified example of links through coke oven and blast furnace + const nodes = [ + {id: 'input'}, + {id: 'oven'}, + {id: 'coke'}, + {id: 'sinter'}, + {id: 'bf'}, + {id: 'output'}, + {id: 'export'} + ] + + const links = [ + // main flow + {source: 'input', target: 'oven', value: 2.5}, + {source: 'oven', target: 'coke', value: 2.5}, + {source: 'coke', target: 'sinter', value: 1}, + {source: 'coke', target: 'bf', value: 1.5}, + {source: 'sinter', target: 'bf', value: 1}, + {source: 'bf', target: 'output', value: 1}, + {source: 'bf', target: 'export', value: 1}, + + // additional export links, and input-sinter + {source: 'sinter', target: 'export', value: 0.2}, + {source: 'oven', target: 'export', value: 0.2}, + {source: 'input', target: 'sinter', value: 0.2}, + + // return loops + {source: 'oven', target: 'input', value: 0.5}, + {source: 'bf', target: 'input', value: 0.5} + ] + + return {nodes, links} +} + +function exampleLinkTypes () { + const nodes = [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'}, + {id: 'd'} + ] + + const links = [ + {source: 'a', target: 'b', value: 2, type: 'x'}, + {source: 'a', target: 'b', value: 2, type: 'y'}, + {source: 'b', target: 'c', value: 1, type: 'x'}, + {source: 'b', target: 'c', value: 2, type: 'y'}, + {source: 'b', target: 'd', value: 1, type: 'x'} + ] + + return {nodes, links} +} + +function exampleLinkTypes2 () { + // this sometimes fails in Safari + return { + nodes: [ + { id: 'a', title: 'a' }, + { id: 'b', title: 'b' }, + { id: 'c', title: 'c' }, + { id: 'x', title: 'd' }, + { id: 'y', title: 'e' } + ], + links: [ + { source: 'a', target: 'x', value: 1.0, type: 'x' }, + { source: 'a', target: 'y', value: 0.7, type: 'y' }, + { source: 'a', target: 'y', value: 0.3, type: 'z' }, + + { source: 'b', target: 'x', value: 2.0, type: 'x' }, + { source: 'b', target: 'y', value: 0.3, type: 'y' }, + { source: 'b', target: 'y', value: 0.9, type: 'z' }, + + { source: 'x', target: 'c', value: 3.0, type: 'x' }, + { source: 'y', target: 'c', value: 1.0, type: 'y' }, + { source: 'y', target: 'c', value: 1.2, type: 'z' } + ] + } +} diff --git a/test/examples.js b/test/examples.js new file mode 100644 index 0000000..49f9d5c --- /dev/null +++ b/test/examples.js @@ -0,0 +1,138 @@ +import { Graph } from 'graphlib' + +export function exampleWithLoop () { + // + // f -------, b<-, + // a -- b -- c -- e ` + // `------ d -' + // \ + // { +// rank.forEach(u => { +// G.setNode(u, { rank: i }); +// }); +// }); + +// // main flow +// G.setEdge('input', 'oven', {}); +// G.setEdge('oven', 'coke', {}); +// G.setEdge('coke', 'sinter', {}); +// G.setEdge('coke', '_coke_bf', {}); +// G.setEdge('_coke_bf', 'bf', {}); +// G.setEdge('sinter', 'bf', {}); +// G.setEdge('bf', 'output', {}); +// G.setEdge('bf', 'export', {}); + +// // additional export links, and input-sinter +// G.setEdge('sinter', '_sinter_export', {}); +// G.setEdge('_sinter_export', 'export', {}); +// G.setEdge('oven', '_oven_export_1', {}); +// G.setEdge('_oven_export_1', '_oven_export_2', {}); +// G.setEdge('_oven_export_2', '_oven_export_3', {}); +// G.setEdge('_oven_export_3', 'export', {}); +// G.setEdge('input', '_input_sinter_1', {}); +// G.setEdge('_input_sinter_1', '_input_sinter_2', {}); +// G.setEdge('_input_sinter_2', 'sinter', {}); + +// // return loops +// G.setEdge('oven', '_oven_input_1', {}); +// G.setEdge('_oven_input_1', '_oven_input_2', {}); +// G.setEdge('_oven_input_2', 'input', {}); +// G.setEdge('bf', '_bf_input_1', {}); +// G.setEdge('_bf_input_1', '_bf_input_2', {}); +// G.setEdge('_bf_input_2', '_bf_input_3', {}); +// G.setEdge('_bf_input_3', '_bf_input_4', {}); +// G.setEdge('_bf_input_4', '_bf_input_5', {}); +// G.setEdge('_bf_input_5', 'input', {}); + +// let initialOrder = [ +// ['input', '_oven_input_2', '_bf_input_5'], +// ['_bf_input_4', '_oven_input_1', '_input_sinter_1', 'oven'], +// ['coke', '_oven_input_2', '_bf_input_3', '_oven_export_1'], +// ['_bf_input_2', '_oven_export_2', '_coke_bf', 'sinter'], +// ['_bf_input_1', 'bf', '_sinter_export', '_oven_export_3'], +// ['export', 'output'], +// ]; + +// return {G, ranks, initialOrder}; +// } diff --git a/test/get-document-body.js b/test/get-document-body.js index 51d2a62..b6ec6d8 100644 --- a/test/get-document-body.js +++ b/test/get-document-body.js @@ -1,8 +1,9 @@ -module.exports = function() { +module.exports = function () { if (process.browser) { - return document.querySelector('body'); + return document.querySelector('body') } else { - const jsdom = require("jsdom"); - return jsdom.jsdom().querySelector('body'); + const {JSDOM} = require('jsdom') + const {document} = (new JSDOM()).window + return document.querySelector('body') } -}; +} diff --git a/test/groups-test.js b/test/groups-test.js new file mode 100644 index 0000000..06b6528 --- /dev/null +++ b/test/groups-test.js @@ -0,0 +1,69 @@ +import sankeyDiagram from '../src/diagram'; +import sankey from '../src/sankey.js' + +import getBody from './get-document-body'; +import { select } from 'd3-selection'; +import { timerFlush } from 'd3-timer' +import test from 'tape'; + + +test('groups: draws box around nodes', t => { + // prepare data + const graph = { + nodes: [ + {id: 'a1'}, + {id: 'a2'}, + {id: 'b'} + ], + links: [ + {source: 'a1', target: 'b', value: 1}, + {source: 'a2', target: 'b', value: 1} + ] + } + + const groups = [ + {title: 'Group', nodes: ['a1', 'a2']}, + {title: 'B', nodes: ['b']} + ] + + sankey().size([600, 300])(graph) + + // diagram + const diagram = sankeyDiagram().groups(groups) + const el = render(graph, diagram); + + t.equal(el.selectAll('.node').size(), 3, + 'right number of nodes'); + + t.equal(el.selectAll('.link').size(), 2, + 'right number of links'); + + t.equal(el.selectAll('.group').size(), 2, + 'right number of groups'); + + // padding of 10px + const rects = el.selectAll('.group').select('rect').nodes() + t.equal(select(rects[0]).attr('width'), '21', 'group1 width'); + t.equal(select(rects[0]).attr('height'), '270', 'group1 height'); + t.equal(select(rects[1]).attr('width'), '21', 'group2 width'); + t.equal(select(rects[1]).attr('height'), '180', 'group2 height'); + + t.end(); +}); + + +function render(datum, diagram) { + const el = select(getBody()).append('div'); + el.datum(datum).call(diagram); + flushAnimationFrames(); + return el; +} + + +/* Make animations synchronous for testing */ +var flushAnimationFrames = function() { + var now = Date.now; + Date.now = function() { return Infinity; }; + timerFlush(); + Date.now = now; +}; diff --git a/test/linkPath-test.js b/test/linkPath-test.js new file mode 100644 index 0000000..db8d848 --- /dev/null +++ b/test/linkPath-test.js @@ -0,0 +1,333 @@ +import sankeyLink from '../src/linkPath.js' +import tape from 'tape' +import compareSVGPath from './compareSVGPath.js' + +// tape('sankeyLink() has the expected defaults', test => { +// var link = sankeyLink() +// test.equal(link.segments()({segments: 'foo'}), 'foo') +// test.end() +// }) + +// tape('sankeyLink.segments(s) tests that s is a function', test => { +// var link = sankeyLink() +// test.throws(function () { link.segments(42) }) +// test.throws(function () { link.segments(null) }) +// test.end() +// }) + +tape('sankeyLink() path curves downwards with different radii', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 10}, + {x: 30, y: 70, ri: 20} + ], + dy: 2 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A11 11 1.571 0 1 11,10 ' + + 'L11,50 ' + + 'A19 19 1.571 0 0 30,69 ' + + 'L30,71 ' + + 'A21 21 1.571 0 1 9,50 ' + + 'L9,10 ' + + 'A9 9 1.571 0 0 0,1 ' + + 'Z') + test.end() +}) + +tape('sankeyLink() path curves upwards with different radii', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 10}, + {x: 30, y: -70, ri: 20} + ], + dy: 2 + } + + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A9 9 1.571 0 0 9,-10 ' + + 'L9,-50 ' + + 'A21 21 1.571 0 1 30,-71 ' + + 'L30,-69 ' + + 'A19 19 1.571 0 0 11,-50 ' + + 'L11,-10 ' + + 'A11 11 1.571 0 1 0,1 ' + + 'Z') + test.end() +}) + +tape('sankeyLink() default link shape has two adjacent circular arcs', test => { + const link = { + points: [ + {x: 0, y: 0}, + {x: 15, y: 10} + ], + dy: 2 + } + + // radius = 5 + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A6 6 0.729 0 1 4,0.527 ' + + 'L12.333,7.981 ' + + 'A4 4 0.729 0 0 15,9 ' + + 'L15,11 ' + + 'A6 6 0.729 0 1 11,9.472 ' + + 'L2.666,2.018 ' + + 'A4 4 0.729 0 0 0,1 ' + + 'Z') + test.end() +}) + +tape('sankeyLink() reduces to straight line', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 1}, + {x: 10, y: 0, ri: 1} + ], + dy: 2 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A0 0 0 0 0 0,-1 ' + + 'L10,-1 ' + + 'A0 0 0 0 0 10,-1 ' + + 'L10,1 ' + + 'A0 0 0 0 0 10,1 ' + + 'L0,1 ' + + 'A0 0 0 0 0 0,1 ' + + 'Z') + test.end() +}) + +// XXX check this with r0, r1 +tape('sankeyLink() specifying link radius', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 1}, + {x: 2, y: 10, ri: 1} + ], + dy: 2 + } + + // radius = 1, angle = 90 + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A2 2 1.570 0 1 2,0.999 ' + + 'L2,9 ' + + 'A0 0 1.570 0 0 2,9 ' + + 'L2,11 ' + + 'A2 2 1.570 0 1 0,9 ' + + 'L0,1 ' + + 'A0 0 1.570 0 0 0,1 ' + + 'Z') + test.end() +}) + +tape('sankeyLink() minimum thickness when dy small', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 1}, + {x: 2, y: 10, ri: 1} + ], + dy: 2 + } + + const path1 = sankeyLink()(link) + link.dy = 0.01 + const path2 = sankeyLink()(link) + + compareSVGPath(test, path1, path2) + test.end() +}) + +tape('sankeyLink() thickness goes to zero when dy = 0', test => { + const link = { + points: [ + {x: 0, y: 0, ro: 1}, + {x: 10, y: 0, ri: 1} + ], + dy: 0 + } + + compareSVGPath(test, sankeyLink()(link), + 'M0,0 ' + + 'A0 0 0 0 0 0,0 ' + + 'L10,0 ' + + 'A0 0 0 0 0 10,0 ' + + 'L10,0 ' + + 'A0 0 0 0 0 10,0 ' + + 'L0,0 ' + + 'A0 0 0 0 0 0,0 ' + + 'Z') + test.end() +}) + +// tape('sankeyLink() self-loops are drawn below with default radius 1.5x width', test => { +// let link = sankeyLink(), +// node = {}, +// segment = { +// x0: 0, +// x1: 0, +// y0: 0, +// y1: 0, +// dy: 10, +// source: node, +// target: node, +// }; + +// // Arc: A rx ry theta large-arc-flag direction-flag x y +// compareSVGPath(test, link(segment), +// 'M0.1,-5 ' + +// 'A12.5 12.5 6.283 1 1 -0.1,-5 ' + +// 'L-0.1,5 ' + +// 'A2.5 2.5 6.283 1 0 0.1,5 ' + +// 'Z'); + +// test.end(); +// }); + +tape('sankeyLink() flow from forward to reverse node', test => { + const link = { + points: [ + {x: 0, y: 0, d: 'r'}, + {x: 0, y: 50, d: 'l'} + ], + dy: 10 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-5 ' + + 'A15 15 1.570 0 1 15,10 ' + + 'L15,40 ' + + 'A15 15 1.570 0 1 0,55 ' + + 'L0,45 ' + + 'A5 5 1.570 0 0 5,40 ' + + 'L5,10 ' + + 'A5 5 1.570 0 0 0,5 ' + + 'Z') + + // force radius + link.points[0].ro = link.points[1].ri = 20 + compareSVGPath(test, sankeyLink()(link), + 'M0,-5 ' + + 'A25 25 1.570 0 1 25,20 ' + + 'L25,30 ' + + 'A25 25 1.570 0 1 0,55 ' + + 'L0,45 ' + + 'A15 15 1.570 0 0 15,30 ' + + 'L15,20 ' + + 'A15 15 1.570 0 0 0,5 ' + + 'Z') + + test.end() +}) + +tape('sankeyLink() flow from reverse to forward node', test => { + const link = { + points: [ + {x: 0, y: 0, d: 'l'}, + {x: 0, y: 50, d: 'r'} + ], + dy: 10 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-5 ' + + 'A15 15 1.570 0 0 -15,10 ' + + 'L-15,40 ' + + 'A15 15 1.570 0 0 0,55 ' + + 'L0,45 ' + + 'A5 5 1.570 0 1 -5,40 ' + + 'L-5,10 ' + + 'A5 5 1.570 0 1 0,5 ' + + 'Z') + test.end() +}) + +tape('sankeyLink() flow from reverse to reverse node', test => { + const link = { + points: [ + {x: 20, y: 0, d: 'l'}, + {x: 0, y: 0, d: 'l'} + ], + dy: 10 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-5 ' + + 'A0 0 0 0 0 0,-5 ' + + 'L20,-5 ' + + 'A0 0 0 0 0 20,-5 ' + + 'L20,5 ' + + 'A0 0 0 0 0 20,5 ' + + 'L0,5 ' + + 'A0 0 0 0 0 0,5 ' + + 'Z') + test.end() +}) + +// tape('sankeyLink() flow from forward to offstage node', test => { +// let segment = { +// x0: 0, +// y0: 5, +// x1: 10, +// y1: 30, +// dy: 10, +// d0: 'r', +// d1: 'd' +// } + +// // Arc: A rx ry theta large-arc-flag direction-flag x y +// compareSVGPath(test, sankeyLink()(segment), +// 'M0,0 ' + +// 'A15 15 1.570 0 1 15,15 ' + +// 'L15,30 5,30 5,15 ' + +// 'A5 5 1.570 0 0 0,10 ' + +// 'Z') +// test.end() +// }) + +tape('sankeyLink() with multiple segments', test => { + const link = { + points: [ + {x: 0, y: 0}, + {x: 10, y: 0}, + {x: 20, y: 0} + ], + dy: 2 + } + + // Arc: A rx ry theta large-arc-flag direction-flag x y + compareSVGPath(test, sankeyLink()(link), + 'M0,-1 ' + + 'A0 0 0 0 0 0,-1 ' + + 'L10,-1 ' + + 'A0 0 0 0 0 10,-1 ' + + 'L10,1 ' + + 'A0 0 0 0 0 10,1 ' + + 'L0,1 ' + + 'A0 0 0 0 0 0,1 ' + + 'Z' + + 'M10,-1 ' + + 'A0 0 0 0 0 10,-1 ' + + 'L20,-1 ' + + 'A0 0 0 0 0 20,-1 ' + + 'L20,1 ' + + 'A0 0 0 0 0 20,1 ' + + 'L10,1 ' + + 'A0 0 0 0 0 10,1 ' + + 'Z') + test.end() +}) diff --git a/test/sankey-basic-test.js b/test/sankey-basic-test.js new file mode 100644 index 0000000..5c523e6 --- /dev/null +++ b/test/sankey-basic-test.js @@ -0,0 +1,244 @@ +import tape from 'tape' +import sankey from '../src/sankey.js' + +tape('sankey() has the expected defaults', test => { + var s = sankey() + test.equal(s.nodeId()({id: 'foo'}), 'foo') + // test.equal(s.nodeBackwards()({direction: 'l'}), true) + test.deepEqual(s.sourceId()({source: 'bar', sourcePort: 'a'}), {id: 'bar', port: 'a'}) + test.deepEqual(s.targetId()({target: 'baz', targetPort: 'b'}), {id: 'baz', port: 'b'}) + test.equal(s.linkType()({type: 'x'}), 'x') + test.end() +}) + +tape('sankey(graph) builds the graph structure', test => { + var s = sankey() + var l + var graph = s({ + nodes: [ + {id: 'a'}, + {id: 'b'} + ], + links: [ + (l = {source: 'a', target: 'b', type: 'c'}) + ] + }) + + test.deepEqual(graph.nodes[0].incoming, [], 'node a incoming') + test.deepEqual(graph.nodes[1].outgoing, [], 'node b outgoing') + test.deepEqual(graph.nodes[0].outgoing, [graph.links[0]], 'node a outgoing') + test.deepEqual(graph.nodes[1].incoming, [graph.links[0]], 'node b incoming') + + test.equal(graph.links[0].source, graph.nodes[0], 'link source') + test.equal(graph.links[0].target, graph.nodes[1], 'link target') + + // original objects modified? + // test.equal(l.source, 'a') + test.end() +}) + +// tape('sankey(graph) sets port locations', test => { +// test.deepEqual(graph.nodes[0].ports, [ + +// ], 'node a ports') +// { +// id: '', +// y: 0, +// dy: 0, +// incoming: [], +// outgoing: [graph.links[0]] +// }]) +// test.deepEqual(graph.nodes[1].subdivisions, [{ +// id: '', +// y: 0, +// dy: 0, +// incoming: [graph.links[0]], +// outgoing: [] +// }]) + +// test.deepEqual(graph.nodes[0].subdivisions, [{ +// id: '', +// y: 0, +// dy: 0, +// incoming: [], +// outgoing: [graph.links[0]] +// }]) +// test.deepEqual(graph.nodes[1].subdivisions, [{ +// id: '', +// y: 0, +// dy: 0, +// incoming: [graph.links[0]], +// outgoing: [] +// }]) +// }) + +// tape('sankey(graph) can be called again', test => { +// var input1 = { +// nodes: [ +// {id: 'a'}, +// {id: 'b'} +// ], +// links: [ +// {source: 'a', target: 'b', type: 'c', value: 1} +// ] +// } + +// var input2 = { +// nodes: [ +// {id: 'a'}, +// {id: 'b'} +// ], +// links: [ +// {source: 'a', target: 'b', type: 'c', value: 1} +// ] +// } + +// var graph1 = sankey()(input1) +// var graph2 = sankey()(input2) +// graph2 = sankey()(input2) + +// // console.log('links', graph1.links) +// test.deepEqual(graph1.nodes[0], graph2.nodes[0]) +// test.end() +// }) + +tape('sankey(graph) sets node and link values', test => { + var s = sankey().linkValue(function (d) { return d.val }) + var graph = s({ + nodes: [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'} + ], + links: [ + {source: 'a', target: 'b', type: 'x', val: 7}, + {source: 'a', target: 'b', type: 'y', val: 2}, + {source: 'b', target: 'c', type: 'x', val: 3} + ] + }) + + // test.equal(graph.links[0].value, 7) + // test.equal(graph.links[1].value, 2) + // test.equal(graph.links[2].value, 3) + test.equal(graph.nodes[0].value, 9) + test.equal(graph.nodes[1].value, 9) + test.equal(graph.nodes[2].value, 3) + test.end() +}) + +tape('sankey(nodes, edges) observes the specified id accessor functions', test => { + var s = sankey() + .nodeId(function (d) { return d.foo }) + .sourceId(function (d) { return d.bar }) + .targetId(function (d) { return d.baz }) + .linkType(function (d) { return d.fred }) + + var graph = s({ + nodes: [ + {foo: 'a'}, + {foo: 'b'} + ], + links: [ + {bar: 'a', baz: 'b', fred: 'c'} + ] + }) + + // test.equal(graph.nodes[0].id, 'a') + // test.equal(graph.nodes[1].id, 'b') + test.equal(graph.links[0].source, graph.nodes[0]) + test.equal(graph.links[0].target, graph.nodes[1]) + // test.equal(graph.links[0].type, 'c') + test.end() +}) + +tape('nodeId() is given the node and its index', test => { + var s = sankey().nodeId(function (d, i) { return i }) + var graph = s({nodes: [{id: 'a'}, {id: 'b'}], links: [{source: 0, target: 1}]}) + test.equal(graph.links[0].source.id, 'a') + test.equal(graph.links[0].target.id, 'b') + test.end() +}) + +tape('sankey.nodeId(id) tests that nodeId is a function', test => { + var s = sankey() + test.throws(function () { s.nodeId(42) }) + test.throws(function () { s.nodeId(null) }) + test.end() +}) + +tape('sankey.nodeBackwards(id) tests that nodeBackwards is a function', test => { + var s = sankey() + test.throws(function () { s.nodeBackwards(42) }) + test.throws(function () { s.nodeBackwards(null) }) + test.end() +}) + +tape('sankey.sourceId(id) tests that id is a function', test => { + var s = sankey() + test.throws(function () { s.sourceId(42) }) + test.throws(function () { s.sourceId(null) }) + test.end() +}) + +tape('sankey.targetId(id) tests that id is a function', test => { + var s = sankey() + test.throws(function () { s.targetId(42) }) + test.throws(function () { s.targetId(null) }) + test.end() +}) + +tape('sankey.linkType(id) tests that id is a function', test => { + var s = sankey() + test.throws(function () { s.linkType(42) }) + test.throws(function () { s.linkType(null) }) + test.end() +}) + +tape('sankey(graph) throws an error if a node is missing', test => { + var s = sankey() + test.throws(function () { + s({nodes: [], links: [{source: 'a', target: 'b'}]}) + }, /\bmissing\b/) + test.end() +}) + +tape('sankey(graph) throws an error if multiple nodes have the same id', test => { + var s = sankey() + test.throws(function () { + s({nodes: [{id: 'a'}, {id: 'a'}], links: []}) + }, /\bduplicate\b/) + test.end() +}) + +// tape('sankey(graph) works with multiple links between same nodes', test => { +// var s = sankey() +// var graph = s({ +// nodes: [ +// {id: 'a'}, +// {id: 'b'} +// ], +// links: [ +// {source: 'a', target: 'b', type: 'c'}, +// {source: 'a', target: 'b', type: 'c'} +// ] +// }) + +// test.deepEqual(graph.nodes[0].incoming, []) +// test.deepEqual(graph.nodes[0].outgoing, [graph.links[0], graph.links[1]]) +// test.deepEqual(graph.nodes[1].incoming, [graph.links[0], graph.links[1]]) +// test.deepEqual(graph.nodes[1].outgoing, []) +// test.equal(graph.links[0].source, graph.nodes[0]) +// test.equal(graph.links[0].target, graph.nodes[1]) +// test.equal(graph.links[1].source, graph.nodes[0]) +// test.equal(graph.links[1].target, graph.nodes[1]) +// test.end() +// }) + +// tape('sankey(graph) adds a new node if source or target id does not exist', test => { +// var s = sankey() +// var graph = s({nodes: [{id: 'a'}], links: [{source: 'a', target: 'b'}]}) +// test.equal(graph.nodes.length, 2) +// test.equal(graph.nodes[0].id, 'a') +// test.equal(graph.nodes[1].id, 'b') +// test.end() +// }) diff --git a/test/sankey-layout-subdivisions-test.js b/test/sankey-layout-subdivisions-test.js new file mode 100644 index 0000000..0c25132 --- /dev/null +++ b/test/sankey-layout-subdivisions-test.js @@ -0,0 +1,85 @@ +import sankey from '../src/sankey.js' +import tape from 'tape' +import { assertAlmostEqual } from './assert-almost-equal' + +tape('sankey() aligns ports', test => { + const graph = { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'} + ], + links: [ + {source: '0', target: '2', targetPort: 'a', type: '02a', value: 5}, + {source: '0', target: '2', targetPort: 'b', type: '02b', value: 5}, + {source: '1', target: '2', targetPort: 'a', type: '12a', value: 5} + ] + } + const ordering = [['0', '1'], ['2']] + const layout = sankey().size([2, 12]).ordering(ordering) + layout.sortPorts(function (a, b) { return a.id.localeCompare(b.id) }) + layout(graph) + + test.deepEqual(graph.links.map(l => l.dy), [2, 2, 2], 'link thicknesses') + + // Order at node 2 should be link 0, 2, 1 + const n2 = graph.nodes[2] + assertAlmostEqual(test, graph.links[0].points[1].y, n2.y0 + 1, 1e-3, 'l0') + assertAlmostEqual(test, graph.links[1].points[1].y, n2.y0 + 5, 1e-3, 'l1') + assertAlmostEqual(test, graph.links[2].points[1].y, n2.y0 + 3, 1e-3, 'l2') + + // Node 2 should have ports positioned + test.deepEqual(n2.ports.map(d => ({id: d.id, y: d.y, dy: d.dy})), [ + { id: 'a', y: 0, dy: 4 }, + { id: 'b', y: 4, dy: 2 } + ], 'ports') + + // Changes order + // graph.nodes[2].ports = [{id: 'b'}, {id: 'a'}] + layout.sortPorts(function (a, b) { return b.id.localeCompare(a.id) }) + layout(graph) + + assertAlmostEqual(test, graph.links[0].points[1].y, n2.y0 + 3, 1e-3, 'l0 again') + assertAlmostEqual(test, graph.links[1].points[1].y, n2.y0 + 1, 1e-3, 'l1 again') + assertAlmostEqual(test, graph.links[2].points[1].y, n2.y0 + 5, 1e-3, 'l2 again') + test.deepEqual(n2.ports.map(d => ({id: d.id, y: d.y, dy: d.dy})), [ + { id: 'b', y: 0, dy: 2 }, + { id: 'a', y: 2, dy: 4 } + ], 'ports again') + + test.end() +}) + +// tape('sankey() aligns links accouting for ports', test => { +// const graph = { +// nodes: [ +// {id: '0'}, +// {id: '1', ports: [{id: 'a'}, {id: 'b'}]} +// ], +// links: [ +// {source: '0', target: '1', targetPort: 'a', type: 'a', value: 5}, +// {source: '0', target: '1', targetPort: 'b', type: 'b', value: 5} +// ] +// } +// const ordering = [['0'], ['1']] +// const layout = sankey().size([2, 8]).ordering(ordering) +// layout(graph) + +// test.deepEqual(graph.links.map(l => l.dy), [2, 2], 'link thicknesses') + +// test.deepEqual(pointsY(graph.links[0]), [3, 3], 'l0 before') +// test.deepEqual(pointsY(graph.links[1]), [5, 5], 'l1 before') + +// // Changes order +// graph.nodes[1].ports = [{id: 'b'}, {id: 'a'}] +// layout(graph) + +// test.deepEqual(pointsY(graph.links[0]), [5, 5], 'l0 after') +// test.deepEqual(pointsY(graph.links[1]), [3, 3], 'l1 after') + +// test.end() +// }) + +function pointsY (link) { + return link.points.map(d => d.y) +} diff --git a/test/sankey-layout-test.js b/test/sankey-layout-test.js new file mode 100644 index 0000000..63a988e --- /dev/null +++ b/test/sankey-layout-test.js @@ -0,0 +1,423 @@ +import sankey from '../src/sankey.js' +import tape from 'tape' +import { assertAlmostEqual } from './assert-almost-equal' + +tape('sankey: scale', test => { + const {graph, ordering} = example4to1() + const pos = sankey().ordering(ordering) + + test.equal(pos.scale(), null, 'initially scale is null') + + pos(graph) + test.equal(pos.scale(), 1 / 20 * 0.5, 'default scaling with 50% whitespace') + + pos.whitespace(0).scale(null)(graph) + test.equal(pos.scale(), 1 / 20 * 1.0, 'scaling with 0% whitespace') + + test.end() +}) + +tape('sankey() sets node.rank and node.depth', test => { + // + // .-------. + // 0----1----2 + // `-,__`:::3, + // 4` + // + const graph = { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4', direction: 'l'} + ], + links: [ + {source: '0', target: '1', value: 5}, + {source: '0', target: '3', value: 5}, + {source: '1', target: '2', value: 5}, + {source: '1', target: '3', value: 1}, + {source: '0', target: '2', value: 1}, + {source: '3', target: '4', value: 1} + ] + } + sankey()(graph) + + test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 2, 2], 'node ranks') + test.deepEqual(nodeAttr(graph, d => d.depth), [0, 1, 0, 1, 2], 'node depths') + test.end() +}) + +tape('sankey() sets node.{x0, y0, x1, y1}', test => { + const {graph, ordering} = example4to1() + + // 50% whitespace: scale = 8 / 20 * 0.5 = 0.2 + // margin = 8 * 50% / 5 = 0.8 + // total node height = 4 * 5 * 0.2 = 4 + // remaining space = 8 - 4 - 2*0.8 =2.4 + // spread betweeen 3 gaps = 0.8 + sankey().size([3, 8]).ordering(ordering)(graph) + + test.deepEqual(nodeAttr(graph, d => d.y1 - d.y0), [1, 1, 1, 1, 4], 'node heights') + assertAlmostEqual(test, nodeAttr(graph, d => d.y0), [ + 0.8, + 0.8 + 1 + 0.8, + 0.8 + 1 + 0.8 + 1 + 0.8, + 0.8 + 1 + 0.8 + 1 + 0.8 + 1 + 0.8, + 2 // centred + ], 1e-6, 'node y') + + assertAlmostEqual(test, nodeAttr(graph, d => d.x0), [0, 0, 0, 0, 2], 'node x') + assertAlmostEqual(test, nodeAttr(graph, d => d.x1), [1, 1, 1, 1, 3], 'node x') + test.end() +}) + +tape('sankey() sets node positions using nodePosition()', test => { + const {graph} = example4to1() + sankey().scale(0.2).nodePosition(d => [parseFloat(d.id), parseFloat(d.id)])(graph) + + test.deepEqual(nodeAttr(graph, d => d.y1 - d.y0), [1, 1, 1, 1, 4], 'node heights') + assertAlmostEqual(test, nodeAttr(graph, d => d.y0), [0, 1, 2, 3, 4], 1e-6, 'node y') + assertAlmostEqual(test, nodeAttr(graph, d => d.x0), [0, 1, 2, 3, 4], 1e-6, 'node x0') + assertAlmostEqual(test, nodeAttr(graph, d => d.x1), [1, 2, 3, 4, 5], 1e-6, 'node x1') + test.end() +}) + +tape('sankey() sets link.dy and link.points', test => { + const {graph, ordering} = example4to1() + sankey().size([3, 8]).ordering(ordering)(graph) + + test.deepEqual(graph.links.map(l => l.dy), [1, 1, 1, 1], 'link thicknesses') + + const n0 = graph.nodes[0] + const n4 = graph.nodes[4] + const l0 = graph.links[0] + assertAlmostEqual(test, l0.points[0].x, n0.x1, 1e-3, 'l0 x0') + assertAlmostEqual(test, l0.points[0].y, n0.y0 + l0.dy / 2, 1e-3, 'l0 y0') + assertAlmostEqual(test, l0.points[1].x, n4.x0, 1e-3, 'l0 x1') + assertAlmostEqual(test, l0.points[1].y, n4.y0 + l0.dy / 2, 1e-3, 'l0 y1') + + const n1 = graph.nodes[1] + const l1 = graph.links[1] + assertAlmostEqual(test, l1.points[0].x, n1.x1, 1e-3, 'l1 x0') + assertAlmostEqual(test, l1.points[0].y, n1.y0 + l1.dy / 2, 1e-3, 'l1 y0') + assertAlmostEqual(test, l1.points[1].x, n4.x0, 1e-3, 'l1 x1') + assertAlmostEqual(test, l1.points[1].y, n4.y0 + l0.dy + l1.dy / 2, 1e-3, 'l1 y1') + + test.end() +}) + +tape('sankey() sets link directions', test => { + const graph = { + nodes: [ + {id: 'a'}, + {id: 'b', direction: 'l'} + ], + links: [ + {source: 'a', target: 'b', value: 1} + ] + } + sankey().ordering([['a', 'b']]).size([10, 12])(graph) + + test.equal(graph.links[0].points[0].d, 'r', 'out direction') + test.equal(graph.links[0].points[1].d, 'l', 'in direction') + test.end() +}) + +tape('sankey() sets link.points on long links', test => { + // 0 1 2 3 4 5 6 7 8 9 + // a --- b --- c --- d + // `----*-----*----` + // + const graph = { + nodes: [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'}, + {id: 'd'} + ], + links: [ + {source: 'a', target: 'b', value: 1}, + {source: 'b', target: 'c', value: 1}, + {source: 'c', target: 'd', value: 1}, + {source: 'a', target: 'd', value: 1} + ] + } + sankey().size([10, 12])(graph) + + test.deepEqual(graph.links.map(l => l.dy), [ 3, 3, 3, 3 ]) + test.deepEqual(graph.links[0].points, [ + { x: 1, y: 7.5, ro: 1.5, d: 'r' }, + { x: 3, y: 9.3, ri: 1.5, d: 'r' } + ]) + test.deepEqual(graph.links[3].points, [ + { x: 1, y: 4.5, ro: 1.5, d: 'r' }, + { x: 3.5, y: 2.7, ri: 1.5, ro: Infinity, d: 'r' }, + { x: 6.5, y: 2.7, ri: Infinity, ro: 1.5, d: 'r' }, + { x: 9, y: 4.5, ri: 1.5, d: 'r' } + ]) + test.end() +}) + +tape('sankey.update() sets node.{x0, y0, x1, y1}, link.dy and link.points based on existing node positions', test => { + const graph = { + nodes: [ + {id: 'a', x0: 1, x1: 2, y: 1}, + {id: 'b', x0: 3, x1: 4, y: 1} + ], + links: [ + {source: 'a', target: 'b', value: 1} + ] + } + sankey().scale(1).update(graph) + + test.equal(graph.links[0].dy, 1) + test.deepEqual(graph.links[0].points, [ + { x: 2, y: 1.5, ro: Infinity }, + { x: 3, y: 1.5, ri: Infinity } + ]) + test.end() +}) + +tape('sankey().size() sets width and height', test => { + const graph = { + nodes: [ + {id: '0'}, + {id: '1'} + ], + links: [ + {source: '0', target: '1', value: 5} + ] + } + + sankey().size([100, 100])(graph) + test.deepEqual(nodeAttr(graph, d => d.x0), [0, 99], 'x') + test.deepEqual(nodeAttr(graph, d => d.y0), [25, 25], 'y') + + sankey().size([200, 200])(graph) + test.deepEqual(nodeAttr(graph, d => d.x0), [0, 199], 'x') + test.deepEqual(nodeAttr(graph, d => d.y0), [50, 50], 'y') + + test.end() +}) + +tape('sankey().extent() sets x0, y0, x1, y1', test => { + const graph = { + nodes: [ + {id: '0'}, + {id: '1'} + ], + links: [ + {source: '0', target: '1', value: 5} + ] + } + + sankey().extent([[100, 100], [200, 200]])(graph) + test.deepEqual(nodeAttr(graph, d => d.x0), [100, 199], 'x') + test.deepEqual(nodeAttr(graph, d => d.y0), [125, 125], 'y') + test.end() +}) + +tape('sankey.rankSets() affects ranking of nodes', test => { + const graph = { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + {id: '3'} + ], + links: [ + {source: '0', target: '1', value: 5}, + {source: '1', target: '2', value: 5}, + {source: '0', target: '3', value: 5} + ] + } + + sankey()(graph) + test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 1], 'no rankSets') + + sankey().rankSets([{ type: 'same', nodes: ['2', '3'] }])(graph) + test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 2], 'with rankSets') + + test.end() +}) + +tape('sankey() horizontal positioning', test => { + function nodeX (width) { + const graph = { + nodes: [{id: '0'}, {id: '1'}, {id: '2'}], + links: [ + {source: '0', target: '1', value: 3}, + {source: '1', target: '2', value: 3} + ] + } + sankey().ordering([['0'], ['1'], ['2']]).size([width, 1])(graph) + return graph.nodes.map(d => d.x0) + } + + test.deepEqual(nodeX(7), [0, 3, 6], 'equal when straight') + // test.deepEqual(nodeX([6, 0]), [0, 8, 10], 'min width moves x position'); + // test.deepEqual(nodeX([6, 2]), [0, 7, 10], 'min width moves x position 2'); + // test.deepEqual(nodeX([7, 5]), [0, 10*7/12, 10], 'width allocated fairly if insufficient'); + test.end() +}) + +function nodeAttr (graph, f) { + const r = graph.nodes.map(d => [d.id, f(d)]) + r.sort((a, b) => a[0].localeCompare(b[0])) + return r.map(d => d[1]) +} + +tape('sankey() nodes with zero value are ignored', test => { + const graph = { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'} + ], + links: [ + {source: '0', target: '4', value: 5}, + {source: '1', target: '4', value: 5}, + {source: '2', target: '4', value: 0}, // NB value = 0 + {source: '3', target: '4', value: 5} + ] + } + + sankey().ordering([['0', '1', '2', '3'], ['4']])(graph) + + const y = nodeAttr(graph, d => d.y0) + + const sep01 = y[1] - y[0] + const sep13 = y[3] - y[1] + + test.equal(graph.nodes[2].y1 - graph.nodes[2].y0, 0, 'node 2 should have no height') + assertAlmostEqual(test, sep01, sep13, 1e-6, 'node 2 should not affect spacing of others') + test.end() +}) + +tape('justifiedPositioning: bands', test => { + // 0 -- 2 : band x + // + // 1 -- 3 : band y + // `- 4 : + // + const graph = { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'} + ], + links: [ + {source: '0', target: '2', value: 5}, + {source: '1', target: '3', value: 10}, + {source: '1', target: '4', value: 15} + ] + } + + const s = sankey() + .size([1, 8]) + .ordering([ [['0'], []], [['2'], ['1']], [[], ['3', '4']] ]) + + const nodes = nodeAttr(s(graph), d => d) + + // 50% whitespace: scale = 8 / 20 * 0.5 = 0.2 + const margin = (5 / 30) * 8 / 5 + + // Bands should not overlap + const yb = nodes[0].y1 + margin + test.ok(nodes[0].y0 >= margin, 'node 0 >= margin') + test.ok(nodes[2].y0 >= margin, 'node 2 >= margin') + test.ok(nodes[0].y1 < yb, 'node 0 above boundary') + test.ok(nodes[2].y1 < yb, 'node 2 above boundary') + + test.ok(nodes[1].y0 > yb, 'node 1 below boundary') + test.ok(nodes[3].y0 > yb, 'node 3 below boundary') + test.ok(nodes[4].y0 > yb, 'node 4 below boundary') + test.ok(nodes[1].y1 <= 8, 'node 1 within height') + test.ok(nodes[3].y1 <= 8, 'node 3 within height') + test.ok(nodes[4].y1 <= 8, 'node 4 within height') + + test.end() +}) + +function example4to1 () { + // 0|---\ + // \ + // 1|-\ -| + // \---|4 + // 2|------| + // ,-| + // 3|---/ + // + return { + graph: { + nodes: [ + {id: '0'}, + {id: '1'}, + {id: '2'}, + {id: '3'}, + {id: '4'} + ], + links: [ + {source: '0', target: '4', value: 5}, + {source: '1', target: '4', value: 5}, + {source: '2', target: '4', value: 5}, + {source: '3', target: '4', value: 5} + ] + }, + ordering: [['0', '1', '2', '3'], ['4']] + } +} + +tape('sankey.ordering(order) sets rank, band and depth', test => { + function resultForOrder (order) { + var graph = { + nodes: [ + {id: 'a'}, + {id: 'b'}, + {id: 'c'}, + {id: 'd'} + ], + links: [ + {source: 'a', target: 'b'}, + {source: 'a', target: 'c'}, + {source: 'c', target: 'd'} + ] + } + return rankBandAndDepth(sankey().ordering(order)(graph)) + } + + test.deepEqual(resultForOrder([['a'], ['b', 'c'], ['d']]), { + a: [0, undefined, 0], + b: [1, undefined, 0], + c: [1, undefined, 1], + d: [2, undefined, 0] + }, '2-level order') + + test.deepEqual(resultForOrder([[['a'], []], [['b', 'c'], []], [[], ['d']]]), { + a: [0, 0, 0], + b: [1, 0, 0], + c: [1, 0, 1], + d: [2, 1, 0] + }, '3-level order') + + test.end() +}) + +tape('sankey.ordering() returns ordering', test => { + var order = [['a'], ['b', 'c'], ['d']] + test.equal(sankey().ordering(order).ordering(), order) + test.end() +}) + +function rankBandAndDepth (G) { + const r = {} + G.nodes.forEach(d => { + r[d.id] = [d.rank, d.band, d.depth] + }) + return r +} diff --git a/test/sankey/assignRanks-acyclic-test.js b/test/sankey/assignRanks-acyclic-test.js new file mode 100644 index 0000000..2e88c81 --- /dev/null +++ b/test/sankey/assignRanks-acyclic-test.js @@ -0,0 +1,194 @@ +import makeAcyclic, { findSpanningTree, nodeRelationship } from '../../src/assignRanks/make-acyclic' +import { Graph, alg } from 'graphlib' +import tape from 'tape' + +tape('rank assignment: makeAcyclic()', tape => { + // + // ,--<----------, + // \ ,- d -` + // a -- b -- c + // `-----' + // + const G = new Graph({directed: true}) + G.setEdge('a', 'b', {}) + G.setEdge('b', 'c', {}) + G.setEdge('a', 'c', {}) + G.setEdge('b', 'd', {}) + G.setEdge('d', 'a', {}) + + tape.ok(!alg.isAcyclic(G), 'initially has a cycle') + makeAcyclic(G, 'a') + tape.ok(alg.isAcyclic(G), 'made acyclic') + + tape.deepEqual(G.nodes(), ['a', 'b', 'c', 'd'], 'nodes') + tape.deepEqual(G.edges(), [ + {v: 'a', w: 'b'}, + {v: 'b', w: 'c'}, + {v: 'a', w: 'c'}, + {v: 'b', w: 'd'}, + {v: 'a', w: 'd'} // REVERSED! + ], 'edges') + tape.deepEqual(G.edges().map(e => G.edge(e)), [ + {}, + {}, + {}, + {}, + { reversed: true } + ], 'edges labels') + + tape.end() +}) + +tape('rank assignment: find spanning tree', test => { + const G = new Graph({directed: true}) + G.setEdge('a', 'b') + G.setEdge('b', 'c') + G.setEdge('a', 'c') + G.setEdge('b', 'd') + G.setEdge('d', 'a') + + test.ok(!alg.isAcyclic(G), 'not acyclic to start with') + + const tree = findSpanningTree(G, 'a') + + test.ok(alg.isAcyclic(tree), 'tree should not have cycles') + test.deepEqual(tree.nodes(), ['a', 'b', 'c', 'd'], 'all nodes in tree') + test.deepEqual( + tree.nodes().map(u => tree.node(u)), + [ + { depth: 0, thread: 'b' }, + { depth: 1, thread: 'c' }, + { depth: 2, thread: 'd' }, + { depth: 2, thread: 'a' } + ], + 'depth and thread in tree') + test.deepEqual(tree.edges(), [ + {v: 'a', w: 'b'}, + {v: 'b', w: 'c'}, + {v: 'b', w: 'd'} + ], 'tree edges') + + // add same edges in a different order: a-c before b-c + const G2 = new Graph({directed: true}) + G2.setEdge('a', 'c') + G2.setEdge('a', 'b') + G2.setEdge('b', 'c') + G2.setEdge('b', 'd') + G2.setEdge('d', 'a') + + const tree2 = findSpanningTree(G2, 'a') + + test.ok(alg.isAcyclic(tree2), 'tree2 should not have cycles') + test.deepEqual(tree2.nodes(), ['a', 'c', 'b', 'd'], 'all nodes in tree2') + test.deepEqual(tree2.edges(), [ + {v: 'a', w: 'c'}, + {v: 'a', w: 'b'}, + {v: 'b', w: 'd'} + ], 'tree2 edges') + + test.end() +}) + +tape('rank assignment: find spanning tree - multiple solutions', test => { + // this is a simple version of the double paths in the other examples + + // It doesn'test seem to cause a problem with letters as node ids, but + // numbers are sorted when using G.successors(). + + const G1 = new Graph({directed: true}) + G1.setEdge('0', '1') + G1.setEdge('1', '2') + G1.setEdge('0', '2') + + const G2 = new Graph({directed: true}) + G2.setEdge('0', '2') // different order + G2.setEdge('0', '1') + G2.setEdge('1', '2') + + const tree1 = findSpanningTree(G1, '0') + const tree2 = findSpanningTree(G2, '0') + + test.deepEqual(tree1.edges(), [ + {v: '0', w: '1'}, + {v: '1', w: '2'} + ], 'tree1 edges') + + test.deepEqual(tree2.edges(), [ + {v: '0', w: '2'}, + {v: '0', w: '1'} + ], 'tree2 edges') + + test.end() +}) + +// tape('rank assignment: find spanning tree with reversed edges', test => { +// // const G = new Graph({directed: true}) +// // G.setEdge('a', 'b') +// // G.setEdge('b', 'c') +// // G.setEdge('c', 'd') +// // G.setNode('c', { reversed: true }) +// // G.setNode('d', { reversed: true }) + +// // // test.ok(!alg.isAcyclic(G), 'not acyclic to start with') + +// // const tree = findSpanningTree(G, 'a') + +// // test.ok(alg.isAcyclic(tree), 'tree should not have cycles') +// // test.deepEqual(tree.nodes(), ['a', 'b', 'c', 'd'], 'all nodes in tree') +// // test.deepEqual(tree.nodes().map(u => tree.node(u)), +// // [ +// // { depth: 0, thread: 'b' }, +// // { depth: 1, thread: 'c' }, +// // { depth: 2, thread: 'd' }, +// // { depth: 3, thread: 'a' }, +// // ], +// // 'depth and thread in tree') +// // test.deepEqual(tree.edges(), [ +// // {v: 'a', w: 'b'}, +// // {v: 'b', w: 'c'}, +// // {v: 'c', w: 'd'}, +// // ], 'tree edges') + +// // // add same edges in a different order: a-c before b-c +// // const G2 = new Graph({directed: true}) +// // G2.setEdge('a', 'c') +// // G2.setEdge('a', 'b') +// // G2.setEdge('b', 'c') +// // G2.setEdge('b', 'd') +// // G2.setEdge('d', 'a') + +// // const tree2 = findSpanningTree(G2, 'a') + +// // test.ok(alg.isAcyclic(tree2), 'tree2 should not have cycles') +// // test.deepEqual(tree2.nodes(), ['a', 'c', 'b', 'd'], 'all nodes in tree2') +// // test.deepEqual(tree2.edges(), [ +// // {v: 'a', w: 'c'}, +// // {v: 'a', w: 'b'}, +// // {v: 'b', w: 'd'}, +// // ], 'tree2 edges') + +// test.end() +// }) + +tape('rank assignment: relationship of nodes in tree', test => { + // Same spanning tree as before: + // ,- c + // a -- b -< + // `- d + // + const tree = new Graph({directed: true}) + tree.setNode('a', { depth: 0, thread: 'b' }) + tree.setNode('b', { depth: 1, thread: 'c' }) + tree.setNode('c', { depth: 2, thread: 'd' }) + tree.setNode('d', { depth: 2, thread: 'a' }) + tree.setEdge('a', 'b') + tree.setEdge('b', 'c') + tree.setEdge('b', 'd') + + test.equal(nodeRelationship(tree, 'a', 'b'), 1, 'a-b: descendent') + test.equal(nodeRelationship(tree, 'b', 'd'), 1, 'b-d: descendent') + test.equal(nodeRelationship(tree, 'c', 'b'), -1, 'c-b: ancestor') + test.equal(nodeRelationship(tree, 'c', 'd'), 0, 'c-d: unrelated') + + test.end() +}) diff --git a/test/sankey/assignRanks-grouped-graph-test.js b/test/sankey/assignRanks-grouped-graph-test.js new file mode 100644 index 0000000..b379ea0 --- /dev/null +++ b/test/sankey/assignRanks-grouped-graph-test.js @@ -0,0 +1,107 @@ +import groupedGraph from '../../src/assignRanks/grouped-graph' +import tape from 'tape' +import { Graph } from 'graphlib' + +// XXX reversing edges into Smin and out of Smax? +// XXX reversing edges marked as "right to left"? + +tape('rank assignment: groupedGraph() produces one group per node, without ranksets', (test) => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {}) + graph.setNode('b', {}) + graph.setNode('c', {}) + graph.setEdge('a', 'b', {}) + graph.setEdge('a', 'c', {}) + const GG = groupedGraph(graph) + + test.deepEqual(GG.nodes(), ['0', '1', '2']) + test.deepEqual(GG.node('0'), {type: 'min', nodes: ['a']}) + test.deepEqual(GG.node('1'), {type: 'same', nodes: ['b']}) + test.deepEqual(GG.node('2'), {type: 'same', nodes: ['c']}) + test.deepEqual(GG.edges(), [{v: '0', w: '1'}, {v: '0', w: '2'}]) + test.end() +}) + +tape('rank assignment: groupedGraph() ignores repeated edges', (test) => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {}) + graph.setNode('b', {}) + graph.setEdge('a', 'b', {}, 'x') + graph.setEdge('a', 'b', {}, 'y') + const GG = groupedGraph(graph) + + test.deepEqual(GG.nodes(), ['0', '1']) + test.deepEqual(GG.node('0'), {type: 'min', nodes: ['a']}) + test.deepEqual(GG.node('1'), {type: 'same', nodes: ['b']}) + test.deepEqual(GG.edges(), [{v: '0', w: '1'}]) + test.end() +}) + +tape('rank assignment: groupedGraph() produces one group per rankset', (test) => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {}) + graph.setNode('b', {}) + graph.setNode('c', {}) + graph.setEdge('a', 'b', {}) + graph.setEdge('a', 'c', {}) + const GG = groupedGraph(graph, [{type: 'same', nodes: ['b', 'c']}]) + + test.deepEqual(GG.nodes(), ['0', '1']) + test.deepEqual(GG.node('0'), {type: 'min', nodes: ['a']}) + test.deepEqual(GG.node('1'), {type: 'same', nodes: ['b', 'c']}) + test.deepEqual(GG.edges(), [{v: '0', w: '1'}]) + test.end() +}) + +tape('rank assignment: groupedGraph() respects explicit "min" rankset', (test) => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {}) + graph.setNode('b', {}) + graph.setNode('c', {}) + graph.setEdge('a', 'b', {}) + graph.setEdge('a', 'c', {}) + const GG = groupedGraph(graph, [{type: 'min', nodes: ['b', 'c']}]) + + test.deepEqual(GG.nodes(), ['0', '1']) + test.deepEqual(GG.node('0'), {type: 'min', nodes: ['b', 'c']}) + test.deepEqual(GG.node('1'), {type: 'same', nodes: ['a']}) + test.deepEqual(GG.edges(), [{v: '1', w: '0'}]) + test.end() +}) + +function testGroupedGraph (d0, d1) { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {backwards: d0 === 'l'}) + graph.setNode('b', {backwards: d1 === 'l'}) + graph.setEdge('a', 'b', {}) + return groupedGraph(graph) +} + +tape('rank assignment: linkDelta = 0 for links that change direction, 1 otherwise', (test) => { + test.deepEqual(testGroupedGraph('r', 'r').edge('0', '1'), {delta: 1}, 'rr1') + test.deepEqual(testGroupedGraph('r', 'r').edge('1', '0'), undefined, 'rr2') + + test.deepEqual(testGroupedGraph('r', 'l').edge('0', '1'), {delta: 0}, 'rl1') + test.deepEqual(testGroupedGraph('r', 'l').edge('1', '0'), undefined, 'rl2') + + test.deepEqual(testGroupedGraph('l', 'l').edge('1', '0'), {delta: 1}, 'll1') + test.deepEqual(testGroupedGraph('l', 'l').edge('0', '1'), undefined, 'll2') + + test.deepEqual(testGroupedGraph('l', 'r').edge('1', '0'), {delta: 0}, 'lr1') + test.deepEqual(testGroupedGraph('l', 'r').edge('0', '1'), undefined, 'lr2') + + test.end() +}) + +function testGroupedGraphLoop (d) { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {backwards: d === 'l'}) + graph.setEdge('a', 'a', {}) + return groupedGraph(graph) +} + +tape('rank assignment: groupedGraph() sets delta on self-loops to 0', (test) => { + test.deepEqual(testGroupedGraphLoop('r').edge('0', '0'), {delta: 0}, 'r') + test.deepEqual(testGroupedGraphLoop('l').edge('0', '0'), {delta: 0}, 'r') + test.end() +}) diff --git a/test/sankey/assignRanks-initial-test.js b/test/sankey/assignRanks-initial-test.js new file mode 100644 index 0000000..04ce734 --- /dev/null +++ b/test/sankey/assignRanks-initial-test.js @@ -0,0 +1,53 @@ +import assignInitialRanks from '../../src/assignRanks/initial-ranks' +import { Graph } from 'graphlib' +import tape from 'tape' + +tape('rank assignment: assignInitialRanks', test => { + const G = new Graph({directed: true}) + G.setNode('a', {}) + G.setNode('b', {}) + G.setNode('cd', {}) + G.setNode('e', {}) + G.setNode('f', {}) + G.setEdge('a', 'b', {}) + G.setEdge('a', 'cd', {}) + G.setEdge('b', 'cd', {}) + G.setEdge('cd', 'e', {}) + G.setEdge('b', 'e', {}) // REVERSED from other example + G.setEdge('f', 'cd', {}) + + assignInitialRanks(G) + + test.deepEqual(G.nodes(), ['a', 'b', 'cd', 'e', 'f'], 'nodes') + test.deepEqual(G.nodes().map(u => G.node(u).rank), + [0, 1, 2, 3, 0], 'ranks') + + // Change minimum edge length + G.setEdge('b', 'cd', { delta: 2 }) + G.setEdge('cd', 'e', { delta: 0 }) + assignInitialRanks(G) + + test.deepEqual(G.nodes().map(u => G.node(u).rank), + [0, 1, 3, 3, 0], 'updated ranks') + test.end() +}) + +tape('rank assignment: assignInitialRanks with loop', test => { + // loops can easily happen with grouped nodes from rankSets + const G = new Graph({directed: true}) + G.setNode('a', {}) + G.setNode('b', {}) + G.setNode('c', {}) + G.setEdge('a', 'b', { delta: 1 }) + G.setEdge('b', 'c', { delta: 1 }) + G.setEdge('b', 'b', { delta: 0 }) + assignInitialRanks(G) + + test.deepEqual(G.nodes().map(u => [u, G.node(u).rank]), [ + ['a', 0], + ['b', 1], + ['c', 2] + ], 'node ranks') + + test.end() +}) diff --git a/test/sankey/assignRanks-test.js b/test/sankey/assignRanks-test.js new file mode 100644 index 0000000..e659d35 --- /dev/null +++ b/test/sankey/assignRanks-test.js @@ -0,0 +1,105 @@ +import assignRanks from '../../src/assignRanks/index.js' +import tape from 'tape' +import { Graph } from 'graphlib' + +tape('rank assignment: overall', test => { + // + // f -------, b<-, + // a -- b -- c -- e ` + // `------ d -' + // \ + // { + // + // a -- b + // c -- d + // + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {}) + graph.setNode('b', {}) + graph.setNode('c', {}) + graph.setNode('d', {}) + graph.setEdge('a', 'b', {}) + graph.setEdge('c', 'd', {}) + + const rankSets = [ + { type: 'same', nodes: ['b', 'c'] } + ] + + // Without rankSets + assignRanks(graph, []) + test.deepEqual(ranks(graph), { + 'a': 0, + 'b': 1, + 'c': 0, + 'd': 1 + }, 'ranks without rankSets') + + assignRanks(graph, rankSets) + test.deepEqual(ranks(graph), { + 'a': 0, + 'b': 1, + 'c': 1, + 'd': 2 + }, 'ranks with rankSets') + + test.end() +}) + +function ranks (graph) { + var r = {} + graph.nodes().forEach(u => { r[u] = graph.node(u).rank }) + return r +} diff --git a/test/sankey/layout-links-test.js b/test/sankey/layout-links-test.js new file mode 100644 index 0000000..d0c7fcb --- /dev/null +++ b/test/sankey/layout-links-test.js @@ -0,0 +1,134 @@ +import layoutLinks from '../../src/sankeyLayout/layout-links.js' +import prepareSubdivisions from '../../src/sankeyLayout/prepare-subdivisions.js' +import tape from 'tape' +import { Graph } from 'graphlib' +import { assertAlmostEqual, assertNotAlmostEqual } from '../assert-almost-equal' + +tape('linkLayout: link attributes', test => { + const graph = example2to1(0) + prepareSubdivisions(graph) + layoutLinks(graph) + + // ids + // test.deepEqual(graph.edges.map(e => id), ['0-2-m1', '1-2-m2'], 'id') + test.deepEqual(graph.edges(), [ + {v: '0', w: '2', name: 'm1'}, + {v: '1', w: '2', name: 'm2'} + ], 'ids') + + const edges = graph.edges().map(e => graph.edge(e)) + + // x coordinates + test.deepEqual(edges.map(e => e.x0), [1, 1], 'x0') + test.deepEqual(edges.map(e => e.x1), [3, 3], 'x1') + + // y coordinates + test.deepEqual(edges.map(e => e.y0), [0.5, 3.5], 'y0') + test.deepEqual(edges.map(e => e.y1), [2.5, 3.5], 'y1') + + // directions + test.deepEqual(edges.map(e => e.d0), ['r', 'r'], 'd0') + test.deepEqual(edges.map(e => e.d1), ['r', 'r'], 'd1') + + test.end() +}) + +tape('linkLayout: loose edges', test => { + const graph = example2to1(0) + prepareSubdivisions(graph) + layoutLinks(graph) + + test.deepEqual(graph.edges(), [ + {v: '0', w: '2', name: 'm1'}, + {v: '1', w: '2', name: 'm2'} + ], 'ids') + const edges = graph.edges().map(e => graph.edge(e)) + + // should not overlap + test.ok((edges[0].r1 + edges[0].dy / 2) <= + (edges[1].r1 - edges[1].dy / 2), + 'links should not overlap') + + test.end() +}) + +tape('linkLayout: tight curvature', test => { + // setting f= 0.3 moves up the lower link to constrain the curvature at node + // 2. + const graph = example2to1(0.3) + prepareSubdivisions(graph) + layoutLinks(graph) + + test.deepEqual(graph.edges(), [ + {v: '0', w: '2', name: 'm1'}, + {v: '1', w: '2', name: 'm2'} + ], 'ids') + const edges = graph.edges().map(e => graph.edge(e)) + + // curvature should no longer be symmetric + assertNotAlmostEqual(test, + edges.map(f => f.r0), + edges.map(f => f.r1), 1e-6, + 'radius should not be equal at both ends') + + // should not overlap + assertAlmostEqual(test, + (edges[0].r1 + edges[0].dy / 2), + (edges[1].r1 - edges[1].dy / 2), 1e-6, + 'link curvatures should just touch') + + test.end() +}) + +tape('linkLayout: maximum curvature limit', test => { + // setting f=1 moves up the lower link so far the curvature hits the limit + // 2. + const graph = example2to1(1) + prepareSubdivisions(graph) + layoutLinks(graph) + + test.deepEqual(graph.edges(), [ + {v: '0', w: '2', name: 'm1'}, + {v: '1', w: '2', name: 'm2'} + ], 'ids') + const edges = graph.edges().map(e => graph.edge(e)) + + // curvature should no longer be symmetric + assertNotAlmostEqual(test, + edges.map(f => f.r0), + edges.map(f => f.r1), 1e-6, + 'radius should not be equal at both ends') + + assertAlmostEqual(test, (edges[0].r1 - edges[0].dy / 2), 0, 1e-6, + 'inner link curvature should be zero') + + test.end() +}) + +function example2to1 (f) { + // 0|---\ + // \ + // 1|-\ -| + // \---|2 + // + + // f == 0 means 1-2 is level + // f == 1 means 1-2 is tight below 0-2 + + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('0', {dy: 1, x0: 0, x1: 1, y: 0, subdivisions: [{incoming: [], outgoing: [{v: '0', w: '2', name: 'm1'}]}]}) + graph.setNode('1', {dy: 1, x0: 0, x1: 1, y: 1 + (1 - f) * 2, subdivisions: [{incoming: [], outgoing: [{v: '1', w: '2', name: 'm2'}]}]}) + graph.setNode('2', { + dy: 2, + x0: 3, + x1: 4, + y: 2, + subdivisions: [{ + incoming: [{v: '0', w: '2', name: 'm1'}, {v: '1', w: '2', name: 'm2'}], + outgoing: [] + }] + }) + graph.setEdge('0', '2', {dy: 1}, 'm1') + graph.setEdge('1', '2', {dy: 1}, 'm2') + return graph +} diff --git a/test/sankey/layout-nest-graph-test.js b/test/sankey/layout-nest-graph-test.js new file mode 100644 index 0000000..4a45445 --- /dev/null +++ b/test/sankey/layout-nest-graph-test.js @@ -0,0 +1,48 @@ +import nestGraph from '../../src/sankeyLayout/nest-graph.js' +import tape from 'tape' + +tape('nestGraph()', test => { + // 0|---\ + // \ + // 1|-\ -| + // \---|4 + // 2|------| + // ,-| + // 3|---/ + // + + const nodes = [ + {id: '0', rank: 0, band: 0, depth: 0}, + {id: '1', rank: 0, band: 1, depth: 0}, + {id: '2', rank: 0, band: 1, depth: 2}, + {id: '3', rank: 0, band: 1, depth: 1}, + {id: '4', rank: 1, band: 1, depth: 0} + ] + const nested = nestGraph(nodes) + test.deepEqual(ids(nested), [ + [ ['0'], ['1', '3', '2'] ], + [ [], ['4'] ] + ]) + test.end() +}) + +tape('nestGraph() calculates band values', test => { + // 0 -- 2 : band x + // + // 1 -- 3 : band y + // `- 4 : + // + const nodes = [ + {id: '0', rank: 0, band: 0, depth: 0, value: 5}, + {id: '1', rank: 1, band: 1, depth: 0, value: 25}, + {id: '2', rank: 1, band: 0, depth: 0, value: 5}, + {id: '3', rank: 2, band: 1, depth: 0, value: 10}, + {id: '4', rank: 2, band: 1, depth: 1, value: 15} + ] + test.deepEqual(nestGraph(nodes).bandValues, [5, 25]) + test.end() +}) + +function ids (layers) { + return layers.map(bands => bands.map(nodes => nodes.map(d => d.id))) +} diff --git a/test/sankey/layout-utils-test.js b/test/sankey/layout-utils-test.js new file mode 100644 index 0000000..3238ed9 --- /dev/null +++ b/test/sankey/layout-utils-test.js @@ -0,0 +1,42 @@ +import { findFirst, sweepCurvatureInwards } from '../../src/sankeyLayout/utils.js' +import tape from 'tape' + +tape('sankeyLayout: findFirst() returns first link satisfying test', test => { + const links = [ + {y0: 0, y1: -10}, + {y0: 1, y1: 0}, + {y0: 2, y1: 10} + ] + test.equal(findFirst(links, f => f.y1 > f.y0), 2) + test.end() +}) + +tape('sankeyLayout: sweepCurvatureInwards() increases radius outside to avoid overlap', test => { + const links = [ + { Rmax: 100, dy: 6, r0: 42 }, + { Rmax: 100, dy: 6, r0: 40 } + ] + sweepCurvatureInwards(links, 'r0') + test.deepEqual(links.map(f => f.r0), [46, 40]) + test.end() +}) + +tape('sankeyLayout: sweepCurvatureInwards() reduces radius inside to satisfy Rmax', test => { + const links = [ + { Rmax: 42, dy: 6, r0: 42 }, + { Rmax: 100, dy: 6, r0: 40 } + ] + sweepCurvatureInwards(links, 'r0') + test.deepEqual(links.map(f => f.r0), [42, 36]) + test.end() +}) + +tape('sankeyLayout: sweepCurvatureInwards() limits minimum inner radius', test => { + const links = [ + { Rmax: 8, dy: 6, r0: 4 }, + { Rmax: 100, dy: 6, r0: 3 } + ] + sweepCurvatureInwards(links, 'r0') + test.deepEqual(links.map(f => f.r0), [8, 3]) + test.end() +}) diff --git a/test/sankey/link-ordering-test.js b/test/sankey/link-ordering-test.js new file mode 100644 index 0000000..43af85c --- /dev/null +++ b/test/sankey/link-ordering-test.js @@ -0,0 +1,137 @@ +import orderLinks from '../../src/sankeyLayout/link-ordering.js' +import prepareNodePorts from '../../src/sankeyLayout/prepare-subdivisions.js' +import tape from 'tape' +import { Graph } from 'graphlib' + +tape('orderLinks() works between neighbouring layers', test => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('0', {x0: 0, x1: 1, y: 0}) + graph.setNode('1', {x0: 0, x1: 1, y: 1}) + graph.setNode('2', {x0: 0, x1: 1, y: 2}) + graph.setNode('3', {x0: 0, x1: 1, y: 3}) + graph.setNode('4', {x0: 2, x1: 3, y: 1.5}) + graph.setEdge('0', '4', {dy: 1}) + graph.setEdge('1', '4', {dy: 1}) + graph.setEdge('2', '4', {dy: 1}) + graph.setEdge('3', '4', {dy: 1}) + prepareNodePorts(graph) + orderLinks(graph) + + test.deepEqual(incoming(graph.node('4')), ['0', '1', '2', '3'], + 'incoming') + + // change ordering: put node 3 at top, node 0 at bottom + graph.node('0').y = 3 + graph.node('3').y = 0 + prepareNodePorts(graph) + orderLinks(graph) + + test.deepEqual(incoming(graph.node('4')), ['3', '1', '2', '0'], + 'node 4 incoming swapped') + + test.end() +}) + +tape('orderLinks() starting and ending in same slice', test => { + // + // |--1--| + // ||-2-|| + // 0 --- 3 --- 6 + // ||-4-|| + // |--5--| + // + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('0', {x0: 0, x1: 1, y: 4}) + graph.setNode('1', {x0: 2, x1: 3, y: 0, direction: 'l'}) + graph.setNode('2', {x0: 2, x1: 3, y: 2, direction: 'l'}) + graph.setNode('3', {x0: 2, x1: 3, y: 4}) + graph.setNode('4', {x0: 2, x1: 3, y: 10, direction: 'l'}) + graph.setNode('5', {x0: 2, x1: 3, y: 12, direction: 'l'}) + graph.setNode('6', {x0: 4, x1: 5, y: 4}) + graph.setEdge('0', '3', {dy: 1}) + graph.setEdge('1', '3', {dy: 1}) + graph.setEdge('2', '3', {dy: 1}) + graph.setEdge('4', '3', {dy: 1}) + graph.setEdge('5', '3', {dy: 1}) + graph.setEdge('3', '1', {dy: 1}) + graph.setEdge('3', '2', {dy: 1}) + graph.setEdge('3', '4', {dy: 1}) + graph.setEdge('3', '5', {dy: 1}) + graph.setEdge('3', '6', {dy: 1}) + prepareNodePorts(graph) + orderLinks(graph) + + test.deepEqual(incoming(graph.node('3'), 0), ['2', '1', '0', '5', '4'], 'incoming') + test.deepEqual(outgoing(graph.node('3'), 1), ['2', '1', '6', '5', '4'], 'outgoing') + test.end() +}) + +tape('orderLinks() sorts links with string types', test => { + // + // 0 --| + // 1 --|2 -- 3 + // + const graph = exampleTypes(['m2', 'm1']) + + prepareNodePorts(graph) + orderLinks(graph) + test.deepEqual(incoming(graph.node('2')), ['0/m1', '0/m2', '1/m1', '1/m2'], 'types not aligned') + + test.end() +}) + +tape('orderLinks() sorts links with numeric types', test => { + // + // 0 --| + // 1 --|2 -- 3 + // + const graph = exampleTypes([8, 7]) + + prepareNodePorts(graph) + orderLinks(graph) + test.deepEqual(incoming(graph.node('2')), ['0/7', '0/8', '1/7', '1/8'], 'types not aligned') + + test.end() +}) + +tape('orderLinks() puts self-loops at the bottom', test => { + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('0', {x0: 0, x1: 1, y: 0}) + graph.setNode('1', {x0: 2, x1: 3, y: 0}) + graph.setNode('2', {x0: 4, x1: 5, y: 0}) + graph.setEdge('0', '1', {dy: 1}) + graph.setEdge('1', '1', {dy: 1}) + graph.setEdge('1', '2', {dy: 1}) + prepareNodePorts(graph) + orderLinks(graph) + + test.deepEqual(outgoing(graph.node('1'), 1), ['2', '1'], 'node 1 outgoing') + test.deepEqual(incoming(graph.node('1'), 0), ['0', '1'], 'node 1 incoming') + test.end() +}) + +function exampleTypes (types) { + // + // 0 --| + // 1 --|2 + // + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('0', {x0: 0, x1: 1, y: 0}) + graph.setNode('1', {x0: 0, x1: 1, y: 3}) + graph.setNode('2', {x0: 2, x1: 3, y: 0}) + types.forEach(m => { + graph.setEdge('0', '2', {dy: 1}, m) + graph.setEdge('1', '2', {dy: 1}, m) + }) + return graph +} + +function incoming (node, i) { + if (i === undefined) i = 0 + return node.ports[i].incoming.map(e => e.v + (e.name ? '/' + e.name : '')) +} + +function outgoing (node, i) { + if (i === undefined) i = 0 + return node.ports[i].outgoing.map(e => e.w + (e.name ? '/' + e.name : '')) +} diff --git a/test/sortNodes/count-crossings-test.js b/test/sortNodes/count-crossings-test.js new file mode 100644 index 0000000..347c17b --- /dev/null +++ b/test/sortNodes/count-crossings-test.js @@ -0,0 +1,37 @@ +import { countBetweenCrossings, countLoopCrossings } from '../../src/sortNodes/count-crossings.js' +import { exampleTwoLevel, exampleTwoLevelWithLoops } from './examples' + +import { Graph } from 'graphlib' +import tape from 'tape' + +tape('countBetweenCrossings', test => { + const {G, order} = exampleTwoLevel() + + // layer 1 to layer 2 + const count = countBetweenCrossings(G, order[0], order[1]) + test.equal(count, 12) + + // layer 2 to layer 1 + const G2 = new Graph({ directed: true }) + G.edges().forEach(({v, w}) => G2.setEdge(w, v)) + const count2 = countBetweenCrossings(G2, order[1], order[0]) + test.equal(count2, 12) + + test.end() +}) + +tape('countLoopCrossings', test => { + const {G, order} = exampleTwoLevelWithLoops() + + const count = countLoopCrossings(G, order[0], order[1]) + test.equal(count, 1) + test.end() +}) + +tape('countLoopCrossings: types', test => { + const {G, order} = exampleTwoLevelWithLoops('m') + + const count = countLoopCrossings(G, order[0], order[1]) + test.equal(count, 1) + test.end() +}) diff --git a/test/sortNodes/dummy-nodes-test.js b/test/sortNodes/dummy-nodes-test.js new file mode 100644 index 0000000..c1c187f --- /dev/null +++ b/test/sortNodes/dummy-nodes-test.js @@ -0,0 +1,111 @@ +import { addDummyNodes, dummyNodes } from '../../src/sortNodes/dummy-nodes.js' +import tape from 'tape' +import { Graph } from 'graphlib' + +tape('dummyNodes(edge) adds nothing to short edges', test => { + test.deepEqual(dummyNodes({rank: 0}, {rank: 1}), [], 'forwards') + + test.deepEqual(dummyNodes({rank: 1, backwards: true}, + {rank: 0, backwards: true}), [], 'backwards') + + test.end() +}) + +tape('dummyNodes(edge) adds forwards nodes to forwards-forwards edges', test => { + test.deepEqual(dummyNodes({rank: 0}, {rank: 2}), [ + {rank: 1, backwards: false} + ]) + + test.deepEqual(dummyNodes({rank: 1}, {rank: 4}), [ + {rank: 2, backwards: false}, + {rank: 3, backwards: false} + ]) + + test.end() +}) + +tape('dummyNodes(edge) adds backwards nodes to backwards-backwards edges', test => { + test.deepEqual(dummyNodes({rank: 2, backwards: true}, + {rank: 0, backwards: true}), [ + {rank: 1, backwards: true} + ]) + + test.deepEqual(dummyNodes({rank: 4, backwards: true}, + {rank: 1, backwards: true}), [ + {rank: 3, backwards: true}, + {rank: 2, backwards: true} + ]) + + test.end() +}) + +tape('dummyNodes(edge) adds turn-around nodes to forwards-backwards edges', test => { + // a --, + // | + // b <-- * <-` + test.deepEqual(dummyNodes({rank: 1, backwards: false}, + {rank: 0, backwards: true}), [ + {rank: 1, backwards: true} + ]) + + // a --, + // | + // b <-` + test.deepEqual(dummyNodes({rank: 0, backwards: false}, + {rank: 0, backwards: true}), []) + + // a --> * --, + // | + // b <-` + test.deepEqual(dummyNodes({rank: 0, backwards: false}, + {rank: 1, backwards: true}), [ + {rank: 1, backwards: false} + ]) + + test.end() +}) + +tape('dummyNodes(edge) adds turn-around nodes to backwards-forwards edges', test => { + // ,-- a + // | + // `-> * --> b + test.deepEqual(dummyNodes({rank: 0, backwards: true}, + {rank: 1, backwards: false}), [ + {rank: 0, backwards: false} + ]) + + // ,-- a + // | + // `-> b + test.deepEqual(dummyNodes({rank: 0, backwards: true}, + {rank: 0, backwards: false}), []) + + // ,-- * <-- a + // | + // `-> b + test.deepEqual(dummyNodes({rank: 1, backwards: true}, + {rank: 0, backwards: false}), [ + {rank: 0, backwards: true} + ]) + + test.end() +}) + +tape('addDummyNodes(G) adds nodes to graph', test => { + // + // a -- b -- c + // `---*---` + // + const graph = new Graph({ directed: true, multigraph: true }) + graph.setNode('a', {rank: 0}) + graph.setNode('b', {rank: 1}) + graph.setNode('c', {rank: 2}) + graph.setEdge('a', 'b', {value: 1}) + graph.setEdge('b', 'c', {value: 1}) + graph.setEdge('a', 'c', {value: 1}) + + addDummyNodes(graph) + + test.deepEqual(graph.nodes(), ['a', 'b', 'c', '__a_c_0']) + test.end() +}) diff --git a/test/sortNodes/examples.js b/test/sortNodes/examples.js new file mode 100644 index 0000000..80a8f80 --- /dev/null +++ b/test/sortNodes/examples.js @@ -0,0 +1,82 @@ +import { Graph } from 'graphlib'; + + +export function exampleTwoLevel() { + let G = new Graph({ directed: true }); + + // Example from Barth2004 + G.setEdge('n0', 's0', {}); + G.setEdge('n1', 's1', {}); + G.setEdge('n1', 's2', {}); + G.setEdge('n2', 's0', {}); + G.setEdge('n2', 's3', {}); + G.setEdge('n2', 's4', {}); + G.setEdge('n3', 's0', {}); + G.setEdge('n3', 's2', {}); + G.setEdge('n4', 's3', {}); + G.setEdge('n5', 's2', {}); + G.setEdge('n5', 's4', {}); + + let order = [ + ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'], + ['s0', 's1', 's2', 's3', 's4'], + ]; + + return {G, order}; +} + + +export function exampleTwoLevelMultigraph() { + let G = new Graph({ directed: true, multigraph: true }); + + G.setEdge('a', '1', {}, 'm1'); + G.setEdge('a', '3', {}, 'm1'); + G.setEdge('a', '3', {}, 'm2'); + G.setEdge('b', '2', {}, 'm1'); + G.setEdge('b', '3', {}, 'm1'); + G.setNode('4', {}); + + let order = [ + ['a', 'b'], + ['1', '2', '3', '4'], + ]; + + return {G, order}; +} + + +export function exampleTwoLevelWithLoops(type=undefined) { + let G = new Graph({ directed: true, multigraph: type !== undefined }); + + G.setEdge('n0', 's0', {}, type); + G.setEdge('n0', 'n2', {}, type); // loop + G.setEdge('n1', 's0', {}, type); + G.setEdge('n2', 's1', {}, type); + + let order = [ + ['n0', 'n1', 'n2'], + ['s0', 's1'], + ]; + + return {G, order}; +} + + +// function exampleTwoLevel() { +// let G = new Graph({ directed: true }); + +// G.setEdge('1', 'a'); +// G.setEdge('2', 'b'); +// G.setEdge('2', 'd'); +// G.setEdge('3', 'c'); +// G.setEdge('3', 'd'); +// G.setEdge('4', 'c'); +// G.setEdge('4', 'd'); + +// let nodes = [ +// ['1', '2', '3', '4'], +// ['a', 'b', 'c', 'd'], +// ]; + +// return {G, nodes}; +// } diff --git a/test/sortNodes/index-test.js b/test/sortNodes/index-test.js new file mode 100644 index 0000000..5b32977 --- /dev/null +++ b/test/sortNodes/index-test.js @@ -0,0 +1,37 @@ +import sortNodes from '../../src/sortNodes/index.js' +import tape from 'tape' +import { Graph } from 'graphlib' + +tape('sortNodes()', test => { + // + // a -- b -- d + // `-- c -- e + // + const G = new Graph({directed: true, multigraph: true}) + G.setNode('a', {rank: 0}) + G.setNode('b', {rank: 1}) + G.setNode('c', {rank: 1}) + G.setNode('d', {rank: 2}) + G.setNode('e', {rank: 2}) + G.setEdge('a', 'b', {}) + G.setEdge('a', 'c', {}) + G.setEdge('b', 'd', {}) + G.setEdge('c', 'e', {}) + sortNodes(G) + + test.deepEqual(depths(G), { + 'a': 0, + 'b': 0, + 'c': 1, + 'd': 0, + 'e': 1 + }) + + test.end() +}) + +function depths (G) { + var r = {} + G.nodes().forEach(u => { r[u] = G.node(u).depth }) + return r +} diff --git a/test/sortNodes/median-value-test.js b/test/sortNodes/median-value-test.js new file mode 100644 index 0000000..2365b1a --- /dev/null +++ b/test/sortNodes/median-value-test.js @@ -0,0 +1,18 @@ +import medianValue from '../../src/sortNodes/median-value.js' +import tape from 'tape' + +tape('medianValue', test => { + test.equal(medianValue([3, 4, 6]), 4, + 'picks out middle value') + + test.equal(medianValue([3, 4]), 3.5, + 'returns average of 2 values') + + test.equal(medianValue([]), -1, + 'returns -1 for empty list of positions') + + test.equal(medianValue([0, 5, 6, 7, 8, 9]), 6.75, + 'weighted median for even number of positions') + + test.end() +}) diff --git a/test/sortNodes/neighbour-positions-test.js b/test/sortNodes/neighbour-positions-test.js new file mode 100644 index 0000000..1ab7a37 --- /dev/null +++ b/test/sortNodes/neighbour-positions-test.js @@ -0,0 +1,71 @@ +import neighbourPositions from '../../src/sortNodes/neighbour-positions.js' +import { exampleTwoLevel, exampleTwoLevelMultigraph } from './examples' +import { Graph } from 'graphlib' +import tape from 'tape' + +tape('neighbourPositions', test => { + let {G, order} = exampleTwoLevel() + + test.deepEqual(neighbourPositions(G, order, 0, 1, 'n2'), [0, 3, 4], 'n2') + test.deepEqual(neighbourPositions(G, order, 0, 1, 'n0'), [0], 'n0') + + test.deepEqual(neighbourPositions(G, order, 1, 0, 's4'), [2, 5], 's4') + test.deepEqual(neighbourPositions(G, order, 1, 0, 's0'), [0, 2, 3], 's0') + + test.end() +}) + +tape('neighbourPositions: multigraph', test => { + let {G, order} = exampleTwoLevelMultigraph() + + test.deepEqual(neighbourPositions(G, order, 0, 1, 'a'), [0, 2], 'a') + test.deepEqual(neighbourPositions(G, order, 0, 1, 'b'), [1, 2], 'b') + + test.deepEqual(neighbourPositions(G, order, 1, 0, '1'), [0], '1') + test.deepEqual(neighbourPositions(G, order, 1, 0, '3'), [0, 1], '3') + + test.end() +}) + +tape('neighbourPositions: loops', test => { + // + // a --,1 + // < + // `2 + // + // b -- 3 + // + let G = new Graph({ directed: true }) + G.setEdge('a', '1', {}) + G.setEdge('b', '3', {}) + G.setEdge('2', '1', {}) + + let order = [ + ['a', 'b'], + ['1', '2', '3'] + ] + + test.deepEqual(neighbourPositions(G, order, 0, 1, 'a', true), [0], 'a') + test.deepEqual(neighbourPositions(G, order, 0, 1, 'b', true), [2], 'b') + + // loop gets 0.5 position below other node in this rank, if no other + // neighbours. + test.deepEqual(neighbourPositions(G, order, 1, 0, '1', true), [0], '1') + test.deepEqual(neighbourPositions(G, order, 1, 0, '2', true), [0.5], '2') + test.deepEqual(neighbourPositions(G, order, 1, 0, '3', true), [1], '3') + + test.end() +}) + +// tape('neighbourPositions with loops', test => { +// let {G, order} = exampleTwoLevelWithLoops() + +// test.deepEqual(neighbourPositions(G, order, 0, 1, 'n0'), [0, 2], 'n0') +// test.deepEqual(neighbourPositions(G, order, 0, 1, 'n1'), [0], 'n1') +// test.deepEqual(neighbourPositions(G, order, 0, 1, 'n2'), [0, 1], 'n2') + +// test.deepEqual(neighbourPositions(G, order, 1, 0, 's0'), [0, 1], 's0') +// test.deepEqual(neighbourPositions(G, order, 1, 0, 's1'), [2], 's1') + +// test.end() +// }) diff --git a/test/sortNodes/sort-by-positions-test.js b/test/sortNodes/sort-by-positions-test.js new file mode 100644 index 0000000..fd6bfec --- /dev/null +++ b/test/sortNodes/sort-by-positions-test.js @@ -0,0 +1,24 @@ +import sortByPositions from '../../src/sortNodes/sort-by-positions.js' +import { map } from 'd3-collection' +import tape from 'tape' + +tape('sortByPositions', test => { + let arr + + arr = ['a', 'b', 'c'] + sortByPositions(arr, map({'a': 0, 'b': 2, 'c': 1})) + test.deepEqual(arr, ['a', 'c', 'b'], + 'sorts according to given order') + + arr = ['a', 'b', 'c'] + sortByPositions(arr, map({'a': 2, 'b': 1, 'c': 1})) + test.deepEqual(arr, ['b', 'c', 'a'], + 'stable sort') + + arr = ['a', 'b', 'c'] + sortByPositions(arr, map({'a': 1, 'b': -1, 'c': 0})) + test.deepEqual(arr, ['c', 'b', 'a'], + '-1 means stay in same position') + + test.end() +}) diff --git a/test/sortNodes/swap-nodes-test.js b/test/sortNodes/swap-nodes-test.js new file mode 100644 index 0000000..0e480a0 --- /dev/null +++ b/test/sortNodes/swap-nodes-test.js @@ -0,0 +1,22 @@ +import swapNodes from '../../src/sortNodes/swap-nodes.js' +import { exampleTwoLevel } from './examples' +import tape from 'tape' + +tape('iterateSwappingNodes', test => { + let {G, order} = exampleTwoLevel() + + swapNodes(G, order) + + // it turns out that swapping n2 & n3 and s0 & s1 helps in this example; not + // sure if there's a better way of testing this. + const expected = [ + ['n0', 'n1', 'n3', 'n2', 'n4', 'n5'], + ['s1', 's0', 's2', 's3', 's4'] + ] + test.deepEqual(order, expected, 'swaps to reduce number of crossings') + + swapNodes(G, order) + test.deepEqual(order, expected, 'gives same result if called again') + + test.end() +}) diff --git a/test/sortNodes/weighted-median-sort-test.js b/test/sortNodes/weighted-median-sort-test.js new file mode 100644 index 0000000..8319436 --- /dev/null +++ b/test/sortNodes/weighted-median-sort-test.js @@ -0,0 +1,27 @@ +import sortNodes from '../../src/sortNodes/weighted-median-sort.js' +import { exampleTwoLevel } from './examples' +import tape from 'tape' + +tape('sortNodes: forwards', test => { + let {G, order} = exampleTwoLevel() + + sortNodes(G, order, +1) + test.deepEqual(order, [ + ['n0', 'n1', 'n2', 'n3', 'n4', 'n5'], + ['s1', 's0', 's2', 's3', 's4'] + ], 'forward sweep') + + test.end() +}) + +tape('sortNodes: backwards', test => { + let {G, order} = exampleTwoLevel() + + sortNodes(G, order, -1) + test.deepEqual(order, [ + ['n0', 'n3', 'n1', 'n2', 'n4', 'n5'], + ['s0', 's1', 's2', 's3', 's4'] + ], 'backward sweep') + + test.end() +}) diff --git a/test/test-diagram.js b/test/test-diagram.js deleted file mode 100644 index 8dd8435..0000000 --- a/test/test-diagram.js +++ /dev/null @@ -1,267 +0,0 @@ -import sankeyDiagram from '../src/diagram'; - -import getBody from './get-document-body'; -import d3 from 'd3'; -import test from 'tape'; - - -test('diagram: renders something and updates', t => { - // prepare data - const {nodes, links} = exampleBlastFurnace(); - - // diagram - - const diagram = sankeyDiagram(); - - const el = d3.select(getBody()).append('div'); - - el - .datum({nodes, links}) - .call(diagram); - flushAnimationFrames(); - - t.equal(el.selectAll('.node')[0].length, 21, - 'right number of nodes'); - - t.equal(el.selectAll('.link')[0].length, 26, - 'right number of links'); - - // update does not work in jsdom unless transitions are disabled - if (process.browser) { - const h0 = +el.select('.node').select('rect').attr('height'); - - links.forEach(e => { e.value *= 1.1; }); - el.call(diagram); - flushAnimationFrames(); - const h1 = +el.select('.node rect').attr('height'); - t.ok(h1 > h0, 'height updates'); - } - - t.end(); -}); - - -test('diagram: renders something and updates with transitions disabled', t => { - // prepare data - const {nodes, links} = exampleBlastFurnace(); - - // diagram -- disable transitions - const diagram = sankeyDiagram().duration(null); - const el = d3.select(getBody()).append('div'); - - el - .datum({nodes, links}) - .call(diagram); - - // flushAnimationFrames not needed - t.equal(el.selectAll('.node')[0].length, 21, - 'right number of nodes'); - - t.equal(el.selectAll('.link')[0].length, 26, - 'right number of links'); - - // update - const h0 = +el.select('.node').select('rect').attr('height'); - - links.forEach(e => { e.value *= 1.1; }); - el.call(diagram); - // flushAnimationFrames not needed - const h1 = +el.select('.node rect').attr('height'); - t.ok(h1 > h0, 'height updates'); - - t.end(); -}); - - -test('diagram: types', t => { - const {nodes, links} = exampleLinkTypes(); - - const color = d3.scale.category10(); - const diagram = sankeyDiagram() - .link(sel => sel.style('fill', d => color(d.data.type))); - - const el = render({nodes, links}, diagram); - - t.equal(el.selectAll('.node')[0].length, 4, - 'right number of nodes'); - - t.equal(el.selectAll('.link')[0].length, 5, - 'right number of links'); - - t.end(); -}); - - -test('diagram: types 2', t => { - const example = exampleLinkTypes2(); - - const color = d3.scale.category10(); - const diagram = sankeyDiagram() - .link(sel => sel.style('fill', d => color(d.data.type))); - - const el = render(example, diagram); - - t.equal(el.selectAll('.node')[0].length, 5, - 'right number of nodes'); - - t.equal(el.selectAll('.link')[0].length, 9, - 'right number of links'); - - t.end(); -}); - - -test('diagram: link attributes', t => { - const links = [ - {source: 'a', target: 'b', value: 2, type: 'x', - color: 'red'}, - ]; - - function customLink(link) { - link - .attr('class', d => `link type-${d.data.type}`) - .style('fill', d => d.data.color) - .style('opacity', d => 1 / d.data.value); - } - - const diagram = sankeyDiagram() - .nodeTitle(d => `Node ${d.id}`) - .linkTypeTitle(d => `Type: ${d.data.type}`) - .link(customLink); - - const el = render({links}, diagram), - link = el.selectAll('.link'); - - t.deepEqual(d3.rgb(link.style('fill')), d3.rgb('red'), 'link color'); - t.equal(link.style('opacity'), '0.5', 'link opacity'); - t.equal(link.attr('class'), 'link type-x', 'link class'); - t.equal(link.select('title').text(), - 'Node a → Node b\nType: x\n2.00', 'link title'); - - diagram - .nodeTitle('node') - .linkTypeTitle('z'); - - const el2 = render({links}, diagram), - link2 = el2.selectAll('.link'); - - t.equal(link2.select('title').text(), - 'node → node\nz\n2.00', 'link title (const)'); - - t.end(); -}); - - -test('diagram: node attributes', t => { - const links = [ - {source: 'a', target: 'b', value: 2} - ]; - - function customNode(node) { - node - .attr('class', 'node myclass'); - } - - // disable transitions - const diagram = sankeyDiagram().duration(null); - const el = render({links}, diagram); - - t.equal(el.selectAll('.node').attr('class'), 'node', 'node class before'); - - diagram.node(customNode); - el.call(diagram); - - t.equal(el.selectAll('.node').attr('class'), 'node myclass', 'node class after'); - - t.end(); -}); - -function render(datum, diagram) { - const el = d3.select(getBody()).append('div'); - el.datum(datum).call(diagram); - flushAnimationFrames(); - return el; -} - - -function exampleBlastFurnace() { - // Simplified example of links through coke oven and blast furnace - const nodes = [ - ]; - - const links = [ - // main flow - {source: 'input', target: 'oven', value: 2.5}, - {source: 'oven', target: 'coke', value: 2.5}, - {source: 'coke', target: 'sinter', value: 1}, - {source: 'coke', target: 'bf', value: 1.5}, - {source: 'sinter', target: 'bf', value: 1}, - {source: 'bf', target: 'output', value: 1}, - {source: 'bf', target: 'export', value: 1}, - - // additional export links, and input-sinter - {source: 'sinter', target: 'export', value: 0.2}, - {source: 'oven', target: 'export', value: 0.2}, - {source: 'input', target: 'sinter', value: 0.2}, - - // return loops - {source: 'oven', target: 'input', value: 0.5}, - {source: 'bf', target: 'input', value: 0.5}, - ]; - - return {nodes, links}; -} - - -function exampleLinkTypes() { - const nodes = [ - ]; - - const links = [ - {source: 'a', target: 'b', value: 2, type: 'x'}, - {source: 'a', target: 'b', value: 2, type: 'y'}, - {source: 'b', target: 'c', value: 1, type: 'x'}, - {source: 'b', target: 'c', value: 2, type: 'y'}, - {source: 'b', target: 'd', value: 1, type: 'x'}, - ]; - - return {nodes, links}; -} - - -function exampleLinkTypes2() { - // this sometimes fails in Safari - return { - nodes: [ - { id: 'a', title: 'a' }, - { id: 'b', title: 'b' }, - { id: 'c', title: 'c' }, - { id: 'x', title: 'd' }, - { id: 'y', title: 'e' }, - ], - links: [ - { source: 'a', target: 'x', value: 1.0, type: 'x' }, - { source: 'a', target: 'y', value: 0.7, type: 'y' }, - { source: 'a', target: 'y', value: 0.3, type: 'z' }, - - { source: 'b', target: 'x', value: 2.0, type: 'x' }, - { source: 'b', target: 'y', value: 0.3, type: 'y' }, - { source: 'b', target: 'y', value: 0.9, type: 'z' }, - - { source: 'x', target: 'c', value: 3.0, type: 'x' }, - { source: 'y', target: 'c', value: 1.0, type: 'y' }, - { source: 'y', target: 'c', value: 1.2, type: 'z' }, - ], - alignLinkTypes: true - }; -} - - -/* Make animations synchronous for testing */ - -var flushAnimationFrames = function() { - var now = Date.now; - Date.now = function() { return Infinity; }; - d3.timer.flush(); - Date.now = now; -}; diff --git a/test/test-groups.js b/test/test-groups.js deleted file mode 100644 index c8fe3c6..0000000 --- a/test/test-groups.js +++ /dev/null @@ -1,61 +0,0 @@ -import sankeyDiagram from '../src/diagram'; - -import getBody from './get-document-body'; -import d3 from 'd3'; -import test from 'tape'; - - -test('groups: draws box around nodes', t => { - // prepare data - const nodes = [ - ]; - - const links = [ - {source: 'a1', target: 'b', value: 1}, - {source: 'a2', target: 'b', value: 1}, - ]; - - const groups = [ - {title: 'Group', nodes: ['a1', 'a2']}, - {title: 'B', nodes: ['b']}, - ]; - - // diagram - const diagram = sankeyDiagram(); - const el = render({nodes, links, groups}, diagram); - - t.equal(el.selectAll('.node')[0].length, 3, - 'right number of nodes'); - - t.equal(el.selectAll('.link')[0].length, 2, - 'right number of links'); - - t.equal(el.selectAll('.group')[0].length, 2, - 'right number of groups'); - - // padding of 10px - const rects = el.selectAll('.group').select('rect'); - t.equal(d3.select(rects[0][0]).attr('width'), '20', 'group1 width'); - t.equal(d3.select(rects[0][0]).attr('height'), '270', 'group1 height'); - t.equal(d3.select(rects[0][1]).attr('width'), '20', 'group2 width'); - t.equal(d3.select(rects[0][1]).attr('height'), '180', 'group2 height'); - - t.end(); -}); - - -function render(datum, diagram) { - const el = d3.select(getBody()).append('div'); - el.datum(datum).call(diagram); - flushAnimationFrames(); - return el; -} - - -/* Make animations synchronous for testing */ -var flushAnimationFrames = function() { - var now = Date.now; - Date.now = function() { return Infinity; }; - d3.timer.flush(); - Date.now = now; -}; diff --git a/test/test-linkPath.js b/test/test-linkPath.js deleted file mode 100644 index 55a2652..0000000 --- a/test/test-linkPath.js +++ /dev/null @@ -1,308 +0,0 @@ -import sankeyLink from '../src/linkPath'; - -import test from 'tape'; - -import { assertAlmostEqual } from './assert-almost-equal'; -import compareSVGPath from './compareSVGPath'; - - -test('link SVG: different radii', t => { - let link = sankeyLink(); - let edge1 = { - x0: 0, - y0: 0, - x1: 30, - y1: 70, - dy: 2, - r0: 10, - r1: 20, - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, link(edge1), - 'M0,-1 ' + - 'A11 11 1.571 0 1 11,10 ' + - 'L11,50 ' + - 'A19 19 1.571 0 0 30,69 ' + - 'L30,71 ' + - 'A21 21 1.571 0 1 9,50 '+ - 'L9,10 ' + - 'A9 9 1.571 0 0 0,1 ' + - 'Z', 'edge1'); - - let edge2 = { - x0: 0, - y0: 0, - x1: 30, - y1: -70, - dy: 2, - r0: 10, - r1: 20, - }; - - compareSVGPath(t, link(edge2), - 'M0,-1 ' + - 'A9 9 1.571 0 0 9,-10 ' + - 'L9,-50 ' + - 'A21 21 1.571 0 1 30,-71 ' + - 'L30,-69 ' + - 'A19 19 1.571 0 0 11,-50 '+ - 'L11,-10 ' + - 'A11 11 1.571 0 1 0,1 ' + - 'Z', 'edge2'); - t.end(); -}); - - -test('link SVG: default link shape has two adjacent circular arcs', t => { - let link = sankeyLink(); - let edge = { - x0: 0, - x1: 15, - y0: 0, - y1: 10, - dy: 2 - }; - - // radius = 5 - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, link(edge), - 'M0,-1 ' + - 'A6 6 0.729 0 1 4,0.527 ' + - 'L12.333,7.981 ' + - 'A4 4 0.729 0 0 15,9 ' + - 'L15,11 ' + - 'A6 6 0.729 0 1 11,9.472 '+ - 'L2.666,2.018 ' + - 'A4 4 0.729 0 0 0,1 ' + - 'Z'); - t.end(); -}); - - -test('link SVG: default link shape reduces to straight line', t => { - let link = sankeyLink(); - let edge = { - x0: 0, - y0: 0, - x1: 10, - y1: 0, - dy: 2, - r0: 1, - r1: 1, - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, link(edge), - 'M0,-1 ' + - 'A0 0 0 0 0 0,-1 ' + - 'L10,-1 ' + - 'A0 0 0 0 0 10,-1 ' + - 'L10,1 ' + - 'A0 0 0 0 0 10,1 ' + - 'L0,1 ' + - 'A0 0 0 0 0 0,1 ' + - 'Z'); - - t.end(); -}); - - -// XXX check this with r0, r1 -test('link SVG: specifying link radius', t => { - let link = sankeyLink(); - let edge = { - x0: 0, - x1: 2, - y0: 0, - y1: 10, - dy: 2, - r1: 1, // minimum radius - r2: 1, // minimum radius - }; - - // radius = 1, angle = 90 - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, link(edge), - 'M0,-1 ' + - 'A2 2 1.570 0 1 2,0.999 ' + - 'L2,9 ' + - 'A0 0 1.570 0 0 2,9 ' + - 'L2,11 ' + - 'A2 2 1.570 0 1 0,9 ' + - 'L0,1 ' + - 'A0 0 1.570 0 0 0,1 ' + - 'Z'); - t.end(); -}); - - -test('link SVG: minimum thickness', t => { - let link = sankeyLink(); - let edge = { - x0: 0, - x1: 10, - y0: 0, - y1: 0, - dy: 2 - }; - - let path1 = link(edge); - edge.dy = 0.01; - let path2 = link(edge); - - compareSVGPath(t, path1, path2, 'minimum thickness should be 2'); - - edge.dy = 0; - compareSVGPath(t, link(edge), - 'M0,0 ' + - 'A0 0 0 0 0 0,0 ' + - 'L10,0 ' + - 'A0 0 0 0 0 10,0 ' + - 'L10,0 ' + - 'A0 0 0 0 0 10,0 ' + - 'L0,0 ' + - 'A0 0 0 0 0 0,0 ' + - 'Z', 'should disappear when dy = 0'); - - t.end(); -}); - - -// test('link SVG: self-loops are drawn below with default radius 1.5x width', t => { -// let link = sankeyLink(), -// node = {}, -// edge = { -// x0: 0, -// x1: 0, -// y0: 0, -// y1: 0, -// dy: 10, -// source: node, -// target: node, -// }; - -// // Arc: A rx ry theta large-arc-flag direction-flag x y -// compareSVGPath(t, link(edge), -// 'M0.1,-5 ' + -// 'A12.5 12.5 6.283 1 1 -0.1,-5 ' + -// 'L-0.1,5 ' + -// 'A2.5 2.5 6.283 1 0 0.1,5 ' + -// 'Z'); - -// t.end(); -// }); - - -test('link SVG: flow from forward to reverse node', t => { - let edge = { - x0: 0, - y0: 0, - x1: 0, - y1: 50, - dy: 10, - d0: 'r', - d1: 'l', - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, sankeyLink()(edge), - 'M0,-5 ' + - 'A15 15 1.570 0 1 15,10 ' + - 'L15,40 ' + - 'A15 15 1.570 0 1 0,55 ' + - 'L0,45 ' + - 'A5 5 1.570 0 0 5,40 ' + - 'L5,10 ' + - 'A5 5 1.570 0 0 0,5 ' + - 'Z'); - - // force radius - edge.r0 = edge.r1 = 20; - compareSVGPath(t, sankeyLink()(edge), - 'M0,-5 ' + - 'A25 25 1.570 0 1 25,20 ' + - 'L25,30 ' + - 'A25 25 1.570 0 1 0,55 ' + - 'L0,45 ' + - 'A15 15 1.570 0 0 15,30 ' + - 'L15,20 ' + - 'A15 15 1.570 0 0 0,5 ' + - 'Z'); - - t.end(); -}); - - -test('link SVG: flow from reverse to forward node', t => { - let edge = { - x0: 0, - y0: 0, - x1: 0, - y1: 50, - dy: 10, - d0: 'l', - d1: 'r', - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, sankeyLink()(edge), - 'M0,-5 ' + - 'A15 15 1.570 0 0 -15,10 ' + - 'L-15,40 ' + - 'A15 15 1.570 0 0 0,55 ' + - 'L0,45 ' + - 'A5 5 1.570 0 1 -5,40 ' + - 'L-5,10 ' + - 'A5 5 1.570 0 1 0,5 ' + - 'Z'); - t.end(); -}); - - -test('link SVG: flow from reverse to reverse node', t => { - let edge = { - x0: 20, - y0: 0, - x1: 0, - y1: 0, - dy: 10, - d0: 'l', - d1: 'l', - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, sankeyLink()(edge), - 'M0,-5 ' + - 'A0 0 0 0 0 0,-5 ' + - 'L20,-5 ' + - 'A0 0 0 0 0 20,-5 ' + - 'L20,5 ' + - 'A0 0 0 0 0 20,5 ' + - 'L0,5 ' + - 'A0 0 0 0 0 0,5 ' + - 'Z'); - t.end(); -}); - - -test('link SVG: flow from forward to offstage node', t => { - let edge = { - x0: 0, - y0: 5, - x1: 10, - y1: 30, - dy: 10, - d0: 'r', - d1: 'd', - }; - - // Arc: A rx ry theta large-arc-flag direction-flag x y - compareSVGPath(t, sankeyLink()(edge), - 'M0,0 ' + - 'A15 15 1.570 0 1 15,15 ' + - 'L15,30 5,30 5,15 ' + - 'A5 5 1.570 0 0 0,10 ' + - 'Z'); - t.end(); -});