Built with blockbuilder.org
forked from tomshanley's block: Network flow with happy path
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
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> |