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.
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 it yourself
- 1
Show only the top ten
In
forMonthadd.sort((a, b) => b.rides - a.rides).slice(0, 10)before themap. 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
Ask for a wrong month
In the second example add
"Smarch"to the end of theMONTHSlist and pick it in the selector.byMonth.getreturnsundefined, the?? []makes it an empty list, and the box shows "No stations had any rides in this period." (zero rows, aria-labelRides per station: no data). Take it out again afterwards. - 3
Remove the shared maximum
Delete
{ max: d3.max(rows, (d) => d.rides) }from thedrawStationsBarcall. 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'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
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: 0This 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
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.
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).
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
svgwithroot.appendand does not clear the container, so callingdrawStationsBartwice in one container gives two charts. Callers draw once and callupdate. 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
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 callsupdatewith 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
How it plugs in. The project from Phase 4 gets four changes.
src/charts/stations-bar.jsis new and holds the chart.src/data/load.jsgainsloadStationMonth()forstation-month.csv.src/main.jsstarts the stations chart in its own function, with its own loading and error text, so a problem there never hides the key numbers.index.htmlgets a new#stations-barbox. 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
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
NaNcannot 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. Thearia-labelis rebuilt on every update, so a screen reader hears the current month's story (Phase 15 goes further). - 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.csvwith a message inside the box while the key numbers stay. - 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
Why does drawStationsBar return { update } instead of redrawing the whole chart?
Why is the join keyed by station id?
What are the chart's guards, and what does each protect against?
Practice
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?
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
updateis 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.