Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~33 min· topic 2 of 6

Topic 3.2

The Margin Convention and a Scale by Hand

In one line

Every chart needs two things before it draws a single mark: a frame with room around it for axis labels (the margin convention), and a rule that turns a data value into a pixel position (a scale). You write both by hand, including the inputs that break them.

Think of it like this

A map of a theme park on one sheet of paper. You leave a white border for the title and the key. Then you decide that 1 centimetre on the paper stands for 100 metres in the park. The border is the margin. The rule "1 cm = 100 m" is the scale. Every chart is built from these two things.

Words you'll meet

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

margin
Space kept free around the plot area for axis labels and titles.
plot area
The part of the SVG where the marks are drawn: the outer size minus the margins.
translate
A transform that moves everything inside a group by a number of pixels sideways and down.
scale
A function that turns a data value into a position (or size, or colour). A linear scale does it with a straight line.
domain
The input of a scale: the lowest and highest data value, such as 0 to 4,380 rides.
range
The output of a scale: the pixel positions the domain is stretched over, such as 160 to 0.
slope (m)
How many pixels one unit of data is worth. With a flipped y axis it is negative.
invert
Run a scale backwards: from a pixel position back to the data value. You need it to find the value under the mouse.

Step by step

01Pick the outer size and the margins

The first example uses an SVG of 420 × 220 and margins of 20 at the top and right, 40 at the bottom (room for x labels) and 56 at the left (room for y numbers such as 5,000).

the margin conventionwhole filejs
const margin = { top: 20, right: 20, bottom: 40, left: 56 };
const innerWidth = width - margin.left - margin.right;    // 344
const innerHeight = height - margin.top - margin.bottom;  // 160

02Move one group to the plot area

One <g> gets translate(56, 20). The white frame and the two orange dots are children of that group, so the first dot at (0, 0) appears at 56 pixels from the left and 20 from the top of the SVG, and the second at the far corner of the plot area. The log prints this measurement from the real page.

03Write the scale as y = m·x + b

The second example makes a function makeScale(d0, d1, r0, r1). It works out m and b once, and returns a function that applies them. With the domain 0 to 4,380 and the range 160 to 0, m is −0.03653 and b is 160.

So 0 rides gives 0 × m + 160 = 160 (the bottom), 2,000 gives 86.9, and 4,380 gives 0 (the top). The log prints the real values.

Write the scale as y = m·x + bdiagram
Rendering diagram…

04Flip y so bigger is higher

The range is written the wrong way round on purpose: [160, 0]. In SVG, 0 is the top, so "the biggest value at y = 0" means the biggest value is at the top. If you wrote [0, 160], 4,380 would be at the bottom and the picture would be upside down.

05Run the scale backwards

scale.invert(y) solves y = m·x + b for x: x = (y − b) ÷ m. A pixel in the middle of the plot (y = 80) means 2,190 rides. Phase 8 uses this to tell which day is under the mouse.

06Feed it the awkward inputs

The third example builds the scale from the extent of five data sets: normal, with negatives, one value, all equal and empty. The normal and the negative case work (the negative one has m = -2, so 1 unit of rides is 2 pixels). The one-value, all-equal and empty cases print NaN or Infinity.

At the end, it draws one rectangle with the broken height. The browser's real error appears in the Console: Error: <rect> attribute height: Expected length, "NaN". The bar is not drawn.

07Guard the scale

safeScale handles the two bad cases before the maths: no usable domain returns NaN (so the caller can show an empty state), and a zero-width domain returns the middle of the range. D3 does the same for the second case (Topic 6.1). The log shows 80 for the value 3,000 in a range of 160 to 0.

