Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~25 min· topic 1 of 7

Topic 6.1

Linear Scales

In one line

A linear scale is a function that turns a number from your data (the domain) into a position on the page (the range), in a straight proportion. It also knows how to go backwards, how to stop at the ends, and which round numbers to print on an axis.

Think of it like this

A thermometer glued to a window. The glass tube is the range, 12 centimetres of it. The numbers printed beside it, from -10 to 40 degrees, are the domain. The printer used one rule to place every number: the same number of degrees for every centimetre. Read the mercury's height and you know the temperature; know the temperature and you know where the mercury will be. A scale is that printing rule, written as a function.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

scale
A function that turns a data value into a visual value, such as pixels, by a fixed rule.
domain
The values the scale accepts. For rides: [0, 4380].
range
The values the scale returns. For a bar chart: pixels, such as [0, 500].
invert
Going backwards: from a pixel position to the data value it stands for.
clamp
Stop the output at the ends of the range, so values outside the domain do not go further.

Step by step

01Two rulers, one rule

The first example makes the scale and calls it. The domain ruler (rides) and the range ruler (pixels) are lined up so that 0 meets 0 and 4,380 meets 500. Everything between is in proportion.

That is all a linear scale is. The code prints what it returns for 0, 439 (the quietest day), 3,742 (a Thursday in June), 4,380 and 5,000.

02How ticks() chooses a step

ticks(0, 4380, 10) first divides the span by the wanted count: 4380 ÷ 10 = 438. It takes the power of ten at or below that: 100. The leftover factor is 438 ÷ 100 = 4.38.

It then rounds that factor to 1, 2, 5 or 10 using cut-offs at 1.41, 3.16 and 7.07. These are the geometric midpoints (√2, √10, √50) between 1, 2, 5 and 10, so the chosen step is always the nearest of the four in ratio. 4.38 is at least 3.16 and below 7.07, so the factor is 5, and the step is 5 × 100 = 500.

How ticks() chooses a stepdiagram
Rendering diagram…

03Ask for fewer or more ticks

The count is a hint. 10 gives step 500 (nine ticks). 5 gives 876 ÷ 100 = 8.76, which is 7.07 or more, so the factor is 10 and the step is 1,000. 20 gives 219 ÷ 100 = 2.19, so the factor is 2 and the step is 200.

The table in the third example prints these, plus what happens for a count of 0 (no ticks), a domain of one value (one tick) and a reversed domain (the ticks run downwards).

04Why nice() needs the same count

nice() repeats the same idea on the domain: find the step, round the start down and the end up to a multiple of it, and check again, up to ten times. For [0, 4380] with the default count of 10 the step is 500, so the domain becomes [0, 4500]. With nice(5) the step is 1,000 and the domain becomes [0, 5000].

If the two counts differ, the axis can stop short. nice(10) makes the domain [0, 4500], but an axis that then asks for ticks(5) steps by 1,000 and ends at 4,000, 500 below the end of the domain. Use the same count for both.

Try it yourself

  1. 1

    Squeeze the range

    In the first example, change .range([0, 500]) to .range([0, 250]). Every number halves: 3,742 rides now give 213.6 pixels. The comparison between days is unchanged; only the space changed. A scale separates "what the data is" from "how much room I have".

  2. 2

    Run it backwards

    Change the range to [500, 0]. Now 0 rides gives 500 and 3,742 gives 72.8: the dots swap sides. This is exactly what the y scale does, with the "bottom" of the chart at the large number.

Code & diagrams

A linear scale: domain in, pixels out New tab

The domain comes from the data (d3.max), the range from your layout. Phase 5's join draws one dot per day.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

busiest day: 4380 rides -> 500 pixels
0 rides      -> 0
439 rides    -> 50.1
3,742 rides  -> 427.2
5,000 rides  -> 570.8 (outside the domain: the line keeps going)
invert(250)  -> 2190 rides
clamped: 5,000 -> 500 and -300 -> 0
y(0) = 300   y(4380) = 0   y(2190) = 150
by hand, 3,742 -> 427.2
dots drawn: 14

Try it: interactive

Scale explorer: a linear scale

Drag the value along the domain and watch the pixel position move. Then try a value outside [0, 4380].

Edge cases: what a scale returns for odd input New tab

The last two lines are the course's NaN guard doing its job: the example prints the browser's two SVG errors on purpose, so the page shows no dots.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

0         -> 0
2190      -> 250
"2190"    -> 250
null      -> undefined
undefined -> undefined
NaN       -> undefined
"n/a"     -> undefined
Infinity  -> NaN
-1        -> -0.1141552511415525
domain [0, 0]: 250 250 250
domain [0, NaN]: NaN
unknown(0): 0
Error: <circle> attribute cx: Expected length, "undefined".
Error: <circle> attribute cx: Expected length, "NaN".
ticks, nice and tickFormat: why 0, 500, 1000 New tab

Both axes use the same scale, so every tick is at the right place. Only the numbers chosen differ.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

ticks(0, 4380, 2) step 2000 -> 3 ticks
ticks(0, 4380, 5) step 1000 -> 5 ticks
ticks(0, 4380, 10) step 500 -> 9 ticks
ticks(0, 4380, 20) step 200 -> 22 ticks
count 0: []  one value: [ 5 ]  reversed: [ 4000, 3000, 2000, 1000, 0 ]
nice(10): [ 0, 4500 ]  nice(5): [ 0, 5000 ]  nice(3): [ 0, 6000 ]
0.0 0.2 0.4 0.6 0.8 1.0 | 0% 20% 40% 60% 80% 100%
0 1,000 2,000 3,000 4,000 | 4k
0.1 * 3 = 0.30000000000000004   d3.ticks(0, 1, 10)[3] = 0.3
six equal parts: 0, 730, 1460, 2190, 2920, 3650, 4380
D3 ticks:        0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Build the domain from text

In the first example, change d3.csv("/data/lumen/daily.csv", d3.autoType) to d3.csv("/data/lumen/daily.csv"). Without autoType, every cell is a string.

terminal
$ Console
── what you'll see ──
busiest day: 910 rides -> 500 pixels
3,742 rides -> 2056.0

Break #2

Hide a missing value with unknown(0)

In the second example, add .unknown(0) to the end of the first line: const x = d3.scaleLinear().domain([0, 4380]).range([0, 500]).unknown(0);. The Expected length, "undefined" error is a nuisance, so make it go away.

terminal
$ Console
── what you'll see ──
null -> 0
undefined -> 0
NaN -> 0
"n/a" -> 0
Error: <circle> attribute cx: Expected length, "NaN".

Myth vs fact

Myth

A scale is only for turning numbers into pixels.

Fact

The range can be anything the thing you set accepts: colours, sizes, angles, durations. d3.scaleLinear().domain([0, 100]).range(["red", "blue"])(50) returns rgb(128, 0, 128).

Myth

clamp(true) is a safe default.

Fact

Clamping hides bad data: a value of 90,000 draws as a normal full-length bar. Use it deliberately (a slider handle that must stay on its track), not as a fix for surprising numbers.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    scaleLinear with a domain of [0, 0] returns the middle of the range, because D3's normalize returns 0.5 when the domain has no width (and NaN if the width itself is NaN). A chart of all-zero data draws its dots in the middle instead of failing; check for it if "all zero" is a real state, and show an empty message.

  • ▸

    The domain can have more than two numbers: domain([0, 1000, 4380]).range([0, 100, 500]) is a piecewise scale, with a different slope for each segment. D3 uses it for diverging colours (Topic 6.2). The domain must stay in order (all rising or all falling).

Remember this

  1. 1

    The problem this solves: in Phase 3 you wrote "value ÷ biggest × space" by hand, again and again. It breaks when the data is empty, when the chart needs margins, when y must point upwards, and when you want to turn a mouse position back into a number. One tested function removes all of that.

  2. 2

    Domain and range. The domain is the set of data values the scale accepts: [0, 4380] for rides, from none to the busiest day. The range is where they go: [0, 500] pixels. d3.scaleLinear().domain([0, 4380]).range([0, 500]) returns a function. Call it with 3,742 and you get 427.2, because 3,742 is 85.4 percent of the way through the domain and 427.2 is 85.4 percent of the way through the range. D3 does this in two steps: it first turns the value into a fraction between 0 and 1 ((v - 0) / (4380 - 0)), then maps the fraction into the range.

  3. 3

    Values outside the domain are not refused. A linear scale keeps the straight line going: 5,000 rides gives 570.8 pixels, past the end of the range. This is extrapolation. .clamp(true) stops the output at the ends of the range, so 5,000 becomes 500 and -300 becomes 0. Clamping also hides a problem: a wrong value now draws as a normal-looking bar at the edge.

  4. 4

    The y axis needs a reversed range. SVG's y grows downwards (Topic 1.1), but a bigger number should sit higher. So a y scale uses .range([height, 0]). With margins, the range becomes [height - margin.bottom, margin.top].

  5. 5

    Unusual inputs. null, undefined, NaN and text such as "n/a" are not numbers, so a scale returns undefined (its unknown value, which you can change). "2190" works, because the scale converts it with +. Infinity gives NaN. A domain of [0, 0] has no width to divide by, so every input lands in the middle of the range. All of these fail silently in D3 itself. The failure shows later: setting an attribute to NaN prints Expected length, "NaN" in the Console. D3's .attr treats undefined and null differently: it removes the attribute, so the mark jumps to its default position (0) with no error at all.

  6. 6

    **ticks(), nice() and tickFormat().** scale.ticks(10) returns about ten round numbers inside the domain. D3 chooses steps of 1, 2 or 5 times a power of ten (Walkthrough, steps 2 to 4), which is why the axis reads 0, 500, 1000 and never 0, 730, 1460. scale.nice() stretches the domain itself to the nearest ticks (4,380 becomes 4,500), so the axis ends on a labelled number. scale.tickFormat() returns a function that prints each tick with the right number of decimals and thousands separators.

Explain it without notes

01

What are the domain and the range of a scale? Give both for the rides bar chart.

02

Why does a y scale use range([height, 0])?

03

Why does d3.ticks(0, 4380, 10) return steps of 500, and what would it return for a count of 5?

Practice

01

Write a y scale for a chart 300 pixels tall with a 40 pixel margin at the top and bottom, for rides from 0 to 4,380. What are y(0), y(4380) and y(2979)?

02

With x = d3.scaleLinear().domain([0, 4380]).range([0, 500]), which number of rides is at pixel 100? Which pixel does 3,000 rides get? Write the code that checks it.

Trade-offs

  • ↔

    nice() gives a tidy axis but adds empty space (4,380 stretched to 4,500), so marks fill a bit less of the chart. Skip it when you want the data to touch the edge.

Done when you can

  • I can write a linear scale with a domain and a range, and say what each is.

  • I can explain why a y range runs from the height to 0.

  • I can predict what a scale returns for null, NaN, a string number and a [0, 0] domain.

  • I can explain how ticks() turned 438 into a step of 500.