diff --git a/src/sankey.js b/src/sankey.js index ecef550..dff40a9 100644 --- a/src/sankey.js +++ b/src/sankey.js @@ -1,13 +1,18 @@ -import {ascending, min, sum} from "d3-array"; -import {nest} from "d3-collection"; -import {number} from "d3-interpolate"; +//import {ascending, min, sum} from "d3-array"; +//import {nest} from "d3-collection"; +//import {number} from "d3-interpolate"; -export default function() { +d3.sankey = function() { var sankey = {}, nodeWidth = 24, nodePadding = 8, size = [1, 1], - align = 'justify', // left, right, center or justify + align = 'justify', // left, right, center, justify or none + linkType = 'bezier', + reverse = false, + orderByPath = false, + scaleNodeBreadthsByString = false, + curvature = .5, nodes = [], links = []; @@ -34,6 +39,24 @@ export default function() { links = _; return sankey; }; + + sankey.orderByPath = function(_) { + if (!arguments.length) return orderByPath; + orderByPath = _; + return sankey; + }; + + sankey.curvature = function(_) { + if (!arguments.length) return curvature; + curvature = _; + return sankey; + }; + + sankey.linkType = function(_) { + if (!arguments.length) return linkType; + linkType = _.toLowerCase(); + return sankey; + }; sankey.size = function(_) { if (!arguments.length) return size; @@ -46,6 +69,13 @@ export default function() { align = _.toLowerCase(); return sankey; }; + + sankey.scaleNodeBreadthsByString = function(_) { + if (!arguments.length) return scaleNodeBreadthsByString; + scaleNodeBreadthsByString = _; + return sankey; + }; + sankey.layout = function(iterations) { computeNodeLinks(); @@ -61,21 +91,58 @@ export default function() { return sankey; }; + // SVG path data generator, to be used as "d" attribute on "path" element selection. sankey.link = function() { - var curvature = .5; - function link(d) { var x0 = d.source.x + d.source.dx, x1 = d.target.x, - xi = number(x0, x1), + xi = d3.interpolateNumber(x0, x1), x2 = xi(curvature), x3 = xi(1 - curvature), y0 = d.source.y + d.sy + d.dy / 2, y1 = d.target.y + d.ty + d.dy / 2; - return "M" + x0 + "," + y0 + + if (!d.cycleBreaker) { + if (linkType == "bezier") { + return "M" + x0 + "," + y0 + "C" + x2 + "," + y0 + " " + x3 + "," + y1 + " " + x1 + "," + y1; + + } else if (linkType == "l-bezier") { + x4 = x0 + d.source.dx/4 + x5 = x1 - d.target.dx/4 + x2 = Math.max(xi(curvature), x4+d.dy) + x3 = Math.min(xi(curvature), x5-d.dy) + return "M" + x0 + "," + y0 + + "L" + x4 + "," + y0 + + "C" + x2 + "," + y0 + + " " + x3 + "," + y1 + + " " + x5 + "," + y1 + + "L" + x1 + "," + y1; + } else if (linkType == "trapez") { + // TRAPEZOID connection + return "M" + (x0) + "," + (y0 - d.dy/2) + + "L" + (x0) + "," + (y0 + d.dy/2) + + " " + (x1) + "," + (y1 + d.dy/2) + + " " + (x1) + "," + (y1 - d.dy/2) + " z"; + } + + } else { + var xdelta = (1.5 * d.dy + 0.05 * Math.abs(xs - xt)); + xsc = xs + xdelta; + xtc = xt - xdelta; + var xm = xi(0.5); + var ym = d3.interpolateNumber(ys, yt)(0.5); + var ydelta = (2 * d.dy + 0.1 * Math.abs(xs - xt) + 0.1 * Math.abs(ys - yt)) * (ym < (size[1] / 2) ? -1 : 1); + return "M" + xs + "," + ys + + "C" + xsc + "," + ys + + " " + xsc + "," + (ys + ydelta) + + " " + xm + "," + (ym + ydelta) + + "S" + xtc + "," + yt + + " " + xt + "," + yt; + + } } link.curvature = function(_) { @@ -91,7 +158,9 @@ export default function() { // Also, if the source and target are not objects, assume they are indices. function computeNodeLinks() { nodes.forEach(function(node) { + // Links that have this node as source. node.sourceLinks = []; + // Links that have this node as target. node.targetLinks = []; }); links.forEach(function(link) { @@ -106,39 +175,89 @@ export default function() { // Compute the value (size) of each node by summing the associated links. function computeNodeValues() { - nodes.forEach(function(node) { - node.value = Math.max( - sum(node.sourceLinks, value), - sum(node.targetLinks, value) - ); - }); + if (typeof nodes[0].value == "undefined") { + nodes.forEach(function(node) { + node.value = Math.max( + d3.sum(node.sourceLinks, value), + d3.sum(node.targetLinks, value) + ); + }); + } } + var max_posX = 0 + var summed_str_length = [0]; + // Iteratively assign the breadth (x-position) for each node. // Nodes are assigned the maximum breadth of incoming neighbors plus one; // nodes with no incoming links are assigned breadth zero, while // nodes with no outgoing links are assigned the maximum breadth. function computeNodeBreadths() { - var remainingNodes = nodes, - nextNodes, - x = 0, - reverse = (align === 'right'); // Reverse traversal direction - - while (remainingNodes.length) { - nextNodes = []; - remainingNodes.forEach(function(node) { - node.x = x; - node.dx = nodeWidth; - - node[reverse ? 'targetLinks' : 'sourceLinks'].forEach(function(link) { - var nextNode = link[reverse ? 'source' : 'target']; - if (nextNodes.indexOf(nextNode) < 0) { - nextNodes.push(nextNode); - } + var reverse = (align === 'right'); // Reverse traversal direction + + if (typeof nodes[0].posX == "undefined") { + var remainingNodes = nodes, + x = 0, + nextNodes; + // Work from left to right. + // Keep updating the breath (x-position) of nodes that are target of recently updated nodes. + while (remainingNodes.length && x < nodes.length) { + nextNodes = []; + remainingNodes.forEach(function(node) { + node.x = x; + node.posX = x; + node.dx = nodeWidth; + + node[reverse ? 'targetLinks' : 'sourceLinks'].forEach(function(link) { + var nextNode = link[reverse ? 'source' : 'target']; + if (nextNodes.indexOf(nextNode) < 0) { + nextNodes.push(nextNode); + } + }); + }); + if (nextNodes.length == remainingNodes.length) { + // There must be a cycle here. Let's search for a link that breaks it. + findAndMarkCycleBreaker(nextNodes); + // Start over. + // TODO: make this optional? + return computeNodeBreadths(); + } + else { + remainingNodes = nextNodes; + ++x; + } + } + } else { + nodes.forEach(function(node) { + node.x = node.posX; }); - }); - remainingNodes = nextNodes; - ++x; + } + + // calculate maximum string lengths at each posX + max_posX = d3.max(nodes, function(d) { return(d.x); } ) + 1; + var max_str_length = new Array(max_posX); + nodes.forEach(function(node) { + if (typeof max_str_length[node.x] == "undefined" || node.name.length > max_str_length[node.x]) { + max_str_length[node.x] = node.name.length; + } + + // make a path to the beginning for vertical ordering + if (orderByPath) { + node.path = node.name; + nn = node + while (nn.targetLinks.length) { + if (nn.targetLinks.length > 1) { + console.log("Error - orderByPath not useful when there is more than one parent for a node.") + } + nn = nn.targetLinks[0].source + node.path = nn.name + ";" + node.path; + } + } + + }); + + for (i=1; i= 0; n--) { + link = depthFirstCycleSearch(nodes[n], []); + if (link) { + return link; + } + } + + // Depth-first search to find a link that is part of a cycle. + function depthFirstCycleSearch(cursorNode, path) { + var target, link; + for (var n = cursorNode.sourceLinks.length - 1; n >= 0; n--) { + link = cursorNode.sourceLinks[n]; + if (link.cycleBreaker) { + // Skip already known cycle breakers. + continue; + } + + // Check if target of link makes a cycle in current path. + target = link.target; + for (var l = 0; l < path.length; l++) { + if (path[l].source == target) { + // We found a cycle. Search for weakest link in cycle + var weakest = link; + for (; l < path.length; l++) { + if (path[l].value < weakest.value) { + weakest = path[l]; + } + } + // Mark weakest link as (known) cycle breaker and abort search. + weakest.cycleBreaker = true; + return weakest; + } + } + + // Recurse deeper. + path.push(link); + link = depthFirstCycleSearch(target, path); + path.pop(); + // Stop further search if we found a cycle breaker. + if (link) { + return link; + } + } + } } function moveSourcesRight() { @@ -165,7 +337,7 @@ export default function() { .sort(function(a, b) { return b.x - a.x; }) .forEach(function(node) { if (!node.targetLinks.length) { - node.x = min(node.sourceLinks, function(d) { return d.target.x; }) - 1; + node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1; } }); } @@ -174,24 +346,78 @@ export default function() { nodes.forEach(function(node) { if (!node.sourceLinks.length) { node.x = x - 1; + } else { + //move node to second from right + var nodes_to_right = 0; + node.sourceLinks.forEach(function(n) { + nodes_to_right = Math.max(nodes_to_right,n.target.sourceLinks.length) + }) + if (nodes_to_right==0)node.x = x - 2; } }); } function scaleNodeBreadths(kx) { nodes.forEach(function(node) { - node.x *= kx; + if (scaleNodeBreadthsByString) { + node.x = summed_str_length[node.x]; + } else { + node.x *= kx; + } }); } + // Compute the depth (y-position) for each node. function computeNodeDepths(iterations) { - var nodesByBreadth = nest() - .key(function(d) { return d.x; }) - .sortKeys(ascending) - .entries(nodes) - .map(function(d) { return d.values; }); - // + var more_nodes = nodes; + var nodesByBreadth; + + if (orderByPath) { + nodesByBreadth = new Array(max_posX); + for (i=0; i < nodesByBreadth.length; ++i) { + nodesByBreadth[i] = []; + } + + // Add 'invisible' nodes to account for different depths + for (posX=0; posX < max_posX; ++posX) { + for (j=0; j < nodes.length; ++j) { + if (nodes[j].posX != posX) { + continue; + } + node = nodes[j]; + nodesByBreadth[posX].push(node); + if (node.sourceLinks.length && node.sourceLinks[0].target.posX > node.posX +1) { + for (new_node_posX=node.posX+1; new_node_posX < node.sourceLinks[0].target.posX; ++new_node_posX) { + var new_node = node.constructor(); + new_node.posX = new_node_posX; + new_node.dy = node.dy; + new_node.y = node.y; + new_node.value = node.value; + new_node.path = node.path; + new_node.sourceLinks = node.sourceLinks; + new_node.targetLinks = node.targetLinks; + nodesByBreadth[new_node_posX].push(new_node); + } + } + } + } + } else { + nodesByBreadth = d3.nest() + .key(function(d) { return d.x; }) + .sortKeys(function(a, b) { return a - b; }) // pull request #124 in d3/d3-plugins + .entries(nodes) + .map(function(d) { return d.values; }); + + } + + // Group nodes by breath. + //var nodesByBreadth = d3.nest() + // .key(function(d) { return d.x; }) + // .sortKeys(d3.ascending) + // .entries(nodes) + // .map(function(d) { return d.values; }); + initializeNodeDepth(); resolveCollisions(); for (var alpha = 1; iterations > 0; --iterations) { @@ -202,8 +428,9 @@ export default function() { } function initializeNodeDepth() { - var ky = min(nodesByBreadth, function(nodes) { - return (size[1] - (nodes.length - 1) * nodePadding) / sum(nodes, value); + // Calculate vertical scaling factor. + var ky = d3.min(nodesByBreadth, function(nodes) { + return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value); }); nodesByBreadth.forEach(function(nodes) { @@ -222,7 +449,8 @@ export default function() { nodesByBreadth.forEach(function(nodes) { nodes.forEach(function(node) { if (node.targetLinks.length) { - var y = sum(node.targetLinks, weightedSource) / sum(node.targetLinks, value); + // Value-weighted average of the y-position of source node centers linked to this node. + var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value); node.y += (y - center(node)) * alpha; } }); @@ -237,7 +465,8 @@ export default function() { nodesByBreadth.slice().reverse().forEach(function(nodes) { nodes.forEach(function(node) { if (node.sourceLinks.length) { - var y = sum(node.sourceLinks, weightedTarget) / sum(node.sourceLinks, value); + // Value-weighted average of the y-positions of target nodes linked to this node. + var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value); node.y += (y - center(node)) * alpha; } }); @@ -282,27 +511,46 @@ export default function() { } function ascendingDepth(a, b) { - return a.y - b.y; + if (orderByPath) { + return ( a.path < b.path ? -1 : (a.path > b.path ? 1 : 0 )); + } else { + return a.y - b.y; + } } } + // Compute y-offset of the source endpoint (sy) and target endpoints (ty) of links, + // relative to the source/target node's y-position. + // includes fix by @gmadrid in pull request #74 in d3/d3-plugins function computeLinkDepths() { nodes.forEach(function(node) { node.sourceLinks.sort(ascendingTargetDepth); - node.targetLinks.sort(ascendingSourceDepth); }); nodes.forEach(function(node) { - var sy = 0, ty = 0; + var sy = 0; node.sourceLinks.forEach(function(link) { link.sy = sy; sy += link.dy; }); + }); + nodes.forEach(function(node) { + node.targetLinks.sort(descendingLinkSlope); + }); + nodes.forEach(function(node) { + var ty = 0; node.targetLinks.forEach(function(link) { link.ty = ty; ty += link.dy; }); }); + function descendingLinkSlope(a, b) { + function slope(l) { + return (l.target.y - (l.source.y + l.sy)) / + (l.target.x - l.source.x); + } + return slope(b) - slope(a); + } function ascendingSourceDepth(a, b) { return a.source.y - b.source.y; } @@ -312,13 +560,15 @@ export default function() { } } + // Y-position of the middle of a node. function center(node) { return node.y + node.dy / 2; } - function value(link) { - return link.value; + // Value property accessor. + function value(x) { + return x.value; } return sankey; -} +};