This is the capstone of the data art arc. Twelve episodes of technique -- fetching APIs (episode 80), parsing files (81), mapping data to visuals (82), geographic art (83), temporal layouts (84), network graphs (85), text analysis (86), real-time streams (87), personal data art (88), and sonification (89). Every one of those episodes gave us a tool. Now we use them all at once. The goal: choose a dataset, design a visual language, and build a complete interactive data portrait from raw data to finished piece. Full pipeline. No shortcuts.
A mini-project episode is different from a technique episode. There's no new concept to learn. Instead, it's about combining everything we already know into something cohesive. The challenge isn't "how do I draw a circle" -- it's "what circle should I draw, and why, and what does it mean." Design decisions. Creative decisions. The hardest kind.
I'm going to walk you through my process for building a generative data portrait of global population data. Not because you should use the same dataset -- use whatever speaks to you. But the process is transferable: data selection, cleaning, design sketching, visual vocabulary, layout, interaction, annotation, export. Same steps every time, regardless of what the data is.
The dataset matters more than you think. Not technically -- technically any array of numbers works. But emotionally. The best data portraits come from data you actually care about. Personal data (episode 88) works because the subject is you. Public data works when it tells a story you're curious about. Historical data works when it makes you feel something about the past.
For this project, I'm using world population by country. It's public, it's rich (200+ countries, multiple dimensions per country), and it tells a visual story about how unevenly humans spread across the planet. You could just as easily use your city's weather history, your Spotify listening data, global earthquake records, or a year of your mood journal. The process is the same.
// sample dataset: population, area, continent, GDP per capita
// in practice, load from a JSON file or API (episode 80-81)
const countries = [
{ name: 'China', pop: 1425893, area: 9597, continent: 'Asia', gdppc: 12556 },
{ name: 'India', pop: 1428628, area: 3287, continent: 'Asia', gdppc: 2389 },
{ name: 'United States', pop: 339996, area: 9834, continent: 'N. America', gdppc: 76330 },
{ name: 'Indonesia', pop: 277534, area: 1905, continent: 'Asia', gdppc: 4788 },
{ name: 'Pakistan', pop: 240485, area: 881, continent: 'Asia', gdppc: 1505 },
{ name: 'Nigeria', pop: 223804, area: 924, continent: 'Africa', gdppc: 2184 },
{ name: 'Brazil', pop: 216422, area: 8516, continent: 'S. America', gdppc: 8918 },
{ name: 'Bangladesh', pop: 172954, area: 148, continent: 'Asia', gdppc: 2688 },
{ name: 'Russia', pop: 144236, area: 17098, continent: 'Europe', gdppc: 12195 },
{ name: 'Ethiopia', pop: 126527, area: 1104, continent: 'Africa', gdppc: 1027 },
{ name: 'Japan', pop: 123295, area: 378, continent: 'Asia', gdppc: 33815 },
{ name: 'Mexico', pop: 128901, area: 1964, continent: 'N. America', gdppc: 10948 },
{ name: 'Germany', pop: 83795, area: 357, continent: 'Europe', gdppc: 48718 },
{ name: 'France', pop: 64757, area: 640, continent: 'Europe', gdppc: 40964 },
{ name: 'UK', pop: 67737, area: 243, continent: 'Europe', gdppc: 46125 },
{ name: 'Egypt', pop: 112717, area: 1002, continent: 'Africa', gdppc: 3699 },
{ name: 'South Korea', pop: 51745, area: 100, continent: 'Asia', gdppc: 32423 },
{ name: 'Australia', pop: 26439, area: 7692, continent: 'Oceania', gdppc: 65099 },
{ name: 'Kenya', pop: 55100, area: 580, continent: 'Africa', gdppc: 2099 },
{ name: 'Canada', pop: 40098, area: 9985, continent: 'N. America', gdppc: 52722 }
];
Twenty countries is enough to demonstrate the full pipeline. In a real project you'd load 200+ from a JSON file (episode 81 covered parsing). The point is having multiple dimensions per data point -- population, area, continent, GDP -- so you can map each dimension to a different visual property.
Raw data is never ready to draw. Values span wildly different ranges -- population in millions, area in thousands of square kilometers, GDP from 1,000 to 76,000. Before mapping to visual properties, normalize everything to 0-1 so your map() function (episode 82) works consistently.
function normalize(data, key) {
const values = data.map(d => d[key]);
const min = Math.min(...values);
const max = Math.max(...values);
// avoid division by zero
const range = max - min || 1;
for (const d of data) {
d[key + 'Norm'] = (d[key] - min) / range;
}
}
normalize(countries, 'pop');
normalize(countries, 'area');
normalize(countries, 'gdppc');
// derived value: population density
for (const c of countries) {
c.density = c.pop / c.area;
}
normalize(countries, 'density');
// continent color mapping
const continentColors = {
'Asia': { h: 30, s: 55, l: 50 },
'Europe': { h: 220, s: 50, l: 50 },
'Africa': { h: 45, s: 60, l: 45 },
'N. America': { h: 160, s: 45, l: 45 },
'S. America': { h: 120, s: 50, l: 45 },
'Oceania': { h: 280, s: 45, l: 50 }
};
Population density is a derived metric -- not in the original data, but calculated from two fields that are. Derived metrics often reveal more than the raw numers. Bangladesh has modest population and tiny area, but its density is extraordinary. Russia has the most area but moderate population -- low density. These relationships become visible when you encode density as a separate visual channel.
The continent color map is a design decision. I picked warm hues for warmer climates (Asia=amber, Africa=golden) and cool hues for colder ones (Europe=blue). That's arbitrary -- you could map continents to any palette. But having a consistent color scheme that means something to you makes the portrait more readable.
This is the part most programmers skip, and it's the part that matters most. Before writing any drawing code, grab a piece of paper (or open a blank canvas in whatever you use) and sketch what the portrait should look like. Roughly. Badly. The sketch isn't art -- it's a thinking tool.
My sketch for this portrait: a radial layout where each country is a sector. Sector angle proportional to population (bigger countries get wider slices). Sector radius proportional to area. Color from continent. GDP encoded as brightness within the sector. Density shown as an inner ring thickness.
That's four data dimensions mapped to four visual channels:
Document this mapping. Even if just for yourself. When someone looks at your portrait and asks "what does the blue one mean," you need to know. And when you come back to the code in three months, you'll be glad you wrote it down. Trust me on this one :-)
Allez, time to code it. The radial layout assigns each country an angular slice proportional to its population share. Countries with more people get wider slices. We compute cumulative angles so each sector starts where the previous one ended.
const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 900;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
const cx = 450;
const cy = 450;
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 900);
// sort by continent for visual grouping
countries.sort(function(a, b) {
if (a.continent < b.continent) return -1;
if (a.continent > b.continent) return 1;
return b.pop - a.pop;
});
// compute total population for angle proportions
const totalPop = countries.reduce(function(sum, c) { return sum + c.pop; }, 0);
// draw sectors
let angle = -Math.PI / 2; // start at top
for (const c of countries) {
const sliceAngle = (c.pop / totalPop) * Math.PI * 2;
const nextAngle = angle + sliceAngle;
const midAngle = angle + sliceAngle / 2;
// radius from area (log scale -- Russia would dominate otherwise)
const logArea = Math.log(c.area + 1);
const logMax = Math.log(17098 + 1); // Russia
const logMin = Math.log(100 + 1); // South Korea
const areaNorm = (logArea - logMin) / (logMax - logMin);
const outerR = 120 + areaNorm * 250;
const innerR = 80;
// density ring
const densityWidth = c.densityNorm * 25;
// color from continent + brightness from GDP
const cc = continentColors[c.continent];
const gdpLight = cc.l - 10 + c.gdppcNorm * 25;
// main sector
ctx.beginPath();
ctx.moveTo(
cx + Math.cos(angle) * innerR,
cy + Math.sin(angle) * innerR
);
ctx.lineTo(
cx + Math.cos(angle) * outerR,
cy + Math.sin(angle) * outerR
);
ctx.arc(cx, cy, outerR, angle, nextAngle);
ctx.lineTo(
cx + Math.cos(nextAngle) * innerR,
cy + Math.sin(nextAngle) * innerR
);
ctx.arc(cx, cy, innerR, nextAngle, angle, true);
ctx.closePath();
ctx.fillStyle = `hsla(${cc.h}, ${cc.s}%, ${gdpLight}%, 0.55)`;
ctx.fill();
ctx.strokeStyle = `hsla(${cc.h}, ${cc.s}%, ${gdpLight + 10}%, 0.3)`;
ctx.lineWidth = 0.5;
ctx.stroke();
// density ring (inner edge, thicker = denser)
if (densityWidth > 1) {
ctx.beginPath();
ctx.arc(cx, cy, innerR - densityWidth / 2 - 2, angle + 0.01, nextAngle - 0.01);
ctx.strokeStyle = `hsla(${cc.h}, ${cc.s + 10}%, ${gdpLight + 15}%, 0.5)`;
ctx.lineWidth = densityWidth;
ctx.stroke();
}
// country label for sectors wide enough to read
if (sliceAngle > 0.08) {
const labelR = outerR + 15;
const labelX = cx + Math.cos(midAngle) * labelR;
const labelY = cy + Math.sin(midAngle) * labelR;
ctx.save();
ctx.translate(labelX, labelY);
// rotate text to follow the radial direction
let textAngle = midAngle;
if (midAngle > Math.PI / 2 && midAngle < Math.PI * 1.5) {
textAngle += Math.PI; // flip text on left side so it reads normally
}
ctx.rotate(textAngle);
ctx.fillStyle = `hsla(${cc.h}, 30%, 60%, 0.6)`;
ctx.font = '9px monospace';
ctx.textAlign = midAngle > Math.PI / 2 && midAngle < Math.PI * 1.5 ? 'right' : 'left';
ctx.fillText(c.name, 0, 3);
ctx.restore();
}
angle = nextAngle;
}
// center circle
ctx.beginPath();
ctx.arc(cx, cy, 78, 0, Math.PI * 2);
ctx.fillStyle = '#0a0a1a';
ctx.fill();
ctx.beginPath();
ctx.arc(cx, cy, 78, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(80, 90, 110, 0.2)';
ctx.lineWidth = 1;
ctx.stroke();
// center text
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('WORLD', cx, cy - 5);
ctx.fillText('POPULATION', cx, cy + 8);
A few design notes. I used log scale for the area-to-radius mapping because Russia's area (17 million km2) is 170x South Korea's (100k km2). Without log scale, Russia would fill the canvas and everything else would be a sliver. We hit the same issue with geographic data in episode 83 -- log scaling compresses extreme ranges while preserving relative differences.
Sorting by continent before drawing groups related countries together visually. All the Asian sectors are adjacent, all the European sectors cluster. You see continent-level patterns immediately: Asia dominates the total angle (most of the world's population), Europe has narrow slices but bright sectors (high GDP), Africa has moderate slices with dark fills (lower GDP).
A static portrait tells a story at a glance. An interactive one lets the viewer ask questions. "Which country is that small bright sector?" Hover to find out. We already have the angular geometry -- we just need to detect which sector the mouse is over.
// store sector boundaries for hit testing
const sectors = [];
let buildAngle = -Math.PI / 2;
for (const c of countries) {
const sliceAngle = (c.pop / totalPop) * Math.PI * 2;
const logArea = Math.log(c.area + 1);
const logMax = Math.log(17098 + 1);
const logMin = Math.log(100 + 1);
const areaNorm = (logArea - logMin) / (logMax - logMin);
const outerR = 120 + areaNorm * 250;
sectors.push({
country: c,
startAngle: buildAngle,
endAngle: buildAngle + sliceAngle,
innerR: 80,
outerR: outerR
});
buildAngle += sliceAngle;
}
let hoveredCountry = null;
canvas.addEventListener('mousemove', function(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const dx = mx - cx;
const dy = my - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
let mouseAngle = Math.atan2(dy, dx);
// normalize to match our -PI/2 start
if (mouseAngle < -Math.PI / 2) mouseAngle += Math.PI * 2;
hoveredCountry = null;
for (const s of sectors) {
let start = s.startAngle;
let end = s.endAngle;
// normalize sector angles for comparison
if (start < -Math.PI / 2) start += Math.PI * 2;
if (end < -Math.PI / 2) end += Math.PI * 2;
if (dist >= s.innerR && dist <= s.outerR &&
mouseAngle >= start && mouseAngle <= end) {
hoveredCountry = s.country;
break;
}
}
});
The hit test computes the mouse's angle and distance from center, then checks which sector contains that point. You could redraw the chart every frame with a highlight on the hovered sector, or overlay a tooltip. For a data portrait, I prefer a subtle approach -- brighten the hovered sector and show the raw numbers in the center circle.
function drawTooltip(country) {
if (!country) return;
// draw in the center circle
ctx.fillStyle = '#0a0a1a';
ctx.beginPath();
ctx.arc(cx, cy, 76, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(200, 210, 230, 0.8)';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(country.name, cx, cy - 25);
ctx.fillStyle = 'rgba(160, 170, 190, 0.6)';
ctx.font = '9px monospace';
ctx.fillText('Pop: ' + (country.pop / 1000).toFixed(0) + 'M', cx, cy - 8);
ctx.fillText('Area: ' + country.area.toLocaleString() + ' km2', cx, cy + 5);
ctx.fillText('GDP/cap: $' + country.gdppc.toLocaleString(), cx, cy + 18);
ctx.fillText('Density: ' + country.density.toFixed(0) + '/km2', cx, cy + 31);
}
The center circle becomes a contextual information panel. Hover over a sector and the center fills with that country's raw data. Move away and it returns to the title. No separate tooltip div, no floating labels -- the information lives inside the artwork itself. This keeps the visual clean while still giving the viewer access to detail. We used similar interaction patterns in episode 76 (3D raycasting) and episode 85 (network hover highlighting).
A data portrait without a legend is a puzzle. Beautiful, maybe, but opaque. The legend explains the visual vocabulary -- what each visual property represents. It doesn't need to be a boring table. It can be part of the design.
function drawLegend() {
const lx = 20;
let ly = 760;
ctx.fillStyle = 'rgba(120, 130, 150, 0.4)';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText('slice width = population', lx, ly);
ctx.fillText('slice reach = area (log scale)', lx, ly + 14);
ctx.fillText('color = continent', lx, ly + 28);
ctx.fillText('brightness = GDP per capita', lx, ly + 42);
ctx.fillText('inner ring = density', lx, ly + 56);
// continent color swatches
let swatchX = lx;
const swatchY = ly + 75;
for (const cont of Object.keys(continentColors)) {
const cc = continentColors[cont];
ctx.fillStyle = `hsl(${cc.h}, ${cc.s}%, ${cc.l}%)`;
ctx.fillRect(swatchX, swatchY, 10, 10);
ctx.fillStyle = 'rgba(120, 130, 150, 0.4)';
ctx.fillText(cont, swatchX + 14, swatchY + 9);
swatchX += 90;
// wrap to next row if needed
if (swatchX > 700) {
swatchX = lx;
}
}
}
drawLegend();
Five lines of text mapping visual properties to data dimensions. Six colored swatches for continents. Tucked in the lower-left where it's accessible but doesn't dominate. The legend is part of the artwork -- same font, same color scheme, same transparency. It belongs.
The portrait should work at different window sizes. The simplest approach: use the smaller of width and height as the base dimension, and scale everything proportionally. No hardcoded pixel values for positions -- calculate everything from the canvas center and a base radius.
function resizeCanvas() {
const size = Math.min(window.innerWidth, window.innerHeight) - 40;
canvas.width = size;
canvas.height = size;
// recalculate center and scale factor
const scale = size / 900; // our reference size
// redraw everything at new scale
// (in practice, multiply all radius/position values by scale)
drawPortrait(scale);
}
window.addEventListener('resize', resizeCanvas);
The scale factor is the ratio of actual canvas size to our reference size (900px). Every radius, every font size, every position gets multiplied by this factor. At 450px the portrait is half size but proportionally identical. At 1800px on a retina display it's crisp and detailed. We covered responsive design for canvas in episode 77 (procedural 3D world) -- same principles apply to 2D data art.
A data portrait that only lives in a browser tab is ephemeral. Export it so it can be shared, printed, hung on a wall. We covered PNG and SVG export in episode 29 (building for blockchain) and episode 88 (physical data art). The same functions work here.
function exportPortrait() {
// high-resolution export: 2x for crisp printing
const exportCanvas = document.createElement('canvas');
exportCanvas.width = 1800;
exportCanvas.height = 1800;
const exportCtx = exportCanvas.getContext('2d');
// redraw at 2x scale
drawPortraitToContext(exportCtx, 2.0);
const link = document.createElement('a');
link.download = 'data-portrait-population.png';
link.href = exportCanvas.toDataURL('image/png');
link.click();
}
// add a small export button
canvas.addEventListener('dblclick', exportPortrait);
Double-click to export at 2x resolution. The drawPortraitToContext function takes a context and a scale factor, so the same drawing code works at any resolution. For a portfolio piece or a print, 2x is the minimum -- 3x or 4x if you're printing large format.
We're not just visual artists anymore. Episode 89 gave us sonification. Let's add it: click a sector and hear its data. Population mapped to note duration (big countries play longer), GDP mapped to pitch (richer countries sound higher), continent mapped to timbre.
const audioCtx = new AudioContext();
const continentWaves = {
'Asia': 'triangle',
'Europe': 'sine',
'Africa': 'sawtooth',
'N. America': 'square',
'S. America': 'triangle',
'Oceania': 'sine'
};
const pentatonic = [261.63, 293.66, 329.63, 392.00, 440.00, 523.25, 659.25, 783.99];
function sonifyCountry(country) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// timbre from continent
osc.type = continentWaves[country.continent];
// pitch from GDP per capita
const pitchIdx = Math.round(country.gdppcNorm * (pentatonic.length - 1));
osc.frequency.setValueAtTime(pentatonic[pitchIdx], audioCtx.currentTime);
// duration from population (bigger country = longer note)
const duration = 0.3 + country.popNorm * 0.8;
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(audioCtx.currentTime);
osc.stop(audioCtx.currentTime + duration + 0.05);
}
canvas.addEventListener('click', function(e) {
if (hoveredCountry) {
audioCtx.resume().then(function() {
sonifyCountry(hoveredCountry);
});
}
});
Click Bangladesh and you hear a short, low triangle tone (low GDP, moderate population). Click the United States and you hear a long, high square tone (high GDP, large population). Click Germany and hear a bright sine note, shorter than the US but higher pitched (highest GDP per capita in Europe). The sonification adds a dimension that the visual can't: you HEAR the economic disparity. Rich countries sing high. Poor countries mumble low. And you remember it, because sound sticks in memory differently than color.
For the final touch, add a sweep mode that plays through all countries sequentially, clockwise around the portrait. Each country sounds its note while its sector briefly highlights. A data concert.
function sweepAll() {
let delay = 0;
for (let i = 0; i < countries.length; i++) {
const c = countries[i];
setTimeout(function() {
sonifyCountry(c);
// highlight this sector briefly (you'd redraw with a bright overlay)
highlightedIndex = i;
}, delay * 1000);
// duration proportional to population share
const noteDur = 0.3 + c.popNorm * 0.8;
delay += noteDur + 0.05;
}
}
// press 'p' to play the sweep
document.addEventListener('keydown', function(e) {
if (e.key === 'p') {
audioCtx.resume().then(sweepAll);
}
});
Press P and the portrait comes alive. It sweeps through continents -- a cluster of amber triangle tones for Asia (long notes for China and India, short ones for Bangladesh and South Korea), bright sine tones for Europe (high-pitched Germany and France), deep sawtooth notes for Africa, square tones for the Americas. The sweep is about 15-20 seconds total. When it finishes, you've heard the entire world -- population as rhythm, wealth as pitch, geography as timbre. The visual portrait and the sonic portrait are the same data expressed two ways.
Every data portrait tells a story whether you intend it or not. This one says: the world's population is overwhelmingly Asian. Africa's population is growing fast but remains economically marginalized (wide sectors, dark fills). Europe has a small share of global population but disproportionate wealth (narrow sectors, bright fills). The Americas sit in between. Oceania barely registers on population but has some of the highest per-capita GDP.
Write your own statement. Not for a gallery wall -- for yourself. What did the data show you that you didn't already know? What surprised you in the visualization? The data portrait is a lens. The artist statement is what you saw through it.
The data art arc ends here, but the techniques don't. Every tool we built -- mapping functions, color scales, layout algorithms, interaction patterns, sonification, export pipelines -- carries forward into whatever we do next. Data is always present in creative coding, even when it's generated procedurally rather than loaded from a file. A particle system is data (positions, velocities, lifetimes). A fractal is data (iteration counts). A generative artwork is data made visible. The vocabulary we built across these twelve episodes is permanently part of your creative toolkit.
There's another direction this opens up too -- using machine learning models as creative input. Not just mapping static datasets, but having a neural network interpret your webcam feed, your microphone, your gestures, and turning those interpretations into visual material. The data becomes live, personal, and intelligent. That's a different kind of data art entirely.
That's the data art arc done -- twelve episodes from raw API calls to a full audiovisual data portrait. We've covered eight arcs now across ninety episodes. The tools keep stacking. Next up we're stepping into territory where the data doesn't just sit there waiting to be mapped -- it comes from models that see, listen, and interpret. Different kind of creative input entirely.
Sallukes! Thanks for reading.
X