Skip to content

Instantly share code, notes, and snippets.

@micahstubbs
Last active November 24, 2016 02:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save micahstubbs/2d9ac2340dd81e676be9aca6e2e93a4a to your computer and use it in GitHub Desktop.
Save micahstubbs/2d9ac2340dd81e676be9aca6e2e93a4a to your computer and use it in GitHub Desktop.
Sankey Gradients - Missing Gradient Solution
height: 300
border: no
license: MIT

a solution 🎉

solution
[tweet]

specifically, this stackoverflow answer has the workaround to solve this apparent bug in Chromium's implementation of the SVG 1.1 standard

in d3.sankey.js, we want to alter the return value of the path generator to ensure that we never return perfectly straight paths. inserting this this new moveto command "M" + -10 + "," + -10 on the first line does just that:

return "M" + -10 + "," + -10
     + "M" + x0  + "," + y0
     + "C" + x2  + "," + y0
     + " " + x3  + "," + y1
     + " " + x1  + "," + y1;

an iteration on by Patient Flow Sankey Particles from @micahstubbs

see also the earlier version with 13 layout iterations that happens to avoid any perfectly straight paths.

and also this earlier bug reproduction example with 14 Sankey layout iterations that does produce a couple of those problematic-for-Chromium perfectly straight SVG paths

inspired by the blog post Data-based and unique gradients for visualizations with d3.js and associated example Data based gradients - Simple - Solar system from @nadiehbremer

