Created
April 20, 2026 20:13
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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">▶ Replay</button> | |
| </div> | |
| <div class="ctrl-bottom"> | |
| <div class="cg"> | |
| <span class="clbl">Animation Parameters <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