Skip to main content

Scale Theory & Visual Encoding

Understanding mathematical mapping and visual encoding principles in D3.js.

Mathematical Mapping

Scales are mathematical functions that map from one domain (data space) to another range (visual space). They solve the fundamental problem of converting data values into visual properties.

Domain and Range Relationship

The scale function creates a mathematical relationship between input and output:

// Linear mapping: f(x) = mx + b
const scale = d3
.scaleLinear()
.domain([0, 100]) // Input domain (data space)
.range([0, 500]); // Output range (visual space)

// Mathematical relationship: f(x) = x * 5
console.log(scale(0)); // 0
console.log(scale(50)); // 250
console.log(scale(100)); // 500

// Inverse mapping
console.log(scale.invert(250)); // 50

Scale Categories

Quantitative Scales (Continuous Input)

Linear Scale: Equal intervals in input produce equal intervals in output

const linear = d3.scaleLinear().domain([0, 100]).range([0, 500]);

// 10 → 50, 20 → 100, 30 → 150 (consistent +50 pixel intervals)

Power Scale: Exponential relationships

const power = d3.scalePow().domain([0, 100]).range([0, 500]).exponent(2); // Quadratic scale

// 10 → 5, 20 → 20, 30 → 45 (accelerating growth)

Log Scale: Useful for exponential data

const log = d3.scaleLog().domain([1, 1000]).range([0, 500]).base(10);

// 1 → 0, 10 → 167, 100 → 333, 1000 → 500

Time Scale: Special linear scale optimized for dates

const time = d3
.scaleTime()
.domain([new Date(2024, 0, 1), new Date(2024, 11, 31)])
.range([0, 500]);

// Handles leap years, months with different lengths, etc.

Ordinal Scales (Discrete Input)

Band Scale: Creates equal-width bands with optional padding

const band = d3
.scaleBand()
.domain(['A', 'B', 'C', 'D'])
.range([0, 400])
.padding(0.1);

console.log(band('A')); // 0
console.log(band('B')); // 90
console.log(band.bandwidth()); // 90 (width of each band)

Point Scale: Maps to specific points along a range

const point = d3
.scalePoint()
.domain(['A', 'B', 'C', 'D'])
.range([0, 400])
.padding(0.5);

// Points are evenly distributed with padding

Ordinal Scale: Discrete input to discrete output

const ordinal = d3
.scaleOrdinal()
.domain(['apple', 'orange', 'banana'])
.range(['red', 'orange', 'yellow']);

console.log(ordinal('apple')); // 'red'
console.log(ordinal('orange')); // 'orange'

Color Scale Theory

Sequential Scales

For ordered data where direction matters (low to high values).

const sequential = d3.scaleSequential(d3.interpolateBlues).domain([0, 100]);

// 0 → light blue, 50 → medium blue, 100 → dark blue

Diverging Scales

For data with a meaningful center point (e.g., temperature, profit/loss).

const diverging = d3
.scaleDiverging(d3.interpolateRdYlBu)
.domain([-100, 0, 100]);

// -100 → red, 0 → yellow, 100 → blue

Categorical Scales

For nominal data where categories have no inherent order.

const categorical = d3
.scaleOrdinal(d3.schemeCategory10)
.domain(['Technology', 'Healthcare', 'Finance', 'Education']);

// Each category gets a distinct color

Interpolation Theory

Scales use interpolators to compute intermediate values between domain and range endpoints.

Built-in Interpolators

// Color interpolation
const colorScale = d3
.scaleLinear()
.domain([0, 100])
.range(['blue', 'red'])
.interpolate(d3.interpolateHsl); // Hue-Saturation-Lightness

// Number interpolation
const numberScale = d3.scaleLinear().domain([0, 1]).range([10, 50]);
// Uses d3.interpolateNumber by default

// String interpolation (for compatible strings)
const transformScale = d3
.scaleLinear()
.domain([0, 100])
.range(['translate(0,0)', 'translate(100,0)']);

Custom Interpolators

// Quadratic easing interpolator
const customScale = d3
.scaleLinear()
.domain([0, 1])
.range([0, 100])
.interpolate((a, b) => t => a + (b - a) * t * t);

// Multi-step color interpolation
const multiColorScale = d3
.scaleLinear()
.domain([0, 50, 100])
.range(['red', 'yellow', 'green'])
.interpolate(d3.interpolateHsl);

Visual Encoding Principles

Visual encoding is the process of mapping data values to visual properties. Different visual channels have different perceptual properties and are suited for different types of data.

Channels of Visual Information

Quantitative Channels (Ordered by Accuracy)

  1. Position: Most accurate for precise comparisons
// Position encoding (scatter plot)
svg
.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => xScale(d.income)) // X position
.attr('cy', d => yScale(d.happiness)); // Y position
  1. Length: Second most accurate
// Length encoding (bar chart)
svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('height', d => heightScale(d.value)); // Bar length
  1. Angle/Slope: Moderate accuracy
// Angle encoding (pie chart)
const pie = d3.pie().value(d => d.value);
const arc = d3.arc().innerRadius(0).outerRadius(100);

