Topic 8.7
Build: An Interactive Rides Explorer
In one line
The dashboard becomes an explorer. A crosshair tooltip answers "what happened that day" by mouse, tap and arrow keys, a brush (or three buttons) picks dates, and the station bars follow the selection.
Before you start
You will learn
- Add the tooltip, crosshair, brush and linked bars to the dashboard
- Keep one selection in one place and update every chart from it
- Test an interaction by sending it events instead of using the mouse
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/tooltip.js
- src/charts/rides-line.js
- src/charts/stations-bar.js
- src/main.js
Think of it like this
The stock board in a shop with a pad of sticky notes. Point at a day and a note pops up with its numbers. Draw a ring around a week and the stock list underneath rewrites itself. The board does not get bigger. It gets answers.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- explorer
- A dashboard the reader can point at, select in and share, not only look at.
- crosshair
- A vertical line and a dot that follow the pointer and snap to the nearest day.
- live region
- An element with aria-live: a screen reader announces its new text.
- preset
- A button that sets a common selection, such as June, in one press.
Step by step
01Start with the tooltip part
createTooltip(container) returns { show(text, x, y), hide() }. It shows the text first and measures second, flips and clamps (Topic 8.2) and writes with textContent.
02Rebuild the line with D3 and add the interactions
rides-line.js sorts and filters its rows, so a missing day never reaches the bisector (Topic 8.3). Pointer move, tap, focus and the arrow keys all call one show(i). The brush's end handler ignores moves made by code and reports whole days or null (Topic 8.5).
03Draw the bars so they can be updated
stations-bar.js already keys its rows by station id (Topic 5.4). It gains one delegated listener for hover and focus (Topic 8.4) and a second argument to update, because a sum of three months needs its own full-length number.
04Wire the views to one selection
In main.js, every way of choosing a range ends in selectRange(range): it clamps the range and calls each follower, which updates the note, the brush and the bars (Topic 8.6). The last example runs the files together and sends them events.
Try it yourself
- 1
Move the crosshair with the keyboard
In the editor, click the chart once, then press the left arrow. The tooltip walks one day at a time from the last day. Escape closes it.
- 2
Press the new last-week button
In
start(), add["Last 7 days", d3.utcDay.offset(bounds[1], -7), bounds[1]]to the list of presets, putconst asDate = (v) => (v instanceof Date ? v : parseIso(v));aboveconst presets, and change the click handler toselectRange(from ? [asDate(from), asDate(to)] : null). Pressing it prints25 Dec to 31 Dec: 9,224 rides: the last day of the data is included, becauseboundsends one day after it. - 3
Open a hole in the data
Replace the example's
loadDailyReportby one that setsridestonullfor 10 to 19 August (rows.map((d) => (d.date >= new Date("2025-08-10") && d.date < new Date("2025-08-20") ? { ...d, rides: null } : d))). The line breaks into two pieces. A pointer over 15 August now hides the tooltip, instead of showing 9 August or 20 August as if it were the day under the pointer, and a pointer at 21 August showsThu 21 Aug 2025: 2,256 rides. The Summer total drops to 300,308 rides.
Code & diagrams
The four project files are inlined into this one example (imports and exports removed) because the preview cannot import local files. The last lines send events and print what the dashboard did.
Preview
Console
Nothing logged yet.Expected console output
Loaded 365 days, 0 cleaning notes
mouse on 18 July | Fri 18 Jul 2025: 4,380 rides
finger taps 14 August | Thu 14 Aug 2025: 439 rides
ArrowLeft | Wed 13 Aug 2025: 4,226 rides
before any selection | No dates selected. Drag across the line, or use a button. | top station: Lantern 7,260
drag across June | a range is selected | the bars changed: true
press Summer | 1 Jun to 31 Aug: 334,321 rides | top station: Quay 21,010
click on the chart | No dates selected. Drag across the line, or use a button.
drag to the far right | 1 Dec to 31 Dec: 45,376 rides
drag shorter than a day | No dates selected. Drag across the line, or use a button. | brush drawn: falseYour 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 tooltip that stays inside its container. It shows text only (textContent), never markup.
export function createTooltip(container) {
const element = document.createElement("div");
element.setAttribute("role", "tooltip");
element.hidden = true;
element.style.cssText =
"position:absolute;left:0;top:0;z-index:2;max-width:220px;padding:6px 8px;border-radius:4px;" +
"background:#1d2b36;color:#fff;font:12px system-ui,sans-serif;pointer-events:none;overflow-wrap:anywhere";
container.style.position = "relative";
container.append(element);
return {
element,
/** Show `text` beside the point (x, y), measured in pixels from the container's top-left corner. */
show(text, x, y, gap = 12) {
element.textContent = text;
element.hidden = false; // a hidden box has no size: show it before measuring
const box = container.getBoundingClientRect();
const w = element.offsetWidth, h = element.offsetHeight;
let left = x + gap, top = y - h - gap;
if (left + w > box.width) left = x - w - gap; // no room on the right: go left
if (top < 0) top = y + gap; // no room above: go below
element.style.left = Math.max(0, Math.min(left, box.width - w)) + "px";
element.style.top = Math.max(0, Math.min(top, box.height - h)) + "px";
},
hide() { element.hidden = true; },
};
}
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
Feed the line unsorted data
In the example, delete .sort((a, b) => a.date - b.date) in update of the line chart, and make loadDailyReport return the rows newest first: rows: (await d3.csv("/data/lumen/daily.csv", d3.autoType)).reverse().
Break #2
Leave out the guard for a tiny drag
In rides-line.js, delete the line that starts if (range && range[0] >= range[1]) in the brush's end handler. Run the example and compare the last line.
Break #3
End the chart at the last day
In rides-line.js, change the x.domain([...]) line back to x.domain(d3.extent(days, (d) => d.date)), and the same for x in the last lines of the example.
Myth vs fact
Myth
Interactive means hover.
Fact
Hover is one input. This dashboard has pointer, tap, keyboard and buttons, and all of them end in the same functions.
Myth
A brush that works with a mouse works on a phone.
Fact
d3.brush listens to touch events too, but it also sets touch-action: none on a touch screen, so a finger on the plot cannot scroll the page. The build overrides it with pan-y and adds buttons for the common ranges.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The followers list is a testing seam: Phase 16 calls
selectRangeinstead of dragging a mouse, the same way the last example sends its own events. - ▸
A range is half-open: the start day is in, the end day is out (
date >= start && date < end). That is whyboundsends one day after the last day and why the label subtracts one day (d3.utcDay.offset(range[1], -1)). Two ranges then share a boundary without counting a day twice, and the months touched are found with the same rule.
Remember this
- 1
Priya's need is met. She can point at a day on the line and read its date and rides, with a mouse, with a finger in the depot, or with the left and right arrow keys. She can drag across a stretch of days, or press June or Summer, and the station bars below show where those rides happened. Every chart says what it shows when there is nothing to show.
- 2
What the dashboard starts from. The Phase 7 dashboard: the key numbers, the rides line with D3 axes (
src/charts/rides-line.jsandsrc/parts/axis.js, Phase 6), the month picker with the station bars (src/charts/stations-bar.js, Phase 5), the stacked months and the June heatmap (Phase 7), andsrc/main.jsthat starts them all. Four files change here and they are given in full.main.jskeeps Phase 7's loading and error handling and its stacked and heatmap boxes (marked with comments); the live example at the end runs the rides line and the stations chart only. - 3
What is new.
src/parts/tooltip.jsis Topic 8.2's tooltip as a part.rides-line.jskeeps its D3 axes and adds the crosshair, tap, arrow keys and brush (Topics 8.1 to 8.5), reports a range withonBrush, and moves the brush from code withsetRange.stations-bar.jskeeps its keyed join and gains hover and focus highlighting (Topic 8.4) andupdate(rows, { max }).main.jsgets oneselectRange()that validates a range and calls every follower (Topic 8.6): a note under the line, the brush, and the station bars. - 4
Decisions worth knowing. The range the brush reports is rounded to whole days (the drawn brush is not snapped; Topic 8.5's try-it shows how), and a drag shorter than a day counts as a click and clears the brush. A range ends on the day *after* its last day, so the line's scale reaches one day past the data and the last day can be selected. The label shows the last day included ("1 Jun to 30 Jun"), not the exclusive end. A pointer over a hole in the data shows no tooltip instead of a day that is days away (Topic 8.3). The brush is hard to use with a finger, so three buttons set the same range. The line and the brush group both have
touch-action: pan-y, so the page still scrolls up and down over them (d3.brushwould setnone, Topic 8.5). The station numbers come fromstation-month.csv, which is monthly, so while a range is selected the bars add up every month it touches and a note says so; the month picker is disabled until the selection is cleared. - 5
What the dashboard can do now that it could not before: read any day's numbers by pointer, tap or keyboard; pick a date range three ways; see the stations for any range of days; say "no data" and "no dates selected" instead of failing.
- 6
What is still missing. Station numbers by day (the 10,000 trips cover June only; Phase 12 works with them). Motion when the bars change (Phase 9). Dark mode, colour tokens and a layout that adapts to the wall screen (Phase 10). Maps (Phase 13). A data table and screen-reader detail beyond the labels and the live range note (Phase 15). Tests (Phase 16).
Explain it without notes
Why does every way of choosing a range go through selectRange()?
Why do the brush's end handler and setRange need to know who moved the brush?
Why are the station bars described as monthly in the note?
Practice
Add a button "Last 7 days of data" next to the presets.
Make the tooltip also show the day's temperature from temp_c.
Trade-offs
- ↔
A followers array in
main.jsis easy to follow with three views. With ten, move the selection into a store with subscribers (Topic 8.6). - ↔
Monthly station totals are honest but coarse. Daily station numbers need the trips data and a heavier load, which Phase 12 handles.
Done when you can
I can point at a day by mouse, tap and keyboard and read its numbers.
I can pick dates by brush and by buttons, and see the bars change.
I can say what the dashboard cannot do yet and which phase adds it.
I can run the explorer's files together and send them events.