Topic 3.5
Scatter Plots and Axes by Hand
In one line
A scatter plot puts one dot per row, with two numbers deciding where it sits. To read it, the chart needs axes: tick marks at round numbers, labels and gridlines. You write the rule for choosing nice ticks yourself, and meet overlapping dots and an outlier that squashes everything else.
Think of it like this
Pins in a wall map where the position across is the temperature and the position up is the number of rides. Each day is one pin. When the pins gather along a rising band, warm days and busy days go together. A scatter plot is a map of two numbers.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- scatter plot
- A chart with one dot per row, where two numbers decide the dot's horizontal and vertical position.
- tick
- A short mark on an axis at a round value, usually with a label.
- nice tick
- A tick at a round number: a step of 1, 2 or 5 times a power of ten.
- gridline
- A faint line across the plot at each tick, to help the eye follow a value from the axis.
- axis title
- The words that say what an axis shows, with its unit.
- overplotting
- Dots drawn on top of each other, so you cannot tell one from many.
- outlier
- A value far from the others. It may be an error or the most important fact in the data.
- correlation
- Two numbers that tend to go up and down together. A scatter plot shows it; it does not prove one causes the other.
Step by step
01One circle per day
The first example fetches daily.csv, takes the temperature (column 5, temp_c) and the rides (column 2) of every day, and draws 365 circles. The warm days (20 °C and more) sit high on the chart, the cold ones low.
02Pick nice ticks with niceTicks
The function takes the lowest and highest value and how many ticks you would like. It finds the wanted step, the power of ten, then the nice step (1, 2, 5 or 10 times the power). For the temperatures (9.9 to 24.9, about 6 ticks) it gives 5, 10, 15, 20, 25. For rides (0 to 4,380, about 5 ticks) it gives 0 to 5,000 in steps of 1,000.
const rough = (hi - lo) / count;
const power = Math.pow(10, Math.floor(Math.log10(rough)));
const f = rough / power; // between 1 and 10
const step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * power;03Use the ticks as the scale's domain
x and y map the first tick to one end of the plot and the last tick to the other. Because the axes end on ticks, the data may stop a bit short: 4,380 rides sit below the 5,000 gridline.
04Draw gridlines, tick marks and labels
For each y tick: a light line across the plot and a right-aligned label to the left (x = -8). For each x tick: a short mark below the baseline and a centred label under it. The axis titles go last: the y title is rotated by -90 degrees around its own position.
05See overlapping points
The Console says how many dots share a pixel with another dot: 14 of 365. With a 35% fill, those places look darker. With a solid fill (fill-opacity 1), a pile of dots looks like a single dot.
06Test the tick rule on awkward ranges
The second example prints the ticks for seven ranges: rides, temperature, tiny numbers (0.0001 to 0.0009), negative to positive, huge (a billion), all equal (5, 5) and empty. niceTicks handles all of them: all-equal widens the range by one on each side, and empty returns no ticks.
07Watch one typo squash the chart
The second example then draws the week's seven values twice, as dots on a vertical strip. In the left strip the largest value is 3,992 and the axis stops at 4,000. In the right strip Sunday's 3,451 is typed as 34,510: the axis reaches 40,000 and the six normal days are spread over 4.1 pixels instead of 40.5.
Try it yourself
- 1
Ask for more ticks
In the first example, change the
6in thexTicksline to12and the5in theyTicksline to10. The Console now saysx ticks: 8 10 12 14 16 18 20 22 24 26andy ticks: 0 500 1000 1500 2000 2500 3000 3500 4000 4500. More ticks mean a smaller step, and the axis ends closer to the data (4,500 instead of 5,000). - 2
Remove the see-through
In the first example, change
"fill-opacity": 0.35to"fill-opacity": 1. Every dot is now solid. The Console is unchanged (14dots still share a pixel with another dot), but in the picture the crowded area around 12 to 15 °C turns into one mass and you can no longer tell a single dot from a pile of dots. - 3
Use one more outlier
In the second example, change
34510to5000. The right strip now saystypo - axis to 5000 - the six normal days spread over 32.4 px. A mild outlier costs you a bit of detail; a typo that is ten times larger costs you nearly everything.
Code & diagrams
The code is inside an async function because this example has no import, so it is a classic script. The lone dot low at about 20 degrees is the 14 August outage.
Preview
Console
Nothing logged yet.Expected console output
x ticks: 5 10 15 20 25
y ticks: 0 1000 2000 3000 4000 5000
Dots: 365
Dots that share a pixel with another dot: 14The typo is invented for the demonstration (the real data has no such value). The orange dot is Sunday.
Preview
Console
Nothing logged yet.Expected console output
rides, 0 to 4380 [0,1000,2000,3000,4000,5000]
temperature [5,10,15,20,25]
tiny numbers [0,0.0002,0.0004,0.0006,0.0008,0.001]
negative to positive [-20,0,20,40,60]
huge [0,200000000,400000000,600000000,800000000,1000000000]
all equal (5, 5) [4,4.5,5,5.5,6]
empty data []
normal - axis to 4000 - the six normal days spread over 40.5 px
typo - axis to 40000 - the six normal days spread over 4.1 pxBreak 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 round the ticks
In the second example, change ticks.push(Number((k * step).toFixed(10))); to ticks.push(k * step);.
Break #2
Scale with the data's maximum, not with the last tick
In the first example, change the y line to const y = (v) => h - (v / Math.max(...data.map((d) => d.rides))) * h; (the domain is now 0 to 4,380, but the ticks still go up to 5,000).
Myth vs fact
Myth
The axis should start and end exactly at the smallest and largest value.
Fact
Axes end on round numbers, so people can read them. The data may stop a little short of the edge (here 4,380 of 5,000), and that is the price of readable ticks.
Myth
More gridlines make a chart more precise.
Fact
Too many compete with the data. Four to six light lines per axis are enough; the exact value belongs in a tooltip or a table.
Myth
If two numbers rise together in a scatter plot, one causes the other.
Fact
A scatter plot shows that they move together. Warm days have more rides, but weather is also linked to season, holidays and school. The chart raises the question; it does not answer it.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
D3 splits the job in two.
d3.ticks(start, stop, count)(in d3-array) uses the same 1-2-5 idea with a finer rule (it switches to the next factor at the square roots of 2, 10 and 50) but returns only ticks that lie inside the range, sod3.ticks(0, 4380, 5)stops at 4,000.scale.nice()is the part that widens the domain to 5,000, which is whatniceTickshere does in one go (Topic 6.1). - ▸
Without the guard
if (lo === hi),niceTicks(5, 5, 5)computesrough = 0, soMath.log10(0)is-Infinityand the power of ten is 0, so the step is 0 and the loop runs fromInfinitytoInfinity. The loop never ends: it keeps pushingNaNticks into the array while memory grows, and the tab freezes. A zero-width range is the classic way for chart code to hang a tab; guard it before the maths. - ▸
fill-opacityblends each dot with whatever is underneath, so ten dots at 0.35 are not 3.5 times as dark: opacity compounds as1 − (1 − 0.35)^n, which is 0.58 for two dots, 0.73 for three, and approaches solid quickly. Opacity shows overlap but is a poor way to count: for counts, bin the data (Topic 7.4) or use a heatmap (Topic 7.5). - ▸
Number("")is0, notNaN, andNumber("n/a")isNaN. A blank temperature cell in a CSV therefore becomes a dot at 0 °C, a confident lie, while"n/a"makes aNaNposition and an SVG error. Skip rows with a missing x or y before you draw (Number.isFiniteon both), and say how many were skipped. The Lumen file has none, but a real export will (Phase 4 cleans it).
Remember this
- 1
The problem this solves: a bar chart compares one number per category. A scatter plot answers a different question: do two numbers move together? To read it, you need axes: marks at round numbers (5, 10, 15 °C) with labels, and light lines across the chart to follow them. Choosing those round numbers by hand shows you what D3's
ticks()does for you (Topic 6.1). - 2
A scatter plot is one circle per row. For each day you draw a
<circle>withcx = x(temp)andcy = y(rides). Both scales are linear (Topic 3.2) and both axes need a title with the unit ("Temperature (°C)", "Rides per day"), or the dots are unreadable. - 3
Nice ticks are a step of 1, 2 or 5 times a power of ten. First, the step you would like:
(high − low) ÷ number of ticks wanted. Then the power of ten at or below it (10, 100, 1,000…), and the first of 1, 2, 5 or 10 times that power that is not smaller than the wanted step. For 0 to 4,380 with 5 ticks, the wanted step is 876, the power is 100, and 876 ÷ 100 = 8.76 rounds up to 10, so the step is 1,000: the ticks are 0, 1,000 … 5,000. People can count in 1s, 2s and 5s without effort; steps of 3 or 7 look odd. - 4
The ticks decide the axis, not the data. The scale's domain becomes the first and last tick, so the axis ends on a tick. In the example the data stops at 4,380 and the axis at 5,000, so the top of the chart is a little empty. Tick values like 0.1 × 3 give
0.30000000000000004in floating-point numbers, so the code rounds each tick (Number(x.toFixed(10))) before it prints it. - 5
Each tick is three small drawings. A short mark on the axis, a label next to it (formatted with
toLocaleString, so5000reads5,000), and, if you want, a faint line across the plot: a gridline. Keep gridlines light, so they guide the eye without competing with the dots. The y axis title is rotated withtransform="rotate(-90)". - 6
Overlapping dots hide data, and one outlier can squash the rest. With 365 days, 14 dots sit on the same pixel as another dot. A semi-transparent fill (
fill-opacity0.35) lets dense areas show darker. An outlier is a bigger problem: one typo of 34,510 instead of 3,451 stretches the axis to 40,000 and the six normal days are squeezed from 40 pixels to 4. Fix the data, or say plainly that a value was cut off; never drop it silently. (The real outlier in the first example, 14 August at about 20 °C, is a true low and is the story.)
Explain it without notes
How does the 1-2-5 rule turn the range 0 to 4,380 and a wish for 5 ticks into the ticks 0, 1,000 … 5,000?
Why do ticks and the scale have to come from the same numbers?
What does one outlier do to an axis, and what are your options?
Why round ticks with toFixed before printing them?
Practice
Use niceTicks on the range 12 to 87 with 5 ticks. Work out the answer by hand, then check it by running the function.
Add vertical gridlines to the scatter plot at every x tick, and say how you keep the plot readable.
Draw a dashed line at 3,000 rides (a made-up target) and a small label Target. Where does the label go so that it does not cover a dot?
Trade-offs
- ↔
Nice ticks make the axis readable, but they leave empty space beyond the data. Ticks at the data's own extremes use the space well and are hard to read.
- ↔
Semi-transparent dots show overlap but not counts, and they are lighter overall. Solid small dots are crisper but hide piles. For thousands of points, bin or switch to Canvas (Phase 12).
Done when you can
I can draw a scatter plot with a circle per row and two scales.
I can compute nice ticks with the 1-2-5 rule and use them as the scale domain.
I can draw tick marks, labels, gridlines and axis titles with units.
I can show overlapping points with a semi-transparent fill and say what it cannot show.
I can explain what an outlier does to an axis and the honest ways to deal with it.