Making the isochrone read correctly on devices with no colour: black-and-white printers, basic e-Ink, and for viewers with total colourblindness (achromatopsia).
Status: in progress on branch true-monochrome. Written 2026-08-21; grid
allocation section revised 2026-08-23 once that work landed; the cyclic
question settled 2026-08-31 (see “The cyclic problem”), and contour extraction
implemented.
The palette encodes time as hue, cycling every cycleMinutes. Converting both
themes to perceptual luminance (sRGB relative luminance, expressed 0-255):
| Band | Time (60 min cycle) | Light theme | grey | Dark theme | grey |
|---|---|---|---|---|---|
| 0 | 0-12 min | blue | 40 | cyan | 201 |
| 1 | 12-24 min | green | 57 | green | 186 |
| 2 | 24-36 min | gold | 74 | yellow | 238 |
| 3 | 36-48 min | orange | 43 | orange | 102 |
| 4 | 48-60 min | magenta | 25 | pink | 70 |
Two distinct failures, and the second is the more damaging:
Both numbers above are reproducible from ISOCHRONE_PALETTE_LIGHT /
ISOCHRONE_PALETTE_DARK in web/src/render/colour.js.
Recolouring the existing render - dashes, greys, patterned strokes - would make the output legible but not good. In colour, the eye separates half a million overlapping line segments by hue; remove hue and a dense region is a grey mass however the strokes are patterned. Density itself becomes the problem.
So monochrome changes what is drawn, not just how it is coloured: filled, hatched isochrone bands bounded by labelled contours, which is what a monochrome map would actually do.
The existing colour palette stays exactly as it is. Monochrome is a separate rendering mode, not a re-tint of the current one.
The isochrone is computed on the road network, so “reachable area” is already an interpolation - a claim that the space between roads is reachable, which it is not. Any 2D region is a rendering fiction; the question is only which construction is cheapest and most robust.
Two candidate constructions were considered.
Rejected: polygon buffer and union of the exported band geometry. The SVG
exporter already groups edges into one <path> per colour band, but that path
is ~100k disjoint line segments, not a closed shape - there is no boundary to
take. Producing a region means buffering every segment by a radius and unioning
the results: a full 2D boolean geometry engine (Clipper2/Martinez) over ~100k
shapes per band, with floating-point robustness as the classic failure mode and
vertex counts that explode along every cul-de-sac. It also does not avoid an
arbitrary parameter, since the buffer radius plays exactly the role a raster
cell size would.
Superseded 2026-09-02: triangulate the nodes, then classify triangles.
The choice below was made between buffer-and-union and a raster, and never considered the construction that actually suits this. Buffer-and-union really is a bad idea, for the reasons given. A raster is a worse one than the section admitted: its cell size is not merely “an arbitrary parameter”, it makes the drawing resolution-dependent, so the same map has to be re-derived for every output size and mottles as you zoom. A poster and a screen ended up with different geometry for the same isochrone.
Delaunay-triangulate the node positions instead. A triangle is reachable by the time its slowest corner is, so each falls in exactly one band, and merging the triangles of a band is combinatorial rather than geometric: an edge shared by two same-band triangles cancels against its own reverse, and what is left is the boundary, already correctly wound. No boolean geometry, no tolerance, no cell size. Holes and disjoint components need no special handling at all - a park with no paths simply has no triangles, and a transit isochrone landing in several places simply produces several rings.
The triangulation depends only on where the nodes are, so it is built once per region and kept: Berlin’s 578,000 nodes take about 390 ms and yield 1.15 million triangles, after which a routing run only reclassifies them. Panning and zooming touch no geometry at all.
One length survives - the span above which a triangle is judged to bridge a gap rather than cover ground - but unlike a cell size it is in metres, it is resolution-independent, and Delaunay’s habit of maximising the minimum angle is what makes it meaningful: a river or the edge of the network shows up as a long thin triangle, and a city block does not.
See web/src/render/delaunay.js and web/src/render/band-regions.js.
Rejected in the original plan, and wrong: rasterise the per-band vector geometry, then contour it. The vector edge geometry remains the source of truth; a raster is used only as a transient rendering intermediate, sized to the output (a poster is ~4576px wide), then discarded. Marching squares over that gives closed rings directly.
Note this is not “use the rasteriser as the source of truth” - the existing
paintAllReachableEdgeInterpolationsToTravelTimeGrid walks the same
interpolated edge geometry and writes it into cells, so raster and vector are
the same information at different fidelity, not rival models. What this plan
avoids is the persistent, graph-sized grid; see “Interaction with grid
allocation” below.
timeToFillPatternThe monochrome analogue of timeToColour, and it slots in at the same seam.
Returns a hatch specification per band rather than an RGB triple.
Design constraints:
patternUnits="userSpaceOnUse", so hatches do not rescale with the shape
they fill. Otherwise a large band and a small band acquire different apparent
textures.Bands repeat every cycleMinutes. Filled and hatched, band 6 is
indistinguishable from band 1 - worse than with colour, where repetition at
least reads as repetition.
Decided 2026-08-31: labels, and the cycle stays.
Contour labels (“36 min”) are mandatory, on the same footing as isobar values on a weather chart or height figures on an Ordnance Survey sheet - and for the same reason. A contour map without values on the contours is a picture of a gradient, not a measurement. Capping at a single cycle was rejected outright: an OS sheet does not stop drawing at 500 m, and neither should this.
An intermediate proposal - drop the modulo in monochrome so that bands run monotonically over one cycle with an open-ended top band - was also rejected. It buys unambiguity at the price of the map’s range, which is the wrong trade when labels can buy the same unambiguity and keep the range.
So monochrome keeps the cyclic structure the colour palette has: a repeating cycle of n fill patterns, with the labels telling you which cycle you are in. Both attached references do exactly this - Paullin’s “Rates of Travel” plates label every contour (“6wks.”, “1 day”, “36hrs.”) and a two-tone alternating fill with numbered regions carries the repeat.
n is deliberately not fixed here. Patterns need high contrast between
neighbours, and it may turn out that the honest answer at 1 bit is n=2 -
literally “none” and “some”. That is not guessable from first principles, so
the encoding takes n as a parameter and the number is settled by looking at
real output on real paper.
When this plan was written, initializeMapData allocated two
full-graph-sized grids eagerly and wrote to every cell - about 4 GB on
Portsmouth, whose grid was then sized to a ferry route reaching France. That
has since been fixed on three fronts: ferries no longer inflate a region’s
extent, the grids are sized to the visible view rather than the graph, and
they are allocated only for a renderer that will actually read them, which a
WebGL renderer never does.
The constraint that fix implies for this plan still stands, and is the reason the contouring raster must be transient, sized to the output, and discarded rather than a persistent graph-sized buffer. A contouring pass that allocated per region, at graph resolution, for the lifetime of the map would reintroduce exactly what was removed.
Rasterise the output and look at it at 1:1. web/tools/render-monochrome.mjs
writes an SVG; rsvg-convert -w 1500 -b white out.svg -o out.png turns it into
something that can actually be inspected. This is not a nicety. Reviewing the
SVG in a viewport that downscaled it hid, in turn: a water pattern that was
never drawn at all (a sub-pixel stroke snapped away by crispEdges), ferry
routes drawn as roads and striking off the sheet, and a coastline registered
kilometres from the road network it describes. Each was obvious within seconds
of looking at a raster, and invisible for two rounds without one.
Note also that a “detail view” of an SVG is meaningless - it is vector, the reader can zoom. A detail raster is worth producing.
“Looks fine to me” is how the current palette shipped, so:
Anything drawn alongside the isochrone must go through
projectBoundaryBasemapToGraphPaths. The boundary payload carries its own
projected origin and extent, and they are not the graph’s - for Portsmouth the
origins differ by 2.4 km east and 14.2 km north, and the extents by a factor of
1.23. Rescaling one onto the other, rather than projecting it, puts the
coastline nowhere near the roads it belongs to.
timeToFillPattern plus the coverage test; SVG <pattern> definitions.