Deep Dive: Custom Canvas Charts for 30fps Animation
Why Chart.js couldn't keep up with streaming data, and how a custom Canvas 2D renderer with data decimation achieved smooth 30fps playback.
Deep Dive: Custom Canvas Charts for 30fps Animation
Replacing Chart.js when animation performance matters
Chart.js is fantastic. It handles responsive layouts, touch interactions, tooltips, legends, and dozens of chart types out of the box. For static charts or occasional updates, it’s the obvious choice.
For streaming animations at 30fps? Not so much.
The Problem
The Code Evolution Analyzer plays back repository history as an animation. Each frame advances to a new commit, adding data points to the chart. At 30fps with 2,000 commits, that’s adding 60,000 data points per playback cycle.
Chart.js redraws the entire chart on every update. With 16 language lines and 2,000 points each, that’s 32,000 path operations per frame. The animation stuttered badly—maybe 10fps on a good machine, completely unusable on mobile.
I tried the standard optimizations:
// Disable animations
chart.options.animation = false;
// Reduce tension (simpler curves)
dataset.tension = 0;
// Disable point rendering
dataset.pointRadius = 0;
// Batch updates
chart.update('none');
Better, but still not 30fps. The fundamental issue is architectural: Chart.js wasn’t designed for high-frequency updates with large datasets.
The O(n²) Bug
Before replacing Chart.js entirely, I found an algorithmic bug in my own code. I was rebuilding the entire dataset on every frame:
// BEFORE: O(n²) - rebuilding all data every frame
function updateChart() {
const datasets = [];
for (const lang of ALL_LANGUAGES) {
const data = [];
for (let i = 0; i <= currentIndex; i++) {
data.push(DATA[i].languages[lang]?.code || 0);
}
datasets.push({ label: lang, data });
}
chart.data.datasets = datasets;
chart.update('none');
}
For 2,000 commits and 16 languages, that’s creating 32,000 new array elements every frame. Even without Chart.js rendering, the garbage collector was killing performance.
The fix was incremental updates:
// AFTER: O(n) - only append new data
let lastChartIndex = -1;
function updateChart() {
// Reset if going backwards
if (currentIndex < lastChartIndex) {
// ... rebuild from scratch
lastChartIndex = -1;
}
// Append only new points
for (let i = lastChartIndex + 1; i <= currentIndex; i++) {
for (let j = 0; j < ALL_LANGUAGES.length; j++) {
const lang = ALL_LANGUAGES[j];
const value = DATA[i].languages[lang]?.code || 0;
chart.data.datasets[j].data.push(value);
}
chart.data.labels.push((i + 1).toString());
}
lastChartIndex = currentIndex;
chart.update('none');
}
This helped, but Chart.js still couldn’t render the growing dataset fast enough.
Custom Canvas Renderer
I wrote a replacement renderer using the Canvas 2D API directly. No abstractions, no flexibility, just fast line drawing:
const chartCanvas = document.getElementById('chart-canvas');
const chartCtx = chartCanvas.getContext('2d');
function renderChart() {
const ctx = chartCtx;
// Clear canvas
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, chartWidth, chartHeight);
// Find value range
let maxValue = 0;
for (let i = 0; i <= currentIndex; i++) {
for (const lang of ALL_LANGUAGES) {
const val = DATA[i].languages[lang]?.code || 0;
if (val > maxValue) maxValue = val;
}
}
maxValue *= 1.1; // 10% headroom
// Draw each language line
for (const lang of ALL_LANGUAGES) {
ctx.strokeStyle = LANGUAGE_COLORS[lang];
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i <= currentIndex; i++) {
const val = DATA[i].languages[lang]?.code || 0;
const x = PADDING_LEFT + (i / currentIndex) * plotWidth;
const y = plotHeight - (val / maxValue) * plotHeight + PADDING_TOP;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
}
Raw Canvas 2D is surprisingly fast. For 2,000 points × 16 lines, the browser’s native path rendering handles it easily. But there’s still a problem: as the animation plays, the dataset grows. By the end of playback, we’re rendering 32,000 line segments every frame.
Data Decimation
The solution is decimation: don’t render every point. Human eyes can’t distinguish 2,000 points on a 1,000-pixel-wide canvas anyway—you’re limited by display resolution.
const MAX_RENDER_POINTS = 800;
function renderChart() {
const totalPoints = currentIndex + 1;
const step = Math.max(1, Math.ceil(totalPoints / MAX_RENDER_POINTS));
for (const lang of ALL_LANGUAGES) {
ctx.beginPath();
let firstPoint = true;
// Sample every 'step' points
for (let i = 0; i <= currentIndex; i += step) {
const val = DATA[i].languages[lang]?.code || 0;
const x = PADDING_LEFT + (i / currentIndex) * plotWidth;
const y = plotHeight - (val / maxValue) * plotHeight + PADDING_TOP;
if (firstPoint) {
ctx.moveTo(x, y);
firstPoint = false;
} else {
ctx.lineTo(x, y);
}
}
// Always include the current point for accuracy
if (step > 1) {
const val = DATA[currentIndex].languages[lang]?.code || 0;
ctx.lineTo(plotWidth + PADDING_LEFT, /* calculated y */);
}
ctx.stroke();
}
}
With 800 max points, the rendering cost is bounded regardless of dataset size. A 10,000-commit repository renders as fast as a 100-commit one.
The “always include current point” trick is important: without it, the right edge of the chart jumps around as decimation samples different points. Including the actual current value keeps the leading edge smooth.
High-DPI Handling
Modern displays have 2x or 3x pixel density. Naive canvas rendering looks blurry:
// BLURRY: Canvas at CSS size
canvas.width = rect.width;
canvas.height = rect.height;
The fix is to scale the canvas by devicePixelRatio:
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
chartCtx.scale(dpr, dpr);
// Drawing coordinates remain in CSS pixels
// Canvas internally uses physical pixels
On a Retina display, a 800×400 CSS canvas becomes a 1600×800 physical canvas. Lines render crisp instead of fuzzy.
Frame Rate Throttling
Even with fast rendering, updating the chart 60 times per second wastes CPU. The animation doesn’t need more than 30fps:
const CHART_UPDATE_INTERVAL_MS = 33; // ~30fps
let lastChartUpdateTime = 0;
function updateChart() {
const now = performance.now();
if (now - lastChartUpdateTime < CHART_UPDATE_INTERVAL_MS) {
return; // Skip this frame
}
lastChartUpdateTime = now;
renderChart();
}
This simple throttle cuts rendering work in half without visible quality loss.
Drawing Order for Visibility
With 16 overlapping lines, smaller languages get hidden behind larger ones. The fix is to draw from smallest to largest current value:
// Sort languages by current value (ascending)
const sorted = ALL_LANGUAGES
.map(lang => ({
lang,
color: LANGUAGE_COLORS[lang],
value: DATA[currentIndex].languages[lang]?.code || 0
}))
.sort((a, b) => a.value - b.value);
// Draw in order: smallest first (behind), largest last (front)
for (const { lang, color } of sorted) {
ctx.strokeStyle = color;
// ... draw line
}
Now the dominant language (usually JavaScript) draws on top, but minority languages remain visible underneath.
The Performance Win
| Metric | Chart.js | Custom Canvas |
|---|---|---|
| 100 commits | 45fps | 60fps |
| 1,000 commits | 18fps | 58fps |
| 5,000 commits | 6fps | 55fps |
| 10,000 commits | 2fps | 52fps |
The custom renderer maintains near-60fps regardless of dataset size. With the 30fps throttle, CPU usage drops significantly while animation stays smooth.
Trade-offs
The custom renderer lacks features Chart.js provides:
- No tooltips: Hovering over points doesn’t show values
- No legends: I had to build a separate legend component
- No animations: Line transitions aren’t smoothly animated
- No interactivity: No zoom, pan, or selection
- Less pretty: The rendering is functional, not polished
For an animated playback visualization, these trade-offs are acceptable. The chart is a visualization of historical data, not an interactive exploration tool. Speed matters more than features.
When to Build Custom
Chart.js (or D3, or Recharts, or any charting library) is almost always the right choice. Build custom only when:
- You need streaming/real-time data at high frame rates
- Dataset size is unbounded and grows during use
- Interactivity isn’t needed or is handled separately
- You’ve tried optimizations and they’re not enough
For static charts, occasional updates, or when you need tooltips and interaction—use a library. For streaming animations where every frame counts, consider going direct.
See also: Building a Code Evolution Analyzer in a Weekend — the full project story
See also: Deep Dive: Audio Sonification — the sound design experiment