Files
gh-lyndonkl-claude/skills/d3-visualization/resources/scales-axes.md
2025-11-30 08:38:26 +08:00

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 below
  • axisTop: Ticks above, labels above
  • axisLeft: Ticks left, labels left
  • axisRight: 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