Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~34 min· topic 7 of 7

Topic 12.7

Build: A Year of Trips That Stays Fast

In one line

Add a station map with a whole year of rides under it. The rides are counted and drawn as one dot per hundred, the map is on layers, a quadtree finds the station under a mouse, a finger or the keyboard, and the page shows how long each layer took to draw.

Before you start

You will learn

  • Rebuild the trips chart as a year of counted rides drawn on the layer under a clickable station map
  • Find a station under a mouse, a finger or the keyboard with a quadtree
  • Show on the page how long each layer takes to draw, and what the dots stand for

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/utils/hit-test.js
  • src/charts/station-map.js
  • src/charts/trips-canvas.js
  • src/main.js

Think of it like this

The operations room wall again. Last time it showed every June trip as a dot cloud. Now Priya asks for the whole year, and a pin map where she can point at a station and read it. The wall now has two sheets: a printed sheet with a year of rides as dots (one dot for every hundred rides, with the rule written under it), and a clear sheet on top with the 25 stations, so a pin can be lifted without reprinting the first sheet.

Words you'll meet

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

base layer
The canvas under the stations: the dots of rides. It is redrawn only when the data or month changes.
marks layer
The canvas on top: the station circles, hover ring and selection. It is cheap, so it redraws on every hover change.
one dot per hundred
The aggregation rule: a station with 5,000 rides gets 50 dots. The page says so.
reach
How far from a station's centre a pointer still counts as on it: 8 pixels for a mouse, 24 for a finger.
live region
An element with aria-live="polite". A screen reader announces its text when it changes.
frame-time readout
A line of text on the page that shows how long each layer took to draw.

Step by step

01Add the four files

Your lumen-dashboard/ runs with npm run dev, and the June trips box from Phase 11 in main.js. Phase 11's src/utils/canvas.js (setupCanvas, Topic 11.4) must exist. Create src/utils/hit-test.js and src/charts/station-map.js, replace src/charts/trips-canvas.js (it draws on the map's base layer) and src/main.js (it adds startMap and its box).

02Check the order of the layers

In the page, the base canvas (the dots) comes first and the marks canvas (the station circles) second and on top, so the circles and the pointer are never hidden by dots. The marks canvas is the one that takes focus and pointer events.

03Run, click, tab

Open the page. The map box shows 25 station circles with a cloud of dots around each, and under it: marks layer … ms | 9,339 dots stand for 933,651 rides (1 dot is about 100 rides) | rides layer … ms. Click a station: its name, district, docks and year rides appear above the map. Press Tab to focus the map, then the right and left arrows and Enter.

04Break it on purpose

Rename station-month.csv. The map box shows its own red error and the rest of the page keeps working. Rename it back. Then pick Jan in the month picker: the readout says 434 dots stand for 43,439 rides.

Break it on purposediagram
Rendering diagram…

Try it yourself

  1. 1

    Another dot rule

    In startMap, call drawTripsCanvas(map, rows, { perDot: 500 }). The readout now says 1,867 dots for the same 933,651 rides, and the clouds thin out (January: 89 dots). Whatever you choose, the sentence under the map stays true because it is built from the numbers.

  2. 2

    Lose a station

    In loadStations, set the fourth station's lat (Library) to NaN before the map is drawn. The console warns 1 station(s) have no position and are not on the map, and the readout says 8,784 dots stand for 878,115 rides (1 dot is about 100 rides), and 55,536 rides are left out because their station has no position. The other 24 stations work. (With the same edit, Jan says 406 dots stand for 40,669 rides ... and 2,770 rides are left out: the note follows the month.)

  3. 3

    Start from the other end

    Focus the map and press the left arrow before anything else: the live region says Windmill, Westpark district. Press Enter to select., the last station in name order. The first right arrow says Bridge, the first. The code says where an empty focus starts: a plain (i + step + n) % n from -1 would land on the second-last station.

  4. 4

    Let the page scroll

    On a phone, put a finger on the map and drag down: the page scrolls, because the marks canvas sets no touch-action: none. A tap still selects the nearest station within 24 pixels. Add .style("touch-action", "none") back and a drag that starts on the map no longer scrolls the page: a trap for anyone scrolling past it.

Code & diagrams

The Phase 12 part of the dashboard (modules inlined) New tab

The preview cannot import your local files, so this example pastes hit-test.js, station-map.js, trips-canvas.js and the Phase 12 part of main.js into one module (the loaders and setupCanvas are short stand-ins). In your project they are separate files. At the end, the page presses the arrow keys and Enter on the map and picks a month, then prints what the map said. Draw times appear on the page, not in the Console.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

at the start: 9,339 dots stand for 933,651 rides (1 dot is about 100 rides)
ArrowRight says: Bridge, Riverside district. Press Enter to select.
Enter selects: Bridge (Riverside): 38,288 rides in the year, 20 docks.
January: 434 dots stand for 43,439 rides (1 dot is about 100 rides)
whole year again: 9,339 dots stand for 933,651 rides (1 dot is about 100 rides)

Your project files

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

src/utils/hit-test.jsjs

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

