498 lines
10 KiB
Markdown
498 lines
10 KiB
Markdown
# 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
const scale = d3.scalePow()
|
|
.exponent(2) // Quadratic
|
|
.domain([0, 100])
|
|
.range([0, 500]);
|
|
```
|
|
|
|
---
|
|
|
|
#### scaleLog
|
|
|
|
**Use**: Exponential data (orders of magnitude)
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```javascript
|
|
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`
|
|
|
|
```javascript
|
|
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
|
|
```
|
|
|
|
---
|
|
|
|
#### scaleBand
|
|
|
|
**Use**: Position categorical bars
|
|
|
|
```javascript
|
|
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)
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
yAxis.tickSize(10).tickPadding(5); // Length and spacing
|
|
```
|
|
|
|
---
|
|
|
|
|
|
### Updating Axes
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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
|
|
|
|
```javascript
|
|
// 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](workflows.md)
|
|
- Combine with shape generators: [Shapes & Layouts](shapes-layouts.md)
|
|
- Add to interactive charts: [Transitions & Interactions](transitions-interactions.md)
|