|
<!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> |