Topic 6.7
Build: Rides Over Time with Real Axes
In one line
The dashboard's year-of-rides line is rebuilt with a time scale, a linear scale and D3 axes, in two small reusable files. It has nice ticks, pale gridlines, month labels, an empty state, gaps for missing days and a summary for screen readers.
Before you start
You will learn
- Replace the hand-made rides line with one built from D3 scales and axes
- Put the axis code in a shared file the next charts reuse
- Show an empty state, a gap for a missing day and a screen-reader summary
You write code in
In your Vite project lumen-dashboard/ (run npm run dev), and in the editor on this page
Files you touch
- src/parts/axis.js
- src/charts/rides-line.js
- src/main.js
Think of it like this
Swapping a hand-painted sign for a printed one from a template. The shop looks the same, but now the next sign takes a minute, and every sign has the same fonts and spacing. This build swaps Phase 3's hand-measured axis for scales and an axis helper that every later chart reuses.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- shared part
- A file in src/parts/ that several charts import, such as the axis helpers.
- defined()
- A d3.line setting that says which points exist. A point that does not exist leaves a gap.
- viewBox
- An SVG attribute that makes the drawing scale to its box, so width 100% works.
- update(data)
- The function a chart returns so the page can redraw it with new data.
- empty state
- What the box shows when there is nothing to draw: a message, not a blank.
Step by step
01Create src/parts/axis.js
Two functions: a bottom axis with a tick interval and a label format, and a left axis whose ticks run across the plot as pale gridlines. The font size is set after the axis call, because the axis sets its own on first use.
02Rewrite src/charts/rides-line.js
scaleUtc for x (the rows are UTC days, Topic 6.3), scaleLinear for y with nice(5) and the same count in the axis, d3.line().defined(...) for the path (d3.line turns a list of points into the text of a path's d attribute, the job Phase 3 did with a loop; defined says which points exist, so a missing day leaves a gap; Topic 7.1 teaches it fully). Everything that depends on the data sits in update(rows), so a second call redraws the chart.
03Change one line of src/main.js
drawRidesLine(box, rows) instead of rows.map((d) => d.rides). Run npm run dev: you see month labels, thousands on the y axis, pale gridlines, the line, and the note on 14 August.
04Try the states
The second live example draws the chart with no data, one day, and some days missing, and updates it. The empty message shows, a single day gets a dot, and a missing day leaves a gap in the path.
Try it yourself
- 1
Change the tick count
In
rides-line.jschangeticks: 5toticks: 10in theleftAxisWithGridcall andnice(5)tonice(10). There are now 10 gridlines (0 to 4,500 in steps of 500) instead of 6. Change onlynice(10)and leaveticks: 5: the domain ends at 4,500 but the top label is 4,000, so the line's peak rises above the last gridline. - 2
Drop the 14 August note
Pass
{ notes: {} }as the third argument inmain.js. The note disappears; the label for screen readers drops the "(app down)" part.
Code & diagrams
The three project files are inlined into this one example, because the preview cannot import local files. In your project they are separate files that import each other.
Preview
Console
Nothing logged yet.Expected console output
x ticks: Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
y ticks: 0 1,000 2,000 3,000 4,000 5,000
line starts: M56,210.918L57.78,190.899L59.5
label: Line chart of rides per day, 365 days from 1 Jan 2025. Busiest day: 18 Jul 2025, 4,380 rides. Quietest day: 14 Aug 2025, 439 rides (app down).The same chart object is updated four times. The all-zero case is the [0, 0] domain of Topic 6.1: the example prints what the y axis shows for it.
Preview
Console
Nothing logged yet.Expected console output
empty message shown: true chart shown: false line pieces: 0 lone dots: 0
one day message shown: false chart shown: true line pieces: 1 lone dots: 1
gap message shown: false chart shown: true line pieces: 2 lone dots: 0
full year message shown: false chart shown: true line pieces: 1 lone dots: 0
all zero, y ticks: 0Your project files
Your lumen-dashboard project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
// Axes the charts share. Each function draws into a <g> you place yourself, and can be called again after the scale changed.
import * as d3 from "d3";
/** A bottom axis. ticks: a count or an interval such as d3.utcMonth. format: a function that makes the label text. */
export function bottomAxis(g, scale, { ticks = 8, format } = {}) {
const axis = d3.axisBottom(scale).ticks(ticks).tickSizeOuter(0);
if (format) axis.tickFormat(format);
g.call(axis).attr("font-size", 12); // font-size after the call: the axis sets 10 the first time it draws
return g;
}
/** A left axis whose ticks run across the plot as pale gridlines. width = the plot's width in pixels. */
export function leftAxisWithGrid(g, scale, { width, ticks = 5, format = d3.format(",") } = {}) {
g.call(d3.axisLeft(scale).ticks(ticks).tickFormat(format).tickSize(-width))
.attr("font-size", 12)
.call((axis) => axis.select(".domain").remove()) // the gridlines are enough
.call((axis) => axis.selectAll(".tick line").attr("stroke-opacity", 0.15));
return g;
}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
Pass the old list of numbers
In main.js go back to the old call: drawRidesLine(document.querySelector("#rides-line"), rows.map((d) => d.rides)).
Myth vs fact
Myth
Moving the axis into its own file is over-engineering for one chart.
Fact
The second chart (Phase 5's stations) needs the same margins, fonts and gridlines. One file means one place to fix them.
Myth
nice() and ticks() can use different counts.
Fact
They can, but then the axis may stop short: nice(10) ends the domain at 4,500, and an axis with ticks(5) stops at 4,000. Use the same count in both.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
update()replaces the data without rebuilding the chart: the scales, the axis groups and the path are created once. That is what lets Phase 9 transition between two states. - ▸
The chart renders in a 720 by 300 viewBox and scales with CSS, so text shrinks on a phone. Phase 10 adds container-based sizes so labels keep their size.
Remember this
- 1
Why the dashboard needs this now, and the story's end. Tomás asked why his spreadsheet axis said 0, 730, 1,460 while the dashboard said 0, 1,000, 2,000. Phase 3's line chart had its own copy of the rounding rule (
niceTicks) and placed each point by its position in the list, so a row dropped during cleaning would shift every later point and the month ticks. Every new chart would need its own copy. Now one scale picks every number from the dates themselves and one axis draws it, so every chart that usesleftAxisWithGridshows the same kind of round ticks. Tomás's question has an answer he can repeat: D3 chooses steps of 1, 2 or 5 times a power of ten, so the labels are numbers a person can say. - 2
What it adds.
src/parts/axis.jshas two functions,bottomAxisandleftAxisWithGrid.src/charts/rides-line.jsis rewritten: it takes the rows ({ date, rides }), builds a UTC time scale for x and a linear scale for y, calls the axes, and returns{ update(data) }.src/main.jschanges by one line: the chart now gets the rows, not a list of numbers, because a time scale needs the dates. - 3
How it plugs in. The loader from Phase 4 still supplies cleaned rows with
nullfor missing days. The chart keeps the Phase 4 behaviour on purpose:defined()leaves a gap for each missing day, a day with no neighbours gets a dot, an empty array shows "No rides to show yet.", and the outage day is still marked. - 4
What the dashboard can do now that it could not before: it picks axis numbers by rule instead of by hand, it ticks months on the first of the month in every viewer's time zone, it has gridlines that stay out of the way, and its chart can be re-drawn with new data by calling
update(rows)(Phase 9 animates that). - 5
What is still missing. The line is made by
d3.line(Phase 7 explains it and adds the other chart shapes). Hover and tooltips come in Phase 8, motion in Phase 9, the dark-mode theme colours in Phase 10, and accessible tables in Phase 15. The station bars from Phase 5 are still positioned by hand and have no axis: Topic 6.4's band scale and Topic 6.6's axis are the tools for them, and Phase 7 builds the stacked version with them. - 6
Starting point. The Phase 5 dashboard: Phase 4's loader and states, Phase 5's station bars with the month menu, and Phase 3's hand-made line in
src/charts/rides-line.js, which this build rewrites with D3.
Explain it without notes
Why does the chart take rows with dates instead of a list of numbers?
Why do the axis helpers set the font size after calling the axis?
What does the chart show for no data, one day and a missing day?
Practice
Add a title option that draws a text label above the plot, and a test line for the empty array.
Create src/charts/stations-bar.js's axes with the helpers: which of the two helpers fits a horizontal band-scale chart, and what must change?
Trade-offs
- ↔
A chart that returns
{ update }costs a little more code than one that draws once, and pays back when the data changes (filters, live data, animation).
Done when you can
My project has src/parts/axis.js and the rewritten rides-line.js.
The chart shows month names, thousands, gridlines and the 14 August note.
I tried the empty array and saw the message.
I can say why the chart needs rows with dates.
I can say what the dashboard still lacks and which phase adds it.