Try it yourself

  1. 1

    Give the axis more room

    In the first example, change left: 56 to left: 100. The Console says Plot area: 300 x 160 and Frame starts 100 px from the left and 20 px from the top of the SVG. The frame is 44 pixels narrower and the grey margin on the left is wider: the outer size did not change, the plot area did.

  2. 2

    Find the value under a pixel

    In the second example, add console.log("y = 40 means", Math.round(y.invert(40)), "rides"); at the end. It prints y = 40 means 3285 rides. Check it the other way: y(3285) is 40.

  3. 3

    Use the guard

    In the third example, change const bad = makeScale(lo, hi, 0, 100); to const bad = safeScale(lo, hi, 0, 100);. The error line disappears, a 50-pixel bar is drawn (the middle of 0 to 100), and the log ends with safe, one value 3000 -> 80.

Code & diagrams

The margin convention: a frame inside an SVG New tab

The grey area is the margin. Axis labels will later live there.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Plot area: 344 x 160
Frame starts 56 px from the left and 20 px from the top of the SVG
A linear scale by hand: y = m·x + b New tab

Six values, one rule. The dots for 4,000 and 4,380 are close because the scale is honest: they are only 380 rides apart.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

m = -0.03653  b = 160
   0 rides -> y = 160.0
1000 rides -> y = 123.5
2000 rides -> y = 86.9
3000 rides -> y = 50.4
4000 rides -> y = 13.9
4380 rides -> y = 0.0
y = 80 means 2190 rides
The scale on awkward data: empty, one value, all equal, negative New tab

The last line is the point: the unguarded scale returns NaN, and the browser refuses to draw the bar and prints its real error.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal     domain 1193 4380 | m = -0.050203953561342954 | first value -> 160
negative   domain -20 60 | m = -2 | first value -> 160
one value  domain 3000 3000 | m = -Infinity | first value -> NaN
all equal  domain 2000 2000 | m = -Infinity | first value -> NaN
empty      domain Infinity -Infinity | m = 0 | first value -> (no values)
safe, one value 3000 -> 80
Error: <rect> attribute height: Expected length, "NaN".

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

Forget to move the plot area

In the first example, delete the line plot.setAttribute("transform", "translate(" + margin.left + "," + margin.top + ")");.

terminal
$ Console
── what you'll see ──
Plot area: 344 x 160
Frame starts 0 px from the left and 0 px from the top of the SVG

Break #2

Forget to flip the y range

In the second example, change makeScale(0, 4380, 160, 0) to makeScale(0, 4380, 0, 160).

terminal
$ Console
── what you'll see ──
m = 0.03653 b = 0
0 rides -> y = 0.0
1000 rides -> y = 36.5
2000 rides -> y = 73.1
3000 rides -> y = 109.6
4000 rides -> y = 146.1
4380 rides -> y = 160.0
y = 80 means 2190 rides

Myth vs fact

Myth

A scale is a D3 thing you need a library for.

Fact

A linear scale is one multiplication and one addition. Writing it yourself takes five lines. D3's scaleLinear adds the extras: nice() ticks, invert, clamp, other scale types and the zero-width-domain guard (Topic 6.1).

Myth

Margins are decoration: you can shift things by a few pixels by eye.

Fact

Without a margin, axis labels do not fit in the SVG and get cut off at its edge, and every mark's position has to include the same shift in every line of code. One translate on one <g> removes all of that.

Myth

If a scale returns a number, it is correct.

Fact

The upside-down chart in Break it prints perfectly valid numbers. A scale also cannot know your intent: [0, 160] and [160, 0] are both legal. You have to look at the picture and check one or two values by hand.

Pro corner

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

  • ▸

    translate on a group changes only where its children are drawn, not their coordinates: a rect inside has x="0" in its own coordinate system, and the browser multiplies by the group's transform when it draws. That is why one translate replaces adding margin.left to every x. It also means getBoundingClientRect() (page pixels) and getBBox() (the shape's own user units) give different numbers for the same shape.

  • ▸

    In D3's source (continuous.js in d3-scale), the helper normalize(a, b) returns the constant 0.5 when the domain has zero width, and NaN when the domain itself is NaN. A 0.5 share of the range is the middle, which is the behaviour safeScale copies.

  • ▸

    Floating-point numbers mean y(x) is rarely exact: y(1000) is 123.47..., not 123.5. Round only when you print, or when you write the number into an attribute and the exact value does not matter. Rounding inside the scale makes bars of equal height end at different pixels and hides real differences.