d3.sankey = function() {
var sankey = {},
nodeWidth = 24,
nodePadding = 8,
size = [1, 1],
nodes = [],
links = [];
sankey.nodeWidth = function(_) {
if (!arguments.length) return nodeWidth;
nodeWidth = +_;
return sankey;
};
sankey.nodePadding = function(_) {
if (!arguments.length) return nodePadding;
nodePadding = +_;
return sankey;
};
sankey.nodes = function(_) {
if (!arguments.length) return nodes;
nodes = _;
return sankey;
};
sankey.links = function(_) {
if (!arguments.length) return links;
links = _;
return sankey;
};
sankey.size = function(_) {
if (!arguments.length) return size;
size = _;
return sankey;
};
sankey.layout = function(iterations) {
computeNodeLinks();
computeNodeValues();
computeNodeBreadths();
computeNodeDepths(iterations);
computeLinkDepths();
return sankey;
};
sankey.relayout = function() {
computeLinkDepths();
return sankey;
};
sankey.link = function() {
var curvature = .5;
function link(d) {
var x0 = d.source.x + d.source.dx,
x1 = d.target.x,
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;
// prevent a perfectly straight path
// to avoid missing SVG path gradient bug in Chromium
return "M" + -10 + "," + -10
+ "M" + x0 + "," + y0
+ "C" + x2 + "," + y0
+ " " + x3 + "," + y1
+ " " + x1 + "," + y1;
}
link.curvature = function(_) {
if (!arguments.length) return curvature;
curvature = +_;
return link;
};
return link;
};
// Populate the sourceLinks and targetLinks for each node.
// Also, if the source and target are not objects, assume they are indices.
function computeNodeLinks() {
nodes.forEach(function(node) {
node.sourceLinks = [];
node.targetLinks = [];
});
links.forEach(function(link) {
var source = link.source,
target = link.target;
if (typeof source === "number") source = link.source = nodes[link.source];
if (typeof target === "number") target = link.target = nodes[link.target];
source.sourceLinks.push(link);
target.targetLinks.push(link);
});
}
// Compute the value (size) of each node by summing the associated links.
function computeNodeValues() {
nodes.forEach(function(node) {
node.value = Math.max(
d3.sum(node.sourceLinks, value),
d3.sum(node.targetLinks, value)
);
});
}
// 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;
while (remainingNodes.length) {
nextNodes = [];
remainingNodes.forEach(function(node) {
node.x = x;
node.dx = nodeWidth;
node.sourceLinks.forEach(function(link) {
if (nextNodes.indexOf(link.target) < 0) {
nextNodes.push(link.target);
}
});
});
remainingNodes = nextNodes;
++x;
}
//
moveSinksRight(x);
scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
}
function moveSourcesRight() {
nodes.forEach(function(node) {
if (!node.targetLinks.length) {
node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1;
}
});
}
function moveSinksRight(x) {
nodes.forEach(function(node) {
if (!node.sourceLinks.length) {
node.x = x - 1;
}
});
}
function scaleNodeBreadths(kx) {
nodes.forEach(function(node) {
node.x *= kx;
});
}
function computeNodeDepths(iterations) {
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) {
relaxRightToLeft(alpha *= .99);
resolveCollisions();
relaxLeftToRight(alpha);
resolveCollisions();
}
function initializeNodeDepth() {
var ky = d3.min(nodesByBreadth, function(nodes) {
return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
});
nodesByBreadth.forEach(function(nodes) {
nodes.forEach(function(node, i) {
node.y = i;
node.dy = node.value * ky;
});
});
links.forEach(function(link) {
link.dy = link.value * ky;
});
}
function relaxLeftToRight(alpha) {
nodesByBreadth.forEach(function(nodes, breadth) {
nodes.forEach(function(node) {
if (node.targetLinks.length) {
var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedSource(link) {
return center(link.source) * link.value;
}
}
function relaxRightToLeft(alpha) {
nodesByBreadth.slice().reverse().forEach(function(nodes) {
nodes.forEach(function(node) {
if (node.sourceLinks.length) {
var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
node.y += (y - center(node)) * alpha;
}
});
});
function weightedTarget(link) {
return center(link.target) * link.value;
}
}
function resolveCollisions() {
nodesByBreadth.forEach(function(nodes) {
var node,
dy,
y0 = 0,
n = nodes.length,
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 - size[1];
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;
}
}
});
}
function ascendingDepth(a, b) {
return a.y - b.y;
}
}
function computeLinkDepths() {
nodes.forEach(function(node) {
node.sourceLinks.sort(ascendingTargetDepth);
node.targetLinks.sort(ascendingSourceDepth);
});
nodes.forEach(function(node) {
var sy = 0, ty = 0;
node.sourceLinks.forEach(function(link) {
link.sy = sy;
sy += link.dy;
});
node.targetLinks.forEach(function(link) {
link.ty = ty;
ty += link.dy;
});
});
function ascendingSourceDepth(a, b) {
return a.source.y - b.source.y;
}
function ascendingTargetDepth(a, b) {
return a.target.y - b.target.y;
}
}
function center(node) {
return node.y + node.dy / 2;
}
function value(link) {
return link.value;
}
return sankey;
};
{
"nodes": [
{
"name": "All referred patients",
"id": 0
},
{
"name": "First consult outpatient clinic",
"id": 1
},
{
"name": "No OR-receipt",
"id": 2
},
{
"name": "OR-receipt",
"id": 3
},
{
"name": "No surgery",
"id": 4
},
{
"name": "Start surgery",
"id": 5
},
{
"name": "Emergency",
"id": 6
},
{
"name": "No emergency",
"id": 7
}
],
"links": [
{
"source": 0,
"target": 1,
"value": 1,
"label": 1
},
{
"source": 1,
"target": 2,
"value": 0.64,
"label": 0.64
},
{
"source": 1,
"target": 3,
"value": 0.36,
"label": 0.36
},
{
"source": 3,
"target": 4,
"value": 0.1188,
"label": 0.33
},
{
"source": 3,
"target": 5,
"value": 0.2412,
"label": 0.67
},
{
"source": 5,
"target": 6,
"value": 0.038592,
"label": 0.16
},
{
"source": 5,
"target": 7,
"value": 0.20260799999999998,
"label": 0.84
}
]
}
<!DOCTYPE html>
<meta charset='utf-8'>
<title>Sankey Gradients</title>
<script src='https://cdnjs.cloudflare.com/ajax/libs/d3/4.4.0/d3.min.js'></script>
<script src='d3.sankey.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.19.0/babel.min.js'></script>
<style>
.node rect {
cursor: move;
fill-opacity: .9;
shape-rendering: crispEdges;
}
.node text {
pointer-events: none;
font-family: Helvetica;
font-size: 12px;
}
</style>
<body>
<div id='chart'>
<script lang='babel' type='text/babel'>
const units = '';
const margin = {top: 10, right: 10, bottom: 10, left: 10};
const width = 960 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
// zero decimal places
const formatNumber = d3.format(',.0f');
const format = d => `${formatNumber(d)} ${units}`;
const color = d3.scaleOrdinal()
.domain([
'All referred patients',
'First consult outpatient clinic',
'OR-receipt',
'Start surgery',
// 'No OR-receipt',
// 'No emergency',
// 'No surgery',
'Emergency'
])
.range([
'#90eb9d',
'#f9d057',
'#f29e2e',
'#00ccbc',
'#d7191c'
]);
d3.select('#chart')
.style('visibility', 'visible');
// append the svg canvas to the page
const svg = d3.select('#chart').append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// set the sankey diagram properties
const sankey = d3.sankey()
.nodeWidth(12)
.nodePadding(10)
.size([width, height]);
const path = sankey.link();
// append a defs (for definition) element to your SVG
const defs = svg.append('defs');
// load the data
d3.json('data.json', (error, graph) => {
console.log('graph', graph);
sankey
.nodes(graph.nodes)
.links(graph.links)
.layout(14); // any value > 13 breaks the link gradient
// add in the links
const link = svg.append('g').selectAll('.link')
.data(graph.links)
.enter().append('path')
.attr('class', 'link')
.attr('d', path)
.style('stroke-width', d => Math.max(1, d.dy))
.style('fill', 'none')
.style('stroke-opacity', 0.18)
.sort((a, b) => b.dy - a.dy)
.on('mouseover', function() {
d3.select(this).style('stroke-opacity', 0.5);
})
.on('mouseout', function() {
d3.select(this).style('stroke-opacity', 0.2);
});
// add the link titles
link.append('title')
.text(d => `${d.source.name} → ${d.target.name}\n${format(d.value)}`);
// add in the nodes
const node = svg.append('g').selectAll('.node')
.data(graph.nodes)
.enter().append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.call(d3.drag()
.subject(d => d)
.on('start', function() {
this.parentNode.appendChild(this); })
.on('drag', dragmove));
// add the rectangles for the nodes
node.append('rect')
.attr('height', d => d.dy)
.attr('width', sankey.nodeWidth())
.style('fill', d => {
if(color.domain().indexOf(d.name) > -1){
return d.color = color(d.name);
} else {
return d.color = '#ccc';
}
})
.append('title')
.text(d => `${d.name}\n${format(d.value)}`);
// add in the title for the nodes
node.append('text')
.attr('x', -6)
.attr('y', d => d.dy / 2)
.attr('dy', '.35em')
.attr('text-anchor', 'end')
.attr('transform', null)
.text(d => d.name)
.filter(d => d.x < width / 2)
.attr('x', 6 + sankey.nodeWidth())
.attr('text-anchor', 'start');
// add gradient to links
link.style('stroke', (d, i) => {
console.log('d from gradient stroke func', d);
// make unique gradient ids
const gradientID = `gradient${i}`;
const startColor = d.source.color;
const stopColor = d.target.color;
console.log('startColor', startColor);
console.log('stopColor', stopColor);
const linearGradient = defs.append('linearGradient')
.attr('id', gradientID);
linearGradient.selectAll('stop')
.data([
{offset: '10%', color: startColor },
{offset: '90%', color: stopColor }
])
.enter().append('stop')
.attr('offset', d => {
console.log('d.offset', d.offset);
return d.offset;
})
.attr('stop-color', d => {
console.log('d.color', d.color);
return d.color;
});
return `url(#${gradientID})`;
})
// the function for moving the nodes
function dragmove(d) {
d3.select(this).attr('transform',
`translate(${d.x = Math.max(0, Math.min(width - d.dx, d3.event.x))},${d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))})`);
sankey.relayout();
link.attr('d', path);
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment