|
<!DOCTYPE html> |
|
<meta charset="utf-8"> |
|
<link href="https://fonts.googleapis.com/css?family=Montserrat:400;700" rel="stylesheet"> |
|
<style> |
|
|
|
body, select { |
|
font-family: "Montserrat", sans-serif; |
|
font-size: 11px; |
|
} |
|
|
|
select { display: block; } |
|
|
|
.boundary { |
|
fill: none; |
|
stroke: #ccc; |
|
} |
|
|
|
.circuit path { stroke: #777; } |
|
|
|
path.segment { |
|
fill: none; |
|
stroke-dasharray: 1 2; |
|
} |
|
|
|
.polygons { |
|
fill: none; |
|
pointer-events: all; |
|
} |
|
|
|
text.name { |
|
font-size: 13px; |
|
font-weight: bold; |
|
} |
|
|
|
</style> |
|
<body> |
|
<select id="season-menu"></select> |
|
<svg width="960" height="550"></svg> |
|
</body> |
|
<script src="https://d3js.org/d3.v4.min.js"></script> |
|
<script src="https://d3js.org/d3-queue.v2.min.js"></script> |
|
<script src="https://d3js.org/topojson.v1.min.js"></script> |
|
<script> |
|
const svg = d3.select("svg"), |
|
margin = {top: 20, left: 0}, |
|
width = +svg.attr("width"), |
|
height = +svg.attr("height"), |
|
g = svg.append("g"), |
|
seasons = d3.scaleLinear(), |
|
color = d3.scaleSequential(d3.interpolateCool); |
|
|
|
let races, centered; |
|
|
|
const menu = d3.select("#season-menu") |
|
.on("change", function() { updateCircuits(this.value) }); |
|
|
|
const t = d3.transition() |
|
.duration(750); |
|
|
|
const projection = d3.geoMercator() |
|
.center([0, 45]) |
|
.translate([width / 2, height / 2]) |
|
.scale(width / 6); |
|
|
|
const path = d3.geoPath() |
|
.pointRadius(3) |
|
.projection(projection); |
|
|
|
const voronoi = d3.voronoi() |
|
.x(d => projection(d.circuit.geometry.coordinates)[0]) |
|
.y(d => projection(d.circuit.geometry.coordinates)[1]) |
|
.extent([[0, 0], [width, height]]); |
|
|
|
d3.select(window) |
|
.on("keydown", keydowned); |
|
|
|
d3.queue() |
|
.defer(d3.json, "circuit-map.json") |
|
.defer(d3.csv, "races.csv") |
|
.await(ready); |
|
|
|
function ready(error, world, data) { |
|
if (error) return console.error(error); |
|
|
|
races = data; |
|
races.sort((a, b) => (b.year * 100 + b.round) - (a.year * 100 + a.round)); |
|
|
|
circuits = topojson.feature(world, world.objects.circuits).features; |
|
races.forEach(d => { |
|
d.circuit = circuits.filter(e => e.properties.circuitId == d.circuitId)[0]; |
|
d.ts = d3.timeParse("%Y-%m-%d %H:%M:%S")(d.date + " " + d.time); |
|
}); |
|
|
|
seasons.domain(d3.map(races, d => d.year).keys()); |
|
|
|
menu.selectAll("option") |
|
.data(seasons.domain()) |
|
.enter().append("option") |
|
.attr("value", d => d) |
|
.text(d => d); |
|
|
|
g.append("path") |
|
.datum(topojson.mesh(world, world.objects.countries)) |
|
.attr("class", "boundary") |
|
.attr("d", path); |
|
|
|
g.append("g") |
|
.attr("class", "legend") |
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")"); |
|
|
|
updateCircuits(seasons.domain()[0]); |
|
} |
|
|
|
function updateCircuits(year) { |
|
const circuits = races.filter(d => d.year == year); |
|
|
|
color.domain([0, circuits.length]); |
|
|
|
// circuits |
|
const circuit = g.selectAll(".circuit") |
|
.data(circuits.map(d => d.circuit), d => d.properties.circuitId); |
|
|
|
circuit.exit() |
|
.attr("class", "exit") |
|
.interrupt().transition(t) |
|
.attr("stroke-opacity", 1e-6) |
|
.remove(); |
|
|
|
circuit.enter().append("g") |
|
.attr("class", d => "circuit circuit-" + d.properties.circuitId) |
|
.append("path") |
|
.attr("stroke-opacity", 1e-6) |
|
.attr("d", path) |
|
.attr("fill", (d, i) => color(i)) |
|
.interrupt().transition(t) |
|
.attr("stroke-opacity", 1); |
|
|
|
|
|
// route |
|
const segments = g.selectAll(".segment") |
|
.data(pathSegments(circuits), d => d[0].circuitId + "-" + d[1].circuitId); |
|
|
|
segments.exit() |
|
.interrupt().transition(t) |
|
.attr("stroke-opacity", 1e-6) |
|
.remove(); |
|
|
|
segments.enter().append("path") |
|
.attr("stroke", (d, i) => color(i)) |
|
.attr("stroke-opacity", 1e-6) |
|
.attr("d", d => path({ type: "LineString", coordinates: [ |
|
d[0].circuit.geometry.coordinates, |
|
d[1].circuit.geometry.coordinates] })) |
|
.attr("class", "segment") |
|
.transition(t) |
|
.attr("stroke-opacity", 1); |
|
|
|
|
|
// voronoi overlay |
|
polygons = g.selectAll(".polygons") |
|
.data(voronoi.polygons(circuits), d => d.circuitId); |
|
|
|
polygons.exit().remove(); |
|
|
|
polygons.enter().insert("path") |
|
.attr("class", d =>"polygons polygons-" + d.circuitId) |
|
.attr("d", d => d ? "M" + d.join("L") + "Z" : null) |
|
// .style("stroke", "#A074A0") // show the cells |
|
.on("mouseover", mouseover) |
|
.on("click", clicked); |
|
|
|
menu.property("value", year); |
|
} |
|
|
|
// produce an array of two-element arrays [x, y] for each segment of values. |
|
function pathSegments(values) { |
|
let i = 0, n = values.length, segments = new Array(n - 1); |
|
while (++i < n) segments[i - 1] = [values[i - 1], values[i]]; |
|
return segments; |
|
} |
|
|
|
function keydowned() { |
|
let currentValue = +menu.property("value"); |
|
if (d3.event.metaKey || d3.event.altKey) return; |
|
switch (d3.event.keyCode) { |
|
case 40: currentValue = Math.max(seasons.domain()[seasons.domain().length - 1], currentValue - 1); break; |
|
case 38: currentValue = Math.min(seasons.domain()[0], currentValue + 1); break; |
|
default: return; |
|
} |
|
updateCircuits(currentValue); |
|
} |
|
|
|
function mouseover(d) { |
|
legend = g.select(".legend"); |
|
console.log(d.data); |
|
legend.selectAll("text").remove(); |
|
|
|
legend.append("text") |
|
.attr("class", "name") |
|
// .attr("y", 16) |
|
.text("#" + d.data.round + " " + d.data.name); |
|
|
|
legend.append("text") |
|
.attr("class", "race") |
|
.attr("y", 16) |
|
.text(d3.timeFormat("%A %d %B %H:%M")(d.data.ts)); |
|
|
|
legend.append("text") |
|
.attr("class", "location") |
|
.attr("y", 32) |
|
.text(d.data.circuit.properties.name + ", " + d.data.circuit.properties.location + ", " + d.data.circuit.properties.country); |
|
} |
|
|
|
function clicked(d) { |
|
let dx, dy, k, i; |
|
if (d && centered !== d) { |
|
const centroid = projection(d.data.geometry.coordinates); |
|
dx = centroid[0]; |
|
dy = centroid[1]; |
|
k = 4; |
|
centered = d; |
|
} else { |
|
dx = width / 2; |
|
dy = height / 2; |
|
k = 1; |
|
centered = null; |
|
} |
|
|
|
g.transition() |
|
.duration(750) |
|
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")scale(" + k + ")translate(" + -dx + "," + -dy + ")"); |
|
} |
|
</script> |