Topic 13.7
Build: The Ardenvale Station Map
In one line
The station map becomes a real map of Ardenvale: districts and river from TopoJSON under the stations, circles sized by docks and coloured by bikes there now. A treemap of the whole fleet joins it. It answers Priya: where are the bikes, and which stations are running low?
Before you start
You will learn
- Draw the districts and river from TopoJSON under the station map, with the stations on the same projection
- Size the circles by docks and colour them by bikes now, with a red ring when a station runs low
- Add the fleet treemap, and give both boxes their own loading, empty and error text
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/station-map.js
- src/charts/fleet-treemap.js
- src/main.js
Think of it like this
The big wall map in a bike depot, with a pin for each station. A pin's size is the number of docks, and a coloured sticker says how many bikes are there now. The van driver glances at it and knows where to go. Today the dashboard gets that wall.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- station map
- The dashboard's map: the island's shapes in SVG, with the year of rides and one circle per station on canvases above.
- status
- A snapshot of the live bike counts: for each station, the bikes (and e-bikes) there and the free docks.
- running low
- A station whose bikes are 35% of its docks, or fewer (the
LOWconstant). It gets a red ring. - fitIsland
- The helper that fits one projection to the coast. The shapes and the stations both use it.
Step by step
01Add the one new library
TopoJSON needs topojson-client. D3 is already in the project from Phase 4. Run this in your project folder, then restart npm run dev if it was running.
02Replace the station map
Replace src/charts/station-map.js with the file below. The Phase 12 parts are the same: the canvases, createHitTester, the keyboard and the live region. New: the SVG land layer, fitIsland, the circle colours and setStatus.
03Create the fleet treemap
Add src/charts/fleet-treemap.js. A label is drawn only where it fits. All other stations keep a tooltip. An empty fleet gives a note.
04Replace main.js and open the page
main.js is Phase 12's file with loadJson, two more files loaded in startMap (each with its own .catch, so a failure gives null, not a broken page), the new options passed to drawStationMap, and startFleet. Check: six districts, 25 circles, three red rings, a treemap with 25 stations.
Try it yourself
- 1
Change what counts as running low
In
station-map.js, changeconst LOW = 0.35;to0.5. Every station with half its docks full or fewer gets a ring. Seven stations qualify instead of three (the counts come fromstatus.json). - 2
Colour by e-bikes
status.jsonhasebikesfor each station. InbikesOf, returns.ebikesinstead ofs.bikes. The colours now follow e-bikes (at most 9 at one station), and Bridge reads3 bikes nowinstead of 11. The red ring compares the same number with the docks, so decide whether that still makes sense. - 3
Press the keys
Click the map once, then press ArrowRight a few times: the live line names each station in turn (Bridge first), and Enter selects it. The readout says how many bikes are there now.
Code & diagrams
The project files (hit-test.js, station-map.js, trips-canvas.js, fleet-treemap.js and the new parts of main.js) are inlined into this one example, with imports and exports removed, because the preview cannot import local files. The earlier boxes of main.js are not run here. The last lines are a driver that is not part of the project: it presses keys, then draws the map again with data missing.
Preview
Console
Nothing logged yet.Expected console output
land layer: districts 6 | paths 9
at the start: status at 2025-06-18T08:15:00Z | 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.
selection says: Bridge, Riverside district, 20 docks, 11 bikes now
fleet: 25 stations | 320 bikes. Area shows bikes. 8 small stations show their name when you point at them.
no shapes, no status: no map shapes | bike counts not available
no stations: No stations to show.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.
// The station map: the real shapes of Ardenvale (districts, coast, river from TopoJSON) in an SVG layer, and two stacked canvases on top:
// a base layer other charts draw on (the year of rides), and a marks layer with one circle per station that handles hover, click, touch
// and the keyboard. Circle size = docks. Circle colour = bikes there now (darker = more), with a red ring when a station is running low.
import * as d3 from "d3";
import * as topojson from "topojson-client";
import { setupCanvas } from "../utils/canvas.js";
import { pointerPoint, reachFor, createHitTester } from "../utils/hit-test.js";
/** A canvas ignores var(): read the theme token from the page's CSS at every draw, and fall back when the page has none. */
export function token(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
const LOW = 0.35; // a station with this share of its docks filled, or less, is "running low"
/** One projection for the whole island, fitted to the coast. Returns null when there is no usable topology. */
export function fitIsland(topology, width, height, pad = 12) {
try {
const coast = topojson.feature(topology, topology.objects.coast);
const projection = d3.geoMercator().fitExtent([[pad, pad], [width - pad, height - pad]], coast);
return Number.isFinite(projection.scale()) ? projection : null;
} catch {
return null; // a missing or broken topology: the caller falls back to the flat projection
}
}
/**
* Places stations on a width x height box (the flat fallback; the real map uses fitIsland). Longitude is squeezed by cos(latitude) so the island keeps its shape.
* Returns a Map of station id to [x, y]. Stations with a missing or non-numeric position are left out.
*/
export function projectStations(stations, width, height, pad = 32, topology = null) {
const ok = stations.filter((s) => Number.isFinite(s.lon) && Number.isFinite(s.lat));
const spots = new Map();
if (ok.length === 0) return spots;
const island = topology && fitIsland(topology, width, height);
if (island) { // the real map: the same projection draws the land and places the stations
for (const s of ok) spots.set(s.id, island([s.lon, s.lat]));
return spots;
}
const flat = Math.cos((d3.mean(ok, (s) => s.lat) * Math.PI) / 180);
const [x0, x1] = d3.extent(ok, (s) => s.lon * flat);
const [y0, y1] = d3.extent(ok, (s) => s.lat);
const k = Math.min((width - 2 * pad) / (x1 - x0 || 1), (height - 2 * pad) / (y1 - y0 || 1)); // one factor for both axes
const left = (width - (x1 - x0) * k) / 2;
const top = (height - (y1 - y0) * k) / 2;
for (const s of ok) spots.set(s.id, [left + (s.lon * flat - x0) * k, height - top - (s.lat - y0) * k]);
return spots;
}
/**
* Draws the stations and returns { base, spot, select, selected, note, repaint, resize }.
* options: width, height, onSelect(station or null), topology (the parsed ardenvale.topojson), status (the parsed status.json, or null). base() is the layer under the stations, { ctx, width, height }.
* note(key, text) adds a line to the readout under the map. repaint() draws the marks again (after a theme change).
* resize(width) rebuilds both canvases, so the caller must draw its own layer again. setStatus(status) colours the circles again.
*/
export function drawStationMap(container, stations, options = {}) {
let width = options.width ?? 640;
const height = options.height ?? 360;
const onSelect = options.onSelect ?? (() => {});
const root = d3.select(container);
root.selectAll("*").remove();
const stack = root.append("div").style("position", "relative");
const land = stack.append("svg").attr("aria-hidden", "true").style("position", "absolute").style("left", 0).style("top", 0); // under both canvases
const baseCanvas = stack.append("canvas").attr("aria-hidden", "true").node();
const marksCanvas = stack.append("canvas").attr("tabindex", 0).attr("role", "application")
.attr("aria-label", "Station map with " + stations.length + " stations. Use the left and right arrow keys to move between stations, and Enter to select one.")
.style("position", "absolute").style("left", 0).style("top", 0).node();
d3.select(baseCanvas).style("position", "relative"); // the base canvas sits above the land and below the marks
const status = root.append("p").attr("aria-live", "polite").style("margin", "6px 0 0").style("font-size", "14px");
const readout = root.append("p").style("margin", "2px 0 0").style("font-size", "12px").style("color", "var(--chart-muted, #5b6b78)");
let base, ctx, spots, marks, tester, byName;
let state = options.status ?? null; // { updated, stations: [{ id, bikes, ... }] } or null
const bikesOf = () => new Map((state?.stations ?? []).map((s) => [s.id, s.bikes]));
const mostBikes = () => d3.max([...bikesOf().values()]) || 1;
function drawLand() { // districts, the borders between them (drawn once), coast and river: the same projection as the stations
land.attr("width", width).attr("height", height).selectAll("*").remove();
const topology = options.topology;
const projection = topology && fitIsland(topology, width, height);
if (!projection) return; // no shapes: the stations still show, on a plain background
const path = d3.geoPath(projection);
const districts = topojson.feature(topology, topology.objects.districts);
land.selectAll("path.district").data(districts.features).join("path").attr("class", "district").attr("d", path).attr("fill", "var(--chart-bg, #eef2f4)");
land.append("path").attr("d", path(topojson.mesh(topology, topology.objects.districts, (a, b) => a !== b))).attr("fill", "none").attr("stroke", "#fff").attr("stroke-width", 2);
land.append("path").attr("d", path(topojson.feature(topology, topology.objects.coast))).attr("fill", "none").attr("stroke", "#9db4c0");
land.append("path").attr("d", path(topojson.feature(topology, topology.objects.river))).attr("fill", "none").attr("stroke", "#3a86c8").attr("stroke-width", 3).attr("stroke-linecap", "round");
}
function layout() {
drawLand();
stack.style("width", width + "px").style("height", height + "px");
base = { ctx: setupCanvas(baseCanvas, width, height), width, height };
ctx = setupCanvas(marksCanvas, width, height);
spots = projectStations(stations, width, height, 32, options.topology);
const radius = d3.scaleSqrt([0, d3.max(stations, (s) => s.docks) || 1], [0, 14]);
marks = stations
.filter((s) => spots.has(s.id))
.map((s) => ({ ...s, x: spots.get(s.id)[0], y: spots.get(s.id)[1], r: Math.max(radius(s.docks || 0), 3) }));
tester = createHitTester(marks);
byName = [...marks].sort((a, b) => a.name.localeCompare(b.name));
}
layout();
if (marks.length < stations.length) console.warn((stations.length - marks.length) + " station(s) have no position and are not on the map.");
const notes = new Map(); // key -> text, in the order the keys were first used
function note(key, text) {
notes.set(key, text);
readout.text(Array.from(notes.values()).join(" | "));
}
let hover = null; // station ids, so a rebuild at a new width keeps them
let chosen = null;
const byId = (id) => marks.find((m) => m.id === id) ?? null;
function paint() { // the marks layer is only 25 circles, so redrawing it on every hover change costs almost nothing
const start = performance.now();
ctx.clearRect(0, 0, width, height);
const bikes = bikesOf();
const shade = d3.scaleSequential([0, mostBikes()], d3.interpolateBlues);
for (const m of marks) {
const count = bikes.get(m.id);
const low = count !== undefined && m.docks > 0 && count / m.docks <= LOW;
ctx.beginPath();
ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2);
// colour = bikes there now. No status for this station, or none at all: the theme's ink, a little see-through
ctx.fillStyle = m.id === chosen ? token("--chart-accent", "#e76f51") : count === undefined ? token("--chart-ink", "#264653") : shade(count);
ctx.globalAlpha = m.id === chosen || count !== undefined ? 1 : 0.6;
ctx.fill();
ctx.globalAlpha = 1;
if (low) { // a red ring: running low
ctx.lineWidth = 3;
ctx.strokeStyle = "#d1495b";
ctx.stroke();
} else if (count !== undefined) {
ctx.lineWidth = 1;
ctx.strokeStyle = token("--chart-ink", "#1d2b36");
ctx.stroke();
}
if (m.id === hover) {
ctx.lineWidth = 2.5;
ctx.strokeStyle = token("--chart-ink", "#1d2b36");
ctx.stroke();
}
}
note("marks", "marks layer " + (performance.now() - start).toFixed(1) + " ms");
}
function choose(next) {
chosen = next ? next.id : null;
const now = bikesOf().get(next?.id);
status.text(next ? next.name + ", " + next.district + " district, " + next.docks + " docks" + (now === undefined ? "" : ", " + now + " bikes now") : "No station selected.");
paint();
onSelect(next ? stations.find((s) => s.id === next.id) : null);
}
marksCanvas.addEventListener("pointermove", (event) => {
const [px, py] = pointerPoint(marksCanvas, event, width, height);
const found = tester.find(px, py, reachFor(event)); // 8 px beyond the dot for a mouse, 24 for a finger
const id = found ? found.id : null;
if (id !== hover) {
hover = id;
marksCanvas.style.cursor = found ? "pointer" : "default";
paint();
}
});
marksCanvas.addEventListener("pointerleave", () => {
hover = null;
paint();
});
marksCanvas.addEventListener("click", (event) => {
const [px, py] = pointerPoint(marksCanvas, event, width, height);
choose(tester.find(px, py, reachFor(event))); // a click on empty space clears the selection
});
marksCanvas.addEventListener("keydown", (event) => {
if (marks.length === 0) return;
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
const i = hover ? byName.findIndex((m) => m.id === hover) : -1;
const step = event.key === "ArrowRight" ? 1 : -1;
const next = i < 0 ? byName[step > 0 ? 0 : byName.length - 1] : byName[(i + step + byName.length) % byName.length];
hover = next.id;
status.text(next.name + ", " + next.district + " district. Press Enter to select.");
paint();
} else if (event.key === "Enter" && hover) {
event.preventDefault();
choose(byId(hover));
}
});
status.text(marks.length === 0 ? "No stations to show." : "No station selected.");
paint();
if (!options.topology) note("shapes", "no map shapes"); else if (!fitIsland(options.topology, width, height)) note("shapes", "map shapes unusable");
note("status", state ? "status at " + state.updated : "bike counts not available");
return {
base: () => base,
note,
spot: (id) => spots.get(id),
select: (id) => choose(byId(id)),
selected: () => chosen,
repaint: paint,
setStatus(next) { state = next ?? null; paint(); note("status", state ? "status at " + state.updated : "bike counts not available"); },
resize(nextWidth) {
if (!(nextWidth > 0) || nextWidth === width) return false;
width = nextWidth;
layout();
paint();
return 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
Swap longitude and latitude
In station-map.js, in projectStations, change island([s.lon, s.lat]) to island([s.lat, s.lon]).
Break #2
Point the loader at a wrong file
In main.js, change loadJson("ardenvale.topojson") to loadJson("ardenvale.json").
Myth vs fact
Myth
A map needs a map library.
Fact
For one small island and 25 points, D3's geo tools and one TopoJSON file are enough. A library pays off when you need tiles, street names, routing or smooth zoom over a whole country.
Myth
If the status file fails, the whole map should fail.
Fact
The shapes and stations are the map. The bike counts are extra, so the chart should still draw and say what is missing.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The land is drawn once per width, and a status change only repaints the 25 circles. That split is what lets Phase 16 call
setStatusevery few seconds cheaply. - ▸
The SVG sits under both canvases, so the canvases must be transparent and the base layer must
clearRectrather than fill. Phase 12's rides layer already does. - ▸
A station with no coordinates is left out and counted in a console warning, as in Phase 12. In a real system, report such rows to the people who own the data.
Remember this
- 1
The problem this solves: Priya had a list of stations and no sense of where the low ones were. Her need is met. The station map from Phase 12 now sits on the island itself: six districts, the coast and the river. Every station is a circle. A big circle is a big station. A dark one has many bikes. A red ring marks a station with 35% of its docks full or fewer: Clock Tower (6 bikes of 30 docks), Rope Walk and West Gate (7 of 20 each). Tomás's question is answered too: below the map, a treemap shows the whole fleet of 320 bikes by district and station.
- 2
What the dashboard starts from. This build starts from the Phase 12 dashboard, the newest committed build. Its station map keeps everything: two stacked canvases (a base layer with the year of rides, and a marks layer), a quadtree that finds the station under a mouse or a finger, arrow keys and Enter with a live region, theme colours read at every draw, and a redraw on resize.
src/charts/station-map.jsis given in full and replaces Phase 12's (changed).trips-canvas.jsandhit-test.jsstay as they are.main.jskeeps every earlier box and gains the fleet treemap. - 3
What is new. Under the two canvases sits one SVG with the districts, the borders from
mesh(drawn once), the coast and the river.fitIsland(topology, width, height)fits one Mercator projection to the coast, and the same projection places the stations, so a station is always inside its district. Without a topology the map falls back to Phase 12's flat projection.setStatus(status)colours the circles fromstatus.jsonwith a sequential scale, andsrc/charts/fleet-treemap.jsdraws the fleet (Topic 13.4).main.jsgetsloadJson, which checksresponse.ok. - 4
Decisions worth knowing. The shapes and the bike counts are extras: if
ardenvale.topojsonorstatus.jsonfails, the map still draws (flat projection, ink-coloured circles) and the readout under it says "no map shapes" or "bike counts not available". The land is SVG, not canvas, so it follows the theme's--chart-bgthrough CSS. The year-of-rides dots stay on the base canvas, so their code is untouched. The status is one snapshot (18 June 2025, 08:15 UTC), so there is no timer yet:setStatusis the hook that Phase 16's live feed will call. - 5
What the dashboard can do now that it could not before: show where the stations are on the real island, show which are running low at a glance, show the fleet as boxes inside boxes, and say "no map shapes", "bike counts not available", "no stations" and "no bikes" instead of a blank box. What is still missing: zoom and pan on the map (Topic 13.3 shows the method), dark-theme polish for the new colours (Phase 10 tokens are only used where the map already read them), React versions (Phase 14), screen-reader detail for the treemap (Phase 15), and the live feed (Phase 16).
Explain it without notes
Why does the map still draw when status.json or the TopoJSON fails to load?
Why must the land and the stations use the same projection?
Why is the land SVG while the stations stay on canvas?
Practice
Make the readout under the map also say how many stations are running low.
Make the fleet treemap use the same district colours as the map's districts, by moving the colour scale into src/utils/colors.js.
Trade-offs
- ↔
Repainting all 25 circles on every hover is cheap. For thousands of marks, repaint only what changed or use a layer per state.
- ↔
A separate
loadJsoninmain.jsis quick. If more charts need it, move it intosrc/data/load.jswith the other loaders.
Done when you can
I can draw the real island under the stations with one shared projection.
I can say what the map shows when
status.jsonor the topology fails, and when there are no stations.I can say what the dashboard cannot do yet and which phase adds it.
I can read the treemap and say why eight small stations have no label.