Command Palette

Search for a command to run...

Hectal
PHASE 15Advanced ~96 min· topic 7 of 7

Topic 15.7

Build: A Dashboard Meera Can Use

In one line

Give every chart on the dashboard a name, a description written from its data and a table twin, add a day reader and a one-stop bar list for the keyboard, and say changes aloud. It answers Meera: she can now get each chart's answer without seeing it.

Before you start

You will learn

  • Add two shared parts, describe.js (names, descriptions, spoken messages) and table-twin.js (the table beside a chart)
  • Give the React rides line a description, a table twin and a day reader, and the stations bars a roving tabindex and a spoken update
  • Give the plain D3 charts mounted by D3Box (stacked, heatmap, treemap, map and its trips layer) the same words and tables by changing their modules
  • Run the six-check audit and an axe scan on the result

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/describe.js
  • src/parts/table-twin.js
  • src/charts/RidesLine.jsx
  • src/charts/StationsBar.jsx
  • src/charts/stations-stacked.js
  • src/charts/june-heatmap.js
  • src/charts/fleet-treemap.js
  • src/charts/station-map.js
  • src/charts/trips-canvas.js
  • src/styles/charts.css

Think of it like this

The same operations room, but now every wall sheet has a typed list pinned beside it, and someone reads out the headline of each sheet when it changes. People who look at the sheets lose nothing. People who cannot look can run the room too.

Words you'll meet

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

describe.js
Our file of pure functions that turn rows into a name and a description, plus labelChart and announce.
table-twin.js
Our helper that builds the table twin: caption, sortable headings, a details wrapper and a row cap.
day reader
The slider over the rides line that the arrow keys move from day to day.
roving row list
A transparent list over the stations bars with one Tab stop and arrow-key movement.
accessibilityLayer
A Recharts option that adds its own focus and keyboard behaviour to the chart. We turn it off and use our own.
mounted chart
A plain D3 chart drawn inside a React box by D3Box.

Step by step

01Start from your dashboard

You have the Phase 14 React project running with npm run dev. Every chart still works. This build only adds words, tables and keyboard paths.

02Create the two shared parts

Add src/parts/describe.js, then src/parts/table-twin.js (it imports announce from describe.js). Nothing else imports them yet.

03Replace the React charts

Replace src/charts/RidesLine.jsx and src/charts/StationsBar.jsx. App.jsx does not change: both components keep their props.

04Replace the mounted charts and the CSS

Replace stations-stacked.js, june-heatmap.js, fleet-treemap.js, station-map.js, trips-canvas.js and charts.css. The map hands container and station to the trips layer so it can add its own table.

05Check it with the keyboard and the audit

Press Tab through the page: the rides line is one stop, the bars are one stop. Open each table. Change the month and listen for the announcement. Run the six checks from Topic 15.6 on each chart, then ask Meera to find the busiest station in July.

Check it with the keyboard and the auditdiagram
Rendering diagram…

Try it yourself

  1. 1

    Start the stations chart on January

    In StationsBox change useState("Jul") to useState("Jan"). The Console prints stations bars: group "Rides per station in Jan, bar chart" and rows: 21: the four stations opened in May are not in January, and the name, the row list and the table all follow the data.

Code & diagrams

The Phase 15 dashboard (the files joined into one) New tab

The project's imports of local files can't run in the preview, so the code of D3Box, describe.js, table-twin.js, RidesLine, StationsBar, App and the plain stacked, heatmap and treemap charts is joined into one example, and the loaders are short stand-ins that read the same data. The station map and its rides layer (station-map.js, trips-canvas.js and their helpers) are not joined in: its box shows a one-line stand-in, as in Phase 14. In your project each file stays on its own, and the map has its description and tables too. The Console prints what the page offers a screen reader, and presses a key on the day reader.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

key numbers: 365 | 933,651 | 18 Jul 2025 (4,380) | 14 Aug 2025 (439) | 0
rides line: img "Line chart of rides per day"
  description: 365 days, from 1 Jan 2025 to 31 Dec 2025. It starts at 1,193 rides, peaks at 4,380 on 18 Jul 2025 and ends at 910. The quietest day is 14 Aug 2025, with 439 rides (app down).
  table twin rows: 12 | Tab stops on the svg: 0
stations bars: group "Rides per station in Jul, bar chart"
  rows: 25 | Tab stops among them: 1 | table twin rows: 25 | svgs with a tabindex: 0
