Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~31 min· topic 7 of 7

Topic 5.7

Build: A Station Bar Chart That Updates

In one line

The dashboard gets its first D3 chart: stations ranked by rides, with a month selector. A keyed join makes the bars move, appear and disappear as you change month, and every awkward case (no data, one row, a broken row) is handled.

Before you start

You will learn

  • Add a chart module that draws once and updates many times
  • Drive it from a month selector in src/main.js
  • Handle empty, one-row and broken data inside the chart

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/charts/stations-bar.js
  • src/data/load.js
  • src/main.js
  • index.html

Think of it like this

The board at a race finish. When a new result arrives, the names do not all get wiped and rewritten: the ones that stay slide to their new row, the new runner is added, and a runner who dropped out is taken down. People can follow who is who. That is what the keyed join gives the station chart.

Words you'll meet

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

chart module
A file that exports one function, such as drawStationsBar, which draws a chart into a container and returns its controls.
update function
A function returned by the chart. Calling it with new rows changes the chart to match them.
container
The page element the chart is drawn into.
shared maximum
One number that every update uses for the full-length bar, so bars from different months can be compared.
guard
A check at the entrance of a function that keeps bad data out (a row with no number).

Step by step

01Add the loader

loadStationMonth() in src/data/load.js reads station-month.csv with the same fetchText and parseCsv as the other loaders, so a 404 or a wrong file gives the same clear messages as in Phase 4. d3.autoType turns rides into a number and leaves S01 and Jan as text.

02Write the chart function

drawStationsBar creates the <svg> and an empty-state paragraph once, then defines update(rows) and calls it for the first draw. Inside update: guard the rows, sort a copy, compute the shared maximum, size the box, set the aria-label, then the keyed join on g.row with a rect and two texts per station.

src/charts/stations-bar.js (the join)whole filejs
svg.selectAll("g.row")
  .data(sorted, (d) => d.id)                 // key: the station id
  .join((enter) => { /* create rect + 2 texts once */ return g; })
  .attr("transform", (d, i) => "translate(" + MARGIN.left + "," + (MARGIN.top + i * rowHeight) + ")");

03Create once, update every time

The enter function builds each row's children a single time. After the join, three lines set what depends on the data: the bar's width, the name, the value text. rowsSel.select("rect") passes the row's datum to the child (Topic 5.5), so each line reads d.

04Wire it up in main.js

startStations() loads stations and months together, builds forMonth(month) (rows with rides, with names), adds a labelled <select>, draws July and sets change to call chart.update. A failure writes its message into the box and leaves the rest of the page alone.

05Try the months

July: 25 stations, Lantern first with 7,260. April: 21 stations, the four May stations exit. May: 25 again, they enter. Quay keeps its element through all of it, as the last example checks.

Try the monthsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Show only the top ten

    In forMonth add .sort((a, b) => b.rides - a.rides).slice(0, 10) before the map. Switching month now moves stations in and out of the top ten: that is real enter and exit, driven by rank, and a clear reason to key by station id.

  2. 2

    Ask for a wrong month

    In the second example add "Smarch" to the end of the MONTHS list and pick it in the selector. byMonth.get returns undefined, the ?? [] makes it an empty list, and the box shows "No stations had any rides in this period." (zero rows, aria-label Rides per station: no data). Take it out again afterwards.

  3. 3

    Remove the shared maximum

    Delete { max: d3.max(rows, (d) => d.rides) } from the drawStationsBar call. Every month now stretches its own top bar to full width: January's Quay (2,994) looks as long as July's Lantern (7,260). The picture is tidy and misleading.

Code & diagrams

The chart module with awkward data New tab

The chart's code is inlined here (the project's `import` lines can't run in the preview). The `messy` case shows the guard: the row with no number is left out with a warning, and the zero row is drawn with a zero-length bar.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal: 3 rows | Rides per station: Lantern is busiest with 7,260. 3 stations shown. | text: -
empty: 0 rows | Rides per station: no data | text: No stations had any rides in this period.
one: 1 rows | Rides per station: Quay is busiest with 7,078. 1 station shown. | text: -
1 row(s) without a rides number were left out
messy: 2 rows | Rides per station: Quay is busiest with 7,078. 2 stations shown. | text: -
allZero: 2 rows | Rides per station: 2 stations shown, none with any rides. | text: -
bars with a broken width: 0
The dashboard's stations chart, switching months New tab

