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

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)