stations-stacked: Stacked bar chart of rides per month by district | twin rows: 12
june-heatmap: Heatmap of rides per hour in June, days down and hours across | twin rows: 30
fleet-treemap: Treemap of the fleet by district and station | twin rows: 25
day reader after one ArrowLeft: Tue 30 Dec 2025: 1,446 rides
after Summer: 1 Jun to 31 Aug: 334,321 rides
spoken: Rides per station in the selected dates, bar chart. 25 stations, 334,321 rides in all. Quay is busiest with 21,010 and Windmill is quietest with 5,305. Next after Quay: Lantern 20,406, Library 19,653.

Your project files

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

src/parts/describe.jsjs

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

// Writes the words for a chart from its data, so the words can never disagree with the picture. Also speaks short messages to screen readers.
import { formatDay, formatNumber } from "../utils/format.js";

let counter = 0; // makes each description paragraph's id different

/** Say something to a screen reader without moving focus. One polite live region for the whole page, made on first use. */
export function announce(text) {
  let region = document.getElementById("announcer");
  if (!region) {
    region = document.createElement("div");
    region.id = "announcer";
    region.setAttribute("role", "status"); // role=status is a polite live region
    region.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
    document.body.append(region);
  }
  region.textContent = ""; // clear first: the same sentence twice in a row is only spoken if the text really changes
  setTimeout(() => { region.textContent = text; }, 100);
}

/**
 * Give a chart (an <svg> or a <canvas>) its short name and its longer description.
 * The name goes in aria-label. The description becomes a visible paragraph under the chart, joined with aria-describedby.
 * Call it again after every update: the paragraph is changed in place, so the words follow the data.
 */
export function labelChart(node, { name, text }) {
  let note = node.nextElementSibling;
  if (!note || !note.classList.contains("desc")) {
    note = document.createElement("p");
    note.className = "desc";
    note.id = "desc-" + ++counter;
    node.after(note);
  }
  note.textContent = text; // text only, never markup
  node.setAttribute("aria-label", name);
  node.setAttribute("aria-describedby", note.id);
  if (node.tagName === "CANVAS") node.textContent = name + ". " + text; // what a browser without canvas support shows
}

const plural = (n, one, many) => formatNumber(n) + " " + (n === 1 ? one : many);

/** rows: [{ date, rides }]. notes: { "2025-08-14": "App down" }, a word for a day people will ask about. */
export function describeRides(rows, notes = {}) {
  const name = "Line chart of rides per day";
  const days = rows.filter((d) => Number.isFinite(d.rides)).sort((a, b) => a.date - b.date);
  if (days.length === 0) return { name: name + ": no data", text: "There are no rides to show." };
  const first = days[0], last = days[days.length - 1];
  const gaps = rows.length - days.length; // rows with no usable number: the line shows a gap there
  const gapText = gaps > 0 ? " (" + plural(gaps, "day has", "days have") + " no number and show" + (gaps === 1 ? "s" : "") + " as a gap)" : "";
  if (days.length === 1) return { name, text: "One day with a number: " + formatDay(first.date) + ", " + plural(first.rides, "ride", "rides") + gapText + "." };
  const high = days.reduce((a, b) => (b.rides > a.rides ? b : a));
  const low = days.reduce((a, b) => (b.rides < a.rides ? b : a));
  const word = notes[low.date.toISOString().slice(0, 10)];
  let text = plural(rows.length, "day", "days") + ", from " + formatDay(first.date) + " to " + formatDay(last.date) + gapText + ". ";
  if (high.rides === low.rides) text += "Every day had " + plural(high.rides, "ride", "rides") + ".";
  else {
    text += "It starts at " + formatNumber(first.rides) + " rides, peaks at " + formatNumber(high.rides) + " on " + formatDay(high.date) +
      " and ends at " + formatNumber(last.rides) + ". The quietest day is " + formatDay(low.date) + ", with " + formatNumber(low.rides) +
      " rides" + (word ? " (" + word.toLowerCase() + ")" : "") + ".";
  }
  return { name, text };
}

/** rows: [{ name, rides }], any order. label: the chart's subject, for example "Rides per station". */
export function describeBars(rows, subject) {
  const sorted = rows.filter((d) => Number.isFinite(d.rides)).sort((a, b) => b.rides - a.rides);
  const name = subject + ", bar chart";
  if (sorted.length === 0) return { name: name + ": no data", text: "No stations had any rides in this period." };
  const top = sorted[0], bottom = sorted[sorted.length - 1];
  const total = sorted.reduce((sum, d) => sum + d.rides, 0);
  let text = plural(sorted.length, "station", "stations") + ", " + plural(total, "ride", "rides") + " in all. ";
  if (top.rides === bottom.rides) text += "Every station had " + formatNumber(top.rides) + ".";
  else {
    text += top.name + " is busiest with " + formatNumber(top.rides) + " and " + bottom.name + " is quietest with " + formatNumber(bottom.rides) + ".";
    if (sorted.length >= 4) text += " Next after " + top.name + ": " + sorted.slice(1, 3).map((d) => d.name + " " + formatNumber(d.rides)).join(", ") + ".";
  }
  return { name, text };
}

