Skip to content

Instantly share code, notes, and snippets.

@davo
Last active August 21, 2018 18:52
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 davo/c5aeb1f68d3c813181777c4288022ee1 to your computer and use it in GitHub Desktop.
Save davo/c5aeb1f68d3c813181777c4288022ee1 to your computer and use it in GitHub Desktop.
Network flow with happy path
license: mit
// 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 + ')'
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment