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.

A linear scale mapping a data domain to a pixel range Values in the data domain map through scaleLinear to positions in the pixel range, which an axis renders as ticks. domain [0, 100] data 0 50 100 scaleLinear() .domain().range() range [0, 320] px 0 50 100 axisBottom(scale)
A scale is a pure mapping from data domain to pixel range; the axis generator turns the same scale into rendered tick marks and labels.

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.

Anatomy of a band scale The range divides into outer padding, repeating steps each made of a band plus inner gap, with bandwidth as the drawable band width. Q1 Q2 Q3 Q4 outer step() bandwidth() inner gap x('Q2') returns the band's left edge; center with + bandwidth()/2
A band scale divides the range into outer padding and repeating steps; bandwidth is the band width and step is band-plus-gap, the unit you invert against for hit-testing.

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.interpolate is 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 with drawImage.
  • Axis re-render cost: g.call(axis) runs its own internal data join over the tick marks, so it is O(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 with scale.copy().domain(next), and interpolate between the two snapshots so the running transition reads a stable target — the scale.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 Float32Array and 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: validate domain().length === range().length and guard empty data.
  • Symptom: misaligned ticks on irregular timestamps. Root cause: calendar-aligned timeTicks against 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 from scaleBand.domain(). Fix: rebuild the domain from the current data each update, or guard x(d.category) ?? 0 and 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 with scaleSqrt so perceived area is proportional to value.
  • Symptom: log axis throws or shows blanks. Root cause: scaleLog domain includes zero or negatives. Fix: switch to scaleSymlog for 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() and scale.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.