This is the dashboard's stations part as it stands after Phase 5, with the modules inlined into one example. The key numbers and the rides line from Phase 4 are left out to keep it short: in your project they run beside it, from the same `main.js`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Jul: 25 rows, top: Lantern
Apr: 21 rows (the four May stations exited)
May: 25 rows (they entered)
Quay kept its element: true
Rides per station: Lantern is busiest with 6,649. 25 stations shown.

Your project files

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

src/charts/stations-bar.jsjs

The whole file after this topic. If yours differs, you can copy this one and carry on.

// A horizontal bar chart of stations, busiest at the top. It never loads data and never decides which month to show:
// the caller hands it rows, and calls update() again with new rows. Each row: { id, name, rides }.
import * as d3 from "d3";
import { formatNumber } from "../utils/format.js";

const MARGIN = { top: 4, right: 56, bottom: 4, left: 92 }; // room for the names on the left and the numbers on the right

/**
 * drawStationsBar(container, data, options) -> { update(data) }
 * options: width (default 420), rowHeight (20), max (the number that gets a full-length bar; default: the biggest row),
 *          label (the start of the screen-reader text).
 */
export function drawStationsBar(container, data, options = {}) {
  const { width = 420, rowHeight = 20, max, label = "Rides per station" } = options;
  const innerWidth = width - MARGIN.left - MARGIN.right;

  const root = d3.select(container);
  const svg = root.append("svg").attr("role", "img").style("width", "100%").style("max-width", width + "px").style("height", "auto");
  const empty = root.append("p").attr("role", "status").style("margin", "0").style("color", "#5b6b78");

  function update(rows) {
    // 1. Look after the data first. A row without a usable number would put NaN in an attribute.
    const usable = rows.filter((d) => Number.isFinite(d.rides));
    if (usable.length < rows.length) console.warn(rows.length - usable.length + " row(s) without a rides number were left out");
    const sorted = [...usable].sort((a, b) => d3.descending(a.rides, b.rides)); // a copy: never reorder the caller's array

    // 2. The number that gets a full-length bar. An empty or all-zero list must not divide by zero.
    const most = max ?? d3.max(sorted, (d) => d.rides) ?? 0;
    const length = (rides) => (most > 0 ? Math.min(rides / most, 1) * innerWidth : 0);

    // 3. The box: as tall as the number of rows, and a sentence when there is nothing to draw.
    const height = MARGIN.top + MARGIN.bottom + rowHeight * sorted.length;
    svg.attr("viewBox", "0 0 " + width + " " + Math.max(height, 1));
    svg.style("display", sorted.length === 0 ? "none" : null);
    empty.text(sorted.length === 0 ? "No stations had any rides in this period." : "");
    const shown = sorted.length + (sorted.length === 1 ? " station" : " stations") + " shown";
    svg.attr("aria-label", sorted.length === 0 ? label + ": no data" : sorted[0].rides > 0 ? label + ": " + sorted[0].name + " is busiest with " + formatNumber(sorted[0].rides) + ". " + shown + "." : label + ": " + shown + ", none with any rides.");

    // 4. The data join, keyed by station id: a station keeps its own <g> from one update to the next.
    const rowsSel = svg
      .selectAll("g.row")
      .data(sorted, (d) => d.id)
      .join((enter) => {
        const g = enter.append("g").attr("class", "row");
        g.append("rect").attr("x", 0).attr("height", rowHeight - 4).attr("rx", 2).attr("fill", "#2a9d8f");
        g.append("text").attr("class", "name").attr("x", -6).attr("y", (rowHeight - 4) / 2).attr("dy", "0.35em").attr("text-anchor", "end").attr("font-size", 12).attr("fill", "#1d2b36");
        g.append("text").attr("class", "value").attr("y", (rowHeight - 4) / 2).attr("dy", "0.35em").attr("font-size", 12).attr("fill", "#5b6b78");
        return g;
      })
      .attr("transform", (d, i) => "translate(" + MARGIN.left + "," + (MARGIN.top + i * rowHeight) + ")");

    // 5. Enter rows and kept rows are both in rowsSel, so one block sets what depends on the data.
    // select() passes each row's datum down to its children.
    rowsSel.select("rect").attr("width", (d) => length(d.rides));
    rowsSel.select("text.name").text((d) => d.name);
    rowsSel.select("text.value").attr("x", (d) => length(d.rides) + 6).text((d) => formatNumber(d.rides));
  }

  update(data);
  return { update };
}

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 the key