svg.selectAll('path').data(pie(data)).join('path').attr('d', arc); // Angle represents value
  1. Area: Less accurate for precise comparison
// Area encoding (bubble chart)
svg
.selectAll('circle')
.data(data)
.join('circle')
.attr('r', d => Math.sqrt(areaScale(d.value) / Math.PI)); // Area encoding
  1. Volume: Least accurate (avoid for precise data)

Categorical Channels

  1. Hue: Different colors for categories
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);

svg
.selectAll('circle')
.data(data)
.join('circle')
.attr('fill', d => colorScale(d.category)); // Hue encoding
  1. Shape: Different symbols
const shapeScale = d3
.scaleOrdinal()
.domain(['A', 'B', 'C'])
.range([d3.symbolCircle, d3.symbolSquare, d3.symbolTriangle]);

svg
.selectAll('path')
.data(data)
.join('path')
.attr(
'd',
d3
.symbol()
.type(d => shapeScale(d.type))
.size(100)
);
  1. Texture/Pattern: Different fill patterns
// Define patterns in defs
const patterns = ['dots', 'stripes', 'grid'];

svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('fill', d => `url(#${patterns[d.categoryIndex]})`);

Multiple Encoding Channels

Complex visualizations often use multiple channels simultaneously:

// Multi-dimensional encoding (scatter plot with multiple variables)
svg
.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => xScale(d.income)) // X position: income
.attr('cy', d => yScale(d.happiness)) // Y position: happiness
.attr('r', d => sizeScale(d.population)) // Size: population
.attr('fill', d => colorScale(d.region)) // Color: region
.attr('opacity', d => opacityScale(d.gdp)) // Opacity: GDP
.attr('stroke-width', d => (d.isCapital ? 3 : 1)); // Stroke: capital city

Perceptual Considerations

Weber-Fechner Law

Human perception of differences is relative to the magnitude of the stimuli.

// Use sqrt scale for area encoding to match perception
const areaScale = d3
.scaleSqrt()
.domain([0, d3.max(data, d => d.value)])
.range([0, maxRadius]);

// Circle area is proportional to data value
svg.selectAll('circle').attr('r', d => areaScale(d.value));

Color Perception

Different aspects of color have different perceptual properties:

// Luminance changes are most perceptible
const luminanceScale = d3.scaleSequential(d3.interpolateGreys).domain([0, 100]);

// Hue changes good for categories (but consider colorblindness)
const hueScale = d3
.scaleOrdinal()
.range(['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728']);

// Saturation changes more subtle
const saturationScale = d3
.scaleLinear()
.domain([0, 100])
.range(['hsl(200, 0%, 50%)', 'hsl(200, 100%, 50%)']);

Gestalt Principles in Visualization

Visual perception principles that affect how users interpret visualizations:

Proximity

Elements close together are perceived as related:

// Group related data points visually
const groups = svg
.selectAll('.group')
.data(groupedData)
.join('g')
.attr('class', 'group')
.attr('transform', (d, i) => `translate(${i * 150}, 0)`); // Separate groups

groups
.selectAll('circle')
.data(d => d.values)
.join('circle')
.attr('cx', (d, i) => i * 20) // Close spacing within groups
.attr('cy', 50);

Similarity

Similar elements are perceived as groups:

// Use consistent styling for related elements
svg
.selectAll('.primary-data')
.attr('fill', 'steelblue')
.attr('stroke', 'darkblue')
.attr('stroke-width', 2);

svg
.selectAll('.secondary-data')
.attr('fill', 'lightgray')
.attr('stroke', 'gray')
.attr('stroke-width', 1);

Closure

Mind fills in missing information:

// Dashed lines suggest continuation
svg.append('line').attr('stroke-dasharray', '5,5').attr('stroke', 'gray');

Continuity

Eye follows smooth paths:

// Smooth curves guide the eye
const line = d3
.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveCardinal); // Smooth curve instead of sharp angles

Figure/Ground

Distinguishing foreground from background:

// Use contrast to separate data from background
svg
.append('rect')
.attr('class', 'background')
.attr('fill', '#f9f9f9')
.attr('width', width)
.attr('height', height);

svg
.selectAll('.data-point')
.attr('fill', 'steelblue') // High contrast with background
.attr('stroke', 'white')
.attr('stroke-width', 2);

Encoding Best Practices

  1. Match encoding to data type: Use quantitative channels for quantitative data, categorical channels for categorical data
  2. Consider accuracy requirements: Use position for precise comparisons, color for general patterns
  3. Avoid redundant encoding: Don't use multiple channels for the same data dimension unless necessary
  4. Account for accessibility: Consider colorblindness, provide alternative encodings
  5. Respect cultural conventions: Red often means negative, green positive in financial contexts
// Accessible multi-channel encoding
svg
.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', d => (d.important ? 8 : 5)) // Size for importance
.attr('fill', d => colorScale(d.category)) // Color for category
.attr('stroke', d => (d.important ? 'black' : 'none')) // Stroke for importance
.attr('stroke-width', 2);

Understanding scale theory and visual encoding principles enables creating effective, perceptually accurate visualizations that communicate data clearly and efficiently.