Skip to content

Instantly share code, notes, and snippets.

@bshiro
Last active January 1, 2020 09:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save bshiro/950a2283e0c0a92b784bb67d397dd2e5 to your computer and use it in GitHub Desktop.
Save bshiro/950a2283e0c0a92b784bb67d397dd2e5 to your computer and use it in GitHub Desktop.
Hub and Spoke Chart

Hub and Spoke Chart featuring rotating stacked bar graph with clipping.

Drag and move circle nodes to see rotation and clipping.

source target prop1 prop2 prop3
1 2 68 14 100
2 3 35 32 97
3 4 4 23 19
4 5 89 4 97
5 6 0 98 49
6 7 71 60 64
4 8 93 3 28
8 9 91 1 30
9 10 49 25 24
10 11 99 0 12
2 1 29 13 86
3 2 80 8 16
4 3 48 8 66
5 4 85 0 0
6 5 4 68 97
7 6 52 84 5
8 4 7 77 71
9 8 53 58 11
10 9 0 100 49
11 10 0 3 0
[
{"lineID":1,"color":"0xfdb913","edges":[[1, 2], [2, 3], [3, 4], [4, 5], [5, 6],[6, 7]]},
{"lineID":2,"color":"0xee2b74","edges":[[4,8],[8, 9], [9, 10],[10,11]]}
]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hub and Spoke Chart</title>
<script src="https://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="https://d3js.org/d3-queue.v2.min.js"></script>
</head>
<body>
<script type="text/javascript">
//Width and height
var width = 960;
var height = 500;
var zoomScale = 1;
var stationRadius = 10;
var stationStrokeWidth = 1;
var barLineWidth = 10;
var maskBarWidth = 30;
var barLineHeight = 40;
var metroLineWidth = 8;
var bisectorLineWidth = barLineWidth + 10;
var showLabel = true;
var fontSize = 10;
//Define map projection
var projection = d3.geo.mercator()
.scale(1)
.translate([0, 0]);
var zoom = d3.behavior.zoom()
.translate([0, 0])
.scale(1)
.scaleExtent([1, 8])
.on("zoom", zoomed);
//Define path generator
var path = d3.geo.path()
.projection(projection);
//Create SVG element
var svg = d3.select("body")
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("class", "framed")
.attr("id", "svgMain");
var drag = d3.behavior.drag()
.origin(function (d) {
return d;
})
.on("dragstart", dragstarted)
.on("drag", dragged)
.on("dragend", dragended);
var g = svg.append("g");
svg
.call(zoom) // delete this line to disable free zooming
.call(zoom.event);
var metroLines, metroStations, metroLineFeatures;
var originalStations, distortedStations;
var lineFunction = d3.svg.line()
.x(function (d) {
return d.x;
})
.y(function (d) {
return d.y;
})
.interpolate("linear");
var div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
Point = function (x, y) {
this.x = x;
this.y = y;
};
Point.prototype.subtract = function (o) {
return new Point(this.x - o.x, this.y - o.y);
};
Point.prototype.norm = function () {
return Math.sqrt(this.x * this.x + this.y * this.y);
};
Point.prototype.dist = function (o) {
return this.subtract(o).norm();
};
Point.prototype.vectorAngle = function (o) {
var diff = [this.x - o.x, (height - this.y) - (height - o.y)];
//var angle = Math.atan2(diff[1], diff[0])* (180/Math.PI);
return Math.atan2(diff[1], diff[0]) * (180 / Math.PI);
}
function transformString(a, cx, cy, tx, ty) {
return "rotate(" + a + "," + (cx) + "," + (cy) + ") translate(" + tx + "," + ty + ")";
}
function rotateString(a, cx, cy) {
return "rotate(" + a + "," + (cx) + "," + (cy) + ")";
}
d3_queue.queue()
.defer(d3.json, 'points.geojson') // station points
.defer(d3.json, 'edges.json') // edges
.defer(d3.csv, 'dat.csv')
.await(makeMyMap);
function makeMyMap(error, a, b, dat) {
metroStations = a;
metroLines = b;
var bounds = [[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]];
//console.log(bounds);
metroStations.features.forEach(function (station) {
var scrcoord = projection(station.geometry.coordinates);
for (var i = 0; i < 2; i++) {
bounds[0][i] = scrcoord[i] < bounds[0][i] ? scrcoord[i] : bounds[0][i];
bounds[1][i] = scrcoord[i] > bounds[1][i] ? scrcoord[i] : bounds[1][i];
}
});
var b = bounds,
s = .9 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] - b[0][1]) / height),
t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s * (b[1][1] + b[0][1])) / 2];
// Update the projection to use computed scale & translate.
projection
.scale(s)
.translate(t);
//EDGE Information
metroLineFeatures = [];
for (var i = 0; i < metroLines.length; i++) {
var lineCoords = [];
var j;
for (j = 0; j < metroLines[i].edges.length; j++) {
lineCoords.push(metroStations.features[metroLines[i].edges[j][0] - 1].geometry.coordinates);
}
lineCoords.push(metroStations.features[metroLines[i].edges[j - 1][1] - 1].geometry.coordinates);
var feature = {
"geometry": {"type": "LineString", "coordinates": lineCoords},
"type": "Feature", "properties": {"line": i + 1}
};
metroLineFeatures.push(feature);
}
g.selectAll("path")
.data(metroLineFeatures)
.enter()
.append("path")
.attr("class", "line")
.attr("id", function (d) {
return d.properties.line;
})
.attr("d", path)
.attr("stroke", "#888888")
.attr("stroke-width", metroLineWidth)
.attr("fill", "none");
originalStations = new Array();
distortedStations = new Array();
for (var i = 0, tot = metroStations.features.length; i < tot; i++) {
var screencoords = projection(metroStations.features[i].geometry.coordinates);
originalStations.push(new Point(screencoords[0], screencoords[1]));
distortedStations.push(new Point(screencoords[0], screencoords[1]));
}
var props = ["prop1", "prop2", "prop3"]
var barColor = ['green', 'yellow', 'red'];
dat.forEach(function (edge, i) {
var bardata = props.map(function (c) {
return {s: +edge.source - 1, t: +edge.target - 1, y: +edge[c], y0: 0, y1: 0}
});
var y0 = 0;
bardata.forEach(function (row) {
row.y0 += y0;
y0 += row.y;
});
bardata.forEach(function (row) {
row.y1 = y0;
});
var barname = "s" + edge.source + 't' + edge.target;
var barmask = "mask" + barname;
var layer = g.append("g")
.attr("class", barname)
.attr("start", bardata[0].s)
.attr("end", bardata[0].t)
var clipData = {s: bardata[0].s, t: bardata[0].t};
layer
.append("clipPath")
.datum(clipData)
.attr("id", barmask)
.append("rect")
.attr("class", "cliprect")
.attr("x", function (d) {
return originalStations[d.s].x - maskBarWidth * .5;
})
.attr("y", function (d) {
return originalStations[d.s].y - originalStations[d.t].dist(originalStations[d.s]) * .5;
})
//.attr("y", function(d) {return distortedStations[d.t].y+stationRadius+stationStrokeWidth;})
.attr("width", maskBarWidth)
.attr("height", function (d) {
return originalStations[d.t].dist(originalStations[d.s]) * .5
})
//.style("fill", "purple")
.attr("transform", function (d) {
var ang0 = originalStations[d.t].vectorAngle(originalStations[d.s]);
return rotateString(90 - ang0, originalStations[d.s].x, originalStations[d.s].y);
});
var barG = layer.append("g")
.attr("clip-path", "url(#" + barmask + ")")
barG.selectAll("rect")
.data(bardata)
.enter().append("rect")
.attr("class", "barrect")
.attr("x", function (d) {
return originalStations[d.s].x - barLineWidth * .5;
})
//.attr("y", function(d) {return originalStations[d.s].y-(d.y+ d.y0)/d.y1*barLineHeight;})
.attr("y", function (d) {
return originalStations[d.s].y - (d.y + d.y0) / d.y1 * barLineHeight - ((stationRadius - stationStrokeWidth) / zoomScale);
})
//.attr("height", function(d) { return d.y/d.y1*originalStations[d.t].dist(originalStations[d.s])*.45; })
.attr("height", function (d) {
return d.y / d.y1 * barLineHeight
})
.attr("width", barLineWidth)
.style("fill", function (d, i) {
return barColor[i];
})
.attr("transform", function (d) {
var ang0 = originalStations[d.t].vectorAngle(originalStations[d.s]);
return rotateString(90 - ang0, originalStations[d.s].x, originalStations[d.s].y);
});
barG
.selectAll("text")
.data(bardata)
.enter().append("text")
.attr("class", "bartext")
//.attr('transform', function(d) {
// return 'translate(' + (distortedStations[d.s].x-barLineWidth*.5) + ','
// + (distortedStations[d.s].y-((d.y+ d.y0) *.5)/d.y1*barLineHeight-((stationRadius-stationStrokeWidth)/zoomScale))
// + ')';})
.attr("transform", function (d) {
var ty = (distortedStations[d.s].y - ((d.y * .5 + d.y0) / d.y1 * barLineHeight) - ((stationRadius - stationStrokeWidth) / zoomScale));
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return transformString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y, distortedStations[d.s].x, +ty)
+ " rotate(" + (-(90 - ang0)) + ")";
})
.text(function (d) {
return d.y;
})
.attr('dy', '.35em')
.attr('font-size', fontSize / zoomScale + 'px')
.attr('font-weight', 'bold')
.attr('fill', "black")
.attr('text-anchor', 'middle');
layer.append("g").append("rect")
.datum(clipData)
.attr("class", "bisector")
.attr("x", function (d) {
return originalStations[d.s].x - bisectorLineWidth / zoomScale * .5;
})
.attr("y", function (d) {
return originalStations[d.s].y - originalStations[d.t].dist(originalStations[d.s]) * .5;
})
.attr("height", 2 / zoomScale)
.attr("width", bisectorLineWidth / zoomScale)
.style("fill", function (d) {
return ((barLineHeight + (stationRadius + stationStrokeWidth) / zoomScale) > (distortedStations[d.t].dist(distortedStations[d.s]) * .5)) ? "black" : "transparent"
})
.attr("transform", function (d) {
var ang0 = originalStations[d.t].vectorAngle(originalStations[d.s]);
return rotateString(90 - ang0, originalStations[d.s].x, originalStations[d.s].y);
});
});
g.selectAll("circle")
.data(metroStations.features)
.enter()
.append("circle")
.attr("cx", function (d) {
return projection(d.geometry.coordinates)[0]
})
.attr("cy", function (d) {
return projection(d.geometry.coordinates)[1]
})
.call(drag)
.attr("r", stationRadius)
.attr("stroke-width", stationStrokeWidth)
.attr("stroke", "black")
.style("fill", "white")
.on("mouseover", function (d) {
div.style("left", (d3.event.pageX - width / 2 + 300) + "px")
.style("top", (d3.event.pageY - 28) + "px");
div.transition()
.ease("elastic")
.duration(50)
.style("opacity", .9)
//.text("stationID:"+d.properties.stationID+" "+projection(d.geometry.coordinates));
.text("stationID:" + (d.properties.stationID));
})
.on("mouseout", function (d) {
div.transition()
.ease("elastic")
.duration(50)
div.style("opacity", 0);
});
}
function zoomed() {
zoomScale = d3.event.scale;
//g.style("stroke-width", d3.event.scale / d3.event.scale + "px");
g.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
g.selectAll(".line").attr("stroke-width", metroLineWidth / d3.event.scale);
g.selectAll("circle").attr("r", stationRadius / d3.event.scale).attr("stroke-width", stationStrokeWidth / d3.event.scale);
g.selectAll(".barrect")
.attr("x", function (d) {
return distortedStations[d.s].x - (barLineWidth / d3.event.scale) * .5;
})
.attr("y", function (d) {
return distortedStations[d.s].y - (d.y + d.y0) / d.y1 * barLineHeight - ((stationRadius - stationStrokeWidth) / d3.event.scale);
})
.attr("width", barLineWidth / d3.event.scale);
g.selectAll(".cliprect")
.attr("x", function (d) {
return distortedStations[d.s].x - maskBarWidth / d3.event.scale * .5;
})
.attr("width", maskBarWidth / d3.event.scale);
g.selectAll(".bisector")
.attr("x", function (d) {
return distortedStations[d.s].x - bisectorLineWidth / zoomScale * .5;
})
//.attr("y", function(d) {return distortedStations[d.s].y-distortedStations[d.t].dist(distortedStations[d.s])*.51;})
.attr("height", 2 / zoomScale)
.attr("width", bisectorLineWidth / zoomScale);
g.selectAll("text")
.attr("transform", function (d) {
var ty = (distortedStations[d.s].y - ((d.y * .5 + d.y0) / d.y1 * barLineHeight) - ((stationRadius - stationStrokeWidth) / zoomScale));
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return transformString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y, distortedStations[d.s].x, ty)
+ " rotate(" + (-(90 - ang0)) + ")";
})
.attr('dy', '.25em')
.attr('font-size', fontSize / zoomScale + 'px')
}
function dragstarted(d) {
d3.event.sourceEvent.stopPropagation();
d3.select(this).classed("dragging", true);
}
function dragged(d) {
var coordinates = d3.mouse(this);
d3.select(this).attr("cx", coordinates[0]).attr("cy", coordinates[1]);
distortedStations[d.properties.stationID - 1].x = coordinates[0];
distortedStations[d.properties.stationID - 1].y = coordinates[1];
var templines = [];
for (var i = 0; i < metroLines.length; i++) {
var lineCoords = [];
var j;
for (j = 0; j < metroLines[i].edges.length; j++) {
lineCoords.push(distortedStations[metroLines[i].edges[j][0] - 1]);
}
lineCoords.push(distortedStations[metroLines[i].edges[j - 1][1] - 1]);
templines.push(lineCoords);
}
g.selectAll(".line")
.data(templines)
.attr("d", lineFunction);
var terminal = d.properties.stationID - 1;
var linewidth = barLineWidth / zoomScale;
var layer = svg.selectAll("g[start='" + terminal + "']");
layer.selectAll(".barrect")
.attr("x", function (d) {
return distortedStations[d.s].x - linewidth * .5;
})
//.attr("y", function(d) {return distortedStations[d.s].y-(d.y+ d.y0)/d.y1*barLineHeight;})
.attr("y", function (d) {
return distortedStations[d.s].y - (d.y + d.y0) / d.y1 * barLineHeight - ((stationRadius - stationStrokeWidth) / zoomScale);
})
.attr("height", function (d) {
return d.y / d.y1 * barLineHeight;
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return rotateString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
var barmask = "masks" + (terminal + 1);
//console.log(layer.select("g[id^='"+barmask+"']"))
layer.select("clipPath[id^='mask']").select("rect")
.attr("x", function (d) {
return distortedStations[d.s].x - maskBarWidth / zoomScale * .5;
})
.attr("y", function (d) {
return distortedStations[d.s].y - distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("height", function (d) {
return distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return rotateString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
layer.select(".bisector")
.attr("x", function (d) {
return distortedStations[d.s].x - bisectorLineWidth / zoomScale * .5;
})
.attr("y", function (d) {
return distortedStations[d.s].y - distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("height", 2 / zoomScale)
.attr("width", bisectorLineWidth / zoomScale)
.style("fill", function (d) {
return ((barLineHeight + (stationRadius + stationStrokeWidth) / zoomScale) > (distortedStations[d.t].dist(distortedStations[d.s]) * .5)) ? "black" : "transparent"
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return rotateString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
layer
.selectAll("text")
.attr("transform", function (d) {
var ty = (distortedStations[d.s].y - ((d.y * .5 + d.y0) / d.y1 * barLineHeight) - ((stationRadius - stationStrokeWidth) / zoomScale));
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return transformString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y, distortedStations[d.s].x, ty)
+ " rotate(" + (-(90 - ang0)) + ")";
})
var layer = svg.selectAll("g[end='" + terminal + "']");
layer.selectAll(".barrect")
.attr("x", function (d) {
return distortedStations[d.s].x - linewidth * .5;
})
//.attr("y", function(d) {return distortedStations[d.s].y-(d.y+ d.y0)/d.y1*barLineHeight;})
.attr("y", function (d) {
return distortedStations[d.s].y - (d.y + d.y0) / d.y1 * barLineHeight - ((stationRadius - stationStrokeWidth) / zoomScale);
})
.attr("height", function (d) {
return d.y / d.y1 * barLineHeight;
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.s].vectorAngle(distortedStations[d.t]);
return rotateString(180 + 90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
layer.select("clipPath[id^='mask']").select("rect")
.attr("x", function (d) {
return distortedStations[d.s].x - maskBarWidth / zoomScale * .5;
})
.attr("y", function (d) {
return distortedStations[d.s].y - distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("height", function (d) {
return distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.s].vectorAngle(distortedStations[d.t]);
return rotateString(180 + 90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
layer.select(".bisector")
.attr("x", function (d) {
return distortedStations[d.s].x - bisectorLineWidth / zoomScale * .5;
})
.attr("y", function (d) {
return distortedStations[d.s].y - distortedStations[d.t].dist(distortedStations[d.s]) * .5;
})
.attr("height", 2 / zoomScale)
.attr("width", bisectorLineWidth / zoomScale)
.style("fill", function (d) {
return ((barLineHeight + (stationRadius + stationStrokeWidth) / zoomScale) > (distortedStations[d.t].dist(distortedStations[d.s]) * .5)) ? "black" : "transparent"
})
.attr("transform", function (d) {
var ang0 = distortedStations[d.s].vectorAngle(distortedStations[d.t]);
return rotateString(180 + 90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y);
});
layer
.selectAll("text")
.attr("transform", function (d) {
var ty = (distortedStations[d.s].y - ((d.y * .5 + d.y0) / d.y1 * barLineHeight) - ((stationRadius - stationStrokeWidth) / zoomScale));
var ang0 = distortedStations[d.t].vectorAngle(distortedStations[d.s]);
return transformString(90 - ang0, distortedStations[d.s].x, distortedStations[d.s].y, distortedStations[d.s].x, ty)
+ " rotate(" + (-(90 - ang0)) + ")";
})
}
function dragended(d) {
d3.select(this).classed("dragging", false);
}
</script>
</body>
</html>
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment