Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~28 min· topic 7 of 7

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. 1

    Change the tick count

    In rides-line.js change ticks: 5 to ticks: 10 in the leftAxisWithGrid call and nice(5) to nice(10). There are now 10 gridlines (0 to 4,500 in steps of 500) instead of 6. Change only nice(10) and leave ticks: 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. 2

    Drop the 14 August note

    Pass { notes: {} } as the third argument in main.js. The note disappears; the label for screen readers drops the "(app down)" part.

Code & diagrams

The rides line with D3 scales and axes (modules inlined) New tab

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

Press Run to see the page.

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).
Empty, one day, a gap, and update() New tab

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

Press Run to see the page.

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: 0

Your project files

Your lumen-dashboard project after this topic. Click a file to see it in full.

src/parts/axis.jsjs

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)).

terminal
$ Page
── what you'll see ──
No rides to show yet. (and nothing in the Console)

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. 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 uses leftAxisWithGrid shows 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. 2

    What it adds. src/parts/axis.js has two functions, bottomAxis and leftAxisWithGrid. src/charts/rides-line.js is 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.js changes by one line: the chart now gets the rows, not a list of numbers, because a time scale needs the dates.

  3. 3

    How it plugs in. The loader from Phase 4 still supplies cleaned rows with null for 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. 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. 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. 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

01

Why does the chart take rows with dates instead of a list of numbers?

02

Why do the axis helpers set the font size after calling the axis?

03

What does the chart show for no data, one day and a missing day?

Practice

01

Add a title option that draws a text label above the plot, and a test line for the empty array.

02

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.