Testing how a Sankey with circular links may be constructed.
Work in progress dataset for water cycle
This chart detects circular links, and then uses top and bottom 'channels' to layout those links back to the target node. The middle channel is for "forward" links through the process.
~~This needs more work in terms in aligning the nodes better - the nodes which have circular links are pushed to the top or bottom, but perhaps a bit too rigourously.~~ This version lays out the nodes based on incoming/outgoing circular links, and takes into account whether its in the first or last column.
This version of the sankey code works out padding based on a proportion of the height and max values/nodes in each column.
Need to consider how circular links could work going down the middle if that makes sense, eg from process4 to process1 in this example. Perhaps if the nodes a close in terms of depth, and the risk of crossing other links is lower?
This version improves the lay out of nodes and links to prevent cross overs. Still need to work out how to avoid overlap of nodes on links
But ultimately, this needs a load more sankey datasets thrown at it, to break the layout functions
forked from tomshanley's block: Sankey with circular links
forked from tomshanley's block: Sankey with circular links v2
forked from tomshanley's block: Sankey with circular links v2
xxxxxxxxxx
<html>
<head>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="d3-sankey-circular.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="data.js"></script>
<link href="https://fonts.googleapis.com/css?family=Cherry+Swash:700" rel="stylesheet">
<title>Sankey with circular links</title>
<style>
body {
font-family: 'Cherry Swash', cursive;
}
rect {
shape-rendering: crispEdges;
}
text {
text-shadow: 0 1px 0 #fff;
font-family: monospace
}
.link {
fill: none;
}
</style>
</head>
<body>
<h1>Sankey with circular links</h1>
<div id="chart"></div>
<script>
var margin = { top: 200, right: 100, bottom: 120, left: 100 };
var width = 1200;
var height = 400;
let data = data3;
const nodePadding = 40;
const circularLinkGap = 2;
var sankey = d3.sankey()
.nodeWidth(15)
.nodePadding(nodePadding)
.nodePaddingRatio(0.7)
.scale(0.5)
.size([width, height])
.nodeId(function (d) {
return d.name;
})
.nodeAlign(d3.sankeyLeft)
.iterations(32);
var svg = d3.select("#chart").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
var g = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
var linkG = g.append("g")
.attr("class", "links")
.attr("fill", "none")
.attr("stroke-opacity", 0.2)
.selectAll("path");
var nodeG = g.append("g")
.attr("class", "nodes")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.selectAll("g");
//run the Sankey + circular over the data
let sankeyData = sankey(data);
let sankeyNodes = sankeyData.nodes;
let sankeyLinks = sankeyData.links;
let depthExtent = d3.extent(sankeyNodes, function (d) { return d.depth; });
var colour = d3.scaleSequential(d3.interpolateCool)
.domain(depthExtent);
//Adjust link Y's based on target/source Y positions
sortSourceLinks()
sortTargetLinks()
sortSourceLinks()
sortTargetLinks()
sankeyLinks = addCircularPathData(sankeyLinks);
var node = nodeG.data(sankeyNodes)
.enter()
.append("g");
node.append("rect")
.attr("x", function (d) { return d.x0; })
.attr("y", function (d) { return d.y0; })
.attr("height", function (d) { return d.y1 - d.y0; })
.attr("width", function (d) { return d.x1 - d.x0; })
.style("fill", function (d) { return colour(d.depth); })
.style("opacity", 0.5)
.style("stroke", "white")
.on("mouseover", function (d) {
//d3.select(this).style("opacity", 1);
let thisName = d.name;
node.selectAll("rect")
.style("opacity", function(d){
return highlightNodes(d, thisName)
})
d3.selectAll("path")
.style("opacity", function (l) {
return l.source.name == thisName || l.target.name == thisName ? 1 : 0.3;
})
node.selectAll("text")
.style("opacity", function(d){
return highlightNodes(d, thisName)
})
})
.on("mouseout", function (d) {
d3.selectAll("rect").style("opacity", 0.5);
d3.selectAll("path").style("opacity", 0.7);
d3.selectAll("text").style("opacity", 1);
})
node.append("text")
.attr("x", function (d) { return d.x0 - 6; })
.attr("y", function (d) { return d.y0 + ((d.y1 - d.y0) / 2); })
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.text(function (d) { return d.name; })
.filter(function (d) { return d.x0 < width / 2; })
.attr("x", function (d) { return d.x1 + 6; })
.attr("text-anchor", "start")
node.append("title")
.text(function (d) { return d.name + "\n" + (d.value); });
var link = linkG.data(sankeyLinks)
.enter()
.append("path")
.attr("d", curveSankeyForceLink)
.style("stroke-width", function (d) { return Math.max(1, d.width); })
.style("stroke", function (d) {
return d.circular ? "red" : "black";
})
.style("opacity", 0.7);
link.append("title")
.text(function (d) {
return d.source.name + " → " + d.target.name + "\n Value: " + (d.value);
});
function highlightNodes(node, name) {
let opacity = 0.3
if (node.name == name) {
opacity = 1;
}
node.sourceLinks.forEach(function (link) {
if (link.target.name == name) {
opacity = 1;
};
})
node.targetLinks.forEach(function (link) {
if (link.source.name == name) {
opacity = 1;
};
})
return opacity;
}
//Create a normal curve or circular curve
function curveSankeyForceLink(link) {
let path = ''
if (link.circular) {
path = link.circularPathData.path;
} else {
var normalPath = d3.linkHorizontal()
.source(function (d) {
let x = d.source.x0 + (d.source.x1 - d.source.x0);
let y = d.y0;
return [x, y]
})
.target(function (d) {
let x = d.target.x0;
let y = d.y1;
return [x, y]
})
path = normalPath(link)
}
return path
};
//Return the angle between a straight line between the source and target of the link, and the vertical plane of the node
function linkAngle(link) {
let adjacent = Math.abs(link.y1 - link.y0);
let opposite = Math.abs(link.target.x0 - link.source.x1);
return Math.atan(opposite / adjacent)
}
function circularLinksCross(link1, link2) {
if (link1.source.depth < link2.target.depth) {
return false;
}
else if (link1.target.depth > link2.source.depth) {
return false;
}
else {
return true;
}
}
function calcVerticalBuffer(links) {
links.sort(sortLinkDepthAscending)
links.forEach(function (link, i) {
let buffer = 0;
let j = 0;
for (j; j < i; j++) {
if (circularLinksCross(links[i], links[j])) {
let bufferOverThisLink = links[j].circularPathData.verticalBuffer + (links[j].width / 2) + circularLinkGap;
buffer = bufferOverThisLink > buffer ? bufferOverThisLink : buffer;
}
}
link.circularPathData.verticalBuffer = buffer + (link.width / 2);
})
return links;
}
//calculate the optimum path for a link to reduce overlaps
function addCircularPathData(links) {
let maxLinkWidth = d3.max(links, function (link) { return link.width });
let minRadius = maxLinkWidth;
let maxNodeDepth = d3.max(links, function (link) { return link.target.depth; });
let minY = d3.min(links, function (link) { return link.source.y0 });
let baseRadius = 10;
//create object for circular Path Data
links.forEach(function (link) {
if (link.circular) {
link.circularPathData = {};
}
})
//calc vertical offsets per top/bottom links
let topLinks = links.filter(function (l) { return l.circularLinkType == "top"; })
topLinks = calcVerticalBuffer(topLinks);
let bottomLinks = links.filter(function (l) { return l.circularLinkType == "bottom"; })
bottomLinks = calcVerticalBuffer(bottomLinks);
//add the base data for each link
links.forEach(function (link) {
if (link.circular) {
link.circularPathData.arcRadius = link.width + baseRadius;
link.circularPathData.leftNodeBuffer = 10;
link.circularPathData.rightNodeBuffer = 10;
link.circularPathData.sourceWidth = link.source.x1 - link.source.x0;
link.circularPathData.targetWidth = link.target.x1 - link.target.x0; //probably won't use
link.circularPathData.sourceX = link.source.x0 + link.circularPathData.sourceWidth;
link.circularPathData.targetX = link.target.x0;
link.circularPathData.sourceY = link.y0;
link.circularPathData.targetY = link.y1;
//add left extent coordinates, based on links with same source depth and circularLink type
let thisDepth = link.source.depth;
let thisCircularLinkType = link.circularLinkType;
let sameDepthLinks = links.filter(function (l) { return ((l.source.depth == thisDepth) && (l.circularLinkType == thisCircularLinkType)); })
if (link.circularLinkType == "bottom") {
sameDepthLinks.sort(sortLinkSourceYDescending);
}
else {
sameDepthLinks.sort(sortLinkSourceYAscending);
}
let radiusOffset = 0;
sameDepthLinks.forEach(function (l, i) {
if (l.circularLinkID == link.circularLinkID) {
link.circularPathData.leftSmallArcRadius = baseRadius + (link.width / 2) + radiusOffset;
link.circularPathData.leftLargeArcRadius = baseRadius + (link.width / 2) + (i * circularLinkGap) + radiusOffset;
}
radiusOffset = radiusOffset + l.width;
})
//add right extent coordinates, based on links with same target depth and circularLink type
thisDepth = link.target.depth;
sameDepthLinks = links.filter(function (l) { return ((l.target.depth == thisDepth) && (l.circularLinkType == thisCircularLinkType)); });
if (link.circularLinkType == "bottom") {
sameDepthLinks.sort(sortLinkTargetYDescending)
}
else {
sameDepthLinks.sort(sortLinkTargetYAscending)
}
radiusOffset = 0;
sameDepthLinks.forEach(function (l, i) {
if (l.circularLinkID == link.circularLinkID) {
link.circularPathData.rightSmallArcRadius = baseRadius + (link.width / 2) + radiusOffset;
link.circularPathData.rightLargeArcRadius = baseRadius + (link.width / 2) + (i * circularLinkGap) + radiusOffset;
}
radiusOffset = radiusOffset + l.width;
})
//all links
link.circularPathData.leftInnerExtent = link.circularPathData.sourceX + link.circularPathData.leftNodeBuffer;
link.circularPathData.rightInnerExtent = link.circularPathData.targetX - link.circularPathData.rightNodeBuffer;
link.circularPathData.leftFullExtent = link.circularPathData.sourceX + link.circularPathData.leftLargeArcRadius + link.circularPathData.leftNodeBuffer;
link.circularPathData.rightFullExtent = link.circularPathData.targetX - link.circularPathData.rightLargeArcRadius - link.circularPathData.rightNodeBuffer;
//bottom links
if (link.circularLinkType == "bottom") {
link.circularPathData.verticalFullExtent = (height + 25) + link.circularPathData.verticalBuffer;
link.circularPathData.verticalLeftInnerExtent = link.circularPathData.verticalFullExtent - link.circularPathData.leftLargeArcRadius;
link.circularPathData.verticalRightInnerExtent = link.circularPathData.verticalFullExtent - link.circularPathData.rightLargeArcRadius;
}
//top links
else {
link.circularPathData.verticalFullExtent = minY - 25 - link.circularPathData.verticalBuffer;
link.circularPathData.verticalLeftInnerExtent = link.circularPathData.verticalFullExtent + link.circularPathData.leftLargeArcRadius;
link.circularPathData.verticalRightInnerExtent = link.circularPathData.verticalFullExtent + link.circularPathData.rightLargeArcRadius;
};
link.circularPathData.path = createCircularPathString(link);
}
})
return links;
}
//create a d path using the addCircularPathData
function createCircularPathString(link) {
let pathString = "";
let pathData = {}
if (link.circularLinkType == "top") {
pathString =
// start at the right of the source node
"M" + link.circularPathData.sourceX + " " + link.circularPathData.sourceY + " " +
// line right to buffer point
"L" + link.circularPathData.leftInnerExtent + " " + link.circularPathData.sourceY + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.leftLargeArcRadius + " " + link.circularPathData.leftSmallArcRadius + " 0 0 0 " +
//End of arc X //End of arc Y
link.circularPathData.leftFullExtent + " " + (link.circularPathData.sourceY - link.circularPathData.leftSmallArcRadius) + " " + //End of arc X
// line up to buffer point
"L" + link.circularPathData.leftFullExtent + " " + link.circularPathData.verticalLeftInnerExtent + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.leftLargeArcRadius + " " + link.circularPathData.leftLargeArcRadius + " 0 0 0 " +
//End of arc X //End of arc Y
link.circularPathData.leftInnerExtent + " " + link.circularPathData.verticalFullExtent + " " + //End of arc X
// line left to buffer point
"L" + link.circularPathData.rightInnerExtent + " " + link.circularPathData.verticalFullExtent + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.rightLargeArcRadius + " " + link.circularPathData.rightLargeArcRadius + " 0 0 0 " +
//End of arc X //End of arc Y
link.circularPathData.rightFullExtent + " " + link.circularPathData.verticalRightInnerExtent + " " + //End of arc X
// line down
"L" + link.circularPathData.rightFullExtent + " " + (link.circularPathData.targetY - link.circularPathData.rightSmallArcRadius) + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.rightLargeArcRadius + " " + link.circularPathData.rightSmallArcRadius + " 0 0 0 " +
//End of arc X //End of arc Y
link.circularPathData.rightInnerExtent + " " + link.circularPathData.targetY + " " + //End of arc X
//line to end
"L" + link.circularPathData.targetX + " " + link.circularPathData.targetY;
}
//bottom path
else {
pathString =
// start at the right of the source node
"M" + link.circularPathData.sourceX + " " + link.circularPathData.sourceY + " " +
// line right to buffer point
"L" + link.circularPathData.leftInnerExtent + " " + link.circularPathData.sourceY + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.leftLargeArcRadius + " " + link.circularPathData.leftSmallArcRadius + " 0 0 1 " +
//End of arc X //End of arc Y
link.circularPathData.leftFullExtent + " " + (link.circularPathData.sourceY + link.circularPathData.leftSmallArcRadius) + " " + //End of arc X
// line down to buffer point
"L" + link.circularPathData.leftFullExtent + " " + link.circularPathData.verticalLeftInnerExtent + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.leftLargeArcRadius + " " + link.circularPathData.leftLargeArcRadius + " 0 0 1 " +
//End of arc X //End of arc Y
link.circularPathData.leftInnerExtent + " " + link.circularPathData.verticalFullExtent + " " + //End of arc X
// line left to buffer point
"L" + link.circularPathData.rightInnerExtent + " " + link.circularPathData.verticalFullExtent + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.rightLargeArcRadius + " " + link.circularPathData.rightLargeArcRadius + " 0 0 1 " +
//End of arc X //End of arc Y
link.circularPathData.rightFullExtent + " " + link.circularPathData.verticalRightInnerExtent + " " + //End of arc X
// line up
"L" + link.circularPathData.rightFullExtent + " " + (link.circularPathData.targetY + link.circularPathData.rightSmallArcRadius) + " " +
//Arc around: Centre of arc X and //Centre of arc Y
"A" + link.circularPathData.rightLargeArcRadius + " " + link.circularPathData.rightSmallArcRadius + " 0 0 1 " +
//End of arc X //End of arc Y
link.circularPathData.rightInnerExtent + " " + link.circularPathData.targetY + " " + //End of arc X
//line to end
"L" + link.circularPathData.targetX + " " + link.circularPathData.targetY;
}
return pathString;
}
//sort links based on the distance between the source and tartget node depths
//if the same, then use Y position of the source node
function sortLinkDepthAscending(link1, link2) {
if (linkDepthDistance(link1) == linkDepthDistance(link2)) {
return link1.circularLinkType == "bottom"
? sortLinkSourceYDescending(link1, link2)
: sortLinkSourceYAscending(link1, link2);
}
else {
//return linkDepthDistance(link1) - linkDepthDistance(link2);
return linkDepthDistance(link2) - linkDepthDistance(link1);
}
};
function sortLinkSourceYAscending(link1, link2) {
return link1.y0 - link2.y0;
};
function sortLinkSourceYDescending(link1, link2) {
return link2.y0 - link1.y0;
};
function sortLinkTargetYAscending(link1, link2) {
return link1.y1 - link2.y1;
};
function sortLinkTargetYDescending(link1, link2) {
return link2.y1 - link1.y1;
};
//return the distance between the link's target and source node, in terms of the nodes' depth
function linkDepthDistance(link) {
return link.target.depth - link.source.depth;
};
//return the distance between the link's target and source node, in terms of the nodes' X coordinate
function linkXLength(link) {
return link.target.x0 - link.source.x1;
};
function linkPerpendicularYToLinkSource(longerLink, shorterLink) {
// Return the Y coordinate on the longerLink path * which is perpendicular shorterLink's source.
// * approx, based on a straight line from target to source, when in fact the path is a bezier
//get the angle for the longer link
let angle = linkAngle(longerLink);
//get the adjacent length to the other link's x position
let heightFromY1ToPependicular = linkXLength(shorterLink) / Math.tan(angle);
//add or subtract from longer link1's original y1, depending on the slope
let yPerpendicular = incline(longerLink) == "up"
? longerLink.y1 + heightFromY1ToPependicular
: longerLink.y1 - heightFromY1ToPependicular;
return yPerpendicular;
};
function linkPerpendicularYToLinkTarget(longerLink, shorterLink) {
// Return the Y coordinate on the longerLink path * which is perpendicular shorterLink's source.
// * approx, based on a straight line from target to source, when in fact the path is a bezier
//get the angle for the longer link
let angle = linkAngle(longerLink);
//get the adjacent length to the other link's x position
let heightFromY1ToPependicular = linkXLength(shorterLink) / Math.tan(angle);
//add or subtract from longer link's original y1, depending on the slope
let yPerpendicular = incline(longerLink) == "up"
? longerLink.y1 - heightFromY1ToPependicular
: longerLink.y1 + heightFromY1ToPependicular;
return yPerpendicular;
};
function sortSourceLinks() {
sankeyNodes.forEach(function (node) {
//move any nodes up which are off the bottom
if ((node.y + (node.y1 - node.y0)) > height) {
node.y = node.y - ((node.y + (node.y1 - node.y0)) - height)
}
let nodesSourceLinks = sankeyLinks.filter(function (l) { return l.source.name == node.name });
//if more than 1 link then sort
if (nodesSourceLinks.length > 1) {
nodesSourceLinks.sort(function (link1, link2) {
//if both are not circular...
if (!link1.circular && !link2.circular) {
//if the target nodes are the same depth, then sort by the link's target y
if (link1.target.depth == link2.target.depth) {
return link1.y1 - link2.y1;
}
//if the links slope in different directions, then sort by the link's target y
else if (!sameInclines(link1, link2)) {
return link1.y1 - link2.y1;
//if the links slope in same directions, then sort by any overlap
} else {
if (link1.target.depth > link2.target.depth) {
//if (node.name == "process10") {console.log("here")}
/*let link2Angle = linkAngleFromSource(link2);
let link2AdjToLink1Y = linkXLength(link1) / Math.tan(link2Angle);
let link2Adj = incline(link2) == "up"
? link2.y0 - link2AdjToLink1Y
: link2.y0 + link2AdjToLink1Y;*/
let link2Adj = linkPerpendicularYToLinkTarget(link2, link1)
return link1.y1 - link2Adj;
}
if (link2.target.depth > link1.target.depth) {
/*let link1Angle = linkAngleFromSource(link1);
let link1AdjToLink2Y = linkXLength(link2) / Math.tan(link1Angle);
let link1Adj = incline(link1) == "up"
? link1.y0 - link1AdjToLink2Y
: link1.y0 + link1AdjToLink2Y;*/
let link1Adj = linkPerpendicularYToLinkTarget(link1, link2)
return link1Adj - link2.y1;
}
}
};
//if only one is circular, the move top links up, or bottom links down
if (link1.circular && !link2.circular) {
return link1.circularLinkType == "top" ? -1 : 1;
}
else if (link2.circular && !link1.circular) {
return link2.circularLinkType == "top" ? 1 : -1;
};
//if both links are circular...
if (link1.circular && link2.circular) {
//...and they both loop the same way (both top)
if (link1.circularLinkType === link2.circularLinkType && link1.circularLinkType == "top") {
//...and they both connect to a target with same depth, then sort by the target's y
if (link1.target.depth === link2.target.depth) {
return link1.target.y1 - link2.target.y1
}
//...and they connect to different depth targets, then sort by how far back they
else {
return link2.target.depth - link1.target.depth;
}
}
//...and they both loop the same way (both bottom)
else if (link1.circularLinkType === link2.circularLinkType && link1.circularLinkType == "bottom") {
//...and they both connect to a target with same depth, then sort by the target's y
if (link1.target.depth === link2.target.depth) {
return link2.target.y1 - link1.target.y1;
}
//...and they connect to different depth targets, then sort by how far back they
else {
return link1.target.depth - link2.target.depth;
}
}
//...and they loop around different ways, the move top up and bottom down
else {
return link1.circularLinkType == "top" ? -1 : 1;
}
};
})
}
//update y0 for links
let ySourceOffset = node.y0
//if (node.name == "process10") { console.log(nodesSourceLinks) }
nodesSourceLinks.forEach(function (link) {
link.y0 = ySourceOffset + (link.width / 2);
ySourceOffset = ySourceOffset + link.width;
})
})
}
function sortTargetLinks() {
sankeyNodes.forEach(function (node) {
let nodesTargetLinks = sankeyLinks.filter(function (l) { return l.target.name == node.name });
if (nodesTargetLinks.length > 1) {
nodesTargetLinks.sort(function (link1, link2) {
//if both are not circular, the base on the source y position
if (!link1.circular && !link2.circular) {
if (link1.source.depth == link2.source.depth) {
return link1.y0 - link2.y0;
}
else if (!sameInclines(link1, link2)) {
return link1.y0 - link2.y0;
}
else {
//get the angle of the link to the further source node (ie the smaller depth)
if (link2.source.depth < link1.source.depth) {
let link2Adj = linkPerpendicularYToLinkSource(link2, link1);
return link1.y0 - link2Adj;
}
if (link1.source.depth < link2.source.depth) {
let link1Adj = linkPerpendicularYToLinkSource(link1, link2);
return link1Adj - link2.y0;
}
}
};
//if only one is circular, the move top links up, or bottom links down
if (link1.circular && !link2.circular) {
return link1.circularLinkType == "top" ? -1 : 1;
}
else if (link2.circular && !link1.circular) {
return link2.circularLinkType == "top" ? 1 : -1;
};
//if both links are circular...
if (link1.circular && link2.circular) {
//...and they both loop the same way (both top)
if (link1.circularLinkType === link2.circularLinkType && link1.circularLinkType == "top") {
//...and they both connect to a target with same depth, then sort by the target's y
if (link1.source.depth === link2.source.depth) {
return link1.source.y1 - link2.source.y1
}
//...and they connect to different depth targets, then sort by how far back they
else {
return link1.source.depth - link2.source.depth;
}
}
//...and they both loop the same way (both bottom)
else if (link1.circularLinkType === link2.circularLinkType && link1.circularLinkType == "bottom") {
//...and they both connect to a target with same depth, then sort by the target's y
if (link1.source.depth === link2.source.depth) {
return link1.source.y1 - link2.source.y1;
}
//...and they connect to different depth targets, then sort by how far back they
else {
return link2.source.depth - link1.source.depth;
}
}
//...and they loop around different ways, the move top up and bottom down
else {
return link1.circularLinkType == "top" ? -1 : 1;
}
};
})
}
//update y1 for links
let yTargetOffset = node.y0;
nodesTargetLinks.forEach(function (link) {
link.y1 = yTargetOffset + (link.width / 2);
yTargetOffset = yTargetOffset + link.width;
})
})
}
function sameInclines(link1, link2) {
return incline(link1) == incline(link2) ? true : false;
};
function incline(link) {
//positive = slopes up from source to target
//negative = slopes down from source to target
return (link.y0 - link.y1) > 0 ? "up" : "down";
}
</script>
</body>
</html>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-scale-chromatic.v1.min.js