Skip to content

Instantly share code, notes, and snippets.

@ssthouse
Created June 7, 2022 02:50
Show Gist options
  • Select an option

  • Save ssthouse/b68c88e4293e47b6966084a972c9b121 to your computer and use it in GitHub Desktop.

Select an option

Save ssthouse/b68c88e4293e47b6966084a972c9b121 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3 Selection Playground</title>
<script src="../d3.js"></script>
<script src="./papaparse.js"></script>
</head>
<body>
<style>
.card {
width: 100%;
height: 100%;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
margin: 10px;
}
svg {
overflow: visible;
width: 600px;
height: 600px;
}
svg text {
font-weight: bold;
}
</style>
<div>
<div class="card">
<h1>Bar Race Chart</h1>
<svg id="bar-race-chart-svg"></svg>
<button onclick="stop()">Stop</button>
<button onclick="resume()">Resume</button>
</div>
<script>
let isRunning = false;
function stop() {
isRunning = false;
}
function resume() {
isRunning = true;
}
function fetchData() {
return fetch('./data.csv')
.then((res) => res.text())
.then((text) => Papa.parse(text, { header: true }))
.then((res) => res.data);
}
fetchData().then((data) => {
data.forEach((dataItem) => {
dataItem.date = new Date(dataItem.date);
});
let margin = { top: 16, right: 6, bottom: 6, left: 0 };
let barSize = 48;
let width = 600;
let n = 12;
let duration = 250;
let k = 10;
let height = margin.top + barSize * n + margin.bottom;
// axis scale
let x = d3.scaleLinear([0, 1], [margin.left, width - margin.right]);
let y = d3
.scaleBand()
.domain(d3.range(n + 1))
.rangeRound([margin.top, margin.top + barSize * (n + 1 + 0.1)])
.padding(0.1);
// prepare name & color & datevalues
let names = new Set(data.map((d) => d.name));
let color = (() => {
const scale = d3.scaleOrdinal(d3.schemeTableau10);
if (data.some((d) => d.category !== undefined)) {
const categoryByName = new Map(
data.map((d) => [d.name, d.category])
);
scale.domain(categoryByName.values());
return (d) => scale(categoryByName.get(d.name));
}
return (d) => scale(d.name);
})();
let datevalues = Array.from(
d3.rollup(
data,
([d]) => d.value,
(d) => +d.date,
(d) => d.name
)
)
.map(([date, data]) => [new Date(date), data])
.sort(([a], [b]) => d3.ascending(a, b));
let formatDate = d3.utcFormat('%Y');
let tickFormat = undefined;
let formatNumber = d3.format(',d');
let keyframes = (() => {
const keyframes = [];
let ka, a, kb, b;
for ([[ka, a], [kb, b]] of d3.pairs(datevalues)) {
for (let i = 0; i < k; ++i) {
const t = i / k;
keyframes.push([
new Date(ka * (1 - t) + kb * t),
rank(
(name) =>
(a.get(name) || 0) * (1 - t) + (b.get(name) || 0) * t
),
]);
}
}
keyframes.push([new Date(kb), rank((name) => b.get(name) || 0)]);
return keyframes;
})();
let nameframes = d3.groups(
keyframes.flatMap(([, data]) => data),
(d) => d.name
);
let next = new Map(nameframes.flatMap(([, data]) => d3.pairs(data)));
let prev = new Map(
nameframes.flatMap(([, data]) => d3.pairs(data, (a, b) => [b, a]))
);
function ticker(svg) {
const now = svg
.append('text')
.style('font', `bold ${barSize}px var(--sans-serif)`)
.style('font-variant-numeric', 'tabular-nums')
.attr('text-anchor', 'end')
.attr('x', width - 6)
.attr('y', margin.top + barSize * (n - 0.45))
.attr('dy', '0.32em')
.text(formatDate(keyframes[0][0]));
return ([date], transition) => {
transition.end().then(() => now.text(formatDate(date)));
};
}
function axis(svg) {
const g = svg
.append('g')
.attr('transform', `translate(0,${margin.top})`);
const axis = d3
.axisTop(x)
.ticks(width / 160, tickFormat)
.tickSizeOuter(0)
.tickSizeInner(-barSize * (n + y.padding()));
return (_, transition) => {
g.transition(transition).call(axis);
g.select('.tick:first-of-type text').remove();
g.selectAll('.tick:not(:first-of-type) line').attr(
'stroke',
'white'
);
g.select('.domain').remove();
};
}
function textTween(a, b) {
const i = d3.interpolateNumber(a, b);
return function (t) {
this.textContent = formatNumber(i(t));
};
}
function labels(svg) {
let label = svg
.append('g')
.style('font', 'bold 12px var(--sans-serif)')
.style('font-variant-numeric', 'tabular-nums')
.attr('text-anchor', 'end')
.selectAll('text');
return ([date, data], transition) =>
(label = label
.data(data.slice(0, n), (d) => d.name)
.join(
(enter) =>
enter
.append('text')
.attr(
'transform',
(d) =>
`translate(${x((prev.get(d) || d).value)},${y(
(prev.get(d) || d).rank
)})`
)
.attr('y', y.bandwidth() / 2)
.attr('x', -6)
.attr('dy', '-0.25em')
.text((d) => d.name)
.call((text) =>
text
.append('tspan')
.attr('fill-opacity', 0.7)
.attr('font-weight', 'normal')
.attr('x', -6)
.attr('dy', '1.15em')
),
(update) => update,
(exit) =>
exit
.transition(transition)
.remove()
.attr(
'transform',
(d) =>
`translate(${x((next.get(d) || d).value)},${y(
(next.get(d) || d).rank
)})`
)
.call((g) =>
g
.select('tspan')
.tween('text', (d) =>
textTween(d.value, (next.get(d) || d).value)
)
)
)
.call((bar) =>
bar
.transition(transition)
.attr(
'transform',
(d) => `translate(${x(d.value)},${y(d.rank)})`
)
.call((g) =>
g
.select('tspan')
.tween('text', (d) =>
textTween((prev.get(d) || d).value, d.value)
)
)
));
}
function bars(svg) {
let bar = svg
.append('g')
.attr('fill-opacity', 0.6)
.selectAll('rect');
return ([date, data], transition) =>
(bar = bar
.data(data.slice(0, n), (d) => d.name)
.join(
(enter) =>
enter
.append('rect')
.attr('fill', color)
.attr('height', y.bandwidth())
.attr('x', x(0))
.attr('y', (d) => y((prev.get(d) || d).rank))
.attr('width', (d) => x((prev.get(d) || d).value) - x(0)),
(update) => update,
(exit) =>
exit
.transition(transition)
.remove()
.attr('y', (d) => y((next.get(d) || d).rank))
.attr('width', (d) => x((next.get(d) || d).value) - x(0))
)
.call((bar) =>
bar
.transition(transition)
.attr('y', (d) => y(d.rank))
.attr('width', (d) => x(d.value) - x(0))
));
}
function rank(value) {
const data = Array.from(names, (name) => ({
name,
value: value(name),
}));
data.sort((a, b) => d3.descending(a.value, b.value));
for (let i = 0; i < data.length; ++i) data[i].rank = Math.min(n, i);
return data;
}
async function start() {
const svg = d3.select('#bar-race-chart-svg');
const updateBars = bars(svg);
const updateAxis = axis(svg);
const updateLabels = labels(svg);
const updateTicker = ticker(svg);
// yield svg.node();
console.log(keyframes);
for (const keyframe of keyframes) {
const transition = svg
.transition()
.duration(duration)
.ease(d3.easeLinear);
// Extract the top bar’s value.
x.domain([0, keyframe[1][0].value]);
updateAxis(keyframe, transition);
updateBars(keyframe, transition);
updateLabels(keyframe, transition);
updateTicker(keyframe, transition);
// invalidation.then(() => svg.interrupt());
await transition.end();
while (!isRunning) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
start();
});
</script>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment