|
const numberOfPlayers = 4; |
|
const colorLeadersCount = 3; |
|
|
|
const margin = {top: 35, right: 70, bottom: 30, left: 70}; |
|
const width = 950, |
|
height = 500; |
|
|
|
const devicePixelRatio = window.devicePixelRatio || 1; |
|
|
|
const canvas = d3.select("canvas") |
|
.attr("width", width * devicePixelRatio) |
|
.attr("height", height * devicePixelRatio) |
|
.style("width", width + "px") |
|
.style("height", height + "px"); |
|
|
|
const svg = d3.select("svg") |
|
.style("width", width + "px") |
|
.style("height", height + "px"); |
|
|
|
const color = d3.scaleOrdinal() |
|
.range(["#DB7F85", "#50AB84", "#4C6C86", "#C47DCB", "#B59248", "#DD6CA7", "#E15E5A", "#5DA5B3", "#725D82", "#54AF52", "#954D56"]); |
|
|
|
var xScale = d3.scaleOrdinal() |
|
|
|
var xAxisLeft = d3.axisBottom() |
|
.tickFormat(d3.timeFormat("%b %e")); |
|
|
|
var xAxisRight = d3.axisTop() |
|
.tickFormat(d3.timeFormat("%b %e")); |
|
|
|
var yScale = d3.scaleLinear() |
|
.domain([0 - 0.2, numberOfPlayers - 0.5]) |
|
.range([margin.top, height-margin.bottom]); |
|
|
|
var radius = d3.scaleSqrt() |
|
.domain([0, 0.1]) |
|
.range([0, 4]); |
|
|
|
d3.csv("medals.csv", (error, data) => { |
|
const hostHouse = {}; // Find host countries by date |
|
data.forEach(d => { |
|
d.points = +d.points; |
|
d.date = +d.date; |
|
if (d.host === "y") { |
|
hostHouse[d.date] = d.name; |
|
} |
|
}); |
|
|
|
// nest by name and rank by total popularity |
|
const nested = d3.nest() |
|
.key(d => d.name) |
|
.rollup(leaves => ({ |
|
data: leaves, |
|
sum: d3.sum(leaves, d => d.points) |
|
})) |
|
.entries(data) |
|
.sort((a, b) => d3.descending(a.value.sum, b.value.sum)) |
|
|
|
const topnames = nested.slice(0, numberOfPlayers).map(d => d.key); |
|
data = data.filter(d => topnames.indexOf(d.name) > -1); |
|
|
|
// nest by name and rank by total popularity |
|
window.byDate = {} |
|
d3.nest() |
|
.key(d => d.date) |
|
.key(d => d.name) |
|
// .sortValues(function(a, b) { return a.points - b.points; }) |
|
.rollup((leaves, i) => leaves[0].points) |
|
.entries(data) |
|
.forEach(date => { |
|
byDate[date.key] = {}; |
|
date.values |
|
.sort((a, b) => d3.descending(a.value, b.value)) |
|
.forEach((name, i) => {byDate[date.key][name.key] = i}); |
|
}); |
|
|
|
const dates = Object.keys(hostHouse).map(d => +d); |
|
xScale |
|
.domain(Object.keys(hostHouse)) |
|
.range(new Array(dates.length).fill('').map((d, idx) => |
|
idx * width / (dates.length + 1) + margin.left |
|
)) |
|
|
|
xAxisLeft.scale(xScale).tickValues(dates); |
|
xAxisRight.scale(xScale).tickValues(dates); |
|
|
|
svg.append("g") |
|
.attr("class", "x axis") |
|
.attr("transform", "translate(0," + (height - margin.bottom) + ")") |
|
.call(xAxisLeft); |
|
|
|
svg.append("g") |
|
.attr("class", "x axis") |
|
.attr("transform", "translate(0," + (margin.top - 10) + ")") |
|
.call(xAxisRight); |
|
|
|
// Vertical guide line |
|
const hiddenMargin = 100; |
|
let highlightedYear; |
|
var verticalGuide = svg.append("line") |
|
.attr("class", "guide") |
|
.attr("x1", -hiddenMargin) |
|
.attr("y1", margin.top - 10) |
|
.attr("x2", -hiddenMargin) |
|
.attr("y2", height - margin.bottom) |
|
.style("stroke-width", () => xScale(2) - xScale(0)) //two date interval |
|
.style("opacity", 0); |
|
const mouseTrap = svg.append("rect") |
|
.attr("width", width) |
|
.attr("height", height) |
|
.style("opacity", 0) |
|
.on("mouseover", () => { verticalGuide.style("opacity", 0.1); }) |
|
.on("mouseout", () => { verticalGuide.style("opacity", 0); }) |
|
.on("mousemove", () => { |
|
const mousex = d3.mouse(this)[0] |
|
const x = xScale.invert(mousex); |
|
let found = false; |
|
for (let i = 0; i < dates.length; i++) { |
|
if (Math.abs(dates[i] - x) <= 1) { // game interval (2 dates) in half |
|
highlightedYear = dates[i]; |
|
found = true; |
|
break; |
|
} |
|
} |
|
if (!found) { |
|
highlightedYear = null; |
|
} |
|
|
|
mouseTrap.style("cursor", highlightedYear? "pointer" : "auto"); |
|
verticalGuide.attr("transform", "translate(" + (xScale(highlightedYear)+hiddenMargin) + ", 0)"); |
|
}); |
|
|
|
var ctx = canvas.node().getContext("2d"); |
|
ctx.scale(devicePixelRatio, devicePixelRatio); |
|
|
|
// Draw a circle for each host country |
|
const countrySumRank = nested.map(d => d.key); |
|
for (var date in hostHouse) { |
|
|
|
if (countrySumRank.indexOf(hostHouse[date]) < colorLeadersCount) { |
|
ctx.fillStyle = color(hostHouse[date]); |
|
} else { |
|
ctx.fillStyle = "#888"; |
|
} |
|
|
|
ctx.beginPath(); |
|
ctx.arc(xScale(date), yScale(byDate[date][hostHouse[date]]), 5, 0, 2 * Math.PI); |
|
ctx.fill(); |
|
ctx.closePath(); |
|
} |
|
|
|
nested.slice(0, numberOfPlayers).reverse().forEach((name, idx) => { |
|
var datespopular = name.value.data; |
|
|
|
if (idx >= numberOfPlayers - colorLeadersCount) { |
|
ctx.globalAlpha = 0.85; |
|
ctx.strokeStyle = color(name.key); |
|
ctx.lineWidth = 2.5; |
|
} else { |
|
ctx.globalAlpha = 0.55; |
|
ctx.strokeStyle = "#888"; |
|
ctx.lineWidth = 1; |
|
} |
|
|
|
// bump line |
|
ctx.globalCompositeOperation = "darken"; |
|
ctx.lineCap = "round"; |
|
datespopular.forEach((d, jdx) => { |
|
if (jdx > 0) { |
|
const previousDate = datespopular[jdx-1].date; |
|
|
|
ctx.beginPath(); |
|
const missedLastGame = false |
|
if (missedLastGame) { //skipping games |
|
ctx.setLineDash([5, 10]); |
|
} else { |
|
ctx.setLineDash([]); |
|
} |
|
ctx.moveTo(xScale(previousDate), yScale(byDate[previousDate][name.key])) |
|
// ctx.lineTo(xScale(d.date), yScale(byDate[d.date][name.key])); |
|
ctx.bezierCurveTo( |
|
xScale(previousDate)+15, yScale(byDate[previousDate][name.key]), |
|
xScale(d.date)-15, yScale(byDate[d.date][name.key]), |
|
xScale(d.date), yScale(byDate[d.date][name.key])); |
|
// ctx.closePath(); |
|
ctx.stroke(); |
|
} |
|
}); |
|
}); |
|
|
|
ctx.textAlign = "right"; |
|
ctx.textBaseline = "middle"; |
|
ctx.font = "10px sans-serif"; |
|
nested.slice(0, numberOfPlayers).reverse().forEach((name, i) => { |
|
|
|
const datespopular = name.value.data; |
|
if (i >= numberOfPlayers - colorLeadersCount) { |
|
ctx.fillStyle = color(name.key); |
|
} else { |
|
ctx.fillStyle = "#555"; |
|
} |
|
|
|
ctx.globalCompositeOperation = "source-over"; |
|
ctx.globalAlpha = 0.9; |
|
|
|
// start names |
|
ctx.save(); |
|
ctx.textAlign = "end"; |
|
const start = datespopular[0].date; |
|
const x = xScale(start)-10; |
|
const y = yScale(byDate[start][name.key]); |
|
ctx.fillText(name.key, x, y); |
|
ctx.restore(); |
|
|
|
// end names |
|
ctx.textAlign = "start"; |
|
const end= datespopular[datespopular.length-1].date; |
|
ctx.fillText(name.key, xScale(end)+10, yScale(byDate[end][name.key])); |
|
}); |
|
|
|
// legend |
|
var legendPos = {x: width*0.12, y: height*0.78}; |
|
|
|
ctx.fillStyle = "#888"; |
|
ctx.beginPath(); |
|
ctx.arc(legendPos.x, legendPos.y, 5, 0, 2*Math.PI); |
|
ctx.fill(); |
|
ctx.closePath(); |
|
|
|
ctx.textAlign = "start"; |
|
ctx.fillText("marks the day when that player hosts.", legendPos.x + 10, legendPos.y - 1); |
|
}); |