In stations-bar.js change .data(sorted, (d) => d.id) to .data(sorted).

terminal
$ Console (second example)
── what you'll see ──
Jul: 25 rows, top: Lantern
Apr: 21 rows (the four May stations exited)
May: 25 rows (they entered)
Quay kept its element: false
Rides per station: Lantern is busiest with 6,649. 25 stations shown.

Myth vs fact

Myth

A chart function should load its own data.

Fact

Keeping loading outside makes the chart reusable and testable: you can give it rows from a file, a test or a live feed.

Myth

The update function has to redraw everything.

Fact

With the keyed join, it only changes what the new rows require.

Pro corner

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

  • ▸

    The chart creates its svg with root.append and does not clear the container, so calling drawStationsBar twice in one container gives two charts. Callers draw once and call update. Phase 14 turns this into a component that React owns.

  • ▸

    The shared maximum is a policy, not a detail. Use one maximum across months to compare months, and each month's own maximum to compare stations within one month. Say which one the chart uses.

Remember this

  1. 1

    The need is met. Arjun's chart no longer breaks when the data changes. drawStationsBar(container, data, options) draws once and returns { update(data) }. Priya picks May instead of July, the main file calls update with May's rows, and the join does the rest: Windmill and the three other May stations enter (they had no rides before 10 May), and in April they exit again.

  2. 2

    How it plugs in. The project from Phase 4 gets four changes. src/charts/stations-bar.js is new and holds the chart. src/data/load.js gains loadStationMonth() for station-month.csv. src/main.js starts the stations chart in its own function, with its own loading and error text, so a problem there never hides the key numbers. index.html gets a new #stations-bar box. The hand-made "running low" bars from Phase 2 move into their own box, #status, because they answer a different question (bikes now, not rides last month) and still wait for live data.

  3. 3

    Design choices to notice. The chart never loads data and never picks the month: the caller does (so Phase 8 can drive it from another chart). A copy of the rows is sorted, never the caller's array. Rows without a finite number are skipped with a console warning, so NaN cannot reach an attribute. Bar length uses one shared maximum across all months, so a bar in January and one in July mean the same number of rides. The aria-label is rebuilt on every update, so a screen reader hears the current month's story (Phase 15 goes further).

  4. 4

    What the dashboard can do now that it could not before: show any month's 21 or 25 stations ranked with their numbers, update without redrawing, say "No stations had any rides in this period" when a month is empty, and survive a missing station-month.csv with a message inside the box while the key numbers stay.

  5. 5

    What is still missing. The bars jump: they change place at once, which is hard to follow (Phase 9 adds transitions, and the keys are what make that possible). Bar lengths are hand arithmetic (rides ÷ max × width): Phase 6 replaces it with D3 scales and adds axes. No tooltips or highlighting yet (Phase 8). Colours, fonts and the narrow-screen layout are plain (Phase 10). The "running low" chart is still hand-drawn, until the live status feed is drawn from data later in the course.

Explain it without notes

01

Why does drawStationsBar return { update } instead of redrawing the whole chart?

02

Why is the join keyed by station id?

03

What are the chart's guards, and what does each protect against?

Practice

01

Add options.top to drawStationsBar so drawStationsBar(box, rows, { top: 10 }) shows only the ten busiest stations. Where in update does it go, and why there?

02

Make the month selector also work with the keyboard arrow keys without extra code, and say why.

Trade-offs

  • ↔

    Keeping chart code in one function that returns update is simple. A class or component adds more structure (destroy, resize) that you need when charts come and go (Phase 14).

Done when you can

  • I can write a chart module that returns { update }.

  • I can say what enters, updates and exits when the month changes.

  • I handled empty, one-row and broken rows inside the chart.

  • I can explain the shared maximum.