Data Visualization: How to Create Beautiful Data Art With Code
Data visualization is the art of turning numbers into pictures — and when you write it by hand with code, it becomes something more: a direct conversation between data and aesthetics. No chart libraries. No drag-and-drop dashboards. Just raw data, a Canvas element, and the algorithms that transform one into the other. Every bar, arc, and connection you see is a deliberate design decision expressed in code.
The core of data visualization is mapping: take a value from a data domain (say, a temperature from 0°C to 40°C) and map it to a visual domain (a y-position from 0 to 400 pixels, a color from blue to red, a radius from 2 to 20). This one idea — mapping data to visual properties — underlies every chart, graph, and data art piece ever created. Master it, and you can visualize anything.
This guide covers eight working data visualization systems you can build in your browser. Every example uses vanilla JavaScript and the Canvas API — no D3.js, no Chart.js, no frameworks. Just the math that makes data beautiful.
Why build visualizations from scratch?
Libraries like D3.js are powerful, but they hide the mechanics. When you build from scratch, you understand:
- Scale mapping — how data values translate to pixel positions
- Layout algorithms — how treemaps, force graphs, and radial charts actually work
- Animation — how to smoothly transition between data states
- Interaction — how mouse position maps back from visual space to data space
- Performance — why Canvas beats SVG for thousands of data points
And there's an artistic dimension: when you control every pixel, data visualization becomes data art. The line between "chart" and "artwork" dissolves when you write the rendering code yourself.
Example 1: Animated bar chart with easing
The humble bar chart is the foundation of data visualization. But a hand-coded bar chart can be so much more than a static rectangle — add animation, hover states, and smooth easing, and it becomes alive.
var c = document.createElement('canvas');
c.width = 600; c.height = 400;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var data = [
{ label: 'Mon', value: 45 },
{ label: 'Tue', value: 72 },
{ label: 'Wed', value: 58 },
{ label: 'Thu', value: 91 },
{ label: 'Fri', value: 36 },
{ label: 'Sat', value: 83 },
{ label: 'Sun', value: 67 }
];
var maxVal = Math.max.apply(null, data.map(function(d) { return d.value; }));
var animated = data.map(function() { return 0; });
var hovered = -1;
var padding = { top: 40, right: 30, bottom: 50, left: 50 };
var chartW = c.width - padding.left - padding.right;
var chartH = c.height - padding.top - padding.bottom;
var barW = chartW / data.length * 0.7;
var gap = chartW / data.length * 0.3;
function easeOutElastic(t) {
if (t === 0 || t === 1) return t;
return Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 1;
}
function mapY(val) {
return padding.top + chartH - (val / maxVal) * chartH;
}
c.addEventListener('mousemove', function(e) {
var rect = c.getBoundingClientRect();
var mx = e.clientX - rect.left - padding.left;
hovered = Math.floor(mx / (chartW / data.length));
if (hovered < 0 || hovered >= data.length) hovered = -1;
});
var startTime = Date.now();
function draw() {
var elapsed = (Date.now() - startTime) / 1000;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
// Grid lines
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 1;
for (var g = 0; g <= 5; g++) {
var gy = padding.top + (chartH / 5) * g;
ctx.beginPath();
ctx.moveTo(padding.left, gy);
ctx.lineTo(c.width - padding.right, gy);
ctx.stroke();
ctx.fillStyle = '#555';
ctx.font = '11px monospace';
ctx.textAlign = 'right';
ctx.fillText(Math.round(maxVal - (maxVal / 5) * g), padding.left - 8, gy + 4);
}
// Bars
for (var i = 0; i < data.length; i++) {
var target = data[i].value;
var delay = i * 0.12;
var progress = Math.min(1, Math.max(0, (elapsed - delay) / 0.8));
animated[i] = target * easeOutElastic(progress);
var x = padding.left + i * (chartW / data.length) + gap / 2;
var barH = (animated[i] / maxVal) * chartH;
var y = padding.top + chartH - barH;
// Bar gradient
var grad = ctx.createLinearGradient(x, y, x, padding.top + chartH);
if (i === hovered) {
grad.addColorStop(0, 'hsl(' + (200 + i * 25) + ',90%,65%)');
grad.addColorStop(1, 'hsl(' + (200 + i * 25) + ',80%,35%)');
} else {
grad.addColorStop(0, 'hsl(' + (200 + i * 25) + ',70%,55%)');
grad.addColorStop(1, 'hsl(' + (200 + i * 25) + ',60%,25%)');
}
ctx.fillStyle = grad;
// Rounded top
var r = Math.min(6, barW / 4);
ctx.beginPath();
ctx.moveTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.arcTo(x + barW, y, x + barW, y + r, r);
ctx.lineTo(x + barW, padding.top + chartH);
ctx.lineTo(x, padding.top + chartH);
ctx.closePath();
ctx.fill();
// Value label
if (i === hovered) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'center';
ctx.fillText(data[i].value, x + barW / 2, y - 10);
}
// X label
ctx.fillStyle = i === hovered ? '#fff' : '#888';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText(data[i].label, x + barW / 2, c.height - padding.bottom + 20);
}
// Title
ctx.fillStyle = '#ccc';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.fillText('Weekly Activity', padding.left, 24);
requestAnimationFrame(draw);
}
draw();
The key technique here is the elastic easing function — bars overshoot their target height and bounce back, giving the chart a physical, springy feel. Each bar is delayed by 120ms, creating a wave-like stagger. The mapY function is the scale: it translates data values (0–91) to pixel positions (400–40). This is the fundamental operation of all data visualization.
Example 2: Scatter plot with density heatmap
Scatter plots reveal relationships between two variables. When you have many data points, density becomes the third variable — how many points cluster in each region. This example generates random clustered data and renders both individual points and a density heatmap behind them.
var c = document.createElement('canvas');
c.width = 600; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
// Generate clustered data — 3 clusters
var points = [];
var clusters = [
{ cx: 0.3, cy: 0.7, sx: 0.08, sy: 0.06, n: 80, color: '#4ecdc4' },
{ cx: 0.6, cy: 0.3, sx: 0.1, sy: 0.08, n: 120, color: '#ff6b6b' },
{ cx: 0.8, cy: 0.8, sx: 0.05, sy: 0.1, n: 60, color: '#ffe66d' }
];
clusters.forEach(function(cl) {
for (var i = 0; i < cl.n; i++) {
// Box-Muller transform for normal distribution
var u1 = Math.random(), u2 = Math.random();
var z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
var z1 = Math.sqrt(-2 * Math.log(u1)) * Math.sin(2 * Math.PI * u2);
points.push({
x: cl.cx + z0 * cl.sx,
y: cl.cy + z1 * cl.sy,
color: cl.color
});
}
});
var pad = 50;
var w = c.width - pad * 2;
var h = c.height - pad * 2;
// Density grid
var gridSize = 40;
var density = [];
for (var gy = 0; gy < gridSize; gy++) {
density[gy] = [];
for (var gx = 0; gx < gridSize; gx++) {
var count = 0;
var gcx = gx / gridSize;
var gcy = gy / gridSize;
points.forEach(function(p) {
var dx = p.x - gcx;
var dy = p.y - gcy;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.08) count += 1 - dist / 0.08; // Kernel density
});
density[gy][gx] = count;
}
}
var maxDensity = 0;
density.forEach(function(row) {
row.forEach(function(v) { if (v > maxDensity) maxDensity = v; });
});
var time = 0;
function draw() {
time += 0.016;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
// Draw density heatmap
var cellW = w / gridSize;
var cellH = h / gridSize;
for (var gy = 0; gy < gridSize; gy++) {
for (var gx = 0; gx < gridSize; gx++) {
var d = density[gy][gx] / maxDensity;
if (d > 0.05) {
ctx.fillStyle = 'rgba(100,140,255,' + (d * 0.3).toFixed(3) + ')';
ctx.fillRect(pad + gx * cellW, pad + gy * cellH, cellW + 1, cellH + 1);
}
}
}
// Grid and axes
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 1;
for (var i = 0; i <= 5; i++) {
var gx2 = pad + (w / 5) * i;
var gy2 = pad + (h / 5) * i;
ctx.beginPath(); ctx.moveTo(gx2, pad); ctx.lineTo(gx2, pad + h); ctx.stroke();
ctx.beginPath(); ctx.moveTo(pad, gy2); ctx.lineTo(pad + w, gy2); ctx.stroke();
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText((i * 20).toString(), gx2, c.height - pad + 18);
ctx.textAlign = 'right';
ctx.fillText((100 - i * 20).toString(), pad - 8, gy2 + 4);
}
// Draw points with subtle animation
points.forEach(function(p, idx) {
var px = pad + p.x * w;
var py = pad + p.y * h;
var pulse = 1 + Math.sin(time * 2 + idx * 0.1) * 0.15;
ctx.beginPath();
ctx.arc(px, py, 3 * pulse, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = 0.7;
ctx.fill();
ctx.globalAlpha = 1;
});
// Axis labels
ctx.fillStyle = '#888';
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.fillText('Variable X', c.width / 2, c.height - 8);
ctx.save();
ctx.translate(14, c.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Variable Y', 0, 0);
ctx.restore();
// Title
ctx.fillStyle = '#ccc';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.fillText('Clustered Data with Density Heatmap', pad, 28);
ctx.fillStyle = '#666';
ctx.font = '11px monospace';
ctx.fillText(points.length + ' points, 3 clusters', pad, 44);
requestAnimationFrame(draw);
}
draw();
This example introduces kernel density estimation — for each cell in a grid, we count how many data points fall within a radius, weighted by distance. The result is a smooth heatmap that reveals the shape of each cluster. The Box-Muller transform generates normally distributed random numbers from uniform random numbers — a fundamental technique for simulating realistic data distributions.
Example 3: Radial chart — data as geometry
Radial charts wrap data around a circle, creating visual patterns that are impossible in Cartesian space. This example creates a multi-layered radial visualization of monthly data — part chart, part geometric art.
var c = document.createElement('canvas');
c.width = 500; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var datasets = [
{ label: 'Temperature', data: [2,4,8,14,19,23,25,24,20,14,8,3], color: '#ff6b6b' },
{ label: 'Rainfall', data: [60,50,55,45,50,40,35,40,55,65,70,65], color: '#4ecdc4', max: 80 },
{ label: 'Sunshine', data: [2,3,4,6,7,8,9,8,6,4,2,1], color: '#ffe66d', max: 10 }
];
var cx = c.width / 2;
var cy = c.height / 2;
var maxR = 180;
var time = 0;
function draw() {
time += 0.01;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
// Concentric grid rings
for (var r = 1; r <= 4; r++) {
ctx.beginPath();
ctx.arc(cx, cy, maxR * r / 4, 0, Math.PI * 2);
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 1;
ctx.stroke();
}
// Month spokes
for (var m = 0; m < 12; m++) {
var angle = (m / 12) * Math.PI * 2 - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * maxR, cy + Math.sin(angle) * maxR);
ctx.strokeStyle = '#1a1a2e';
ctx.stroke();
// Month labels
var lx = cx + Math.cos(angle) * (maxR + 18);
var ly = cy + Math.sin(angle) * (maxR + 18);
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(months[m], lx, ly);
}
// Draw each dataset as a filled polygon
datasets.forEach(function(ds, di) {
var dataMax = ds.max || Math.max.apply(null, ds.data);
var animProgress = Math.min(1, Math.max(0, time - di * 0.5) / 1.5);
var ease = 1 - Math.pow(1 - animProgress, 3);
ctx.beginPath();
for (var m = 0; m <= 12; m++) {
var idx = m % 12;
var angle = (idx / 12) * Math.PI * 2 - Math.PI / 2;
var r = (ds.data[idx] / dataMax) * maxR * 0.85 * ease;
var px = cx + Math.cos(angle) * r;
var py = cy + Math.sin(angle) * r;
if (m === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = ds.color.replace(')', ',0.15)').replace('rgb', 'rgba').replace('#', '');
// Hex to rgba
var hex = ds.color;
var rr = parseInt(hex.slice(1,3), 16);
var gg = parseInt(hex.slice(3,5), 16);
var bb = parseInt(hex.slice(5,7), 16);
ctx.fillStyle = 'rgba(' + rr + ',' + gg + ',' + bb + ',0.15)';
ctx.fill();
ctx.strokeStyle = ds.color;
ctx.lineWidth = 2;
ctx.stroke();
// Data points
for (var m2 = 0; m2 < 12; m2++) {
var a2 = (m2 / 12) * Math.PI * 2 - Math.PI / 2;
var r2 = (ds.data[m2] / dataMax) * maxR * 0.85 * ease;
var px2 = cx + Math.cos(a2) * r2;
var py2 = cy + Math.sin(a2) * r2;
ctx.beginPath();
ctx.arc(px2, py2, 3, 0, Math.PI * 2);
ctx.fillStyle = ds.color;
ctx.fill();
}
});
// Legend
datasets.forEach(function(ds, i) {
ctx.fillStyle = ds.color;
ctx.fillRect(15, 15 + i * 20, 12, 12);
ctx.fillStyle = '#aaa';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(ds.label, 32, 21 + i * 20);
});
ctx.fillStyle = '#ccc';
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'center';
ctx.fillText('Annual Climate Patterns', cx, c.height - 12);
requestAnimationFrame(draw);
}
draw();
Radial charts are powerful because they exploit our ability to perceive shapes. A year of temperature data that looks like a gentle wave in a line chart becomes a warm blob in a radial chart — you can literally see the shape of summer. The technique: convert each data index to an angle (index / total * 2π) and each value to a radius. The polygon that connects these points reveals the data's character at a glance. It's the same polar coordinate transformation that creates rose curves and spirographs.
Example 4: Force-directed network graph
Network graphs visualize relationships — social connections, dependencies, hyperlinks, neural pathways. The force-directed layout uses physics: nodes repel each other (like charged particles), while edges act as springs pulling connected nodes together. The system relaxes into a layout where clusters naturally group together.
var c = document.createElement('canvas');
c.width = 600; c.height = 500;
document.body.appendChild(c);
var ctx = c.getContext('2d');
// Generate a small-world network
var nodes = [];
var edges = [];
var numNodes = 40;
for (var i = 0; i < numNodes; i++) {
nodes.push({
x: c.width / 2 + (Math.random() - 0.5) * 200,
y: c.height / 2 + (Math.random() - 0.5) * 200,
vx: 0, vy: 0,
group: Math.floor(i / 10),
radius: 4 + Math.random() * 4
});
// Ring connection
if (i > 0) edges.push({ source: i, target: i - 1 });
// Random shortcuts (small-world property)
if (Math.random() < 0.15 && i > 2) {
edges.push({ source: i, target: Math.floor(Math.random() * i) });
}
// Intra-group connections
var group = Math.floor(i / 10);
for (var j = group * 10; j < i; j++) {
if (Math.random() < 0.3) edges.push({ source: i, target: j });
}
}
var colors = ['#ff6b6b', '#4ecdc4', '#ffe66d', '#a29bfe'];
var dragging = -1;
c.addEventListener('mousedown', function(e) {
var rect = c.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
for (var i = 0; i < nodes.length; i++) {
var dx = nodes[i].x - mx, dy = nodes[i].y - my;
if (Math.sqrt(dx * dx + dy * dy) < nodes[i].radius + 5) {
dragging = i; break;
}
}
});
c.addEventListener('mousemove', function(e) {
if (dragging >= 0) {
var rect = c.getBoundingClientRect();
nodes[dragging].x = e.clientX - rect.left;
nodes[dragging].y = e.clientY - rect.top;
nodes[dragging].vx = 0;
nodes[dragging].vy = 0;
}
});
c.addEventListener('mouseup', function() { dragging = -1; });
function simulate() {
// Repulsion between all pairs (Barnes-Hut would optimize this)
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var dx = nodes[j].x - nodes[i].x;
var dy = nodes[j].y - nodes[i].y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = 800 / (dist * dist);
var fx = (dx / dist) * force;
var fy = (dy / dist) * force;
nodes[i].vx -= fx; nodes[i].vy -= fy;
nodes[j].vx += fx; nodes[j].vy += fy;
}
}
// Spring force along edges
edges.forEach(function(e) {
var s = nodes[e.source], t = nodes[e.target];
var dx = t.x - s.x, dy = t.y - s.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 1;
var force = (dist - 60) * 0.04;
var fx = (dx / dist) * force;
var fy = (dy / dist) * force;
s.vx += fx; s.vy += fy;
t.vx -= fx; t.vy -= fy;
});
// Center gravity
nodes.forEach(function(n) {
n.vx += (c.width / 2 - n.x) * 0.001;
n.vy += (c.height / 2 - n.y) * 0.001;
});
// Update positions with damping
nodes.forEach(function(n, i) {
if (i === dragging) return;
n.vx *= 0.9; n.vy *= 0.9;
n.x += n.vx; n.y += n.vy;
n.x = Math.max(20, Math.min(c.width - 20, n.x));
n.y = Math.max(20, Math.min(c.height - 20, n.y));
});
}
function draw() {
simulate();
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
// Edges
edges.forEach(function(e) {
var s = nodes[e.source], t = nodes[e.target];
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(t.x, t.y);
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
ctx.lineWidth = 1;
ctx.stroke();
});
// Nodes
nodes.forEach(function(n) {
ctx.beginPath();
ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
ctx.fillStyle = colors[n.group];
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 0.5;
ctx.stroke();
});
// Title
ctx.fillStyle = '#ccc';
ctx.font = 'bold 13px monospace';
ctx.fillText('Force-Directed Network', 15, 24);
ctx.fillStyle = '#666';
ctx.font = '11px monospace';
ctx.fillText(nodes.length + ' nodes, ' + edges.length + ' edges — drag to rearrange', 15, 42);
requestAnimationFrame(draw);
}
draw();
The physics simulation runs three forces every frame: repulsion (nodes push each other apart, inversely proportional to distance squared — Coulomb's law), attraction (edges act as springs with a rest length of 60px — Hooke's law), and gravity (a gentle pull toward the center to prevent drift). Damping at 0.9 prevents oscillation. This is the same algorithm used by D3.js and Gephi under the hood — but here it fits in 60 lines. Drag any node to see the network rearrange itself in real time.
Example 5: Treemap — rectangles as data
Treemaps fill a rectangle with smaller rectangles whose areas are proportional to data values. They're brilliant for showing hierarchical data and part-to-whole relationships. This implementation uses the squarified treemap algorithm, which produces rectangles closer to squares (easier to compare) rather than long thin strips.
var c = document.createElement('canvas');
c.width = 600; c.height = 400;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var data = [
{ name: 'JavaScript', value: 65, color: '#f7df1e' },
{ name: 'Python', value: 48, color: '#3776ab' },
{ name: 'TypeScript', value: 35, color: '#3178c6' },
{ name: 'Java', value: 33, color: '#ed8b00' },
{ name: 'C#', value: 28, color: '#68217a' },
{ name: 'C++', value: 22, color: '#00599c' },
{ name: 'PHP', value: 18, color: '#777bb4' },
{ name: 'Go', value: 15, color: '#00add8' },
{ name: 'Rust', value: 13, color: '#ce412b' },
{ name: 'Ruby', value: 10, color: '#cc342d' },
{ name: 'Swift', value: 8, color: '#fa7343' },
{ name: 'Kotlin', value: 7, color: '#7f52ff' }
];
data.sort(function(a, b) { return b.value - a.value; });
var total = data.reduce(function(s, d) { return s + d.value; }, 0);
// Squarified treemap layout
function layoutRow(items, x, y, w, h, horizontal) {
var rowTotal = items.reduce(function(s, d) { return s + d.value; }, 0);
var pos = 0;
items.forEach(function(item) {
var fraction = item.value / rowTotal;
if (horizontal) {
item.rect = { x: x + pos * w, y: y, w: fraction * w, h: h };
pos += fraction;
} else {
item.rect = { x: x, y: y + pos * h, w: w, h: fraction * h };
pos += fraction;
}
});
}
function squarify(items, x, y, w, h) {
if (items.length === 0) return;
if (items.length === 1) {
items[0].rect = { x: x, y: y, w: w, h: h };
return;
}
var horizontal = w >= h;
var side = horizontal ? h : w;
var itemTotal = items.reduce(function(s, d) { return s + d.value; }, 0);
var row = [items[0]];
var remaining = items.slice(1);
function worstRatio(row2) {
var rowSum = row2.reduce(function(s, d) { return s + d.value; }, 0);
var rowFraction = rowSum / itemTotal;
var rowSide = (horizontal ? w : h) * rowFraction;
var worst = 0;
row2.forEach(function(d) {
var otherSide = (d.value / rowSum) * side;
var ratio = Math.max(rowSide / otherSide, otherSide / rowSide);
if (ratio > worst) worst = ratio;
});
return worst;
}
while (remaining.length > 0) {
var testRow = row.concat([remaining[0]]);
if (worstRatio(testRow) <= worstRatio(row)) {
row.push(remaining.shift());
} else {
break;
}
}
var rowSum = row.reduce(function(s, d) { return s + d.value; }, 0);
var rowFraction = rowSum / itemTotal;
if (horizontal) {
var rowW = w * rowFraction;
layoutRow(row, x, y, rowW, h, false);
squarify(remaining, x + rowW, y, w - rowW, h);
} else {
var rowH = h * rowFraction;
layoutRow(row, x, y, w, rowH, true);
squarify(remaining, x, y + rowH, w, h - rowH);
}
}
var pad = 2;
squarify(data, pad, pad, c.width - pad * 2, c.height - pad * 2);
var hovered = -1;
c.addEventListener('mousemove', function(e) {
var rect = c.getBoundingClientRect();
var mx = e.clientX - rect.left;
var my = e.clientY - rect.top;
hovered = -1;
data.forEach(function(d, i) {
if (d.rect && mx >= d.rect.x && mx <= d.rect.x + d.rect.w &&
my >= d.rect.y && my <= d.rect.y + d.rect.h) {
hovered = i;
}
});
});
var startTime = Date.now();
function draw() {
var elapsed = (Date.now() - startTime) / 1000;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
data.forEach(function(d, i) {
if (!d.rect) return;
var r = d.rect;
var grow = Math.min(1, Math.max(0, (elapsed - i * 0.06) / 0.5));
grow = 1 - Math.pow(1 - grow, 3); // ease-out cubic
var inset = 1.5;
var gx = r.x + r.w / 2;
var gy = r.y + r.h / 2;
var gw = (r.w - inset * 2) * grow;
var gh = (r.h - inset * 2) * grow;
ctx.fillStyle = i === hovered ? d.color : d.color + 'cc';
ctx.fillRect(gx - gw / 2, gy - gh / 2, gw, gh);
if (i === hovered) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(gx - gw / 2, gy - gh / 2, gw, gh);
}
// Label
if (gw > 40 && gh > 25) {
ctx.fillStyle = '#000';
ctx.font = (gw > 80 ? 'bold 13px' : '11px') + ' monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(d.name, gx, gy - (gh > 35 ? 7 : 0));
if (gh > 35) {
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillText(((d.value / total * 100).toFixed(1)) + '%', gx, gy + 10);
}
}
});
requestAnimationFrame(draw);
}
draw();
The squarified treemap algorithm works by greedily partitioning the data: it builds rows of rectangles, adding items to the current row as long as the aspect ratio improves. When adding the next item would make the rectangles worse (more elongated), it starts a new row. The result is a space-filling layout where each rectangle's area is exactly proportional to its data value, and shapes stay close to square. This is the same algorithm used by Bloomberg terminals and macOS disk usage visualizers.
Example 6: Streamgraph — layered data flow
A streamgraph is a stacked area chart centered around an axis, creating a flowing, organic shape. It's ideal for showing how multiple categories evolve over time — and when done well, it looks like a river with colored currents.
var c = document.createElement('canvas');
c.width = 700; c.height = 400;
document.body.appendChild(c);
var ctx = c.getContext('2d');
// Generate time series data for 6 categories
var categories = ['Rock', 'Pop', 'Hip-Hop', 'Electronic', 'Jazz', 'Classical'];
var colors2 = ['#e74c3c', '#3498db', '#f39c12', '#2ecc71', '#9b59b6', '#1abc9c'];
var numPoints = 50;
var series = [];
categories.forEach(function(cat, ci) {
var values = [];
var base = 20 + Math.random() * 30;
for (var t = 0; t < numPoints; t++) {
// Smooth random walk with trend
base += (Math.random() - 0.5) * 8;
base = Math.max(5, Math.min(80, base));
// Add genre-specific peaks
var peak = Math.exp(-Math.pow((t - 10 - ci * 6) / 8, 2)) * 40;
values.push(Math.max(2, base + peak));
}
series.push(values);
});
// Calculate stream layout (wiggle baseline)
var stacks = [];
for (var t = 0; t < numPoints; t++) {
var total2 = 0;
series.forEach(function(s) { total2 += s[t]; });
var y0 = -total2 / 2; // Center the stream
var stack = [];
series.forEach(function(s) {
stack.push({ y0: y0, y1: y0 + s[t] });
y0 += s[t];
});
stacks.push(stack);
}
var padL = 50, padR = 20, padT = 40, padB = 30;
var chartW2 = c.width - padL - padR;
var chartH2 = c.height - padT - padB;
var centerY = padT + chartH2 / 2;
// Find max extent for scaling
var maxExtent = 0;
stacks.forEach(function(stack) {
stack.forEach(function(s) {
maxExtent = Math.max(maxExtent, Math.abs(s.y0), Math.abs(s.y1));
});
});
var scaleY = (chartH2 / 2) / maxExtent;
var scaleX = chartW2 / (numPoints - 1);
var time2 = 0;
function draw() {
time2 += 0.008;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
// Draw each layer
for (var si = series.length - 1; si >= 0; si--) {
var reveal = Math.min(1, Math.max(0, (time2 - si * 0.15) / 1));
if (reveal <= 0) continue;
ctx.beginPath();
// Top edge (left to right)
for (var t = 0; t < numPoints; t++) {
var x = padL + t * scaleX;
var y = centerY - stacks[t][si].y1 * scaleY;
var prog = Math.min(1, t / (numPoints * reveal));
y = centerY + (y - centerY) * prog;
if (t === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
// Bottom edge (right to left)
for (var t2 = numPoints - 1; t2 >= 0; t2--) {
var x2 = padL + t2 * scaleX;
var y2 = centerY - stacks[t2][si].y0 * scaleY;
var prog2 = Math.min(1, t2 / (numPoints * reveal));
y2 = centerY + (y2 - centerY) * prog2;
ctx.lineTo(x2, y2);
}
ctx.closePath();
var grad2 = ctx.createLinearGradient(0, padT, 0, padT + chartH2);
grad2.addColorStop(0, colors2[si] + 'ee');
grad2.addColorStop(0.5, colors2[si] + 'aa');
grad2.addColorStop(1, colors2[si] + 'ee');
ctx.fillStyle = grad2;
ctx.fill();
}
// Time axis
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, centerY);
ctx.lineTo(padL + chartW2, centerY);
ctx.stroke();
for (var decade = 0; decade < 5; decade++) {
var dx = padL + (decade / 4) * chartW2;
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText((1980 + decade * 10).toString(), dx, c.height - 8);
}
// Legend
categories.forEach(function(cat, i) {
var lx = padL + i * 95;
ctx.fillStyle = colors2[i];
ctx.fillRect(lx, 12, 10, 10);
ctx.fillStyle = '#aaa';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(cat, lx + 14, 21);
});
ctx.fillStyle = '#ccc';
ctx.font = 'bold 13px monospace';
ctx.textAlign = 'left';
ctx.fillText('Music Genre Popularity (Streamgraph)', padL, c.height - padB + 22);
requestAnimationFrame(draw);
}
draw();
The streamgraph layout centers the total stack around zero, which minimizes the visual area and emphasizes change over time rather than absolute position. Each layer is drawn as a closed path: the top edge traces left-to-right, then the bottom edge traces right-to-left, creating a filled band. The reveal animation sweeps from left to right, making each layer appear as if the data is flowing onto the screen. Streamgraphs were popularized by the New York Times for showing genre popularity — the organic, river-like shapes are more engaging than standard stacked charts.
Example 7: Animated data transitions
The most powerful technique in data visualization is smooth transitions between states. When data changes, don't jump — animate. This creates object constancy: the viewer's eye tracks individual elements as they move to new positions, making changes intuitive rather than disorienting.
var c = document.createElement('canvas');
c.width = 600; c.height = 400;
document.body.appendChild(c);
var ctx = c.getContext('2d');
var items = [];
for (var i = 0; i < 20; i++) {
items.push({
id: i,
value: 10 + Math.random() * 90,
targetValue: 0,
displayValue: 0,
targetX: 0, targetY: 0,
x: 0, y: 0,
color: 'hsl(' + (i * 18) + ',70%,55%)',
label: String.fromCharCode(65 + i)
});
}
var mode = 0; // 0=bar, 1=scatter, 2=pie, 3=bubble
var modeNames = ['Bar Chart', 'Scatter Plot', 'Donut Chart', 'Bubble Pack'];
var transitionStart = 0;
var transitionDuration = 1200;
var padA = 50;
function shuffleValues() {
items.forEach(function(item) {
item.targetValue = 10 + Math.random() * 90;
});
}
function calculateTargets() {
var sorted = items.slice().sort(function(a, b) { return b.targetValue - a.targetValue; });
if (mode === 0) { // Bar
var barW2 = (c.width - padA * 2) / items.length;
items.forEach(function(item) {
var idx = sorted.indexOf(item);
item.targetX = padA + idx * barW2 + barW2 / 2;
item.targetY = c.height - padA - (item.targetValue / 100) * (c.height - padA * 2);
item.targetR = barW2 * 0.35;
});
} else if (mode === 1) { // Scatter
items.forEach(function(item) {
item.targetX = padA + (item.id / 20) * (c.width - padA * 2);
item.targetY = c.height - padA - (item.targetValue / 100) * (c.height - padA * 2);
item.targetR = 6;
});
} else if (mode === 2) { // Donut
var totalVal = items.reduce(function(s, d) { return s + d.targetValue; }, 0);
var angle2 = -Math.PI / 2;
var cx2 = c.width / 2, cy2 = c.height / 2;
items.forEach(function(item) {
var slice = (item.targetValue / totalVal) * Math.PI * 2;
var midAngle = angle2 + slice / 2;
item.targetX = cx2 + Math.cos(midAngle) * 120;
item.targetY = cy2 + Math.sin(midAngle) * 120;
item.targetR = Math.max(8, slice * 30);
angle2 += slice;
});
} else { // Bubble pack
var cx3 = c.width / 2, cy3 = c.height / 2;
// Simple spiral packing
items.forEach(function(item, idx) {
var a = idx * 0.7;
var r3 = 30 + idx * 8;
item.targetX = cx3 + Math.cos(a) * r3;
item.targetY = cy3 + Math.sin(a) * r3;
item.targetR = 10 + (item.targetValue / 100) * 20;
});
}
}
function easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
// Initialize
calculateTargets();
items.forEach(function(item) {
item.x = item.targetX;
item.y = item.targetY;
item.displayValue = item.targetValue;
item.r = item.targetR;
});
// Cycle modes every 3 seconds
setInterval(function() {
mode = (mode + 1) % 4;
shuffleValues();
items.forEach(function(item) {
item.startX = item.x; item.startY = item.y;
item.startR = item.r; item.startValue = item.displayValue;
});
calculateTargets();
transitionStart = Date.now();
}, 3000);
function draw() {
var elapsed2 = Date.now() - transitionStart;
var progress3 = Math.min(1, elapsed2 / transitionDuration);
var eased = easeInOutCubic(progress3);
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, c.width, c.height);
items.forEach(function(item) {
if (item.startX !== undefined) {
item.x = item.startX + (item.targetX - item.startX) * eased;
item.y = item.startY + (item.targetY - item.startY) * eased;
item.r = item.startR + (item.targetR - item.startR) * eased;
item.displayValue = item.startValue + (item.targetValue - item.startValue) * eased;
}
ctx.beginPath();
ctx.arc(item.x, item.y, item.r, 0, Math.PI * 2);
ctx.fillStyle = item.color;
ctx.globalAlpha = 0.8;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
if (item.r > 10) {
ctx.fillStyle = '#fff';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(item.label, item.x, item.y);
}
});
// Mode label
ctx.fillStyle = '#ccc';
ctx.font = 'bold 16px monospace';
ctx.textAlign = 'center';
ctx.fillText(modeNames[mode], c.width / 2, 28);
ctx.fillStyle = '#555';
ctx.font = '11px monospace';
ctx.fillText('Auto-cycles every 3s: bar → scatter → donut → bubble', c.width / 2, 48);
requestAnimationFrame(draw);
}
draw();
This example cycles through four layouts — bar chart, scatter plot, donut, and bubble pack — with each element maintaining its identity (color and letter) across transitions. The easeInOutCubic function provides smooth acceleration and deceleration. Each layout simply assigns different target positions; the animation system interpolates everything. This technique — compute targets, then interpolate — works for any visualization transition. It's the same principle behind Gapminder (Hans Rosling's famous animated statistics) and the morphing charts in data journalism.
Example 8: Data-driven generative art
When data visualization meets creative coding, the result is data art — visualizations that prioritize beauty and emotion over precision. This example takes real weather data patterns and transforms them into an abstract landscape where temperature becomes color, humidity becomes density, and wind becomes movement.
var c = document.createElement('canvas');
c.width = 700; c.height = 450;
document.body.appendChild(c);
var ctx = c.getContext('2d');
// Simulated 365 days of weather data
var weather = [];
for (var d = 0; d < 365; d++) {
var dayAngle = (d / 365) * Math.PI * 2;
var seasonTemp = 15 + 12 * Math.sin(dayAngle - Math.PI / 2); // -3 to 27°C
var noise = (Math.random() - 0.5) * 8;
var temp = seasonTemp + noise;
var humidity = 60 + 20 * Math.cos(dayAngle) + (Math.random() - 0.5) * 15;
var wind = 5 + 10 * Math.abs(Math.sin(dayAngle * 3)) + Math.random() * 8;
var rain = Math.max(0, humidity - 70 + Math.random() * 20);
weather.push({ temp: temp, humidity: humidity, wind: wind, rain: rain, day: d });
}
// Particle system driven by weather data
var particles = [];
var numParticles = 800;
for (var i = 0; i < numParticles; i++) {
particles.push({
x: Math.random() * c.width,
y: Math.random() * c.height,
day: Math.floor(Math.random() * 365),
size: 1 + Math.random() * 2,
life: Math.random()
});
}
function tempToColor(temp2) {
// Cold (blue) to hot (red) through warm (orange/yellow)
var t = (temp2 + 5) / 35; // normalize -5 to 30 → 0 to 1
t = Math.max(0, Math.min(1, t));
if (t < 0.3) {
// Blue to cyan
var f = t / 0.3;
return 'hsl(' + (240 - f * 60) + ',70%,' + (30 + f * 20) + '%)';
} else if (t < 0.6) {
// Cyan to yellow
var f2 = (t - 0.3) / 0.3;
return 'hsl(' + (180 - f2 * 120) + ',80%,' + (50 + f2 * 10) + '%)';
} else {
// Yellow to red
var f3 = (t - 0.6) / 0.4;
return 'hsl(' + (60 - f3 * 60) + ',85%,' + (55 - f3 * 15) + '%)';
}
}
var time3 = 0;
var currentDay = 0;
function draw() {
time3 += 0.016;
currentDay = (currentDay + 0.3) % 365;
var today = weather[Math.floor(currentDay)];
// Fade trail
ctx.fillStyle = 'rgba(10,10,10,0.05)';
ctx.fillRect(0, 0, c.width, c.height);
particles.forEach(function(p) {
var w = weather[p.day];
// Wind drives horizontal movement
var windForce = w.wind / 30;
// Temperature drives vertical position tendency
var tempForce = (w.temp - 15) / 30;
// Humidity drives particle density/opacity
var humidAlpha = 0.3 + (w.humidity / 100) * 0.5;
p.x += Math.sin(time3 + p.day * 0.02) * windForce * 2 + windForce * 0.5;
p.y += Math.cos(time3 * 0.7 + p.x * 0.005) * 0.5 - tempForce * 0.3;
p.life -= 0.002;
if (p.x > c.width) p.x = 0;
if (p.x < 0) p.x = c.width;
if (p.y > c.height) p.y = 0;
if (p.y < 0) p.y = c.height;
if (p.life <= 0) {
p.x = Math.random() * c.width;
p.y = Math.random() * c.height;
p.day = (p.day + 1) % 365;
p.life = 0.5 + Math.random() * 0.5;
}
var color = tempToColor(w.temp);
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.5 + w.rain / 40), 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = humidAlpha * p.life;
ctx.fill();
ctx.globalAlpha = 1;
});
// Day indicator — circular calendar at bottom right
var calCx = c.width - 60;
var calCy = c.height - 60;
var calR = 35;
ctx.beginPath();
ctx.arc(calCx, calCy, calR, 0, Math.PI * 2);
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
// Colored arc showing year progress
var dayAngle2 = (currentDay / 365) * Math.PI * 2 - Math.PI / 2;
ctx.beginPath();
ctx.arc(calCx, calCy, calR, -Math.PI / 2, dayAngle2);
ctx.strokeStyle = tempToColor(today.temp);
ctx.lineWidth = 4;
ctx.stroke();
// Month label
var monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
var monthIdx = Math.floor(currentDay / 30.4);
ctx.fillStyle = '#aaa';
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(monthNames[monthIdx % 12], calCx, calCy);
// Stats
ctx.fillStyle = '#888';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(today.temp.toFixed(1) + '°C ' + today.humidity.toFixed(0) + '% humidity ' + today.wind.toFixed(0) + ' km/h', 12, c.height - 12);
// Title
ctx.fillStyle = '#ccc';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.fillText('Weather as Art — 365 Days of Data', 12, 24);
ctx.fillStyle = '#666';
ctx.font = '11px monospace';
ctx.fillText('Temperature → color, humidity → opacity, wind → motion, rain → size', 12, 42);
requestAnimationFrame(draw);
}
draw();
This is where data visualization becomes art. Each particle carries a day's worth of weather data and expresses it through visual properties: temperature maps to a blue-to-red color spectrum, humidity controls opacity, wind drives horizontal drift, and rainfall increases particle size. The result is an ever-shifting cloudscape that encodes an entire year's climate in a single visual poem. A cold, windy January is a stream of small blue particles racing across the screen. A humid August is a dense, warm orange fog that barely moves.
This is the essence of creative data visualization: every aesthetic choice is also a data choice. Color isn't decoration — it's temperature. Motion isn't animation — it's wind. When you build visualizations from code, you decide what every pixel means.
Fundamental techniques: a reference
Every data visualization example above uses the same core techniques. Here's a quick reference:
- Linear scale:
output = outMin + (value - inMin) / (inMax - inMin) * (outMax - outMin) - Log scale: replace
valuewithMath.log(value)— compresses large ranges - Color scale: map a value to HSL hue (0=red, 120=green, 240=blue) or interpolate between two RGB colors
- Easing: apply an easing curve to transition progress: ease-out (
1 - (1-t)^3), ease-in-out, elastic - Hit testing: reverse the scale — map mouse pixel position back to data coordinates
- Layout algorithms: treemap (recursive partitioning), force-directed (physics simulation), radial (polar coordinates)
Beyond charts: data visualization as creative coding
The best data visualizations don't look like charts at all. They look like art that happens to encode information. Some directions to explore:
- Map real-time data (audio from the microphone via Web Audio API, device sensors, live APIs) to visual properties
- Use particle systems where each particle represents a data point — 10,000 particles = 10,000 data points in motion
- Apply Perlin noise fields that are seeded or modulated by data — organic shapes that encode information
- Build cellular automata where the initial conditions come from a dataset — the data's "DNA" produces emergent visual patterns
- Create SVG animations that morph between different chart types — a bar chart that flows into a radial chart that dissolves into a network
- Use color theory deliberately: sequential palettes for ordered data, diverging palettes for data with a meaningful midpoint, categorical palettes for groups
On Lumitree, every micro-world is a tiny data visualization — it just visualizes abstract data (random seeds, visitor input, mathematical functions) rather than spreadsheets. The techniques are identical: map a domain to visual properties, animate transitions, and make every pixel meaningful. Whether you're building a dashboard for a startup or an art installation for a gallery, the code is the same. The only difference is what you choose to visualize — and how much beauty you let the data express.