Using data from the US Census, this visualization displays popular names that have historically been androgynous.
Control points are not intersected by the path for several non-linear interpolations for lines ("basis" in this case). Because of this, point tracking for non-linear lines should be calculated from the svg-path rather than from the d3-scales. Mike Bostock has two examples showing how to do this with a single path. This gist shows one approach for point tracking with multiple paths. Other approaches are shown here and here.
This approach saves precomputed closestPoints of each data point's linear coordinates respective to their parent path. The precomputation took 63.85 seconds (~0.16 seconds per point). What still remains for this gist is to automatically generate a precomputation file; it was manually constructed for this dataset.
xxxxxxxxxx
<meta charset="utf-8">
<style>
body {
width: 960px;
margin: auto;
position: relative;
}
svg {
font: 11px "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.axis--y path {
display: none;
}
.androgs {
fill: none;
stroke: #aaa;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 1.5px;
}
.androg--hover {
stroke: #000;
}
.focus text {
text-anchor: middle;
text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}
.voronoi path {
fill: none;
pointer-events: all;
}
.voronoi--show path {
stroke: red;
stroke-opacity: .2;
}
#form {
position: absolute;
top: 20px;
right: 30px;
}
</style>
<label id="form" for="show-voronoi">
Show Voronoi
<input type="checkbox" id="show-voronoi" disabled>
</label>
<script src="https://d3js.org/d3.v3.js"></script>
<script>
var years,
yearFormat = d3.time.format("%Y");
var margin = {top: 20, right: 30, bottom: 30, left: 40},
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var x = d3.time.scale()
.range([0, width]);
var y = d3.scale.linear()
.range([height, 0]);
var color = d3.scale.category20();
var voronoi = d3.geom.voronoi()
.clipExtent([[-margin.left, -margin.top], [width + margin.right, height + margin.bottom]]);
var line = d3.svg.line()
.interpolate("basis")
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.value); });
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
d3.json("points.json", function(error_json, points) {
d3.csv("steady_andro.csv", type, function(error_csv, androgs) {
x.domain(d3.extent(years));
y.domain([0, d3.max(androgs, function(c) { return d3.max(c.values, function(d) { return d.value; }); })]).nice();
svg.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height + ")")
.call(d3.svg.axis()
.scale(x)
.orient("bottom"));
svg.append("g")
.attr("class", "axis axis--y")
.call(d3.svg.axis()
.scale(y)
.orient("left")
.ticks(10, "%"))
.append("text")
.attr("x", 4)
.attr("dy", ".32em")
.style("font-weight", "bold")
.text("Percent Female");
svg.append("g")
.attr("class", "androgs")
.selectAll("path")
.data(androgs)
.enter().append("path")
.attr("d", function(d) { d.line = this;
return line(d.values);});
var focus = svg.append("g")
.attr("transform", "translate(-100,-100)")
.attr("class", "focus");
focus.append("circle")
.attr("r", 3.5);
focus.append("text")
.attr("y", -10);
// ------ Voronoi Section ------ //
d3.select("#show-voronoi")
.property("disabled", false)
.on("change", function() { voronoiGroup.classed("voronoi--show", this.checked); });
var keyFunction = null;
if (error_json != null && error_json.status == 404) {
voronoi
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.value); })
keyFunction = function(d) {
var p = closestPoint(d.androg.line, [x(d.date), y(d.value)]);
console.log(d.androg.name, d.date, p);
d.p = p;
return p[0] + "," + p[1];
}
} else {
voronoi
.x(function(d) { return points[d.androg.name][getUTCStringNoTime(d.date)].x })
.y(function(d) { return points[d.androg.name][getUTCStringNoTime(d.date)].y });
keyFunction = function(d) {
var x = points[d.androg.name][getUTCStringNoTime(d.date)].x;
var y = points[d.androg.name][getUTCStringNoTime(d.date)].y;
d.p = [x, y];
return x + "," + y;
}
}
var voronoiGroup = svg.append("g")
.attr("class", "voronoi");
voronoiGroup.selectAll("path")
.data(voronoi(d3.nest()
.key(function(d) { return keyFunction(d); })
.rollup(function(v) { return v[0]; })
.entries(d3.merge(androgs.map(function(d) { return d.values; })))
.map(function(d) { return d.values; })))
.enter().append("path")
.attr("d", function(d) { return "M" + d.join("L") + "Z"; })
.datum(function(d) { return d.point; })
.on("mouseover", mouseover)
.on("mouseout", mouseout);
function mouseover(d) {
d3.select(d.androg.line).classed("androg--hover", true);
d.androg.line.parentNode.appendChild(d.androg.line);
focus.attr("transform", "translate(" + d.p[0] + "," + d.p[1] + ")");
focus.select("text").text(d.androg.name + ": " + d.value.slice(0, 4));
}
function mouseout(d) {
d3.select(d.androg.line).classed("androg--hover", false);
focus.attr("transform", "translate(-100,-100)");
}
function getUTCStringNoTime(js_date) {
return js_date.toUTCString().split(' ').slice(0,4).join(' ')
}
});
});
function closestPoint(pathNode, point) {
var pathLength = pathNode.getTotalLength(),
numberOfItems = pathNode.getPathSegAtLength(pathLength),
precision = (pathLength / numberOfItems) * .5,
best,
bestLength,
bestDistance = Infinity;
// linear scan for coarse approximation
for (var scan, scanLength = 0, scanDistance; scanLength <= pathLength; scanLength += precision) {
if ((scanDistance = distance2(scan = pathNode.getPointAtLength(scanLength))) < bestDistance) {
best = scan, bestLength = scanLength, bestDistance = scanDistance;
}
}
// binary search for precise estimate
precision *= .5;
while (precision > .5) {
var before,
after,
beforeLength,
afterLength,
beforeDistance,
afterDistance;
if ((beforeLength = bestLength - precision) >= 0 && (beforeDistance = distance2(before = pathNode.getPointAtLength(beforeLength))) < bestDistance) {
best = before, bestLength = beforeLength, bestDistance = beforeDistance;
} else if ((afterLength = bestLength + precision) <= pathLength && (afterDistance = distance2(after = pathNode.getPointAtLength(afterLength))) < bestDistance) {
best = after, bestLength = afterLength, bestDistance = afterDistance;
} else {
precision *= .5;
}
}
best = [best.x, best.y];
best.distance = Math.sqrt(bestDistance);
return best;
function distance2(p) {
var dx = p.x - point[0],
dy = p.y - point[1];
return dx * dx + dy * dy;
}
}
function type(d, i) {
if (!i) years = Object.keys(d).map(yearFormat.parse).filter(Number);
var androg = {
name: d.name.replace(/ (msa|necta div|met necta|met div)$/i, ""),
values: null
};
androg.values = years.map(function(m) {
return {
androg: androg,
date: m,
value: d[yearFormat(m)]
};
});
return androg;
}
</script>
Modified http://d3js.org/d3.v3.js to a secure url
https://d3js.org/d3.v3.js