/** months: [{ month, total }]; districts: [{ name, total }]. Both are the sums the chart draws. */
export function describeStacked(months, districts) {
  const name = "Stacked bar chart of rides per month by district";
  const total = months.reduce((sum, d) => sum + d.total, 0);
  if (total === 0) return { name: name + ": no data", text: "There are no rides to show." };
  const busy = months.reduce((a, b) => (b.total > a.total ? b : a));
  const quiet = months.reduce((a, b) => (b.total < a.total ? b : a));
  const lead = districts.reduce((a, b) => (b.total > a.total ? b : a));
  return {
    name,
    text: plural(total, "ride", "rides") + " in " + plural(months.length, "month", "months") + ". The busiest month is " + busy.month + " with " + formatNumber(busy.total) +
      " and the quietest is " + quiet.month + " with " + formatNumber(quiet.total) + ". " + lead.name + " carried the most, " +
      Math.round((lead.total / total) * 100) + " percent of all rides.",
  };
}

/** cells: [{ day, hour, rides }]. */
export function describeHeatmap(cells) {
  const name = "Heatmap of rides per hour in June, days down and hours across";
  const total = cells.reduce((sum, d) => sum + d.rides, 0);
  if (cells.length === 0 || total === 0) return { name: name + ": no data", text: "There are no rides to show." };
  const byHour = new Map();
  for (const d of cells) byHour.set(d.hour, (byHour.get(d.hour) ?? 0) + d.rides);
  const [hour, hourTotal] = [...byHour].reduce((a, b) => (b[1] > a[1] ? b : a));
  const hot = cells.reduce((a, b) => (b.rides > a.rides ? b : a));
  const at = (h) => String(h).padStart(2, "0") + ":00";
  return {
    name,
    text: "Across the month the busiest hour of the day is " + at(hour) + ", with " + formatNumber(hourTotal) + " rides. The single busiest hour was " +
      at(hot.hour) + " on " + hot.day + " June, with " + formatNumber(hot.rides) + " rides.",
  };
}

/** For the station map: stations is [{ name, district, docks }]. */
export function describeStations(stations) {
  const name = "Map of the bike stations";
  if (stations.length === 0) return { name: name + ": no data", text: "There are no stations to show." };
  const districts = new Set(stations.map((s) => s.district));
  const big = stations.reduce((a, b) => (b.docks > a.docks ? b : a));
  return {
    name,
    text: plural(stations.length, "station", "stations") + " in " + plural(districts.size, "district", "districts") + ". Circle size shows docks: " + big.name + " is the biggest with " + formatNumber(big.docks) +
      ". The table lists every station. Dots from the rides layer sit near their station: they show how many rides start there, not where on the street.",
  };
}

/** For the fleet treemap: bikes is the total, districts is [{ name, bikes }], count the number of stations. */
export function describeFleet(bikes, districts, count) {
  const name = "Treemap of the fleet by district and station";
  if (!(bikes > 0)) return { name: name + ": no data", text: "There are no bikes in the fleet data." };
  const lead = districts.reduce((a, b) => (b.bikes > a.bikes ? b : a));
  return { name, text: plural(bikes, "bike", "bikes") + " in " + plural(districts.length, "district", "districts") + " and " + plural(count, "station", "stations") + ". Area shows bikes. " + lead.name + " has the most, " + formatNumber(lead.bikes) + "." };
}

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

Let Recharts add its own keyboard layer

In StationsBar.jsx delete accessibilityLayer={false} from <BarChart ...>.

terminal
$ Console, the stations lines
── what you'll see ──
rows: 25 | Tab stops among them: 1 | table twin rows: 25 | svgs with a tabindex: 1

Break #2

Read the ref inside the cleanup

