The problem
Toronto publishes every Bike Share trip as open data: 7.3 million of them for 2025, about 218 MB of CSV. A file like that only answers questions for someone who already knows what to ask. I wanted to watch the city’s bike traffic move on a phone, with nothing to install.
What it does
Every station is a sphere. Its size is how fast the station’s bike count is changing in that hour, and its colour is the direction: green stations are gaining bikes, red ones are losing them. The year is folded into one typical Monday-to-Sunday week, so you can watch downtown fill on weekday mornings and empty in the evening. There is also a day view, and a year view where each frame is a month and what animates is the change from one month to the next.
How it’s built
2025 is closed history. Every question anyone can ask of that year already has a fixed answer, so nothing is queried while the map plays. A Python batch job reads the City’s files once and writes compact binary files of 16-bit numbers: the flow in and out of every station for every hour of the week, at three levels of detail. The whole year comes to 1.9 MB. The page downloads those files, and the GPU draws each station as a shaded sphere in a custom WebGL layer over a MapLibre map. Dragging the time slider is a lookup in memory, and any static host can serve the page.
Every claim the short video version makes, such as downtown filling up in the morning, was checked against the numbers before it went in.
Decisions
- No backend. The data will never change, so a server would spend its life recomputing answers that were knowable in advance, and it would add latency, downtime and cost to a page that has none of those today.
- New data means re-running the batch job. When the City publishes more of 2026 the job rebuilds the files and the page reads the new span from a manifest, and the day I want live data is the day a query layer starts paying for itself.
- Size is scaled against the 99th percentile. Flow is heavy-tailed, and dividing by the maximum drew 11 of 1,054 spheres and looked like a rendering bug.
How it broke, and what changed
Stations along Yonge Street in midtown stayed red all day. That looked like a bug, and half of it was. The colour flipped from red to green at one fixed threshold, applied to stations whose traffic differs by a factor of a thousand. At quiet stations, arrivals trickled in below the threshold while departures came in one commuter spike above it, so those stations latched red and never flipped back. One absolute threshold across a field that varied that much was a different rule for every station. Each station now has its own threshold: half of its own average absolute net flow.
The other half was real. Several of those stations do lose bikes over the week, one of them 62%. Midtown sits above the Davenport escarpment: people ride downhill and don’t ride back, and the trucks that rebalance the bikes aren’t in the trip data. Red is the honest answer there, so those stations were left alone.
A second bug kept coming back. Size is meant to carry the amount, colour the direction, and brightness nothing. Three times, in three versions, magnitude crept back in as colour saturation or brightness, so a small sphere also came out pale and dim. Each time the cause was written up correctly in the project log, and each time it returned, because a note doesn’t run. Now a check asserts the rule against the code. Before trusting it, I had it run against three deliberate breaks, one for each way the rule has failed, and confirmed it caught all three.
What’s still rough
- 495,563 trips (6.8%) aren’t drawn. They start or end at stations retired during 2025 that are missing from the current station list, and the City’s historical station table would fix that.
- There is no browser test suite. Two sizing bugs were invisible in the page structure and obvious in a screenshot, so changes get checked by looking.
- The public build dates from 23 September and is behind my working copy.
- There is no live “right now” view. The City’s live station feed could drive one, and it would be the first part that needs a server.