// Finding the mark under the pointer on a canvas. A canvas draws pixels and keeps no shapes, so we keep the positions ourselves.
import * as d3 from "d3";

/** Converts a pointer event to canvas coordinates in CSS pixels, even when the canvas is shown smaller or larger than its box. */
export function pointerPoint(canvas, event, cssWidth, cssHeight) {
  const box = canvas.getBoundingClientRect();
  if (!(box.width > 0) || !(box.height > 0)) return [NaN, NaN]; // a hidden canvas has no size to divide by
  return [((event.clientX - box.left) * cssWidth) / box.width, ((event.clientY - box.top) * cssHeight) / box.height];
}

/** How far from a mark the pointer may be and still count: a fingertip is bigger than a mouse arrow. */
export function reachFor(event, mouse = 8, touch = 24) {
  return event.pointerType === "touch" || event.pointerType === "pen" ? touch : mouse;
}

/**
 * Builds a finder over items (anything with x and y in CSS pixels). find(x, y, reach) returns the nearest item
 * within reach, or null. Items without a finite x and y are left out, and counted in skipped.
 * When marks overlap, the nearest centre wins, which is also what a person expects.
 */
export function createHitTester(items, { x = (d) => d.x, y = (d) => d.y } = {}) {
  const usable = items.filter((d) => Number.isFinite(x(d)) && Number.isFinite(y(d)));
  const tree = d3.quadtree(usable, x, y);
  return {
    skipped: items.length - usable.length,
    size: usable.length,
    find(px, py, reach = 8) {
      if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
      return tree.find(px, py, reach) ?? null; // undefined means nothing within reach
    },
  };
}

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

Draw dots for a station with no position

In drawTripsCanvas, delete the line if (!spot) { skipped += count; continue; } // a station with no position: counted, not drawn, and set one station's lat to NaN before drawing.

terminal
$ Console
── what you'll see ──
1 station(s) have no position and are not on the map.
TypeError: Cannot read properties of undefined (reading '0')

Myth vs fact

Myth

Aggregating the rides means the map is less true.

Fact

It is true to what the rides are: counts per station. The readout says one dot is about 100 rides. Drawing 933,651 overlapping dots would be slower and would show less.

Pro corner

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

  • ▸

    Frame times in the readout come from performance.now() around the draw, so they measure script time. Real frame time also includes the browser's painting, which the Performance panel shows.

  • ▸

    The jitter that spreads the dots around a station is made from the station id and the dot number, so the picture is the same on every redraw and for every user. The dots show how many rides start at a station, not where on the street they started.

Remember this

  1. 1

    Priya's need is met. With a full year (933,651 rides) on the map nothing freezes, because the page draws 9,339 dots from counts instead of 933,651 marks. She can click a station by mouse or finger and read its district, docks and rides for the year. Arjun's frame-time readout under the map says how long each layer took.

  2. 2

    Starting point: the Phase 11 dashboard, the newest committed build. You have lumen-dashboard/ with everything from Phases 4 to 11: the loaders, the crosshair, brush and presets, the keyed transitions, the themes, onResize, src/utils/canvas.js (setupCanvas, Topic 11.4) and the June trips box. Four files change: src/utils/hit-test.js and src/charts/station-map.js are new, src/charts/trips-canvas.js is rewritten for the whole year and for the map's base layer, and src/main.js keeps all of that and swaps the June trips box for the map box. index.html does not change.

  3. 3

    How the parts fit. drawStationMap builds two stacked canvases: a base layer (empty, for others to draw on) and a marks layer (25 circles, hover ring, selection). drawTripsCanvas draws on the base layer, so a hover redraws only 25 circles and never the 9,339 dots. createHitTester in hit-test.js is a quadtree over the station positions, with a bigger reach for touch.

  4. 4

    Honest and robust. The rides are counted per station first, and the readout says "9,339 dots stand for 933,651 rides (1 dot is about 100 rides)". Rounding each station to whole dots is why it says "about". A station with no position is left out and the readout says how many rides that is. A missing station file shows an error in its own box. Keyboard users get the arrow keys and Enter, with the result read out through a live region.

  5. 5

    What the dashboard can do now that it could not before: show a year of rides on a map without slowing the page, let anyone select a station by mouse, finger or keyboard, switch month and see the rides layer redraw alone (January: 434 dots for 43,439 rides), and report its own draw times. Still missing: the stations are on a flat projection, not the real map of Ardenvale with districts and the river (Phase 13), the table twin and a full screen-reader pass for the map (Phase 15).

Explain it without notes

01

Why are the dots on one canvas and the station circles on another?

02

Why does the readout say "about 100 rides" per dot?

03

How does a keyboard user select a station?

Practice

01

Add a Clear button next to the month picker that selects no station. Which method of the map do you call?

02

Make the map announce the station's rides for the picked month, not the whole year, when a month is chosen.

Done when you can

  • My project has hit-test.js, station-map.js, the new trips-canvas.js and the new main.js.

  • The page shows 9,339 dots standing for 933,651 rides, and the draw times.

  • I clicked a station and used the arrow keys and Enter.

  • I broke the stations file on purpose and saw only this box change.

  • I can say what the map still lacks and which phase adds it.