In RidesLine.jsx, in the table twin effect, delete the line starting const el = twinBox.current;, call tableTwin(twinBox.current, { and write twinBox.current.replaceChildren(); in the cleanup.

terminal
$ Console
── what you'll see ──
[error] Uncaught TypeError: Cannot read properties of null (reading 'replaceChildren')
[warn] An error occurred in the <RidesLine> component.

Myth vs fact

Myth

A chart library's accessibility layer is enough.

Fact

Recharts' own layer added a Tab stop to an SVG we had hidden from screen readers. Read what a library adds, and test it with the keyboard.

Myth

Adding tables doubles the page's weight.

Fact

A twin is a closed details of at most a few hundred cells: 12 rows for the year, 25 for the stations. Big data is summarised first (Topic 15.3).

Myth

Accessibility belongs in a separate pass at the end.

Fact

Here it is a handful of pure functions and two small components that every chart uses. Adding the next chart costs one call.

Pro corner

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

  • ▸

    The announcement is made from an effect that compares the new words with the last ones, so a first draw is silent and StrictMode's double run says nothing twice. A live region that speaks on every render is the common way accessibility work makes a page unusable.

  • ▸

    The map keeps its Phase 13 keyboard model (role="application", arrow keys, Enter), and now has a description and a table of the stations. role="application" turns off a screen reader's own browsing keys over the map, so it is worth a session with a real screen reader before keeping it.

  • ▸

    Both mounted-chart helpers use nextElementSibling to find their description paragraph. If you wrap a chart in another element, move the paragraph with it, or labelChart makes a second one.

Remember this

  1. 1

    Meera's need is met. She opens the dashboard and every chart has a name, a short description with the finding, and a table of its numbers. The rides line has a day reader she can move with the arrow keys, the stations bars are one Tab stop with the arrow keys moving between stations, and a changed month or range is spoken. She asked for the answer of each chart: she can get it.

  2. 2

    Starting point: the Phase 14 dashboard, a React app. You have lumen-dashboard/ with everything from Phases 4 to 14: RidesLine.jsx (D3 maths, React marks, brush, crosshair), StationsBar.jsx (Recharts), and the plain D3 charts mounted through D3Box (stacked months, June heatmap, fleet treemap, the station map with its rides layer). src/main.js is retired. Ten files change: describe.js and table-twin.js are new, and eight existing files are edited. App.jsx, D3Box.jsx and index.html do not change.

  3. 3

    How the parts plug in. Names and descriptions are pure functions of the rows (describeRides, describeBars, describeStacked, describeHeatmap, describeFleet, describeStations), so a React component calls them while it renders and a plain D3 module calls them while it draws (labelChart writes the result onto the node). The table twin is plain DOM, so React mounts it into an empty <div> it never looks inside, the same one-owner rule as D3Box (Topic 14.1). The cleanup keeps the element it started with, because React clears a ref before its cleanup runs.

  4. 4

    Keyboard. The rides line has one role="slider" over the chart: arrows, Page Up and Down (a week), Home and End, each speaking the day through aria-valuetext. The stations bars hide Recharts' own SVG from assistive technology (accessibilityLayer={false} stops it from adding a Tab stop of its own) and lay a transparent list over the bars: one row has tabindex="0" and the arrow keys move it. Tab and every other key are left to the browser, and the existing buttons for the brush keep dragging optional (WCAG 2.5.7).

  5. 5

    What the dashboard can do now that it could not before: every chart has a role, a name and a description built from its data; every chart has a table twin (the rides line sums months, the heatmap sums days, the treemap and the map list their stations); the rides line and the stations bars work from the keyboard; a new month or range is spoken. Measured on the project: axe-core 4.13.0 reports 0 violations in light and dark, and 1 item it cannot decide (color-contrast, 141 nodes); the page is 320 pixels wide at a 320 pixel window; keyboard users have 16 Tab stops where the Phase 14 page had 9, because each of the seven tables is a closed details with one stop.

  6. 6

    Still missing: the live feed and every loading, partial and error state at scale (Phase 16); the checks written as tests that run on every build (Phase 16); a visible pause for anything that updates by itself (Phase 16); and a real session with Meera on her own screen reader, which no scan replaces. The heatmap and the map's rides layer have no cell-by-cell keyboard path: their tables carry the numbers. The old plain rides-line.js and stations-bar.js of Phases 6 to 10 are no longer used by the React app and were not changed.

Explain it without notes

01

Why are the descriptions pure functions of the rows and not text in the components?

02

How does a React component use a vanilla helper such as tableTwin without the two fighting over the DOM?

03

Why does the stations chart need both a hidden Recharts SVG and a row list?

04

What does the dashboard still not do for Meera?

Practice

01

Add a description to a new chart in one line, using the helpers. Which two calls are needed for a plain D3 chart, and which for a React one?

02

Why does StationsBar compare the new words with the last ones before it announces?

Trade-offs

  • ↔

    Hiding Recharts' SVG and laying a row list over it costs about thirty lines and loses Recharts' own keyboard behaviour. It gives one Tab stop and the exact words we choose.

  • ↔

    A table twin for every chart adds seven Tab stops. Each is a closed details, so a keyboard user can skip them by section, and each answers a question the chart cannot answer to someone who cannot see it.

Done when you can

  • My project has describe.js, table-twin.js and the eight changed files.

  • Every chart has a name, a description and a table I can open.

  • The rides line is one Tab stop with a day reader, and the stations bars are one Tab stop.

  • I changed the month and saw the announcement, and ran the six-check audit.

  • I can say what is still missing and which phase adds it.