Built with blockbuilder.org
forked from tomshanley's block: Network flow with happy path
license: mit |
Built with blockbuilder.org
forked from tomshanley's block: Network flow with happy path
// Function that appends a path to selection that has sankey path data attached | |
// The path is formatted as dash array, and triangle paths to create arrows along the path | |
function pathArrows () { | |
var arrowLength = 10 | |
var gapLength = 50 | |
var arrowHeadSize = 4 | |
var path = null; | |
function appendArrows (selection) { | |
let totalDashArrayLength = arrowLength + gapLength | |
let arrows = selection | |
.append('path') | |
.attr('d', path) | |
.style('stroke-width', 1) | |
.style('stroke', 'black') | |
.style('stroke-dasharray', arrowLength + ',' + gapLength) | |
arrows.each(function (arrow) { | |
let thisPath = d3.select(this).node() | |
let parentG = d3.select(this.parentNode) | |
let pathLength = thisPath.getTotalLength() | |
let numberOfArrows = Math.ceil(pathLength / totalDashArrayLength) | |
// remove the last arrow head if it will overlap the target node | |
if ( | |
(numberOfArrows - 1) * totalDashArrayLength + | |
(arrowLength + (arrowHeadSize + 1)) > | |
pathLength | |
) { | |
numberOfArrows = numberOfArrows - 1 | |
} | |
let arrowHeadData = d3.range(numberOfArrows).map(function (d, i) { | |
let length = i * totalDashArrayLength + arrowLength | |
let point = thisPath.getPointAtLength(length) | |
let previousPoint = thisPath.getPointAtLength(length - 2) | |
let rotation = 0 | |
if (point.y == previousPoint.y) { | |
rotation = point.x < previousPoint.x ? 180 : 0 | |
} else if (point.x == previousPoint.x) { | |
rotation = point.y < previousPoint.y ? -90 : 90 | |
} else { | |
let adj = Math.abs(point.x - previousPoint.x) | |
let opp = Math.abs(point.y - previousPoint.y) | |
let angle = Math.atan(opp / adj) * (180 / Math.PI) | |
if (point.x < previousPoint.x) { | |
angle = angle + (90 - angle) * 2 | |
} | |
if (point.y < previousPoint.y) { | |
rotation = -angle | |
} else { | |
rotation = angle | |
} | |
} | |
return { x: point.x, y: point.y, rotation: rotation } | |
}) | |
let arrowHeads = parentG | |
.selectAll('.arrow-heads') | |
.data(arrowHeadData) | |
.enter() | |
.append('path') | |
.attr('d', function (d) { | |
return ( | |
'M' + | |
d.x + | |
',' + | |
(d.y - arrowHeadSize / 2) + | |
' ' + | |
'L' + | |
(d.x + arrowHeadSize) + | |
',' + | |
d.y + | |
' ' + | |
'L' + | |
d.x + | |
',' + | |
(d.y + arrowHeadSize / 2) | |
) | |
}) | |
.attr('class', 'arrow-head') | |
.attr('transform', function (d) { | |
return 'rotate(' + d.rotation + ',' + d.x + ',' + d.y + ')' | |
}) | |
.style('fill', 'black') | |
}) | |
} | |
appendArrows.arrowLength = function (value) { | |
if (!arguments.length) return arrowLength | |
arrowLength = value | |
return appendArrows | |
} | |
appendArrows.gapLength = function (value) { | |
if (!arguments.length) return gapLength | |
gapLength = value | |
return appendArrows | |
} | |
appendArrows.arrowHeadSize = function (value) { | |
if (!arguments.length) return arrowHeadSize | |
arrowHeadSize = value | |
return appendArrows | |
} | |
appendArrows.path = function(pathFunction) { | |
if (!arguments.length) { | |
return path | |
} | |
else{ | |
if (typeof pathFunction === "function") { | |
path = pathFunction; | |
return appendArrows | |
} | |
else { | |
path = function() { return pathFunction } | |
return appendArrows; | |
} | |
} | |
}; | |
return appendArrows; | |
} |
<!DOCTYPE html> | |
<head> | |
<meta charset="utf-8"> | |
<script src="https://d3js.org/d3.v4.min.js"></script> | |
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script> | |
<script src="d3-path.arrows.js"></script> | |
<style> | |
body { | |
margin:50; | |
top:50; | |
right:50; | |
bottom:50; | |
left:50; | |
} | |
text { | |
fill: #fff; | |
text-anchor: middle; | |
} | |
circle { | |
stroke: white; | |
stroke-width: 5; | |
} | |
.arrow { | |
stroke-width: 2; | |
stroke: white; | |
fill: none; | |
} | |
.arrow-head { | |
fill: white; | |
} | |
.link { | |
fill: none; | |
} | |
</style> | |
</head> | |
<body> | |
<script> | |
let data = [ | |
{ | |
"source": "node1", | |
"target": "node2", | |
"value": 20, | |
"mainflow": true | |
}, | |
{ | |
"source": "node1", | |
"target": "node3", | |
"value": 8, | |
"mainflow": false | |
}, | |
{ | |
"source": "node1", | |
"target": "node4", | |
"value": 5, | |
"mainflow": false | |
}, | |
{ | |
"source": "node2", | |
"target": "node1", | |
"value": 9, | |
"mainflow": false | |
}, | |
{ | |
"source": "node2", | |
"target": "node3", | |
"value": 18, | |
"mainflow": true | |
}, | |
{ | |
"source": "node2", | |
"target": "node4", | |
"value": 5, | |
"mainflow": false | |
}, | |
{ | |
"source": "node3", | |
"target": "node1", | |
"value": 5, | |
"mainflow": false | |
}, | |
{ | |
"source": "node3", | |
"target": "node2", | |
"value": 3, | |
"mainflow": false | |
}, | |
{ | |
"source": "node3", | |
"target": "node4", | |
"value": 15, | |
"mainflow": true | |
}, | |
{ | |
"source": "node4", | |
"target": "node1", | |
"value": 5, | |
"mainflow": false | |
}, | |
{ | |
"source": "node4", | |
"target": "node2", | |
"value": 8, | |
"mainflow": false | |
}, | |
{ | |
"source": "node4", | |
"target": "node3", | |
"value": 5, | |
"mainflow": false | |
} | |
] | |
/*let data = [ | |
{"source":"node0","value":1686813,"target":"node1", "mainflow": true}, | |
{"source":"node2","value":1083523,"target":"node1", "mainflow": false}, | |
{"source":"node3","value":1285005,"target":"node1", "mainflow": false}, | |
{"source":"node4","value":1485331,"target":"node1", "mainflow": false}, | |
{"source":"node0","value":63398,"target":"node2", "mainflow": false}, | |
{"source":"node5","value":794704,"target":"node4", "mainflow": false}, | |
{"source":"node6","value":794704,"target":"node4", "mainflow": false}, | |
{"source":"node1","value":63398,"target":"node2", "mainflow": false}, | |
{"source":"node0","value":618423,"target":"node3", "mainflow": false}, | |
{"source":"node1","value":502228,"target":"node3", "mainflow": false}, | |
{"source":"node1","value":1166311,"target":"node4", "mainflow": false}, | |
{"source":"node0","value":1166311,"target":"node4", "mainflow": false}, | |
{"source":"node3","value":794704,"target":"node4", "mainflow": false}, | |
]*/ | |
let radians = 0.0174532925 | |
let width = 1000 | |
let height = 400 | |
let centre = height/2 | |
var arrowLength = 10 | |
var gapLength = 50 | |
var arrowHeadSize = 7 | |
let totalDashArrayLength = arrowLength + gapLength | |
let nestedData = d3.nest() | |
.key(function(d){ return d.source }) | |
.entries(data) | |
nestedData.forEach(function(d){ | |
d.total = d.values.reduce(function(sum, v){ return sum + v.value }, 0) | |
}) | |
let allNodes = [] | |
data.map(function(d){ | |
allNodes.push(d.target) | |
allNodes.push(d.source) | |
}) | |
let seriesNest = d3.nest() | |
.key(function(d){ return d }) | |
.entries(allNodes) | |
let series = seriesNest.map(function(d){ | |
return d.key | |
}) | |
let n = series.sort(d3.ascending) | |
let radius = d3.scaleSqrt() | |
.domain([0, d3.max(nestedData, function(d){ return d.total })]) | |
.range([0, 50]) | |
let strokeWidth = d3.scaleLinear() | |
.domain([0, d3.max(data, function(d){ return d.value })]) | |
.range([0, 50]) | |
let nodeCentreX = d3.scalePoint() | |
.padding(0.5) | |
.domain(series) | |
.range([0,width]) | |
let colour = d3.scaleOrdinal(d3.schemeDark2) | |
.domain(series) | |
var svg = d3.select("body").append("svg") | |
.attr("width", width) | |
.attr("height", height) | |
var g = svg.append("g") | |
var links = g.selectAll("path") | |
.data(nestedData) | |
.enter() | |
.append("g") | |
.attr("transform", function(d) { | |
return "translate(" + nodeCentreX(d.key) + "," + centre + ")" | |
}) | |
links.selectAll("g") | |
.data(function(d){ return d.values }) | |
.enter() | |
.append("path") | |
.attr("class", "link") | |
.style("stroke", function(d) { return colour(d.target) }) | |
.style("stroke-width", function(d) { return strokeWidth(d.value) }) | |
.style("opacity", function(d) { return d.mainflow ? 1 : 0.5 }) | |
.attr("d", function(d){ return pathData(d.source, d.target, d.mainflow) }) | |
let arrows = links.selectAll("g") | |
.data(function(d){ return d.values }) | |
.enter() | |
.append("path") | |
.attr("class", "arrow") | |
.attr("d", function(d){ return pathData(d.source, d.target, d.mainflow) }) | |
.style('stroke-dasharray', arrowLength + ',' + gapLength) | |
.each(appendArrowHead) | |
var nodes = g.selectAll("circle") | |
.data(nestedData) | |
.enter() | |
.append("g") | |
.attr("transform", function(d) { | |
return "translate(" + nodeCentreX(d.key) + "," + centre + ")" | |
}) | |
nodes.append("circle") | |
.attr("cx", 0) | |
.attr("cy", 0) | |
.attr("r", function(d){ return radius(d.total) }) | |
.style("fill", function(d) { return colour(d.key) }) | |
nodes.append("text") | |
.text(function(d){ return d.key }) | |
.attr("dy", "0.35em") | |
function pathData(source, target, main) { | |
if (main) { | |
return "M0,0 L" + nodeCentreX.step() + ",0" | |
} | |
else { | |
let x1 = 0 | |
let x2 = nodeCentreX(target) - nodeCentreX(source) | |
let r1 = x2/2 | |
let r2 = x2/(3 + (nodeCentreX.step()/Math.abs(x2))) | |
let y = 0 | |
let sweep = 1 | |
return "M" + x1 + "," + y + " " | |
+ "A" + r1 + " " + r2 + " 0 0 " + sweep + " " + x2 + " " + y | |
} | |
} | |
function appendArrowHead(arrow) { | |
let thisPath = d3.select(this).node() | |
let parentG = d3.select(this.parentNode) | |
let pathLength = thisPath.getTotalLength() | |
let numberOfArrows = Math.ceil(pathLength / totalDashArrayLength) | |
// remove the last arrow head if it will overlap the target node | |
if ( | |
(numberOfArrows - 1) * totalDashArrayLength + | |
(arrowLength + (arrowHeadSize + 1)) > | |
pathLength | |
) { | |
numberOfArrows = numberOfArrows - 1 | |
} | |
let arrowHeadData = d3.range(numberOfArrows).map(function (d, i) { | |
let length = i * totalDashArrayLength + arrowLength | |
let point = thisPath.getPointAtLength(length) | |
let previousPoint = thisPath.getPointAtLength(length - 2) | |
let rotation = 0 | |
if (point.y == previousPoint.y) { | |
rotation = point.x < previousPoint.x ? 180 : 0 | |
} else if (point.x == previousPoint.x) { | |
rotation = point.y < previousPoint.y ? -90 : 90 | |
} else { | |
let adj = Math.abs(point.x - previousPoint.x) | |
let opp = Math.abs(point.y - previousPoint.y) | |
let angle = Math.atan(opp / adj) * (180 / Math.PI) | |
if (point.x < previousPoint.x) { | |
angle = angle + (90 - angle) * 2 | |
} | |
if (point.y < previousPoint.y) { | |
rotation = -angle | |
} else { | |
rotation = angle | |
} | |
} | |
return { x: point.x, y: point.y, rotation: rotation } | |
}) | |
let arrowHeads = parentG | |
.selectAll('.arrow-heads') | |
.data(arrowHeadData) | |
.enter() | |
.append('path') | |
.attr('d', function (d) { | |
return ( | |
'M' + | |
d.x + | |
',' + | |
(d.y - arrowHeadSize / 2) + | |
' ' + | |
'L' + | |
(d.x + arrowHeadSize) + | |
',' + | |
d.y + | |
' ' + | |
'L' + | |
d.x + | |
',' + | |
(d.y + arrowHeadSize / 2) | |
) | |
}) | |
.attr('class', 'arrow-head') | |
.attr('transform', function (d) { | |
return 'rotate(' + d.rotation + ',' + d.x + ',' + d.y + ')' | |
}) | |
} | |
</script> | |
</body> |