Plot = import("https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.16/+esm")
lines = transpose(lines_data)
bins = transpose(bins_data)
matches = transpose(matches_data)
colors = ({"Match day": "#ee3124", "Baseline": "#8A8A8A"})
fmtTime = m =>
`${String(Math.floor(m / 60) % 24).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`
minToLabel = m => {
const h = Math.floor(m / 60) % 24;
return `${h % 12 === 0 ? 12 : h % 12} ${h < 12 ? "am" : "pm"}`;
}
legend = () => html`<div class="chart-legend">
${Object.entries(colors).map(([k, c]) =>
html`<span><span class="key" style="background:${c}"></span>${k}</span>`)}
</div>`
function matchChart(m, area, w) {
const d = lines.filter(r => r.match_id === m.match_id && r.area === area);
if (d.length === 0) {
return html`<p class="text-muted fst-italic my-4">No data for this match / area yet.</p>`;
}
const b = bins.filter(r => r.match_id === m.match_id && r.area === area);
const ymax = d3.max(d, r => r.smooth) * 1.1;
const plot = Plot.plot({
width: w, height: 320,
marginLeft: 50, marginRight: 25, marginBottom: 45,
x: {domain: [m.x0, m.x1], ticks: d3.range(Math.ceil(m.x0 / 60) * 60, m.x1 + 1, 60),
tickFormat: minToLabel, label: "Departure time",
labelAnchor: "center", labelArrow: "none", labelOffset: 38},
y: {domain: [0, ymax], grid: true, label: "Bixi trips per minute",
labelAnchor: "center", labelArrow: "none"},
color: {domain: Object.keys(colors), range: Object.values(colors)},
marks: [
Plot.rect([m], {x1: "ko", x2: "fw", y1: 0, y2: ymax,
fill: "#F2E8C9", fillOpacity: 0.6}),
Plot.text([m], {x: r => (r.ko + r.fw) / 2, y: ymax, dy: 10,
text: () => "Match", fill: "#7A6A3A", fontSize: 12}),
// highlighted bin under the pointer; drives the tooltip below
Plot.rect(b, Plot.pointerX({x1: "bin", x2: r => r.bin + hover_bin,
y1: 0, y2: ymax, px: r => r.bin + hover_bin / 2,
maxRadius: Infinity,
fill: "currentColor", fillOpacity: 0.06})),
Plot.line(d, {x: "minute", y: "smooth", z: "series", stroke: "series",
strokeWidth: 1.6}),
Plot.ruleY([0])
]
});
const tip = html`<div class="hover-tip"></div>`;
const wrap = html`<div class="plot-wrap">${plot}${tip}</div>`;
const sign = v => (v >= 0 ? "+" : "");
wrap.addEventListener("pointermove", e => {
tip.style.top = `${e.clientY - wrap.getBoundingClientRect().top}px`;
});
plot.addEventListener("input", () => {
const v = plot.value;
if (!v) { tip.style.display = "none"; return; }
const mid = v.bin + hover_bin / 2;
const scale = plot.getBoundingClientRect().width / w; // svg may be shrunk
const x = plot.scale("x").apply(mid) * scale;
const flip = mid > (m.x0 + m.x1) / 2;
const diff = v.md - v.bl;
const pct = v.bl > 0 ? ` (${sign(diff)}${(100 * diff / v.bl).toFixed(1)}%)` : "";
tip.innerHTML = `
<strong>${fmtTime(v.bin)} – ${fmtTime(v.bin + hover_bin)}</strong>
<div><span class="hover-swatch" style="background:${colors["Match day"]}"></span>${m.day_label}: ${Math.round(v.md)} trips/min</div>
<div><span class="hover-swatch" style="background:${colors.Baseline}"></span>Baseline: ${Math.round(v.bl)} trips/min</div>
<div class="hover-diff">${sign(diff)}${diff.toFixed(1)}${pct}</div>`;
tip.style.left = `${x + (flip ? -14 : 14)}px`;
tip.style.transform = flip ? "translate(-100%, -50%)" : "translateY(-50%)";
tip.style.display = "block";
});
return wrap;
}BIXIing to the World Cup
How did bike share use in Montréal change during World Cup matches?
Select which stations to include, and hover over a chart to compare the match day to the baseline in 10-minute blocks.
Data & Methods
Data: Bixi open data.