Scales & Axes Configuration
Recreate a scale inside the render loop or hardcode its tick count and you get coordinate drift, overlapping labels, and GC pauses the moment the container resizes.
Concept overview
A scale is a pure function that maps an input domain (data values) to an output range (pixels, or normalized clip-space for shaders). Axes are generators that turn a scale into navigable tick marks and labels. Together they sit between the data join and paint: the enter-update-exit pattern decides which nodes exist, and scales decide where each node lands. They are a core stage of the D3.js data binding and layout architecture, and scale outputs are applied inside the join’s update phase described in data joins and key functions.
The contract is explicit: declare domain([min, max]) and range([from, to]) once, then mutate the domain on data change rather than constructing a new instance. Apply scale.nice() to round domain edges to readable intervals. For Canvas or WebGL, map scale outputs to device-pixel ratios or normalized [-1, 1] coordinates rather than relying on SVG’s viewBox transform.
A scale’s most underused property is invert. The forward map scale(value) → pixel is what positions marks; the inverse scale.invert(pixel) → value is what powers every interaction that starts from a cursor position — brushing, zooming, tooltips, click-to-select. Continuous scales (scaleLinear, scaleTime, scaleLog, scalePow) expose invert; ordinal and band scales do not, because their mapping is not bijective, so for scaleBand you reconstruct the inverse manually from step() and paddingOuter(). Treating the scale as the single source of truth for both directions is what keeps a chart’s input and output coordinate systems from drifting apart: if the same xScale both places the bars and decodes the cursor, a pan or resize that updates the domain automatically keeps hit-testing correct.
Two modifiers change the domain’s behavior at its edges and are frequently confused. nice() extends the domain outward to round numbers so ticks land on readable values, which adds a little padding; clamp(true) constrains the output so values outside the domain map to the nearest range endpoint instead of extrapolating past it. You want nice() for readable axes and clamp(true) whenever a brush, zoom, or out-of-range datum could otherwise produce a pixel coordinate outside the plot area. They are independent and often used together.
Band scales decompose their range into a repeating unit that is easy to get wrong by an off-by-half-a-band, so it helps to see the anatomy explicitly. The step is the repeating unit — one band plus one inner gap — and bandwidth is the drawable width of a single band; outer padding reserves space before the first and after the last band.
Decision table: choosing a scale
| Scale | Input domain | Output | Use when |
|---|---|---|---|
scaleLinear |
continuous numeric | continuous | linear quantitative axes |
scaleLog |
positive continuous | continuous | values spanning orders of magnitude |
scaleTime |
dates | continuous | time series; UTC variant for global data |
scaleBand |
discrete categories | banded slots | bar charts, categorical x-axis |
scalePoint |
discrete categories | points | line/dot plots over categories |
scaleOrdinal |
discrete | discrete (colors) | categorical color encoding |
scaleSqrt |
continuous numeric | continuous | area encodings (bubble radius) where value ∝ area |
scalePow |
continuous numeric | continuous | tunable non-linear emphasis via .exponent() |
scaleQuantize |
continuous numeric | discrete buckets | choropleth/heatmap class breaks |
The two most common encoding mistakes the table guards against are using a linear scale for radius and using a log scale that includes zero. Bubble size must use scaleSqrt (or scalePow with exponent 0.5) because human perception reads circle area, not radius, so a linear radius scale exaggerates large values by the square. And scaleLog cannot represent zero or negative numbers — its domain must be strictly positive — so for data that crosses or touches zero, reach for scaleSymlog, which is linear near zero and logarithmic in the tails. Choosing the scale type is therefore a perceptual decision as much as a numeric one: the scale is where the data’s meaning is encoded into geometry, and the wrong type misrepresents the data before a single pixel is drawn.
Reference spec
import { scaleLinear, scaleBand } from 'd3-scale';
import { axisBottom } from 'd3-axis';
// scaleLinear<Range, Output>(): continuous → continuous
const x = scaleLinear()
.domain([0, 100]) // data extent
.range([0, 800]) // pixel extent
.nice(); // round domain edges
x(50); // → 400 (forward map)
x.invert(400); // → 50 (reverse map for brushing/zoom)
// axis generator surface
const axis = axisBottom(x)
.ticks(8) // approximate count, not exact
.tickSizeOuter(0)
.tickPadding(8)
.tickFormat((d) => `${d}%`);
The band-scale surface is different and worth its own note, because it is where layout bugs hide. scaleBand<string>() partitions the range into uniform bands separated by inner padding and bordered by outer padding:
const x = scaleBand<string>()
.domain(['Q1', 'Q2', 'Q3', 'Q4'])
.range([0, 800])
.paddingInner(0.1) // gap between bands, as a fraction of step
.paddingOuter(0.05); // gap before first / after last band
x('Q2'); // → left edge of the Q2 band (number)
x.bandwidth(); // → width of one band — use for rect width
x.step(); // → band + gap; the unit you invert against for hit-testing
x('Q2') returns the band’s left edge, not its center, so center a label or a point with x(d)! + x.bandwidth() / 2. The ! non-null assertion is necessary because scaleBand returns undefined for a value outside its domain — a silent source of NaN positions when a datum’s category was dropped from the domain but not from the data. Validate that every bound datum’s category exists in domain() before positioning, or guard the call.
The axis generators share a signature: axisBottom(scale), axisTop, axisLeft, axisRight each return a callable that you invoke with g.call(axis) against a <g> element. Their tuning surface — .ticks(count, format?), .tickValues(array), .tickFormat(fn), .tickSize(n), .tickSizeInner/Outer(n), .tickPadding(n) — controls density and labeling. .ticks(n) is a hint: D3 picks a round-number interval near n, so you cannot demand exactly seven ticks, but you can force an exact set with .tickValues([...]), which is the escape hatch for irregular or data-aligned ticks.
Step-by-step implementation
The sequence below assumes the scale outlives the data: you build it once when the chart mounts and feed it new domains as data arrives, so it stays the stable coordinate authority that both the join’s update phase and your interaction handlers read from. The steps map onto the two halves of the contract — set up the pure mapping once, then keep its domain in sync with the data and its tick density in sync with the container.
import { select } from 'd3-selection';
import { scaleLinear } from 'd3-scale';
import { axisBottom } from 'd3-axis';
const xScale = scaleLinear().range([0, 800]); // instantiated once
const axis = axisBottom(xScale).tickSizeOuter(0).tickPadding(8);
function renderAxis(g: SVGGElement, data: number[], width: number): void {
xScale.domain([0, Math.max(...data)]).nice(); // mutate domain, no realloc
const tickCount = Math.max(3, Math.floor(width / 80)); // PERF: density from width
axis.ticks(tickCount);
select(g)
.attr('aria-hidden', 'true') // A11Y: decorative ticks skip focus
.call(axis);
}
Decoding a cursor back to a category demonstrates why the scale must own both directions. scaleBand has no invert, so reconstruct it from step():
function bandInvert(x: ScaleBand<string>, px: number): string | null {
const domain = x.domain();
const step = x.step();
const start = x(domain[0])!; // left edge of first band
const index = Math.floor((px - start + x.paddingInner() * step / 2) / step);
// PERF: O(1) decode — no scan over the domain array.
return domain[index] ?? null; // null when the cursor is in outer padding
}
Because this reuses the same x scale the bars were drawn with, a domain change, resize, or zoom that updates x keeps the decode correct automatically — the input and output coordinate systems can never drift because they are literally the same object.
Performance & memory notes
The performance story for scales is almost entirely about not rebuilding them. A scale carries internal interpolator state, and constructing a new one allocates closures, resets that state, and severs any transition that was interpolating against the old instance. In a resize handler or a render loop, that turns a free operation into a per-frame allocation and a visible animation snap. Every rule below follows from treating the scale as a long-lived object whose domain you mutate, never replace.
- Allocation churn: constructing a scale per frame allocates closures and breaks transition continuity. Cache the instance; mutate
.domain(). - Interpolation cost:
d3.interpolateis fine per transition but expensive per frame at scale; precompute lookup tables or normalize in a shader for Canvas/WebGL. - Isolate state for concurrent transitions: use
scale.copy()to derive a target scale so an in-flight animation does not read a half-mutated domain. - Frame budget: batch axis DOM writes and defer heavy recomputes to
requestAnimationFrame; pre-render static axis backgrounds to an off-screen canvas and composite withdrawImage. - Axis re-render cost:
g.call(axis)runs its own internal data join over the tick marks, so it isO(ticks)per call, not free. On a resize that fires many times, debounce the axis redraw or you pay the tick join on every intermediate width. - Domain mutation vs continuity: mutating
.domain()in place changes what an in-flight transition is interpolating toward mid-tween. When you animate a domain change, capture the source domain, derive the target withscale.copy().domain(next), and interpolate between the two snapshots so the running transition reads a stable target — thescale.copy()discipline noted above. - Coordinate caching for Canvas: because the scale is a pure function, you can precompute the pixel position of every datum once per domain change into a
Float32Arrayand reuse it across redraw frames, recomputing only when the domain actually moves rather than on every paint.
Accessibility checklist
Troubleshooting
Most scale and axis defects fall into three families: a mis-chosen scale type that misrepresents the data, a domain that drifts out of sync with the data or the cursor, and tick density that ignores the container width. The entries below cover all three; each names the specific D3 call that fixes it.
- Symptom: ticks overlap on dense time data. Root cause: hardcoded tick count exceeding available width. Fix: derive density from width — see preventing axis label overlap on dense time series.
- Symptom: axis renders
NaN. Root cause: mismatched domain/range lengths or an empty domain. Fix: validatedomain().length === range().lengthand guard empty data. - Symptom: misaligned ticks on irregular timestamps. Root cause: calendar-aligned
timeTicksagainst sparse data. Fix: filter ticks to real data points — see customizing D3 time scales for irregular timestamps. - Symptom: elements render off-canvas. Root cause: out-of-domain values extrapolating past the range. Fix: call
.clamp(true)so out-of-bounds values map to the nearest range edge. - Symptom: band positions return
undefined/NaN. Root cause: a datum’s category is missing fromscaleBand.domain(). Fix: rebuild the domain from the current data each update, or guardx(d.category) ?? 0and surface the dropped category as a data-quality error. - Symptom: bubble sizes look exaggerated. Root cause: radius mapped with
scaleLinear, so area grows with the square of value. Fix: encode radius withscaleSqrtso perceived area is proportional to value. - Symptom: log axis throws or shows blanks. Root cause:
scaleLogdomain includes zero or negatives. Fix: switch toscaleSymlogfor zero-crossing data, or clamp the domain to a small positive floor. - Symptom: hit-testing drifts after a zoom. Root cause: the cursor is decoded with a stale scale while marks use the rescaled one. Fix: make a single (possibly zoom-rescaled) scale the source of truth for both
scale()andscale.invert().
Frequently Asked Questions
Why should I cache scale instances instead of recreating them?
Constructing a scale allocates closures and resets internal interpolator state, which triggers GC pressure during resize and render loops and snaps any in-flight transition back to a fresh baseline. Instantiate the scale once and call .domain(newDomain) to update it so transition continuity and frame budget are preserved.
How do I pick the right number of ticks?
Treat .ticks(n) as an approximate hint, not an exact count — D3 rounds to readable intervals. Derive the count from container width, for example Math.max(3, Math.floor(width / 80)), so labels stay readable as the chart resizes rather than colliding at narrow breakpoints.
When do I need scaleUtc instead of scaleTime?
Use scaleUtc whenever data spans timezones or daylight-saving boundaries. scaleTime uses the browser’s local time, which injects or drops phantom hours around DST transitions and shifts tick spacing between users in different regions; scaleUtc keeps the domain deterministic everywhere.
How do scales work without a DOM in Canvas or WebGL?
The scale function is independent of the DOM, so you call it the same way and apply the numeric output directly to ctx.fillRect or a vertex buffer. For WebGL, normalize the scale output into [-1, 1] clip space; for Canvas, multiply by devicePixelRatio to stay crisp on retina screens.
Related
- D3.js data binding and layout architecture — the overview this guide sits under.
- Data joins and key functions — applying scale outputs in the update phase.
- Customizing D3 time scales for irregular timestamps — sparse temporal data.
- Preventing axis label overlap on dense time series — tick density on crowded axes.
- Transition and animation sequences — animating domain changes smoothly.