10 KiB
Scales & Axes
Why Scales Matter
The Problem: Data values (0-1000, dates, categories) don't directly map to visual properties (pixels 0-500, colors, positions). Hardcoding transformations (x = value * 0.5) breaks when data changes.
D3's Solution: Scale functions encapsulate domain-to-range mapping. Change data → update domain → visualization adapts automatically.
Key Principle: "Separate data space from visual space." Scales are the bridge.
Scale Fundamentals
Domain and Range
const scale = d3.scaleLinear()
.domain([0, 100]) // Data min/max (input)
.range([0, 500]); // Visual min/max (output)
scale(0); // → 0px
scale(50); // → 250px
scale(100); // → 500px
Domain: Input data extent Range: Output visual extent Scale: Function mapping domain → range
Scale Types
Continuous → Continuous
scaleLinear
Use: Quantitative data, proportional relationships
const xScale = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([0, width]);
Math: Linear interpolation y = mx + b
scaleSqrt
Use: Sizing circles by area (not radius)
const radiusScale = d3.scaleSqrt()
.domain([0, 1000000]) // Population
.range([0, 50]); // Max radius
// Circle area ∝ population (perceptually accurate)
Why: Area = πr², so r = √(Area). Sqrt scale makes area proportional to data.
scalePow
Use: Custom exponents
const scale = d3.scalePow()
.exponent(2) // Quadratic
.domain([0, 100])
.range([0, 500]);
scaleLog
Use: Exponential data (orders of magnitude)
const scale = d3.scaleLog()
.domain([1, 1000]) // Don't include 0!
.range([0, 500]);
scale(1); // → 0
scale(10); // → 167
scale(100); // → 333
scale(1000); // → 500
Note: Log undefined at 0. Domain must be positive.
scaleTime
Use: Temporal data
const xScale = d3.scaleTime()
.domain([new Date(2020, 0, 1), new Date(2020, 11, 31)])
.range([0, width]);
Works with: Date objects, timestamps
scaleSequential
Use: Continuous data → color gradients
const colorScale = d3.scaleSequential(d3.interpolateBlues)
.domain([0, 100]);
colorScale(0); // → light blue
colorScale(50); // → medium blue
colorScale(100); // → dark blue
Interpolators: interpolateBlues, interpolateReds, interpolateViridis, interpolateRainbow (avoid rainbow!)
Continuous → Discrete
scaleQuantize
Use: Divide continuous domain into discrete bins
const colorScale = d3.scaleQuantize()
.domain([0, 100])
.range(['green', 'yellow', 'orange', 'red']);
colorScale(20); // → 'green' (0-25)
colorScale(40); // → 'yellow' (25-50)
colorScale(75); // → 'orange' (50-75)
colorScale(95); // → 'red' (75-100)
Equal bins: Domain divided evenly by range length.
scaleQuantile
Use: Divide data into quantiles (equal-sized groups)
const colorScale = d3.scaleQuantile()
.domain(data.map(d => d.value)) // Actual data values
.range(['green', 'yellow', 'orange', 'red']);
// Quartiles: 25% of data in each color
Difference from quantize: Bins have equal data counts, not equal domain width.
scaleThreshold
Use: Explicit breakpoints
const colorScale = d3.scaleThreshold()
.domain([40, 60, 80]) // Split points
.range(['green', 'yellow', 'orange', 'red']);
colorScale(30); // → 'green' (<40)
colorScale(50); // → 'yellow' (40-60)
colorScale(70); // → 'orange' (60-80)
colorScale(90); // → 'red' (≥80)
Use for: Letter grades, risk levels, custom categories.
Discrete → Discrete
scaleOrdinal
Use: Map categories to categories (often colors)
const colorScale = d3.scaleOrdinal()
.domain(['A', 'B', 'C'])
.range(['red', 'green', 'blue']);
colorScale('A'); // → 'red'
colorScale('B'); // → 'green'
Built-in schemes: d3.schemeCategory10, d3.schemeTableau10
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
scaleBand
Use: Position categorical bars
const xScale = d3.scaleBand()
.domain(['A', 'B', 'C', 'D'])
.range([0, 500])
.padding(0.1); // 10% spacing
xScale('A'); // → 0
xScale('B'); // → 125
xScale.bandwidth(); // → 112.5 (width of each band)
Key method: .bandwidth() returns width for bars/columns.
scalePoint
Use: Position categorical points (scatter, line chart categories)
const xScale = d3.scalePoint()
.domain(['A', 'B', 'C', 'D'])
.range([0, 500])
.padding(0.5);
xScale('A'); // → 50 (centered in first position)
xScale('B'); // → 200
Difference from band: Points (no width), bands (width for bars).
Scale Selection Guide
Quantitative linear: scaleLinear | Exponential: scaleLog | Circle sizing: scaleSqrt Temporal: scaleTime | Categorical bars: scaleBand | Categorical points: scalePoint Categorical colors: scaleOrdinal | Bins (equal domain): scaleQuantize | Bins (equal data): scaleQuantile Custom breakpoints: scaleThreshold | Color gradient: scaleSequential
Scale Methods
Domain from Data
// Extent (min, max)
const xScale = scaleLinear()
.domain(d3.extent(data, d => d.x)) // [min, max]
.range([0, width]);
// Max only (0 to max)
const yScale = scaleLinear()
.domain([0, d3.max(data, d => d.y)])
.range([height, 0]);
// Custom
const yScale = scaleLinear()
.domain([-100, 100]) // Symmetric around 0
.range([height, 0]);
Inversion
const xScale = scaleLinear()
.domain([0, 100])
.range([0, 500]);
xScale(50); // → 250 (data → visual)
xScale.invert(250); // → 50 (visual → data)
Use: Convert mouse position to data value.
Clamping
const scale = scaleLinear()
.domain([0, 100])
.range([0, 500])
.clamp(true);
scale(-10); // → 0 (clamped to range min)
scale(150); // → 500 (clamped to range max)
Without clamp: scale(150) → 750 (extrapolates beyond range).
Nice Domains
const yScale = scaleLinear()
.domain([0.201, 0.967])
.range([height, 0])
.nice();
yScale.domain(); // → [0.2, 1.0] (rounded)
Use: Clean axis labels (0.2, 0.4, 0.6 instead of 0.201, 0.401...).
Axes
Creating Axes
// Scales first
const xScale = scaleBand().domain(categories).range([0, width]);
const yScale = scaleLinear().domain([0, max]).range([height, 0]);
// Axis generators
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
// Render axes
svg.append('g')
.attr('transform', `translate(0, ${height})`) // Position at bottom
.call(xAxis);
svg.append('g')
.call(yAxis);
Axis orientations:
axisBottom: Ticks below, labels belowaxisTop: Ticks above, labels aboveaxisLeft: Ticks left, labels leftaxisRight: Ticks right, labels right
Customizing Ticks
// Number of ticks (approximate)
yAxis.ticks(5); // D3 chooses ~5 "nice" values
// Explicit tick values
xAxis.tickValues([0, 25, 50, 75, 100]);
// Tick format
yAxis.tickFormat(d => d + '%'); // Add percent sign
yAxis.tickFormat(d3.format('.2f')); // 2 decimal places
yAxis.tickFormat(d3.timeFormat('%b')); // Month abbreviations
Tick Styling
yAxis.tickSize(10).tickPadding(5); // Length and spacing
Updating Axes
function update(newData) {
// Update scale domain
yScale.domain([0, d3.max(newData, d => d.value)]);
// Update axis with transition
svg.select('.y-axis')
.transition()
.duration(500)
.call(d3.axisLeft(yScale));
}
Color Scales
Categorical Colors
const color = d3.scaleOrdinal(d3.schemeCategory10); // Built-in scheme
const color = d3.scaleOrdinal().domain(['low', 'medium', 'high']).range(['green', 'yellow', 'red']); // Custom
Sequential & Diverging Colors
// Sequential
const color = d3.scaleSequential(d3.interpolateBlues).domain([0, 100]);
// Interpolators: Blues, Reds, Greens, Viridis, Plasma, Inferno (avoid Rainbow!)
// Diverging
const color = d3.scaleDiverging(d3.interpolateRdYlGn).domain([-100, 0, 100]);
Common Patterns
// Responsive: update range on resize
const xScale = scaleLinear().domain([0, 100]).range([0, width]);
// Multi-scale chart
const xScale = scaleTime().domain(dateExtent).range([0, width]);
const colorScale = scaleOrdinal(schemeCategory10);
const sizeScale = scaleSqrt().domain([0, maxPop]).range([0, 50]);
// Symmetric domain
const max = d3.max(data, d => Math.abs(d.value));
const yScale = scaleLinear().domain([-max, max]).range([height, 0]);
Common Pitfalls
Pitfall 1: Forgetting to Invert Y Range
// WRONG - high values at bottom
const yScale = scaleLinear().domain([0, 100]).range([0, height]);
// CORRECT - high values at top
const yScale = scaleLinear().domain([0, 100]).range([height, 0]);
Pitfall 2: Log Scale with Zero
// WRONG - log(0) undefined
const scale = scaleLog().domain([0, 1000]);
// CORRECT - start at small positive number
const scale = scaleLog().domain([1, 1000]);
Pitfall 3: Not Updating Domain
// WRONG - scale domain never changes
const yScale = scaleLinear().domain([0, 100]).range([height, 0]);
update(newData); // New max might be 200!
// CORRECT
function update(newData) {
yScale.domain([0, d3.max(newData, d => d.value)]);
// Re-render with updated scale
}
Pitfall 4: Using Band Scale for Quantitative Data
// WRONG
const xScale = scaleBand().domain([0, 10, 20, 30]); // Numbers!
// CORRECT - use linear for numbers
const xScale = scaleLinear().domain([0, 30]).range([0, width]);
Next Steps
- Use scales in charts: Workflows
- Combine with shape generators: Shapes & Layouts
- Add to interactive charts: Transitions & Interactions