Remember this

  1. 1

    The problem this solves: a chart needs room around its drawing for axis numbers and titles, and it needs a rule that turns "4,380 rides" into "pixel 0". Without a margin, the numbers on the left axis are cut off by the edge of the picture. Without a scale, you work out every position by hand, as you did for the seven bars in Phase 0. Libraries hide both; here you write them once, so you know what is inside.

  2. 2

    Outer size, margins, inner size. The <svg> has an outer width and height. You keep a margin object with a number for each side: { top: 20, right: 20, bottom: 40, left: 56 }. The drawing area, called the plot area, is what is left: innerWidth = width - margin.left - margin.right and innerHeight = height - margin.top - margin.bottom. This is called the margin convention; D3's author, Mike Bostock, popularised it, and nearly every D3 chart starts with it.

  3. 3

    One group, moved once. You create a <g> (a group, Topic 1.6) and give it transform="translate(left, top)". Everything you put inside the group uses coordinates that start at the plot area's top-left corner: (0, 0) there is (56, 20) on the page. Axes and labels go in the margin: at negative x or at y beyond innerHeight, still inside the <svg>, so they are visible.

  4. 4

    A linear scale is a straight line: y = m·x + b. It takes a value from the domain (the data you have, for example 0 to 4,380 rides) and gives a position in the range (the pixels you have, for example 160 to 0). The slope is m = (range end − range start) ÷ (domain end − domain start), the number of pixels one unit of data is worth. The offset is b = range start − m × domain start, where the first domain value lands. In code the function is x => m * x + b.

  5. 5

    SVG's y grows downwards, so the vertical range is flipped. Bigger rides must be higher on the screen, which means a smaller y. So for the y scale you write the range as [innerHeight, 0]: 0 rides lands at the bottom (160) and 4,380 at the top (0). The slope m is negative, and that is fine. Forgetting the flip draws the chart upside down, and no error appears.

  6. 6

    Inputs that break the formula. The slope divides by the domain's width, d1 − d0. If every value is the same (or there is only one), the width is 0: you divide by zero, m is infinite and every position is NaN. An empty array gives Math.min() = Infinity and Math.max() = -Infinity. Negative values work, but the domain must then include them. The browser rejects a NaN attribute with an error (shown below). The fix, which D3 also uses, is to put a single value in the middle of the range.

Explain it without notes

01

What are the outer size, the margins and the plot area, and why does the plot area get a translate?

02

Work out m and b for a y scale from the domain [0, 4380] to the range [160, 0]. What does m being negative mean?

03

What happens to the scale when all values are equal, and what does the browser do when the result reaches an SVG attribute?

04

Why does D3's scaleLinear put a single value in the middle of the range?

Practice

01

An SVG is 600 × 300 with margin { top: 10, right: 20, bottom: 40, left: 60 }. What are innerWidth and innerHeight, and where on the page does the plot area's top-left corner sit?

02

Write the x scale for twelve months (index 0 to 11) across a plot area 460 pixels wide, using makeScale. Where does July (index 6) land?

03

A y scale has the domain [-20, 60] and the range [160, 0]. Work out m, b and the position of 0.

Trade-offs

  • ↔

    Writing the scale by hand costs five lines and teaches you exactly what a scale is. A library adds the guards, nice() and other scale types, so by hand you take on the edge cases yourself.

  • ↔

    A bigger margin leaves less room for the plot but never cuts off labels. A smaller margin looks tidier until a long label (10,000) does not fit. Measure the longest label or leave generous space (Topic 3.3 measures labels).

Done when you can

  • I can compute innerWidth and innerHeight from the outer size and the margins.

  • I can use one translated group so marks use plot-area coordinates.

  • I can write a linear scale as m·x + b and compute m and b from a domain and a range.

  • I can explain why the y range is [innerHeight, 0] and what happens if I forget.

  • I can say what the scale returns for empty data, one value and all-equal values, and how I guard them.