Skip to content

Instantly share code, notes, and snippets.

@micahstubbs
Last active October 8, 2017 05:22
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/5ea74175fa23348ce19ec141aae2d220 to your computer and use it in GitHub Desktop.
Save micahstubbs/5ea74175fa23348ce19ec141aae2d220 to your computer and use it in GitHub Desktop.
origin destination map experiment
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

forked from micahstubbs's block: Sankey Gradients - Missing Gradient Solution

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": "origin",
"id": 0
},
{
"name": "origin",
"id": 1
},
{
"name": "origin",
"id": 2
},
{
"name": "origin",
"id": 3
},
{
"name": "destination",
"id": 4
}
],
"links": [
{
"source": 0,
"target": 4,
"value": 0.45,
"label": 0.45
},
{
"source": 1,
"target": 4,
"value": 0.26,
"label": 0.26
},
{
"source": 2,
"target": 4,
"value": 0.21,
"label": 0.21
},
{
"source": 3,
"target": 4,
"value": 0.08,
"label": 0.08
}
]
}
<!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>
g rect {
cursor: move;
fill-opacity: 0.8;
shape-rendering: crispEdges;
}
g path {
stroke-opacity: 0.8
}
g 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 sankeyMargin = {top: 0, right: 180, bottom: 0, left: 0};
const width = 960 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const minardBlue = '#4F819C';
// 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 sankeyWidth = width - sankeyMargin.right;
const sankey = d3.sankey()
.nodeWidth(12)
.nodePadding(10)
.size([sankeyWidth, 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')
.sort((a, b) => b.dy - a.dy)
.on('mouseover', function() {
d3.select(this).style('stroke-opacity', 0.5);
d3.selectAll('text').style('fill', 'white');
})
.on('mouseout', function() {
d3.select(this).style('stroke-opacity', 0.8);
d3.selectAll('text').style('fill', 'none');
});
// 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})`)
.attr('class', d => d.name)
.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 = minardBlue;
}
})
.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)
//.style('fill', 'white')
.style('fill', 'none')
.text(d => `${d.id} ${d.name}`)
.filter(d => d.x < width / 2)
.attr('x', 6 + sankey.nodeWidth())
.attr('text-anchor', 'start');
d3.selectAll('.destination')
.append('path')
.style('opacity', 0.8)
.style('fill', minardBlue)
.attr('d', d => `M ${d.dy*0.5},${d.dy*0.5} 0,${d.dy} 0,0 z`)
.attr('transform', d => `translate(${sankey.nodeWidth()},0)`)
// 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