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)
- 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
- Length: Second most accurate
// Length encoding (bar chart)
svg
.selectAll('rect')
.data(data)
.join('rect')
.attr('height', d => heightScale(d.value)); // Bar length
- 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
- 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
- Volume: Least accurate (avoid for precise data)
Categorical Channels
- 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
- 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)
);
- 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
- Match encoding to data type: Use quantitative channels for quantitative data, categorical channels for categorical data
- Consider accuracy requirements: Use position for precise comparisons, color for general patterns
- Avoid redundant encoding: Don't use multiple channels for the same data dimension unless necessary
- Account for accessibility: Consider colorblindness, provide alternative encodings
- 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.