Using particles to indicate flow between reservoirs in a sankey diagram. Original sankey diagram and layout done by Mike Bostock.
This method takes advantage of a couple of really useful built-in SVG functions: svg:path.getTotalLength()
and svg:path.getPointAtLength()
that allows you to calculate where a particle would need to be along a path. Because this is animation and the individual particles don't need to be interacted with, the particles are drawn with canvas, though you can drag the nodes around to see the particles change routes.
forked from emeeks's block: Sankey Particles
forked from glimpsedchaos's block: Sankey Particles
forked from glimpsedchaos's block: Sankey Particles
forked from glimpsedchaos's block: Sankey Particles
xxxxxxxxxx
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sankey Particles</title>
<style>
.flow_node rect {
//cursor: move;
fill-opacity: .9;
shape-rendering: crispEdges;
}
.flow_node text {
pointer-events: none;
}
.flow_link {
fill: none;
//stroke: #000;
stroke: white;
stroke-opacity: .15;
}
.flow_link:hover {
stroke-opacity: .25;
}
svg {
position: absolute;
}
canvas {
position: absolute;
}
body{
background: black;
}
</style>
</head>
<body>
<canvas width="1000" height="1000" ></canvas>
<svg width="1000" height="1000" ></svg>
<!-- Referencing v4 works -->
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="https://d3js.org/d3-color.v1.min.js"></script>
<script src="https://d3js.org/d3-interpolate.v1.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="https://d3js.org/d3-timer.v1.min.js"></script>
<script src="https://d3js.org/d3-hierarchy.v1.min.js"></script>
<script src="d3.sankey.js" charset="utf-8" type="text/javascript"></script>
<script type="text/javascript">
var flowData =
{
"nodes": [
{ "name": "F5SDPROD", "segment": 0, "active": 1 },
{ "name": "F5SDPROD - HA1", "segment": 0, "active": 1},
{ "name": "F5SDPROD - HA2", "segment": 0, "active": 1},
{ "name": "F5SDPROD VIP Health", "segment": 0, "health": 93},
{ "name": "F5WANPROD", "segment": 0, "active": 1 },
{ "name": "F5WANPROD - HA1", "segment": 0, "active": 1},
{ "name": "F5WANPROD - HA2", "segment": 0, "active": 1},
{ "name": "F5WANPROD VIP Health", "segment": 0, "health": 100},
{ "name": "F5DRSDPROD", "segment": 0, "active": 1 },
{ "name": "F5DRSDPROD - HA1", "segment": 0, "active": 1},
{ "name": "F5DRSDPROD - HA2", "segment": 0, "active": 1},
{ "name": "F5DRSDPROD VIP Health", "segment": 0, "health": 96},
{ "name": "F5DRWANPROD", "segment": 0, "active": 1 },
{ "name": "F5DRWANPROD - HA1", "segment": 0, "active": 1},
{ "name": "F5DRWANPROD - HA2", "segment": 0, "active": 1},
{ "name": "F5DRWANPROD VIP Health", "segment": 0, "health": 97},
{ "name": "F5SDTBF", "segment": 0, "active": 1 },
{ "name": "F5SDTBF - HA1", "segment": 0, "active": 1},
{ "name": "F5SDTBF - HA2", "segment": 0, "active": 1},
{ "name": "F5SDTBF VIP Health", "segment": 0, "health": 92},
{ "name": "F5WANTBF", "segment": 0, "active": 1 },
{ "name": "F5WANTBF - HA1", "segment": 0, "active": 1},
{ "name": "F5WANTBF - HA2", "segment": 0, "active": 1},
{ "name": "F5WANTBF VIP Health", "segment": 0, "health": 90}
],
"links": [
{ "source": "F5SDPROD", "target": "F5SDPROD - HA1", "value": 10, "active": 1 },
{ "source": "F5SDPROD", "target": "F5SDPROD - HA2", "value": 10, "active": 0 },
{ "source": "F5SDPROD - HA1", "target": "F5SDPROD VIP Health", "value": 10, "active": 1 },
{ "source": "F5SDPROD - HA2", "target": "F5SDPROD VIP Health", "value": 10, "active": 0 },
{ "source": "F5WANPROD", "target": "F5WANPROD - HA1", "value": 10, "active": 1 },
{ "source": "F5WANPROD", "target": "F5WANPROD - HA2", "value": 10, "active": 0 },
{ "source": "F5WANPROD - HA1", "target": "F5WANPROD VIP Health", "value": 10, "active": 1 },
{ "source": "F5WANPROD - HA2", "target": "F5WANPROD VIP Health", "value": 10, "active": 0 },
{ "source": "F5DRSDPROD", "target": "F5DRSDPROD - HA1", "value": 10, "active": 1 },
{ "source": "F5DRSDPROD", "target": "F5DRSDPROD - HA2", "value": 10, "active": 0 },
{ "source": "F5DRSDPROD - HA1", "target": "F5DRSDPROD VIP Health", "value": 10, "active": 1 },
{ "source": "F5DRSDPROD - HA2", "target": "F5DRSDPROD VIP Health", "value": 10, "active": 0 },
{ "source": "F5DRWANPROD", "target": "F5DRWANPROD - HA1", "value": 10, "active": 1 },
{ "source": "F5DRWANPROD", "target": "F5DRWANPROD - HA2", "value": 10, "active": 0 },
{ "source": "F5DRWANPROD - HA1", "target": "F5DRWANPROD VIP Health", "value": 10, "active": 1 },
{ "source": "F5DRWANPROD - HA2", "target": "F5DRWANPROD VIP Health", "value": 10, "active": 0 },
{ "source": "F5SDTBF", "target": "F5SDTBF - HA1", "value": 10, "active": 1 },
{ "source": "F5SDTBF", "target": "F5SDTBF - HA2", "value": 10, "active": 0 },
{ "source": "F5SDTBF - HA1", "target": "F5SDTBF VIP Health", "value": 10, "active": 1 },
{ "source": "F5SDTBF - HA2", "target": "F5SDTBF VIP Health", "value": 10, "active": 0 },
{ "source": "F5WANTBF", "target": "F5WANTBF - HA1", "value": 10, "active": 0 },
{ "source": "F5WANTBF", "target": "F5WANTBF - HA2", "value": 10, "active": 1 },
{ "source": "F5WANTBF - HA1", "target": "F5WANTBF VIP Health", "value": 10, "active": 0 },
{ "source": "F5WANTBF - HA2", "target": "F5WANTBF VIP Health", "value": 10, "active": 1 },
]
}
var margin = {top: 1, right: 250, bottom: 6, left: 1},
width = 800 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var formatNumber = d3.format(",.0f"),
format = function(d) { return formatNumber(d) + " TWh"; },
color = d3.scaleOrdinal(d3.schemeCategory10);
var svg = d3.select("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var sankey = d3.sankey()
.nodeWidth(15)
.nodePadding(10)
.size([width, height]);
var path = sankey.link();
var freqCounter = 1;
// load the data
d3.hierarchy(flowData, function (graph) {
var nodeMap = {};
graph.nodes.forEach(function (x) { nodeMap[x.name] = x; });
graph.links = graph.links.map(function (x) {
return {
source: nodeMap[x.source],
target: nodeMap[x.target],
value: x.value,
active: x.active
};
});
sankey
.nodes(graph.nodes)
.links(graph.links)
.layout(32);
// add in the links
var link = svg.append("g").selectAll(".flow_link")
.data(graph.links)
.enter().append("path")
.attr("class", "flow_link")
.style("stroke", function (d) {
if (d.active == 0 && d.source.active ==1) {
return "#17a2b8";
}
})
.attr("d", path)
.style("stroke-width", function (d) { return Math.max(1, d.dy); })
.sort(function (a, b) { return b.dy - a.dy; });
link.append("title")
.text(function (d) { return d.source.name + " → " + d.target.name; });
var node = svg.append("g").selectAll(".flow_node")
.data(graph.nodes)
.enter().append("g")
.attr("class", "flow_node")
.attr("transform", function (d) { return "translate(" + d.x + "," + d.y + ")"; });
node.append("rect")
.attr("height", function (d) { return d.dy; })
.attr("width", sankey.nodeWidth())
.style("fill", function (d) {
if (d.segment == 0) {
return d.color = color(d.name.replace(/ .*/, ""));
}
if (d.segment == 1) {
return "#8856a7";
}
})
.style("stroke", "none")
.append("title")
.text(function (d) { return d.name; });
node.append("text")
.attr("x", -6)
.attr("y", function (d) { return d.dy / 2; })
.attr("dy", ".35em")
.attr("transform", null)
//.text(function (d) { return d.name + " " + d.health + "%"; })
.text(function (d) {
if (!d.health){
return d.name
}
else{
return d.name + " " + d.health + "%";
}
})
.style("fill",function (d) {
if (!d.health){ return "fff"}
else{
if(d.health < 95){
return "#dc3545";
}
if(d.health < 100){
return "#ffc107";
}
return "#28a745";
}
})
.attr("x", 6 + sankey.nodeWidth())
.attr("text-anchor", "start")
.style("font-size", "1.0em");
function dragmove(d) {
d3.select(this).attr("transform", "translate(" + d.x + "," + (d.y = Math.max(0, Math.min(height - d.dy, d3.event.y))) + ")");
sankey.relayout();
link.attr("d", path);
}
var linkExtent = d3.extent(graph.links, function (d) { return 1 });
var frequencyScale = d3.scaleLinear().domain(linkExtent).range([.01, .01]);
var particleSize = d3.scaleLinear().domain(linkExtent).range([1, 5]);
graph.links.forEach(function (link) {
link.freq = frequencyScale(link.o_value);
if(link.active==0){
link.particleSize = 0;
}
else{
link.particleSize = 3;
}
link.particleColor = d3.scaleLinear().domain([0, 10])
.range(["#f000", link.target.color]);
})
var t = d3.timer(tick, 10);
var particles = [];
function tick(elapsed, time) {
particles = particles.filter(function (d) { return d.current < d.path.getTotalLength() });
d3.selectAll("path.flow_link")
.each(
function (d) {
for (var x = 0; x < 2; x++) {
var offset = (Math.random() - .5) * (d.dy - 4);
if (Math.random() < d.freq) {
var length = this.getTotalLength();
particles.push({ link: d, time: elapsed, offset: offset, path: this, length: length, animateTime: length, speed: 0.5 + (Math.random()) })
}
}
});
particleEdgeCanvasPath(elapsed);
}
function particleEdgeCanvasPath(elapsed) {
var context = d3.select("canvas").node().getContext("2d")
context.clearRect(0, 0, 1000, 1000);
context.fillStyle = "gray";
context.lineWidth = "1px";
for (var x in particles) {
var currentTime = elapsed - particles[x].time;
particles[x].current = currentTime * 0.1 * particles[x].speed;
var currentPos = particles[x].path.getPointAtLength(particles[x].current);
context.beginPath();
context.fillStyle = particles[x].link.particleColor(0);
context.arc(currentPos.x, currentPos.y + particles[x].offset, particles[x].link.particleSize, 0, 2 * Math.PI);
context.fill();
}
}
});
</script>
</body>
</html>
https://d3js.org/d3.v5.min.js
https://d3js.org/d3-color.v1.min.js
https://d3js.org/d3-interpolate.v1.min.js
https://d3js.org/d3-scale-chromatic.v1.min.js
https://d3js.org/d3-timer.v1.min.js
https://d3js.org/d3-hierarchy.v1.min.js