Learn Creative Coding (#82) - Mapping Data to Visual Properties

in StemSocialyesterday

Learn Creative Coding (#82) - Mapping Data to Visual Properties

cc-banner

Last episode we parsed CSV and JSON files -- cracked them open, extracted the numbers, cleaned the junk, and normalized everything to 0-1 ranges. We ended with a Belgian cities map where population drove circle size and color. That was a taste of what's coming. But we only scratched the surface of how data can map to visuals. Position and size are the obvious channels. There's a whole vocabulary beyond those two.

This episode is about that vocabulary. The map() function and its cousins. Which visual property should carry which data dimension? Position, size, color, opacity, shape, rotation, line thickness -- each one communicates differently, and the choice of mapping IS the creative statement. Same dataset, different mappings, completely different artwork. We touched on this idea back in episode 79 when we talked about data as material. Now we're building the actual toolkit for it.

The map function: your most important tool

If you've used p5.js, you already know map(). It takes a value from one range and converts it to another. In vanilla JavaScript we write our own:

function map(value, inMin, inMax, outMin, outMax) {
  return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}

// temperature 0-40 -> x position 50-750
const x = map(temperature, 0, 40, 50, 750);

// population 1000-5000000 -> radius 3-40
const r = map(population, 1000, 5000000, 3, 40);

// wind speed 0-100 -> opacity 0.1-0.9
const alpha = map(windSpeed, 0, 100, 0.1, 0.9);

That's it. Five parameters: the value, where it comes from (input min, input max), and where it goes (output min, output max). This little function is the bridge between data space and visual space. Everything we do in this episode is a variation of this core idea.

One thing to watch: if your value goes outside the input range (temperature of -5 when your range is 0-40), the output goes outside the output range too. Sometimes that's fine. Sometimes it breaks your layout. A constrained version clamps the result:

function mapClamped(value, inMin, inMax, outMin, outMax) {
  const t = Math.max(0, Math.min(1, (value - inMin) / (inMax - inMin)));
  return outMin + t * (outMax - outMin);
}

Now values below inMin map to outMin and values above inMax map to outMax. No surprises. I use the clamped version for anything that controls layout (position, size) and the unclamped version for things where overflow is interesting (color hue wrapping around, opacity going slightly beyond 1 -- canvas clips it anyway).

Position: the most intuitive channel

Spatial position is what humans read most naturally. Our eyes are wired to detect where things are before we notice their color or size. That makes position the strongest visual channel -- the one that carries the most information with the least effort from the viewer.

X from one variable, Y from another. That's a scatter plot. But forget the chart framing -- no axes, no gridlines, no labels. Just dots in space. The spatial pattern itself tells the story.

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

// 200 data points: two correlated variables with noise
const points = [];
for (let i = 0; i < 200; i++) {
  const base = Math.random();
  points.push({
    income: base * 80000 + 20000 + (Math.random() - 0.5) * 15000,
    happiness: base * 6 + 2 + (Math.random() - 0.5) * 3
  });
}

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

const incomes = points.map(p => p.income);
const minInc = Math.min(...incomes);
const maxInc = Math.max(...incomes);

for (const p of points) {
  const x = map(p.income, minInc, maxInc, 60, 740);
  const y = map(p.happiness, 0, 10, 560, 40);

  ctx.beginPath();
  ctx.arc(x, y, 3, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(140, 180, 255, 0.5)';
  ctx.fill();
}

200 dots. Income on x, happiness on y. The correlation is visible as a diagonal cloud -- high income clusters in the upper right, low income in the lower left. No axes needed. The spatial arrangement says it all. And because we added noise to the correlation, the cloud has scatter and texture. It feels organic, not mechanical.

Size: map to area, not radius

This is the one people get wrong constantly. If you map a data value directly to circle radius, a value that's 2x bigger looks 4x bigger visually. Because area = pi * r^2. Doubling the radius quadruples the area, and humans perceive size by area, not radius.

The fix: map your value to area, then derive the radius from that.

// WRONG: value -> radius directly
// const radius = map(population, minPop, maxPop, 5, 40);

// RIGHT: value -> area, then sqrt for radius
const area = map(population, minPop, maxPop, 25, 1600);
const radius = Math.sqrt(area / Math.PI);

With the corrected version, a city with 2x the population has a circle with 2x the area -- which looks 2x bigger, as it should. The visual proportions match the data proportions. This might sound pedantic but it makes a massive difference. Try both side by side and you'll see -- the wrong version exaggerates large values and squishes small ones together. The correct version distributes them evenly.

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

// five cities with populations
const cities = [
  { name: 'Village', pop: 5000 },
  { name: 'Town', pop: 50000 },
  { name: 'City', pop: 200000 },
  { name: 'Metro', pop: 800000 },
  { name: 'Mega', pop: 3000000 }
];

const maxPop = 3000000;

for (let i = 0; i < cities.length; i++) {
  const x = 100 + i * 150;

  // correct: area-proportional
  const area = (cities[i].pop / maxPop) * 2500;
  const r = Math.sqrt(area / Math.PI);

  ctx.beginPath();
  ctx.arc(x, 200, r, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(100, 200, 180, 0.6)';
  ctx.fill();

  ctx.fillStyle = 'rgba(200, 200, 220, 0.6)';
  ctx.font = '11px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(cities[i].name, x, 200 + r + 16);
}

The village is a small dot. The mega city is big but not absurdly dominant. The progression looks proportional because it IS proportional. Visual honesty in a single Math.sqrt() call. :-)

Color: hue, saturation, brightness

Color is the second strongest visual channel after position. But color is actually three channels in one -- hue, saturation, and lightness (or brightness). Each can carry different data.

Hue works best for categorical data. Different categories = different colors. Blue for water, green for land, red for fire. Or for diverging scales: blue for cold, red for hot, white/neutral in the middle.

Lightness works well for quantity. Dark = low value, bright = high value (or the reverse). It's intuitively ordered in a way that random hues aren't -- most people read light-to-dark as a scale without being told.

Saturation is subtle. High saturation = important/confident. Low saturation = uncertain/background. It works as a secondary emphasis channel but rarely carries primary information well.

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

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

// 50 data points with value and category
const items = [];
for (let i = 0; i < 50; i++) {
  items.push({
    value: Math.random() * 100,
    category: Math.floor(Math.random() * 4)
  });
}

// category hues
const catHues = [0, 90, 200, 310];  // red, green, blue, pink

for (let i = 0; i < items.length; i++) {
  const x = map(i, 0, 49, 40, 760);
  const item = items[i];

  // hue from category, lightness from value
  const hue = catHues[item.category];
  const lightness = map(item.value, 0, 100, 20, 65);

  ctx.beginPath();
  ctx.arc(x, 150, 10, 0, Math.PI * 2);
  ctx.fillStyle = `hsl(${hue}, 60%, ${lightness}%)`;
  ctx.fill();
}

Hue tells you the category at a glance. Lightness tells you the magnitude within that category. Two data dimensions encoded in a single colored circle. The HSL color model was basically designed for this -- it separates the channels in a way that maps naturally to data dimensions.

One warning: don't use rainbow colormaps (smoothly cycling through the full hue range) for quantitative data. The human eye doesn't perceive hue as ordered -- purple doesn't feel "more" than green. Hue works for categories (this is a different thing) but not for amounts (this is more of that thing). For amounts, use a single-hue gradient (light blue to dark blue) or a diverging palette (blue through white to red).

Opacity: density from transparency

Opacity is underrated in data art. When you draw thousands of semi-transparent elements, the density emerges naturally. Areas where many elements overlap become opaque and vivid. Areas with few elements stay faint and subtle. You get a density map for free without any explicit calculation.

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

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

// 3000 points clustered around two centers
for (let i = 0; i < 3000; i++) {
  let x, y;
  if (Math.random() < 0.6) {
    // cluster 1
    x = 300 + (Math.random() - 0.5) * 200 + (Math.random() - 0.5) * 100;
    y = 300 + (Math.random() - 0.5) * 200 + (Math.random() - 0.5) * 100;
  } else {
    // cluster 2
    x = 550 + (Math.random() - 0.5) * 150 + (Math.random() - 0.5) * 80;
    y = 350 + (Math.random() - 0.5) * 150 + (Math.random() - 0.5) * 80;
  }

  ctx.beginPath();
  ctx.arc(x, y, 4, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(120, 160, 255, 0.06)';
  ctx.fill();
}

3000 dots at 6% opacity. Individually invisible. Together they form two luminous clouds with visible density gradients. The cluster cores are bright where hundreds of dots overlap. The edges fade out as density drops. And the overlap zone between the two clusters shows their relationship -- a shared space where both populations mix. This is something you can't see with opaque dots. The transparency makes the structure visible.

You can also map opacity to a data variable directly. More important data = more opaque. Less important = more transparent. It's a natural importance weighting.

Shape: when categories need distinction

Different shapes for different categories: circles for type A, squares for type B, triangles for type C. This works well when you have a small number of categories (3-5) and each data point is individually visible. Above that, shapes become indistinguishable and you're better off using color.

function drawShape(ctx, x, y, type, size) {
  ctx.beginPath();
  if (type === 0) {
    // circle
    ctx.arc(x, y, size, 0, Math.PI * 2);
  } else if (type === 1) {
    // square
    ctx.rect(x - size, y - size, size * 2, size * 2);
  } else if (type === 2) {
    // triangle
    ctx.moveTo(x, y - size);
    ctx.lineTo(x + size, y + size * 0.8);
    ctx.lineTo(x - size, y + size * 0.8);
    ctx.closePath();
  } else {
    // diamond
    ctx.moveTo(x, y - size);
    ctx.lineTo(x + size * 0.7, y);
    ctx.lineTo(x, y + size);
    ctx.lineTo(x - size * 0.7, y);
    ctx.closePath();
  }
  ctx.fill();
}

Shapes add a dimension that's independent of color and size. A red large circle vs a red large triangle -- you can see both the color similarity and the shape difference at a glance. But shape is a weaker channel than position, size, or color. It requires focused attention to distinguish. Use it as a secondary encoding, not the primary one.

Rotation: direction from data

Rotation is the most underused visual channel. An angled line or arrow conveys direction naturally -- wind direction, trend direction, flow. Most data artists default to circles, which have no directional component. Switching to oriented elements adds dynamism and information.

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

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

// wind data on a grid
const cols = 16;
const rows = 12;
const cellW = 800 / cols;
const cellH = 600 / rows;

for (let r = 0; r < rows; r++) {
  for (let c = 0; c < cols; c++) {
    const x = c * cellW + cellW / 2;
    const y = r * cellH + cellH / 2;

    // fake wind: flows roughly left-to-right with some turbulence
    const angle = Math.sin(c * 0.4) * 0.5 + Math.cos(r * 0.3) * 0.4 + 0.2;
    const speed = 3 + Math.sin(c * 0.5 + r * 0.3) * 2 + Math.random() * 1.5;

    // line length from speed, angle from direction
    const len = speed * 5;

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);

    // line
    ctx.beginPath();
    ctx.moveTo(-len / 2, 0);
    ctx.lineTo(len / 2, 0);
    ctx.strokeStyle = `rgba(140, 200, 255, ${map(speed, 1, 8, 0.3, 0.8)})`;
    ctx.lineWidth = 1.5;
    ctx.stroke();

    // arrowhead
    ctx.beginPath();
    ctx.moveTo(len / 2, 0);
    ctx.lineTo(len / 2 - 5, -3);
    ctx.lineTo(len / 2 - 5, 3);
    ctx.closePath();
    ctx.fillStyle = `rgba(140, 200, 255, ${map(speed, 1, 8, 0.3, 0.8)})`;
    ctx.fill();

    ctx.restore();
  }
}

A grid of arrows. Each arrow's angle shows wind direction and its length shows wind speed. Opacity also encodes speed as a redundant channel (strong winds are brighter). The result looks like a flow field -- which it basically is, but driven by data instead of noise. You can see the overall flow pattern at a glance: where the wind curves, where it accelerates, where it's calm.

Multiple simultaneous mappings

Here's where it gets interesting. Each visual channel can carry a different data dimension independently. Position from latitude/longitude. Size from population. Color from category. Opacity from recency. That's four data dimensions in a single visualization, all readable at once.

The rule of thumb: 3-4 simultaneous mappings is the maximum before the visual becomes noise. Beyond that the viewer can't separate the channels anymore. Pick the most important dimensions and give them the strongest channels (position, color). Put secondary dimensions on weaker channels (opacity, size). Leave the rest out entirely -- not every dataset column needs a visual encoding.

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

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

// multi-dimensional dataset: european cities
const cities = [
  { name: 'Antwerp', lat: 51.2, lon: 4.4, pop: 529000, type: 'port' },
  { name: 'Brussels', lat: 50.9, lon: 4.4, pop: 1209000, type: 'capital' },
  { name: 'Ghent', lat: 51.1, lon: 3.7, pop: 264000, type: 'university' },
  { name: 'Paris', lat: 48.9, lon: 2.4, pop: 2161000, type: 'capital' },
  { name: 'Amsterdam', lat: 52.4, lon: 4.9, pop: 873000, type: 'port' },
  { name: 'Berlin', lat: 52.5, lon: 13.4, pop: 3645000, type: 'capital' },
  { name: 'Munich', lat: 48.1, lon: 11.6, pop: 1472000, type: 'university' },
  { name: 'Hamburg', lat: 53.6, lon: 10.0, pop: 1841000, type: 'port' },
  { name: 'Cologne', lat: 50.9, lon: 7.0, pop: 1086000, type: 'university' },
  { name: 'Vienna', lat: 48.2, lon: 16.4, pop: 1911000, type: 'capital' },
  { name: 'Zurich', lat: 47.4, lon: 8.5, pop: 421000, type: 'university' },
  { name: 'Rotterdam', lat: 51.9, lon: 4.5, pop: 652000, type: 'port' }
];

const typeColors = { capital: 350, port: 200, university: 130 };

// ranges
const lats = cities.map(c => c.lat);
const lons = cities.map(c => c.lon);
const pops = cities.map(c => c.pop);

for (const c of cities) {
  // position from coordinates
  const x = map(c.lon, Math.min(...lons), Math.max(...lons), 80, 720);
  const y = map(c.lat, Math.max(...lats), Math.min(...lats), 60, 540);

  // size from population (area-proportional)
  const area = map(c.pop, 0, Math.max(...pops), 100, 3000);
  const r = Math.sqrt(area / Math.PI);

  // color from type
  const hue = typeColors[c.type];

  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.55)`;
  ctx.fill();
  ctx.strokeStyle = `hsla(${hue}, 55%, 70%, 0.3)`;
  ctx.lineWidth = 1;
  ctx.stroke();

  // label
  ctx.fillStyle = 'rgba(200, 200, 220, 0.6)';
  ctx.font = '10px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(c.name, x, y + r + 12);
}

Position encodes geography (lon -> x, lat -> y). Size encodes population (area-proportional). Color encodes city type (red-ish for capitals, blue for ports, green for universities). Three data dimensions, three visual channels. You can immediately see that capitals tend to be big (they're the largest circles), ports cluster near certain longitudes (the coast), and university cities are smaller. All from one glance at an unlabeled map with colored circles.

Non-linear mapping: when straight lines lie

Not everything should be a linear map. When data spans orders of magnitude, a linear map crushes the small values into invisibility. We covered log normalization in ep081 for exactly this reason. But there are other non-linear mappings that serve different purposes.

Square root for area perception (we already saw this with circle sizes). Logarithmic for exponential data (populations, income, earthquake energy). Easing curves for emphasis -- an ease-in function makes small differences near zero more visible, while an ease-out function emphsizes differences near the maximum.

// easing functions as mapping curves
function easeIn(t) { return t * t; }
function easeOut(t) { return 1 - (1 - t) * (1 - t); }
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); }

// linear: small values compressed, large values spread
// easeOut: small values spread out, large values compressed
// useful when you want to see detail in the low range

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

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

const values = [2, 5, 8, 12, 15, 45, 80, 95];

// row 1: linear mapping
for (let i = 0; i < values.length; i++) {
  const t = values[i] / 100;
  const x = 50 + i * 90;
  const r = 3 + t * 25;
  ctx.beginPath();
  ctx.arc(x, 60, r, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(100, 180, 255, 0.6)';
  ctx.fill();
}

// row 2: easeOut mapping (emphasize small values)
for (let i = 0; i < values.length; i++) {
  const t = easeOut(values[i] / 100);
  const x = 50 + i * 90;
  const r = 3 + t * 25;
  ctx.beginPath();
  ctx.arc(x, 160, r, 0, Math.PI * 2);
  ctx.fillStyle = 'rgba(255, 160, 100, 0.6)';
  ctx.fill();
}

The top row (linear, blue) shows the small values as tiny dots and the large ones as big circles. The bottom row (easeOut, orange) spreads out the small values -- you can actually distinguish between 2, 5, 8, and 12 now. The large values are still large but don't dominate as much. The easing curve acts as a lens that magnifies the part of the data range you want to examine more closely.

Breaking the rules: data art vs data viz

Data visualization has rules. Don't start your y-axis at a non-zero value (it exaggerates differences). Don't use rainbow colormaps (they create false boundaries). Don't use 3D effects on 2D charts (they distort proportions). These rules exist because visualization's goal is accurate communication.

Data art can break every one of these rules. Intentionally.

Start at a non-zero baseline to amplify emotional impact. Use wild color gradients for aesthetic texture. Distort proportions to create visual tension. The difference is intent -- breaking a rule by accident is a mistake, breaking it on purpose is a creative choice. But you have to know the rules first. You can't subvert something you don't understand.

The practical takeaway: when you're mapping data to visuals, ask yourself -- is my goal clarity or expression? If clarity: follow the rules, use proportional mappings, label things. If expression: break whatever serves your vision. Most data art lives somewhere in the middle -- partially readable, partially abstract. The tension between understanding and mystery is what makes it compelling.

Creative exercise: three views of one dataset

Allez, time to put all of this together. We'll take a single dataset and create three completely different visual encodings. Same data, three different stories.

// the dataset: 30 days of sleep, exercise, and mood
const days = [];
for (let i = 0; i < 30; i++) {
  const weekday = i % 7 < 5;
  days.push({
    day: i,
    sleep: weekday ? 6.5 + Math.random() * 2 : 7 + Math.random() * 3,
    exercise: weekday ? 20 + Math.random() * 40 : 30 + Math.random() * 90,
    mood: 4 + Math.random() * 5 + (weekday ? 0 : 1)
  });
}

View 1: timeline with size and color.

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

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

for (let i = 0; i < days.length; i++) {
  const d = days[i];
  const x = map(i, 0, 29, 40, 760);

  // size from exercise minutes
  const area = map(d.exercise, 0, 120, 50, 800);
  const r = Math.sqrt(area / Math.PI);

  // color from mood
  const hue = map(d.mood, 3, 10, 240, 40);  // blue (low mood) to warm (high mood)

  // y from sleep hours
  const y = map(d.sleep, 5, 10, 170, 30);

  x1.beginPath();
  x1.arc(x, y, r, 0, Math.PI * 2);
  x1.fillStyle = `hsla(${hue}, 55%, 50%, 0.5)`;
  x1.fill();
}

View 2: radial clock where each day is a spoke.

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

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

const cx = 200;
const cy = 200;

for (let i = 0; i < days.length; i++) {
  const d = days[i];
  const angle = (i / 30) * Math.PI * 2 - Math.PI / 2;

  // spoke length from exercise
  const len = map(d.exercise, 0, 120, 30, 150);

  // spoke width from sleep
  const width = map(d.sleep, 5, 10, 1, 6);

  // color from mood
  const hue = map(d.mood, 3, 10, 240, 40);

  const ex = cx + Math.cos(angle) * len;
  const ey = cy + Math.sin(angle) * len;

  x2.beginPath();
  x2.moveTo(cx, cy);
  x2.lineTo(ex, ey);
  x2.strokeStyle = `hsla(${hue}, 60%, 55%, 0.7)`;
  x2.lineWidth = width;
  x2.lineCap = 'round';
  x2.stroke();
}

View 3: grid of squares, one per day.

const c3 = document.createElement('canvas');
c3.width = 400; c3.height = 250;
document.body.appendChild(c3);
const x3 = c3.getContext('2d');

x3.fillStyle = '#0a0a1a';
x3.fillRect(0, 0, 400, 250);

for (let i = 0; i < days.length; i++) {
  const d = days[i];
  const col = i % 7;
  const row = Math.floor(i / 7);
  const px = 30 + col * 50;
  const py = 30 + row * 50;

  // square size from exercise
  const s = map(d.exercise, 0, 120, 10, 42);

  // color hue from mood, lightness from sleep
  const hue = map(d.mood, 3, 10, 240, 40);
  const lightness = map(d.sleep, 5, 10, 25, 60);

  x3.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`;
  x3.fillRect(px - s / 2, py - s / 2, s, s);
}

Same thirty data points in all three views. The timeline shows patterns over time -- you can see weekday/weekend cycles in the vertical position (more sleep on weekends pushes dots up). The radial view compresses time into a circle and emphasizes the exercise dimension (long spokes on active days). The grid view highlights the weekly rhythm through its 7-column structure and encodes three dimensions simultaneously (exercise in size, mood in hue, sleep in brightness).

Different mappings tell different stories. The timeline asks "how did my month unfold?" The radial asks "which days were most active?" The grid asks "is there a weekly pattern?" Same data, three questions, three anwers. The mapping is the question.

What's coming

We can map data to any visual property now -- position, size, color, opacity, shape, rotation, and combinations of them all. We know when to use linear vs non-linear mappings, how to encode multiple dimensions simultaneously, and when to break the rules for artistic effect. The vocabulary is in place.

But we've been working with generic datasets -- arrays of numbers, fake cities, simulated sleep logs. Geographic data is a special category with its own challenges and opportunities. Coordinates, projections, boundaries, regions -- mapping the actual shape of the earth onto a flat canvas involves its own set of creative decisions. That's next on the horizon.

't Komt erop neer...

  • The map() function converts a value from one range to another: map(value, inMin, inMax, outMin, outMax). This is the fundamental tool for connecting data space to visual space. Write a clamped version for layout-sensitive properties (position, size) and use the unclamped version for properties where overflow is harmless (color hue, opacity)
  • Position is the strongest visual channel -- humans read spatial location before anything else. Map your most important data dimension to position. X from one variable, Y from another creates an instant scatter plot without any chart framing
  • When mapping data to circle size, map to AREA not radius. radius = Math.sqrt(area / Math.PI). If you map directly to radius, a 2x value looks 4x as large because area scales with r squared. Area-proportional sizing keeps visual proportions honest
  • Color is three channels: hue for categories (different types = different colors), lightness for quantity (dark = low, bright = high), saturation for emphasis/confidence. Don't use rainbow colormaps for quantitative data -- hue doesn't have a natural ordering. Use single-hue gradients or diverging palettes instead
  • Opacity creates density maps for free. Thousands of semi-transparent elements accumulate where they overlap, revealing cluster structure without explicit density calculation. Also useful as a direct importance weighting -- more important data = more opaque
  • Shape (circle, square, triangle) encodes categories but is a weaker channel than color or position. Effective for small datasets where each element is individually visible. Falls apart above ~100 points. Use it as a secondary encoding, not primary
  • Rotation is underused. Angled lines and arrows convey direction naturally -- wind, trend, flow. Switching from circles to oriented elements adds dynamism and an extra data dimension that most people overlook
  • 3-4 simultaneous mappings is the practical maximum. Position for the most important dimension, color for the second, size or opacity for the third. Beyond four, channels interfere and the viewer can't separate them. Not every data column needs a visual encoding -- leave things out
  • Non-linear mapping (logarithmic, square root, easing curves) fixes skewed distributions. When data spans orders of magnitude, linear mapping crushes small values. Log scaling or an easeOut curve spreads out the low range so you can actually see detail there
  • Data visualization rules (proportional axes, no rainbow maps, no 3D effects) exist for clarity. Data art can break them intentionally for emotional or aesthetic effect. But know the rules first -- accidental rule-breaking is a bug, intentional rule-breaking is a creative choice

Sallukes! Thanks for reading.

X

@femdev