Skip to content

Instantly share code, notes, and snippets.

@dmarx
Created April 20, 2026 20:13
Show Gist options
  • Select an option

  • Save dmarx/39d78c3bbbb20f878dde86a17b04758e to your computer and use it in GitHub Desktop.

Select an option

Save dmarx/39d78c3bbbb20f878dde86a17b04758e to your computer and use it in GitHub Desktop.
D3 animated bar chart demo to hopefully make Dan Olson's life a tiny bit easier. Context: https://bsky.app/profile/digthatdata.bsky.social/post/3mjx6xbvsws2i
<!DOCTYPE html>
<!-- yt_views.html -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Weekly Views · Channel Growth 2017–2026</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<style>
:root {
--paper: #F5F0E2;
--panel: #EDE7D3;
--panel-deep: #E4DCC8;
--border: #CEC5AD;
--ink: #28200E;
--ink-mid: #7A6E58;
--ink-faint: #B0A48C;
--bar: #9B9488;
--bar-h: #6E6860;
--avg: #B83030;
--avg-glow: rgba(184,48,48,0.18);
--grid: #DDD6C2;
--accent: #8B6B38;
--accent-lt: #C49A5A;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--paper);
color: var(--ink);
font-family: 'Libre Baskerville', Georgia, serif;
min-height: 100vh;
padding: 2.25rem 3rem 3rem;
}
/* Header */
.hd {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1.5px solid var(--border);
padding-bottom: 0.65rem;
margin-bottom: 1.4rem;
}
.hd h1 {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.8rem;
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1;
}
.hd .sub { font-size: 0.78rem; color: var(--ink-mid); font-style: italic; }
/* Chart */
#chart-wrap { width: 100%; position: relative; }
svg { display: block; width: 100%; height: auto; }
.bar { fill: var(--bar); }
.bar:hover { fill: var(--bar-h); cursor: crosshair; }
.gridline { stroke: var(--grid); stroke-width: 0.8; }
.x-axis .tick line { stroke: var(--border); stroke-width: 1; }
.x-axis .domain, .y-axis .domain { display: none; }
.x-axis .tick text { font-family: 'Libre Baskerville', serif; font-size: 11px; fill: var(--ink-mid); }
.y-axis .tick text { font-family: 'Libre Baskerville', serif; font-size: 11px; fill: var(--ink-mid); }
.y-axis .tick line { display: none; }
.axis-lbl { font-family: 'Libre Baskerville', serif; font-size: 10.5px; fill: var(--ink-faint); font-style: italic; }
.avg-path {
fill: none;
stroke: var(--avg);
stroke-width: 2.2px;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px var(--avg-glow));
opacity: 0;
pointer-events: none;
}
/* Tooltip */
.tip {
position: absolute;
background: var(--ink);
color: var(--paper);
font-family: 'Libre Baskerville', serif;
font-size: 0.72rem;
line-height: 1.5;
padding: 0.38rem 0.65rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.08s;
white-space: nowrap;
z-index: 10;
}
/* Legend */
.legend {
display: flex;
gap: 1.2rem;
align-items: center;
position: absolute;
top: 10px; right: 4px;
font-size: 0.7rem;
color: var(--ink-mid);
font-style: italic;
}
.legend-swatch {
display: inline-block;
width: 22px; height: 3px;
vertical-align: middle;
margin-right: 4px;
border-radius: 2px;
}
/* Controls panel */
.ctrl-panel {
margin-top: 0.8rem;
background: var(--panel);
border: 1px solid var(--border);
padding: 1rem 1.4rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
/* Top strip: mode buttons left, replay button right */
.ctrl-top {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 0.9rem;
border-bottom: 1px solid var(--border);
gap: 1rem;
}
/* Bottom strip: two slider groups side by side */
.ctrl-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.cg { display: flex; flex-direction: column; gap: 0.5rem; }
.clbl {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--ink-mid);
font-weight: 700;
}
.est {
font-size: 0.67rem;
color: var(--ink-faint);
font-style: italic;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
/* Mode buttons */
.mode-row { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.mbtn {
background: transparent;
border: 1px solid var(--border);
color: var(--ink-mid);
font-family: 'Libre Baskerville', serif;
font-size: 0.72rem;
padding: 0.32rem 0.7rem;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap;
}
.mbtn:hover { border-color: var(--accent-lt); color: var(--accent); }
.mbtn.active { background: var(--accent); border-color: var(--accent); color: var(--paper); }
/* Slider rows */
.sliders { display: flex; flex-direction: column; gap: 0.65rem; }
.sr {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sr.hidden { display: none !important; }
.sr-name {
font-size: 0.73rem;
color: var(--ink);
width: 118px;
flex-shrink: 0;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--panel-deep);
outline: none;
flex: 1;
min-width: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--border);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 13px; height: 13px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--paper);
box-shadow: 0 1px 3px rgba(0,0,0,0.25);
}
input[type="range"]::-moz-range-thumb {
width: 13px; height: 13px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--paper);
cursor: pointer;
}
/* Numeric text inputs (unclamped free entry) */
input[type="number"] {
width: 56px;
padding: 0.18rem 0.32rem;
font-family: 'Libre Baskerville', serif;
font-size: 0.72rem;
font-style: italic;
color: var(--ink-mid);
background: var(--paper);
border: 1px solid var(--border);
text-align: right;
flex-shrink: 0;
outline: none;
-moz-appearance: textfield;
}
input[type="number"]:focus { border-color: var(--accent-lt); color: var(--ink); }
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; }
.unit-lbl {
font-size: 0.68rem;
color: var(--ink-faint);
font-style: italic;
flex-shrink: 0;
width: 22px;
}
/* Replay button */
.play-btn {
flex-shrink: 0;
background: var(--ink);
color: var(--paper);
border: none;
font-family: 'Playfair Display', serif;
font-size: 0.92rem;
padding: 0.55rem 1.5rem;
cursor: pointer;
letter-spacing: 0.03em;
transition: background 0.15s;
white-space: nowrap;
}
.play-btn:hover { background: var(--accent); }
</style>
</head>
<body>
<header class="hd">
<h1>Weekly YouTube Views</h1>
<span class="sub">Synthetic channel · Jan 2017 — Mar 2026 · 480 weeks</span>
</header>
<div id="chart-wrap">
<div class="legend">
<span><span class="legend-swatch" style="background:var(--bar)"></span>Weekly views</span>
<span><span class="legend-swatch" style="background:var(--avg)"></span>Rolling avg</span>
</div>
<div class="tip" id="tip"></div>
</div>
<div class="ctrl-panel">
<div class="ctrl-top">
<div class="cg">
<span class="clbl">Animation Mode</span>
<div class="mode-row">
<button class="mbtn active" data-mode="fixed">Fixed Interval</button>
<button class="mbtn" data-mode="sequential">Sequential</button>
<button class="mbtn" data-mode="overlap">Overlapping</button>
</div>
</div>
<button class="play-btn" id="btn-play">&#9654; Replay</button>
</div>
<div class="ctrl-bottom">
<div class="cg">
<span class="clbl">Animation Parameters &nbsp;<span class="est" id="est"></span></span>
<div class="sliders">
<div class="sr" id="row-dur">
<span class="sr-name">Bar grow time</span>
<input type="range" id="sl-dur" min="1" max="2000" value="10" step="1">
<input type="number" id="ni-dur" value="10">
<span class="unit-lbl">ms</span>
</div>
<div class="sr" id="row-stagger">
<span class="sr-name">Stagger delay</span>
<input type="range" id="sl-stagger" min="1" max="200" value="7" step="1">
<input type="number" id="ni-stagger" value="7">
<span class="unit-lbl">ms</span>
</div>
<div class="sr hidden" id="row-overlap">
<span class="sr-name">Overlap</span>
<input type="range" id="sl-overlap" min="1" max="99" value="60" step="1">
<input type="number" id="ni-overlap" value="60">
<span class="unit-lbl">%</span>
</div>
</div>
</div>
<div class="cg">
<span class="clbl">Rolling Average</span>
<div class="sliders">
<div class="sr">
<span class="sr-name">Window width</span>
<input type="range" id="sl-win" min="1" max="104" value="8" step="1">
<input type="number" id="ni-win" value="8">
<span class="unit-lbl">wks</span>
</div>
</div>
</div>
</div>
</div>
<script>
/* ============================================================
DATA GENERATION
============================================================ */
function generateData() {
const data = [];
const start = new Date(2017, 0, 1);
const N = 480;
let s = 98765;
const rng = () => { s = (Math.imul(1664525, s) + 1013904223) | 0; return (s >>> 0) / 0xFFFFFFFF; };
for (let i = 0; i < N; i++) {
const date = new Date(start.getTime() + i * 7 * 86400000);
const t = i / (N - 1);
const base = 0.2 * Math.pow(2750, t);
const phase = (date.getMonth() + date.getDate() / 31) / 12;
const seasonal = 1 + 0.10 * Math.cos(phase * 2 * Math.PI + 1.0);
const noise = Math.exp((rng() - 0.5) * 0.52);
const spike = rng() < 0.022 ? 1.6 + rng() * 3.0 : 1.0;
data.push({ date, week: i, views: Math.max(0.05, base * seasonal * noise * spike) });
}
return data;
}
function rollingAvg(data, win) {
const half = Math.floor(win / 2);
return data.map((d, i) => {
const lo = Math.max(0, i - half);
const hi = Math.min(data.length - 1, i + half);
return { date: d.date, week: d.week, avg: d3.mean(data.slice(lo, hi + 1), x => x.views) };
});
}
/* ============================================================
CHART SETUP
============================================================ */
const data = generateData();
const M = { top: 28, right: 24, bottom: 46, left: 62 };
const VW = 1340, VH = 490;
const IW = VW - M.left - M.right;
const IH = VH - M.top - M.bottom;
const svg = d3.select('#chart-wrap')
.append('svg')
.attr('viewBox', `0 0 ${VW} ${VH}`)
.attr('preserveAspectRatio', 'xMidYMid meet');
const g = svg.append('g').attr('transform', `translate(${M.left},${M.top})`);
const xSc = d3.scaleTime().domain(d3.extent(data, d => d.date)).range([0, IW]);
const yMax = d3.max(data, d => d.views) * 1.08;
const ySc = d3.scaleLinear().domain([0, yMax]).range([IH, 0]);
const BW = Math.max(1.4, IW / data.length * 0.90);
const yTicks = 6;
const yAxisG = g.append('g').attr('class', 'y-axis');
yAxisG.call(
d3.axisLeft(ySc).ticks(yTicks).tickSize(0)
.tickFormat(d => d === 0 ? '' : (d >= 1000 ? `${(d/1000).toFixed(1)}B` : `${d}M`))
);
yAxisG.select('.domain').remove();
yAxisG.selectAll('.tick text').attr('dx', -6);
ySc.ticks(yTicks).forEach(v => {
g.append('line').attr('class', 'gridline')
.attr('x1', 0).attr('x2', IW).attr('y1', ySc(v)).attr('y2', ySc(v));
});
g.append('text').attr('class', 'axis-lbl')
.attr('transform', 'rotate(-90)').attr('y', -M.left + 12).attr('x', -IH / 2)
.attr('text-anchor', 'middle').text('Views (millions)');
const years = d3.timeYear.range(data[0].date, data[data.length - 1].date);
g.append('g').attr('class', 'x-axis axis').attr('transform', `translate(0,${IH})`)
.call(d3.axisBottom(xSc).tickValues(years).tickFormat(d3.timeFormat('%Y')).tickSize(5))
.select('.domain').remove();
g.selectAll('.bar').data(data).join('rect')
.attr('class', 'bar')
.attr('x', d => xSc(d.date) - BW / 2)
.attr('y', IH)
.attr('width', BW)
.attr('height', 0);
g.append('path').attr('class', 'avg-path').attr('id', 'avg-path');
/* ============================================================
TOOLTIP
============================================================ */
const tip = document.getElementById('tip');
const cwrap = document.getElementById('chart-wrap');
const dateFmt = d3.timeFormat('%b %d, %Y');
g.selectAll('.bar')
.on('mousemove', function (event, d) {
const r = cwrap.getBoundingClientRect();
tip.style.opacity = '1';
tip.innerHTML = `<strong>${dateFmt(d.date)}</strong><br>${d.views >= 1000 ? (d.views/1000).toFixed(2)+'B' : d.views.toFixed(1)+'M'} views`;
tip.style.left = `${event.clientX - r.left + 12}px`;
tip.style.top = `${event.clientY - r.top - 46}px`;
})
.on('mouseleave', () => { tip.style.opacity = '0'; });
/* ============================================================
ANIMATION
============================================================ */
let pending = null;
let barsReady = false;
function getParams() {
const mode = document.querySelector('.mbtn.active').dataset.mode;
const barDur = Math.max(1, parseFloat(document.getElementById('ni-dur').value) || 10);
const stagger = Math.max(1, parseFloat(document.getElementById('ni-stagger').value) || 7);
const ovlPct = Math.min(99, Math.max(1, parseFloat(document.getElementById('ni-overlap').value) || 60));
return { mode, barDur, stagger, ovlPct };
}
function calcDelay(i, p) {
if (p.mode === 'fixed') return i * p.stagger;
if (p.mode === 'sequential') return i * p.barDur;
return i * p.barDur * (1 - p.ovlPct / 100);
}
function totalAnimTime(p) {
const n = data.length;
if (p.mode === 'fixed') return (n - 1) * p.stagger + p.barDur;
if (p.mode === 'sequential') return n * p.barDur;
return (n - 1) * p.barDur * (1 - p.ovlPct / 100) + p.barDur;
}
function playAnimation() {
if (pending) clearTimeout(pending);
barsReady = false;
g.selectAll('.bar').interrupt();
g.select('#avg-path').interrupt()
.style('opacity', 0)
.attr('stroke-dasharray', null)
.attr('stroke-dashoffset', null);
g.selectAll('.bar').attr('y', IH).attr('height', 0);
const p = getParams();
g.selectAll('.bar')
.transition()
.delay((d, i) => calcDelay(i, p))
.duration(p.barDur)
.ease(d3.easeCubicOut)
.attr('y', d => ySc(d.views))
.attr('height', d => IH - ySc(d.views));
const total = totalAnimTime(p);
pending = setTimeout(() => { barsReady = true; drawRollingAvg(); }, total + 220);
}
/* ============================================================
ROLLING AVERAGE
============================================================ */
function drawRollingAvg(animated = true) {
if (!barsReady) return;
const win = Math.max(1, parseFloat(document.getElementById('ni-win').value) || 8);
const avgs = rollingAvg(data, win);
const line = d3.line()
.x(d => xSc(d.date)).y(d => ySc(d.avg))
.curve(d3.curveCatmullRom.alpha(0.5));
const path = g.select('#avg-path').datum(avgs).attr('d', line).style('opacity', 1);
if (!animated) {
path.attr('stroke-dasharray', null).attr('stroke-dashoffset', null);
return;
}
const len = path.node().getTotalLength();
path.attr('stroke-dasharray', `${len} ${len}`).attr('stroke-dashoffset', len)
.transition().duration(1800).ease(d3.easeQuadInOut).attr('stroke-dashoffset', 0);
}
/* ============================================================
CONTROLS
============================================================ */
document.querySelectorAll('.mbtn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
syncVisibility();
updateEst();
});
});
function syncVisibility() {
const mode = document.querySelector('.mbtn.active').dataset.mode;
document.getElementById('row-stagger').classList.toggle('hidden', mode !== 'fixed');
document.getElementById('row-overlap').classList.toggle('hidden', mode !== 'overlap');
}
function updateEst() {
const p = getParams();
const ms = totalAnimTime(p);
const s = ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
document.getElementById('est').textContent = `— est. ${s}`;
}
/* Bidirectional slider ↔ number input.
Number input is the source of truth (allows out-of-slider-range values).
Slider tracks within its own min/max. */
function wirePair(slId, niId, onUpdate) {
const sl = document.getElementById(slId);
const ni = document.getElementById(niId);
sl.addEventListener('input', () => {
ni.value = sl.value;
onUpdate();
});
ni.addEventListener('input', () => {
const v = parseFloat(ni.value);
if (!isNaN(v)) sl.value = Math.min(+sl.max, Math.max(+sl.min, v));
onUpdate();
});
}
wirePair('sl-dur', 'ni-dur', updateEst);
wirePair('sl-stagger', 'ni-stagger', updateEst);
wirePair('sl-overlap', 'ni-overlap', updateEst);
wirePair('sl-win', 'ni-win', () => {
if (barsReady) { g.select('#avg-path').interrupt(); drawRollingAvg(false); }
});
document.getElementById('btn-play').addEventListener('click', playAnimation);
/* ============================================================
INIT
============================================================ */
syncVisibility();
updateEst();
window.addEventListener('load', () => setTimeout(playAnimation, 600));
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment