Learn Creative Coding (#91) - The Ethics and Aesthetics of Data Art

Words
3079
Reading
14 min
Listen
Play
3M

Learn Creative Coding (#91) - The Ethics and Aesthetics of Data Art

cc-banner

Last episode we built the capstone of the data arc -- a full generative data portrait from raw data to finished audiovisual piece. Choosing a dataset, cleaning and normalizing it, designing a visual vocabulary, coding the radial layout, adding interaction, sonification, legends, responsive scaling, export. The whole pipeline. Twelve episodes of technique (79-90) gave us a powerful toolkit for turning numbers into pictures and sound. But here's something we haven't talked about yet, and it matters more than any map() function: the responsibility that comes with it.

A beautiful visualization of bad data is worse than an ugly chart of good data. Aesthetics can bypass critical thinking. When something looks polished, people trust it -- they assume someone checked the numbers, verified the source, considered the edge cases. But we know how easy it is to make something look polished. We've been doing it for ninety episodes. A gradient here, a smooth animation there, some reverb on the sonification, and suddenly your visualization has authority. That authority is dangerous when the underlying data is incomplete, misleading, or biased.

This episode is about the space where ethics meets aesthetics in data art. Who's represented in the data and who's missing? What do your color choices imply? When does aggregation become erasure? When does beauty become deception? These aren't hypothetical questions -- they come up every time you encode data into visuals. We'll write code that demonstrates each issue, because understanding the problem concretely is better than reading about it abstractly.

Beautiful lies: when aesthetics mislead

The simplest form of data deception: truncating the Y axis. A bar chart where the axis starts at 95 instead of 0 makes a 2% difference look like a 10x difference. Everyone knows this trick in theory. Fewer people spot it in practice, especially when the chart is beautifully rendered.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

const data = [
  { label: '2022', value: 96.2 },
  { label: '2023', value: 97.1 },
  { label: '2024', value: 97.8 },
  { label: '2025', value: 98.4 }
];

function drawChart(startY, offsetX, title) {
  const maxVal = 100;
  const barW = 50;
  const gap = 30;
  const baseY = 350;

  ctx.fillStyle = 'rgba(140, 150, 170, 0.5)';
  ctx.font = '11px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(title, offsetX + 120, 30);

  for (let i = 0; i < data.length; i++) {
    const x = offsetX + i * (barW + gap);
    // map value from [startY, maxVal] to pixel height
    const norm = (data[i].value - startY) / (maxVal - startY);
    const barH = norm * 280;
    const y = baseY - barH;

    const hue = 200 + norm * 30;
    ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;
    ctx.fillRect(x, y, barW, barH);

    ctx.fillStyle = 'rgba(140, 150, 170, 0.5)';
    ctx.font = '9px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(data[i].label, x + barW / 2, baseY + 15);
    ctx.fillText(data[i].value.toFixed(1), x + barW / 2, y - 5);
  }

  // y-axis label at bottom
  ctx.fillStyle = 'rgba(100, 110, 130, 0.4)';
  ctx.font = '8px monospace';
  ctx.textAlign = 'right';
  ctx.fillText(startY.toString(), offsetX - 5, baseY + 3);
  ctx.fillText(maxVal.toString(), offsetX - 5, 68);
}

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

// honest version: axis starts at 0
drawChart(0, 30, 'honest (0-100)');

// misleading version: axis starts at 95
drawChart(95, 430, 'misleading (95-100)');

Same data. Same numbers. Same labels. The left chart shows a gentle upward trend -- 96 to 98, a 2.2 point increase over four years. Honest. The right chart, axis starting at 95, makes the same 2.2 point increase look like the bars nearly tripled. The visual impression is completely different even though the numbers are identical.

In a creative coding context, you're not usually making bar charts. But the same principle applies to any visual encoding. If your circle sizes map from a truncated range, small differences look huge. If your color gradient covers a narrow slice of the spectrum, similar values look dramatically different. Every mapping function has assumptions baked in, and those assumptions shape the story the visualization tells.

The fix isn't complicated: be explicit about your ranges. Document them. Show the full scale when it matters. And when you deliberately compress a range for artistic effect, know that you're doing it. Sounds obvious, right? :-) You'd be surprised how often it gets skipped.

Who's in the data, who's missing

Every dataset has blind spots. Census data undercounts homeless populations. Crime statistics reflect policing patterns, not crime patterns -- neighborhoods with more police report more crime, which justifies more police, which reports more crime. Health data skews toward populations that have access to healthcare. Social media data skews toward people who use social media, which skews young, urban, and relatively affluent.

When you visualize a dataset, you're implicitly saying "this is what the world looks like." But it's only what the measured world looks like. The unmeasured parts are invisible -- and invisibility in data is a form of erasure.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// simulated survey responses by age group
// notice: under-18 and over-75 are barely represented
const ageGroups = [
  { range: '0-17',  count: 12,  color: { h: 200, s: 40, l: 35 } },
  { range: '18-24', count: 85,  color: { h: 180, s: 50, l: 40 } },
  { range: '25-34', count: 210, color: { h: 160, s: 55, l: 42 } },
  { range: '35-44', count: 175, color: { h: 140, s: 50, l: 40 } },
  { range: '45-54', count: 130, color: { h: 120, s: 45, l: 38 } },
  { range: '55-64', count: 95,  color: { h: 100, s: 40, l: 36 } },
  { range: '65-74', count: 45,  color: { h: 80,  s: 35, l: 34 } },
  { range: '75+',   count: 8,   color: { h: 60,  s: 30, l: 32 } }
];

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 500);

const totalCount = ageGroups.reduce(function(s, g) { return s + g.count; }, 0);
const cx = 300;
const cy = 250;
const radius = 180;

let angle = -Math.PI / 2;

for (const g of ageGroups) {
  const sliceAngle = (g.count / totalCount) * Math.PI * 2;
  const nextAngle = angle + sliceAngle;
  const midAngle = angle + sliceAngle / 2;

  ctx.beginPath();
  ctx.moveTo(cx, cy);
  ctx.arc(cx, cy, radius, angle, nextAngle);
  ctx.closePath();

  ctx.fillStyle = `hsla(${g.color.h}, ${g.color.s}%, ${g.color.l}%, 0.6)`;
  ctx.fill();
  ctx.strokeStyle = `hsla(${g.color.h}, ${g.color.s}%, ${g.color.l + 15}%, 0.3)`;
  ctx.lineWidth = 1;
  ctx.stroke();

  // label if slice is wide enough
  if (sliceAngle > 0.15) {
    const labelR = radius + 20;
    const lx = cx + Math.cos(midAngle) * labelR;
    const ly = cy + Math.sin(midAngle) * labelR;
    ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';
    ctx.font = '9px monospace';
    ctx.textAlign = midAngle > Math.PI / 2 || midAngle < -Math.PI / 2 ? 'right' : 'left';
    ctx.fillText(g.range + ' (' + g.count + ')', lx, ly + 3);
  }

  angle = nextAngle;
}

// annotation: the missing populations
ctx.fillStyle = 'rgba(255, 120, 100, 0.5)';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText('under-18: 1.6% of responses', 560, 200);
ctx.fillText('over-75: 1.0% of responses', 560, 216);
ctx.fillText('combined: 2.6% of data', 560, 232);
ctx.fillStyle = 'rgba(255, 120, 100, 0.3)';
ctx.fillText('but 28% of actual population', 560, 252);

The pie chart looks "complete" -- it accounts for 100% of the responses. But the responses themselves are biased. Children and elderly people barely participated. If you visualize this as "the age distribution" without mentioning it's survey data, you've made a quarter of the population invisible. The chart isn't lying exactly -- every number is correct. But the framing is dishonest because it presents a sample as if it were the whole picture.

For your own data art: always ask "who generated this data?" Online surveys miss people without internet. English-language datasets miss everyone who doesn't speak English. GPS data misses people who don't carry phones. The absences matter as much as the presences.

Aggregation: when averages erase individuals

A dot on a map representing 10,000 people is efficient. It's also violence -- 10,000 individual stories collapsed into a single pixel. Aggregation is necessary (you can't draw ten thousand dots on a phone screen), but it hides the variation within groups. An average income of $50,000 could mean everyone earns $50k, or it could mean half earn $20k and half earn $80k. The average is the same. The reality is completely different.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

// two cities with same average income but different distributions
function generateNormal(mean, stddev, n) {
  const result = [];
  for (let i = 0; i < n; i++) {
    // box-muller transform
    const u1 = Math.random();
    const u2 = Math.random();
    const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
    result.push(mean + z * stddev);
  }
  return result;
}

// city A: tight distribution around 50k
const cityA = generateNormal(50000, 8000, 200);
// city B: bimodal -- half around 25k, half around 75k
const cityB = [];
for (let i = 0; i < 100; i++) {
  cityB.push(25000 + (Math.random() - 0.5) * 12000);
  cityB.push(75000 + (Math.random() - 0.5) * 12000);
}

function drawDistribution(data, offsetY, label, hue) {
  ctx.fillStyle = 'rgba(150, 160, 180, 0.4)';
  ctx.font = '10px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(label, 20, offsetY - 5);

  const avg = data.reduce(function(s, v) { return s + v; }, 0) / data.length;

  for (let i = 0; i < data.length; i++) {
    const x = 20 + ((data[i] - 10000) / 90000) * 760;
    const y = offsetY + 20 + (Math.random() * 60);

    ctx.beginPath();
    ctx.arc(x, y, 2.5, 0, Math.PI * 2);
    ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.35)`;
    ctx.fill();
  }

  // average line
  const avgX = 20 + ((avg - 10000) / 90000) * 760;
  ctx.beginPath();
  ctx.moveTo(avgX, offsetY + 10);
  ctx.lineTo(avgX, offsetY + 90);
  ctx.strokeStyle = 'rgba(255, 200, 100, 0.6)';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.fillStyle = 'rgba(255, 200, 100, 0.6)';
  ctx.font = '9px monospace';
  ctx.textAlign = 'center';
  ctx.fillText('avg: $' + Math.round(avg).toLocaleString(), avgX, offsetY + 102);
}

drawDistribution(cityA, 30, 'City A: equal distribution', 200);
drawDistribution(cityB, 210, 'City B: bimodal (inequality)', 340);

Both cities have the same average (the yellow line lands in the same spot). But City A is a cluster -- most people earn close to the average. City B is split -- two distinct groups, one well below and one well above. If you only show the average, you can't tell them apart. The dots reveal what the average hides.

This is why the data art we've been building matters. A single summary statistic flattens complexity. A visualization that shows individual data points preserves it. When you have the choice between showing an aggregate and showing the underlying distribution, the distribution is almost always more honest. It's also more beautiful -- the patterns in the dots tell a richer story than a single line ever could.

Color and culture: nothing is neutral

Red means danger. Green means safe. Right? In Western culture, mostly. In China, red means prosperity and good fortune -- it's the color of celebration. Green has Islamic associations in many Middle Eastern and North African countries. White means purity in the West; it means death and mourning in parts of East Asia.

When you encode data with color, you're making cultural choices whether you intend to or not. A climate map that uses red for hot and blue for cold feels "natural" -- but that natural feeling is cultural conditioning. A map that used green for hot and purple for cold would convey exactly the same information; it would just feel wrong to people who learned the red-hot/blue-cold association from water taps and weather forecasts.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 350;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 350);

// same data, three color schemes, three different emotional reads
const values = [2, 4, 6, 8, 10, 8, 6, 4, 3, 5, 7, 9];

function drawColorBar(values, offsetY, colorFn, label) {
  ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
  ctx.font = '10px monospace';
  ctx.textAlign = 'left';
  ctx.fillText(label, 20, offsetY - 8);

  for (let i = 0; i < values.length; i++) {
    const norm = (values[i] - 1) / 9;
    const x = 20 + i * 62;
    const color = colorFn(norm);

    ctx.fillStyle = color;
    ctx.fillRect(x, offsetY, 56, 50);

    ctx.fillStyle = 'rgba(200, 210, 230, 0.6)';
    ctx.font = '9px monospace';
    ctx.textAlign = 'center';
    ctx.fillText(values[i].toString(), x + 28, offsetY + 30);
  }
}

// red-green: "danger to safe" reading
drawColorBar(values, 40, function(n) {
  const h = n * 120;  // 0=red, 120=green
  return `hsla(${h}, 60%, 40%, 0.7)`;
}, 'red-green (danger to safe)');

// blue-yellow: colorblind-safe, no moral loading
drawColorBar(values, 140, function(n) {
  const h = 220 - n * 170;  // 220=blue, 50=yellow
  return `hsla(${h}, 55%, 45%, 0.7)`;
}, 'blue-yellow (colorblind-safe, neutral)');

// purple-orange: warm/cool without moral weight
drawColorBar(values, 240, function(n) {
  const h = 280 - n * 240;  // 280=purple, 40=orange
  return `hsla(${h}, 50%, 42%, 0.7)`;
}, 'purple-orange (aesthetic, no moral loading)');

Same twelve values, three palettes. The red-green version implies "low is bad, high is good" -- we read red as warning and green as positive. The blue-yellow version is neutral and colorblind-safe (the viridis family of palettes works on this principle). The purple-orange version is purely aesthetic -- no moral weight attached to either end.

Which palette you choose depends on what you want the viewer to feel. If low values genuinely ARE bad (air quality, test scores), red-green might be appropriate. If the values are neutral (population, frequency, duration), loading them with danger/safety colors is misleading. And -- this one's important -- about 8% of men and 0.5% of women have red-green color vision deficiency. If red and green are your only distinguishing features, one in twelve male viewers can't read your visualization at all.

Always add a secondary encoding. Shape, pattern, label, position -- anything that doesn't rely solely on color to convey meaning. Colorblind-safe palettes (viridis, cividis, inferno) are designed with this constraint in mind. Use them by default and deviate only when you have a good reason.

Dark patterns in data art

Data visualization has its own gallery of manipulative techniques. Some are subtle, some are blatant. Knowing them helps you avoid them accidentally -- and recognize them when others use them.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

// cherry-picking time range to tell different stories
const fullData = [
  { year: 2018, value: 40 },
  { year: 2019, value: 55 },
  { year: 2020, value: 30 },
  { year: 2021, value: 65 },
  { year: 2022, value: 50 },
  { year: 2023, value: 70 },
  { year: 2024, value: 60 },
  { year: 2025, value: 75 }
];

function drawLine(data, offsetX, width, label) {
  ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
  ctx.font = '10px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(label, offsetX + width / 2, 30);

  const minV = 0;
  const maxV = 100;

  ctx.beginPath();
  for (let i = 0; i < data.length; i++) {
    const x = offsetX + (i / (data.length - 1)) * width;
    const y = 350 - ((data[i].value - minV) / (maxV - minV)) * 290;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.strokeStyle = 'rgba(100, 180, 255, 0.7)';
  ctx.lineWidth = 2;
  ctx.stroke();

  // data point labels
  ctx.fillStyle = 'rgba(120, 130, 150, 0.4)';
  ctx.font = '8px monospace';
  for (let i = 0; i < data.length; i++) {
    const x = offsetX + (i / (data.length - 1)) * width;
    ctx.fillText(data[i].year.toString(), x, 370);
  }
}

// full picture: volatile but generally up
drawLine(fullData, 30, 300, 'full data (2018-2025)');

// cherry-picked: 2021-2024 shows decline
const declining = fullData.filter(function(d) { return d.year >= 2021 && d.year <= 2024; });
drawLine(declining, 450, 300, 'cherry-picked (2021-2024)');

The full data shows a volatile but generally upward trend. The cherry-picked slice (2021-2024) shows what looks like a decline -- 65 down to 50 then back to 60. Both are "accurate." Neither is lying. But the cherry-picked version tells a story that the full data doesn't support. Time range selection is a creative choice, and it's also an ethical one.

Other common dark patterns: using area instead of height for comparison (a circle with 2x radius has 4x area, making differences look bigger), 3D perspective that distorts proportions (the front bars look bigger than the back bars even with equal values), dual Y axes that let you imply correlaton by scaling two unrelated series to overlap. All of these are tools. Like any tool, they can be used honestly or dishonestly. The difference is intent and disclosure.

Consent and privacy in data art

Visualizing social media data, health records, or location traces always involves other people's information. Even "public" data can be harmful when aggregated -- individual pieces of information that are harmless alone can be combined to identify specific people. This is called deanonymization, and it's surprisingly easy. A study showed that 87% of Americans can be uniquely identified by just three data points: zip code, gender, and date of birth.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

// simulating progresssive deanonymization
// each additional data point narrows the pool of possible people
const stages = [
  { label: 'zip code only',              remaining: 25000, note: '~25,000 people' },
  { label: '+ gender',                   remaining: 12500, note: '~12,500 people' },
  { label: '+ birth year',               remaining: 450,   note: '~450 people' },
  { label: '+ birth month',              remaining: 38,    note: '~38 people' },
  { label: '+ birth day',                remaining: 1,     note: 'identified' }
];

const maxR = 160;

for (let i = 0; i < stages.length; i++) {
  const s = stages[i];
  const x = 80 + i * 140;
  const cy = 200;

  // radius proportional to log of remaining people
  const logMax = Math.log(25000);
  const logVal = Math.log(Math.max(s.remaining, 1));
  const r = 5 + (logVal / logMax) * maxR;

  ctx.beginPath();
  ctx.arc(x, cy, r, 0, Math.PI * 2);
  const alpha = 0.15 + (1 - logVal / logMax) * 0.45;
  ctx.fillStyle = `hsla(350, 50%, 50%, ${alpha})`;
  ctx.fill();

  ctx.fillStyle = 'rgba(150, 160, 180, 0.5)';
  ctx.font = '8px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(s.label, x, cy + r + 20);
  ctx.fillText(s.note, x, cy + r + 32);
}

ctx.fillStyle = 'rgba(255, 130, 100, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('progressive deanonymization with public data', 400, 30);

Five circles shrinking from large (25,000 candidates) to a single point (one person). Each additional data field eliminates candidates until only one remains. Zip code alone is vague. Add gender and you halve it. Add birth year and you're down to hundreds. Add the full birthday and you've likely identified a specific individual -- using nothing but information that's routinely shared on social media profiles, medical forms, and public records.

For data artists, the lesson is practical: if you're working with datasets that contain demographic or behavioral fields, consider what combinations might identify real people. Anonymization isn't just removing names. It's thinking about what combinations of remaining fields could serve as fingerprints. And if you're creating personal data art (episode 88), think about what your data implies about the people around you -- your location history includes who you were with, your communication patterns reveal your relationships.

The artist's responsibility: every visualization is an argument

You choose what to show, how to show it, and what to leave out. Every one of those choices shapes the story. A map centered on Europe puts Europe at the center of the world. A timeline starting in 1492 implies history began with Columbus. A color scheme that codes Africa in dark tones and Europe in bright tones carries connotations that go way beyond data encoding.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

// same education data, two different framings
const regions = [
  { name: 'Region A', grad: 85, dropout: 15 },
  { name: 'Region B', grad: 72, dropout: 28 },
  { name: 'Region C', grad: 91, dropout: 9 },
  { name: 'Region D', grad: 68, dropout: 32 }
];

// framing 1: graduation rates (positive)
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('graduation rate (%)', 200, 30);

for (let i = 0; i < regions.length; i++) {
  const r = regions[i];
  const x = 50 + i * 90;
  const barH = r.grad * 3;
  const y = 360 - barH;

  ctx.fillStyle = `hsla(160, 50%, ${30 + r.grad * 0.2}%, 0.6)`;
  ctx.fillRect(x, y, 70, barH);

  ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';
  ctx.font = '9px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(r.name, x + 35, 378);
  ctx.fillText(r.grad + '%', x + 35, y - 5);
}

// framing 2: dropout rates (negative)
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('dropout rate (%)', 600, 30);

for (let i = 0; i < regions.length; i++) {
  const r = regions[i];
  const x = 450 + i * 90;
  const barH = r.dropout * 3;
  const y = 360 - barH;

  ctx.fillStyle = `hsla(0, 50%, ${30 + r.dropout * 0.5}%, 0.6)`;
  ctx.fillRect(x, y, 70, barH);

  ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';
  ctx.font = '9px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(r.name, x + 35, 378);
  ctx.fillText(r.dropout + '%', x + 35, y - 5);
}

Left side: graduation rates in green. Region D at 68% looks respectable -- most students graduate. Right side: the same data expressed as dropout rates in red. Region D at 32% looks alarming -- nearly a third of students are failing. Same fact, different frame, different emotional response. The graduation framing celebrates success. The dropout framing highlights failure. Neither is wrong. Both are choices.

As a data artist, you make this choice every time you pick what to visualize. Showing income growth or wage stagnation. Showing forest coverage or deforestation. Showing recovery rates or mortality rates. The choice of frame is the choice of argument. Be honest about which argument you're making.

Decoration vs information: the spectrum

Data art sits on a spectrum. At one end: pure information (maximum clarity, proper axes, labels, honest proportions). At the other end: pure art (data as aesthetic material, abstract forms, no labels, no axes, maximum beauty). Most data art lives somewhere in the middle.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 400);

const data = [3, 7, 2, 8, 5, 9, 4, 6, 8, 3, 7, 10, 5, 2, 6, 8, 4, 7, 9, 5];

// information end: labeled bar chart
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText('information', 110, 20);

for (let i = 0; i < data.length; i++) {
  const x = 15 + i * 10;
  const h = data[i] * 25;
  const y = 280 - h;
  ctx.fillStyle = 'rgba(100, 160, 220, 0.7)';
  ctx.fillRect(x, y, 8, h);
}

// axis
ctx.strokeStyle = 'rgba(80, 90, 110, 0.3)';
ctx.beginPath();
ctx.moveTo(13, 280);
ctx.lineTo(220, 280);
ctx.stroke();

// art end: same data as abstract radial form
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '9px monospace';
ctx.textAlign = 'center';
ctx.fillText('art', 600, 20);

const artCx = 600;
const artCy = 180;

for (let i = 0; i < data.length; i++) {
  const angle = (i / data.length) * Math.PI * 2 - Math.PI / 2;
  const nextAngle = ((i + 1) / data.length) * Math.PI * 2 - Math.PI / 2;
  const r = 30 + data[i] * 12;

  ctx.beginPath();
  ctx.moveTo(artCx, artCy);
  ctx.arc(artCx, artCy, r, angle, nextAngle);
  ctx.closePath();

  const norm = (data[i] - 1) / 9;
  const hue = 200 + norm * 80;
  ctx.fillStyle = `hsla(${hue}, 50%, 40%, ${0.2 + norm * 0.3})`;
  ctx.fill();
}

The bar chart on the left is readable, precise, boring. The radial form on the right is beautiful, expressive, harder to read accurately. Both encode the same twenty values. The bar chart tells you "value 12 is 10." The radial form tells you "the shape of this data feels spiky and uneven." Different information for different purposes.

Know where you're aiming on the spectrum. A medical dashboard should be on the information end. A gallery installation can be on the art end. A data portrait (episode 90) lives in the middle -- readable enough to learn from, beautiful enough to hang on a wall. The mistake is ending up on one end when you meant to be on the other.

Citation and transparency

Credit your data sources. Acknowledge limitations. If you transformed the data -- filtered it, aggregated it, normalized it, removed outliers -- say so. Transparency builds trust, and trust is what separates data art from data propaganda.

const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 200;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 800, 200);

// example attribution block for a data artwork
const lines = [
  'DATA SOURCE',
  'World Bank Open Data (data.worldbank.org)',
  'Population estimates, 2023 revision',
  '',
  'TRANSFORMATIONS',
  'Filtered to countries with population > 1M',
  'GDP per capita in 2021 constant USD',
  'Population density derived (pop / land area)',
  'Log scale applied to area and density',
  '',
  'MISSING',
  'No data for 12 countries (conflict zones)',
  'GDP figures unavailable for 8 countries'
];

ctx.font = '9px monospace';
ctx.textAlign = 'left';

for (let i = 0; i < lines.length; i++) {
  const isHeader = lines[i] === lines[i].toUpperCase() && lines[i].length > 0;
  ctx.fillStyle = isHeader
    ? 'rgba(180, 160, 120, 0.5)'
    : 'rgba(130, 140, 160, 0.4)';
  ctx.fillText(lines[i], 30, 20 + i * 14);
}

Three sections: source, transformations, and what's missing. This is the minimum. The source tells viewers where to check the data. The transformations list tells them how you processed it. The "missing" section is the most important -- it admits what the visualization doesn't show. Most data art skips this entirely, and that's a missed opportunity. Acknowledging gaps is more honest and more interesting than pretending they don't exist.

You can make the attribution part of the design. Same font, same color scheme, tucked in a corner or on a separate panel. It doesn't have to be ugly. We did this with the legend in episode 90 -- the attribution block belongs to the artwork just like the legend does.

The case for data art anyway

After all this caution, it's worth saying: data art matters. Accurate or not, beautiful data art makes people LOOK at data who wouldn't otherwise engage with it. A properly labeled bar chart of climate data might be scrolled past. A generative artwork driven by the same data might stop someone mid-scroll. That emotional hook has value. People process information better when they care about it, and aesthetics create caring.

The goal isn't to stop making data art because it's ethically complicated. The goal is to make data art that's aware of its complications. Show the beauty AND the source. Make the art AND the attribution. Create the emotional hook AND the honest context. The best data art does both simultaneously -- it's beautiful enough to attract attention and transparent enough to deserve it.

Creative exercise: the honest comparison

Take a dataset you've already visualized in this arc and create two versions. Version one: maximize accuracy and clarity. Proper axes, labels, honest proportions, colorblind-safe palette, attribution block. Version two: maximize beauty and emotion. Abstract encoding, no labels, artistic color choices, emphasis on feeling over precision. Then compare them.

const canvas = document.createElement('canvas');
canvas.width = 900;
canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, 900, 400);

const data = [12, 28, 45, 67, 52, 38, 71, 43, 55, 30, 62, 48];
const months = ['J','F','M','A','M','J','J','A','S','O','N','D'];

// VERSION 1: honest, labeled, readable
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('accurate version', 200, 25);

for (let i = 0; i < data.length; i++) {
  const x = 50 + i * 30;
  const norm = data[i] / 80;
  const barH = norm * 280;
  const y = 360 - barH;

  ctx.fillStyle = `hsla(210, 40%, ${35 + norm * 15}%, 0.7)`;
  ctx.fillRect(x, y, 24, barH);

  ctx.fillStyle = 'rgba(130, 140, 160, 0.4)';
  ctx.font = '8px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(months[i], x + 12, 375);
  ctx.fillText(data[i].toString(), x + 12, y - 4);
}

// y-axis marks
ctx.fillStyle = 'rgba(90, 100, 120, 0.3)';
ctx.font = '8px monospace';
ctx.textAlign = 'right';
for (let v = 0; v <= 80; v += 20) {
  const y = 360 - (v / 80) * 280;
  ctx.fillText(v.toString(), 44, y + 3);
  ctx.beginPath();
  ctx.moveTo(48, y);
  ctx.lineTo(420, y);
  ctx.strokeStyle = 'rgba(60, 70, 90, 0.15)';
  ctx.lineWidth = 0.5;
  ctx.stroke();
}

// VERSION 2: beautiful, abstract, emotional
ctx.fillStyle = 'rgba(140, 150, 170, 0.4)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('artistic version', 680, 25);

const artCx = 680;
const artCy = 200;

for (let i = 0; i < data.length; i++) {
  const angle = (i / 12) * Math.PI * 2 - Math.PI / 2;
  const nextAngle = ((i + 1) / 12) * Math.PI * 2 - Math.PI / 2;
  const norm = data[i] / 80;
  const r = 40 + norm * 120;

  // petal
  const midAngle = (angle + nextAngle) / 2;
  ctx.beginPath();
  ctx.moveTo(
    artCx + Math.cos(angle) * 35,
    artCy + Math.sin(angle) * 35
  );
  ctx.quadraticCurveTo(
    artCx + Math.cos(midAngle) * r,
    artCy + Math.sin(midAngle) * r,
    artCx + Math.cos(nextAngle) * 35,
    artCy + Math.sin(nextAngle) * 35
  );

  const hue = 180 + norm * 100;
  ctx.fillStyle = `hsla(${hue}, 55%, 45%, ${0.2 + norm * 0.35})`;
  ctx.fill();
}

The left version is a proper bar chart. You can read exact values, compare months, identify July as the peak (71). The right version is a flower. You can feel the seasonal rhythm -- winter petals are short and cool, summer petals reach outward and glow warm. You can't read "71" from it. But you can feel the shape of the year.

What does each version communicate that the other can't? The bar chart communicates precision. The flower communicates gestalt -- the overall shape, the rhythm, the seasonal feel. Neither is complete on its own. The best data art finds ways to include both: the emotional hook of the artistic form AND enough context for the viewer to engage critically. A legend. A source. A scale. Something that says "this is real data, and here's where it came from."

Where this connects to what's next

The ethics conversation isn't separate from the technical work -- it IS the technical work. Every map() call is a framing decision. Every color choice carries cultural weight. Every aggregation hides individual variation. The responsible data artist isn't someone who avoids these issues. It's someone who's aware of them and makes conscious choices.

The data art arc (episodes 79-91) is complete. We've built tools for fetching, parsing, mapping, laying out, interacting with, sonifying, and now critically examining data as creative material. These twelve episodes are a foundation. Everything we do from here builds on it -- and the ethical awareness from this episode applies to everything we build, not just data art. When we start working with models that interpret images, track bodies, and classify content, the responsibility question gets even bigger. That's exactly where we're headed.

't Komt erop neer...

  • A beautiful visualization of misleading data is more dangerous than an ugly accurate one. Aesthetics create trust, and trust can be misplaced. Truncated axes, compressed color ranges, and cherry-picked time windows are all ways that beauty becomes deception
  • Every dataset has blind spots. Census data undercounts homeless populations. Survey data skews toward people who take surveys. Online data skews young and connected. When you visualize a biased sample, you make the missing population invisible -- and invisibility is erasure
  • Aggregation hides variation. Two cities with the same average income can have completely different distributions (tight cluster vs bimodal inequality). Showing individual data points alongside summaries is more honest than showing the average alone
  • Color is cultural, not universal. Red-green encodes "danger-safe" in Western culture but carries different meaning elsewhere. 8% of men have red-green color deficiency. Use colorblind-safe palettes (viridis, cividis) by default, and add secondary encodings (shape, pattern, position) that don't rely on color alone
  • Dark patterns in data visualization include truncated Y axes, cherry-picked time ranges, area-scaled comparisons (2x radius = 4x area), 3D perspective distortion, and misleading dual Y axes. Know these so you can avoid them -- or use them consciously
  • Privacy requires thinking about combinations, not just individual fields. Zip code + gender + birthday identifies 87% of Americans. Even "public" data can be harmful when aggregated. If you're working with demographic data, consider what combinations could serve as fingerprints
  • Every visualization is an argument. Graduation rates vs dropout rates, forest coverage vs deforestation, income growth vs wage stagnation -- the choice of what to show and how to frame it IS the editorial choice. Be honest about which argument you're making
  • Data art sits on a spectrum from pure information to pure art. Know where you're aiming. Medical dashboards need clarity. Gallery installations can be abstract. The best data portraits live in the middle -- beautiful enough to attract and transparent enough to deserve attention
  • Cite your sources. Document your transformations. Acknowledge what's missing. Transparency builds trust. An attribution block (source, transformations, gaps) is part of the design, not an afterthought
  • Data art matters because it makes people LOOK at data they'd otherwise ignore. The emotional hook of beautiful visualization has genuine value. The goal isn't to stop making data art because it's complicated -- it's to make data art that's aware of its complications

This wraps up the data art arc -- thirteen episodes from raw API calls to ethical responsibility. Every technique carries forward, and so does the awareness. The tools keep stacking. The things we build from here get more interesting because the input isn't just static data anymore -- it's live, interpreted, and personal in ways that make these ethical questions even more relevant.

Sallukes! Thanks for reading.

X

femdev@femdev

Learn Creative Coding (#91) - The Ethics and Aesthetics of Data Art | Ecency