Isochrone

PLAN.md — Isochrone Web App (Revised)

Goal: A client-side web app that computes isochrones from a preprocessed OpenStreetMap graph, loaded as a compact binary. Originally scoped to walking in Berlin; as delivered it covers sixteen regions, the walk, cycle, drive, ferry and public-transport modes, and renders isochrone edges on the GPU with per-endpoint time interpolation, over a preprocessed district-boundary basemap. The 10 m/pixel raster described in the earlier phases below survives only as the fallback for browsers without WebGL.

Status of the transit work. Phase 11 was written when GTFS/CSA support was architected but stubbed. It has since shipped: Berlin (VBB) and Adelaide (Adelaide Metro) both carry timetables, and the Connection Scan runs at query time in the browser. The individual checkboxes below record what did and did not land.

Projection. Each region declares its own projected EPSG code in data_pipeline/regions.json, and the code is stored in the binary header so the client knows what it is reading. Berlin uses UTM zone 33N (EPSG:25833), whose scale factor at the central meridian is 0.9996 — 1 projected metre = 1.0004 m of true surface travel, an error well under 0.1% across the city, and symmetric in both axes, so N pixels horizontally is N×10 m of surface travel to sub-pixel accuracy across the whole extent. The same reasoning governs the choice made for each other region; the projection maths are isolated to a single module.

Estimates assume a junior developer familiar with JavaScript and basic GIS concepts.


Phase 1 — Project Setup

1.1 Create repository structure

Estimated time: 30 min

Tasks


1.2 Create Python development environment

Estimated time: 45 min

Tasks


1.3 Configure vanilla JavaScript runtime

Estimated time: 45 min

Decision: Use native browser ES modules. No Node.js build toolchain, no bundler, no npm scripts.

1.3.1 Set module loading strategy

Estimated time: 15 min

Tasks

1.3.2 Define static-serving workflow

Estimated time: 15 min

Tasks

1.3.3 Create source layout

Estimated time: 15 min

Tasks


Phase 2 — Data Exploration and Schema Design

Schema is designed after data is understood, not before.

2.1 Explore OSM data for Berlin

Estimated time: 45 min

2.1.1 Fetch Berlin OSM extract via Overpass

Estimated time: 15 min

Tasks

2.1.2 Survey walkable way tags

Estimated time: 30 min

Tasks


2.2 Design binary graph schema

Estimated time: 45 min

Informed by exploration above.

File layout

[ Header: 64 bytes ]
[ Node table: N_nodes × 16 bytes ]
[ Edge table: N_edges × 12 bytes ]
[ Stop table: N_stops × 24 bytes ]        ← zeroed in MVP; populated post-MVP
[ Transit edge table: N_tedges × 20 bytes ] ← zeroed in MVP; populated post-MVP

Header (64 bytes)

Offset Type Field
0 uint32 magic 0x49534F43 (“ISOC”)
4 uint8 version (=2)
5 uint8 flags (bit 0 = has_transit)
6 uint16 reserved
8 uint32 N_nodes
12 uint32 N_edges
16 uint32 N_stops
20 uint32 N_tedges
24 float64 origin_easting (m, UTM)
32 float64 origin_northing (m, UTM)
40 uint16 epsg_code (e.g. 25833)
42 uint16 grid_width_px
44 uint16 grid_height_px
46 uint16 reserved
48 float32 pixel_size_m (= 10.0)
52 uint32 node_table_offset
56 uint32 edge_table_offset
60 uint32 stop_table_offset
(ext) uint32 tedge_table_offset (at byte 60 in v1 header extension)

64 bytes total (padded to 64 for alignment).

Node record (16 bytes)

Offset Type Field
0 int32 x_m (easting offset from origin, metres, signed)
4 int32 y_m (northing offset from origin, metres, signed)
8 uint32 first_edge_index (index into edge table)
12 uint16 edge_count
14 uint16 flags (bit 0 = is_stop_attachment)

Edge record (12 bytes, v2)

Offset Type Field
0 uint32 target_node_index
4 uint16 cost_seconds (walking, uint16 → max ~18 min per edge, sufficient)
6 uint16 flags (bit 0 sidewalk_present; bits 8..11 carry oneway/roundabout/directional-speed tag-presence markers for later restriction logic)
8 uint32 packed metadata: bits 0..7 mode_mask, bits 8..15 road_class_id, bits 16..31 maxspeed_kph

Tooling reads both v1 and v2. Writers emit v2.

Stop record (24 bytes, post-MVP)

Offset Type Field
0 int32 x_m
4 int32 y_m
8 uint32 nearest_node_index
12 uint32 first_tedge_index
16 uint16 tedge_count
18 uint8 transport_type (0=bus,1=tram,2=subway,3=rail)
19 uint8 reserved
20 uint32 name_offset (into string table, post-MVP extension)

Transit edge record (20 bytes, post-MVP)

Offset Type Field
0 uint32 from_stop_index
4 uint32 to_stop_index
8 uint32 departure_seconds_from_midnight
12 uint16 travel_seconds
14 uint16 route_id (internal index)
16 uint32 service_day_mask (bitmask: bit 0=Mon … bit 6=Sun)

Transit edges are sorted by departure_seconds_from_midnight to enable CSA (see post-MVP phases).


2.3 Implement binary writer utilities

Estimated time: 45 min

2.3.1 Write Python binary writer module

Estimated time: 25 min

Tasks

2.3.2 Write reader test script

Estimated time: 20 min

Tasks


Phase 3 — OSM Walking Graph Extraction

3.1 Parse Overpass JSON extract and filter walkable ways

Estimated time: 1 hour

3.1.1 Load Overpass JSON and iterate ways

Estimated time: 20 min

Tasks

3.1.2 Load referenced nodes

Estimated time: 20 min

Tasks

3.1.3 Handle missing node references

Estimated time: 20 min

Tasks


3.2 Project coordinates to UTM 33N

Estimated time: 30 min

Tasks

For Berlin: bounding box is roughly 45 km × 38 km → grid is ~4 500 × 3 800 px → ~17 megapixels. At 4 bytes/pixel (RGBA), the pixel buffer is ~68 MB — within browser working memory. The canvas element will be this size but only the visible viewport is painted to screen.


3.3 Build adjacency list

Estimated time: 1 hour

3.3.1 Extract directed edges from ways

Estimated time: 25 min

Tasks

3.3.2 Compute edge walking cost

Estimated time: 20 min

Tasks

3.3.3 Sort and index adjacency list

Estimated time: 15 min

Tasks


3.4 Graph simplification

Estimated time: 1 hour 30 min

Simplification reduces node count by ~60–70 %, shrinking the binary graph and speeding up routing.

3.4.1 Tag stop-attachment nodes as non-mergeable

Estimated time: 10 min

Tasks

3.4.2 Detect degree-2 nodes eligible for merging

Estimated time: 20 min

Tasks

3.4.3 Merge linear chains

Estimated time: 30 min

Tasks

3.4.4 Reindex nodes and edges

Estimated time: 30 min

Tasks


3.5 Validate walking graph

Estimated time: 30 min

Tasks


Phase 4 — Binary Graph Export (MVP: Walking Only)

4.1 Assemble and serialise binary graph

Estimated time: 45 min

4.1.1 Write header

Estimated time: 15 min

Tasks

4.1.2 Write node and edge tables

Estimated time: 20 min

Tasks

4.1.3 Write empty stop and transit tables

Estimated time: 10 min

Tasks


4.2 Validate binary output

Estimated time: 30 min

Tasks


Phase 5 — Web Client Shell

5.1 Create HTML application skeleton

Estimated time: 30 min

Tasks


5.2 Implement district-boundary basemap

Estimated time: 1 hour

Use data_pipeline/output/berlin-district-boundaries-canvas.json generated from OSM administrative boundaries. OSM attribution remains required (© OpenStreetMap contributors).

5.2.1 Load and map boundary JSON

Estimated time: 25 min

Tasks

5.2.2 Draw boundary basemap

Estimated time: 35 min

Tasks


5.3 Implement binary graph loader

Estimated time: 1 hour

5.3.1 Fetch binary file with progress

Estimated time: 25 min

Tasks

5.3.2 Parse TypedArrays from ArrayBuffer

Estimated time: 35 min

Tasks


Phase 6 — Pixel Grid and Canvas Rendering

Berlin at 10 m/pixel: ~4 500 × 3 800 px ≈ 17 Mpx. The raster buffer is an ImageData object of this size maintained in JS memory and blitted to canvas on each update.

6.1 Allocate and manage pixel grid

Estimated time: 30 min

Tasks


6.2 Map graph nodes to grid cells

Estimated time: 20 min

Tasks


6.3 Render reachable cells

Estimated time: 45 min

6.3.1 Colour mapping

Estimated time: 20 min

Tasks

6.3.2 Paint reachable nodes

Estimated time: 25 min

Tasks


6.4 Blit isochrone layer onto basemap

Estimated time: 20 min

Tasks


Phase 7 — Progress Indication

Routing on Berlin’s full graph takes 0.5–2 s depending on time limit. Progress indication is required for both the initial load and each routing run.

7.1 Loading progress UI

Estimated time: 30 min

Tasks


7.2 Routing progress indication

Estimated time: 45 min

Estimated time: 30 min

Tasks

7.2.2 Routing status text

Estimated time: 15 min

Tasks


Phase 8 — Routing Engine

8.1 Implement binary min-heap priority queue

Estimated time: 45 min

Tasks


8.2 Implement walking Dijkstra

Estimated time: 1 hour

8.2.1 Initialise search structures

Estimated time: 20 min

Tasks

8.2.2 Implement node expansion loop

Estimated time: 25 min

Tasks

8.2.3 Integrate with time-sliced rendering

Estimated time: 15 min

Tasks


8.3 Stub transit integration point

Estimated time: 20 min

Tasks


Phase 9 — Map Interaction

9.1 Convert click coordinates to graph nodes

Estimated time: 45 min

9.1.1 Map canvas pixel to UTM coordinates

Estimated time: 20 min

Tasks

9.1.2 Find nearest graph node

Estimated time: 25 min

Tasks


9.2 Wire click to routing engine

Estimated time: 30 min

Tasks


9.3 Time control

Estimated time: 20 min

Tasks


Phase 10 — Build, Compression, and Deployment

10.1 Compress binary graph

Estimated time: 20 min

Tasks


10.2 Production static package

Estimated time: 20 min

Tasks


10.3 Deploy to GitHub Pages

Estimated time: 30 min

Tasks


10.4 Post-MVP: Multimodal Road Schema + Extraction Foundation

Estimated time: 4 hours 30 min

This phase adds the schema and extraction prerequisites for bike/car mode support and speed-aware routing. It intentionally starts at data/model level before UI and algorithm changes.

10.4.1 Define binary schema v2 for road-mode routing

Estimated time: 45 min

Tasks

10.4.2 Expand OSM extraction tags for mode/speed

Estimated time: 1 hour

Tasks

10.4.3 Normalize speed and access semantics

Estimated time: 1 hour 15 min

Tasks

10.4.4 Export and validation updates

Estimated time: 50 min

Tasks

10.4.5 Runtime read path scaffolding (no UI yet)

Estimated time: 40 min

Tasks


10.5 Routing Hot-Path Performance Follow-Ups

Estimated time: 2 hours 30 min

Tasks

Benchmark note (2026-03-11):

10.5.1 Planned (Not Implemented): Parallel SSSP Direction for Multi-Mode (Item 6)

Estimated time: 5 hours

Tasks


10.6 Post-MVP: Multi-Location OSM Fetch Generalization

Estimated time: 3 hours 45 min

This phase generalizes the current Berlin-specific Overpass fetch flow into a reusable multi-location pipeline stage without changing routing internals yet.

Implementation note: this shipped with a different concrete design than originally planned below — a single data_pipeline/regions.json registry (array of region entries) instead of one manifest file per location, and a region-data.py CLI (fetch / build / all subcommands) instead of a bare parameterized shell script. The design goals (deterministic selectors, templated queries, tested, non-Berlin fixtures, documented onboarding) are all met; the file layout is just simpler than planned. Tasks below are checked against what actually exists.

10.6.1 Add location manifests

Estimated time: 40 min

Tasks

10.6.2 Add Overpass query templating

Estimated time: 45 min

Tasks

10.6.3 Replace hardcoded fetch script with parameterized fetch entrypoint

Estimated time: 45 min

Tasks

10.6.4 Standardize per-location artifact layout

Estimated time: 25 min

Tasks

10.6.5 Add location-aware pipeline wiring

Estimated time: 25 min

Tasks

10.6.6 Generalize Overpass survey tooling

Estimated time: 20 min

Tasks

10.6.7 Tests and migration docs

Estimated time: 25 min

Tasks

Currently configured regions (data_pipeline/regions.json, 16): berlin, paris, cologne, athens, london, rome, portsmouth, rhode-island, luxembourg-country, singapore, adelaide, nairobi, mexico-city, ottawa, zurich-canton, cyprus. Deployed (present in web/src/data/locations.json, i.e. actually fetched/built and shipped, 12): berlin, paris, cologne, london, rome, rhode-island, luxembourg-country, singapore, mexico-city, ottawa, zurich-canton, cyprus.

All 16 configured regions are deployed as of 2026-08-20. The last four were resolved as follows:


10.7 Post-MVP: Basemap Context Layers (Coastal Water, Forest, Inland Water, Waterways, Airports)

Estimated time: 6 hours

Not in the original plan — added in response to user feedback wanting more visual context on the map. Rendering-only: none of this affects the routing graph, edge costs, or reachability. See the “Note On Public Polygons” section below — polygon context is deliberately not treated as uniformly walkable.

10.7.1 Coastal water polygons (opt-in, external dataset)

Estimated time: 2 hours

Tasks

10.7.2 Forest, inland water, waterway, and airport context (always-on, same Overpass fetch)

Estimated time: 2.5 hours

Tasks

10.7.3 Multipolygon relation support for water and airports

Estimated time: 1.5 hours

Tasks

Verification note (2026-08-06): confirmed against London — the tidal Thames (previously rendered as a bare, gapped centerline with an unfilled border) is a single 89-member relation and now renders as a continuous filled body with correct island holes; Heathrow (a relation) and 6 smaller airfields (ways) render as a new muted airport context layer. Rolled out to the other deployed regions (berlin, paris, rome, luxembourg-country, cologne, rhode-island) the same day.

Update: the “Water” transport mode described as deferred above was subsequently implemented — see 10.8.


10.8 Water/ferry transport mode

Implements the routing half of the “Water” mode deferred in 10.7.3 above — the rendering-only waterway/sea layers already existed; this adds an actual connectivity graph and a fourth selectable mode.

Tasks


Phase 11 — Post-MVP: Global Public Transit Data Pipeline

This phase is explicitly deferred from MVP. Goal: support public transport data ingestion and routing for any region, not just Berlin/Germany, by separating source formats from a canonical routing format.

Implementation note (Berlin pilot, landed): the sections below marked [x] reflect a deliberately trimmed pass — GTFS static only (no GTFS-RT/NeTEx/SIRI), Berlin only, and weekday-recurring calendar patterns rather than full calendar_dates.txt exception fidelity (single-date holiday overrides aren’t modeled) — confirmed against user intent (“proceed with Berlin specifically so I can test the result”, later extended per user request to cover every date the feed actually supports rather than one locked reference day). See data_pipeline/src/isochrone_pipeline/gtfs_transit.py, the transitFeed block in data_pipeline/regions.json, and runConnectionScanFromWalkingReachableStops/runWalkingIsochroneFromSourceNode in web/src/app.js. Full feed-registry generalization (11.1, 11.7) and realtime/NeTEx adapters (11.2.2, 11.2.3) remain deferred.

11.1 Define feed registry and region config

Estimated time: 45 min

Tasks


11.2 Source adapters (raw formats)

Estimated time: 3 hours

11.2.1 GTFS static adapter

Estimated time: 1 hour 15 min

Tasks

11.2.2 GTFS-Realtime adapter (optional overlay)

Estimated time: 45 min

Tasks

11.2.3 NeTEx/SIRI adapter scaffold

Estimated time: 1 hour

Tasks


11.3 Canonical internal transit model (useful processing format)

Estimated time: 2 hours

Tasks


11.4 Validation and quality gates

Estimated time: 1 hour 30 min

Tasks


11.5 Build routing-optimized transit structures

Estimated time: 2 hours

Tasks


11.6 Runtime integration in web router

Estimated time: 2 hours

Tasks


11.7 Multi-region onboarding process

Estimated time: 1 hour

Tasks


Phase 12 — Post-MVP: UX and Sharing Enhancements

This phase was originally deferred. Based on user feedback, 12.6 (map zoom/pan) is now the next planned UX task; the rest remain lower-priority follow-up items.

12.1 Clarify cyclic legend semantics

Estimated time: 45 min

Tasks

Decision note: use equal-width segmentation across the cycle (five 20% bands) for predictable looping behavior and clearer legend interpretation.

12.2 Add theme support

Estimated time: 1 hour

Tasks

12.3 Export rendered result to SVG

Estimated time: 2 hours

Tasks

12.4 Expose routability counts

Estimated time: 45 min

Tasks

12.5 Persist last interaction in URL

Estimated time: 1 hour

Tasks

12.6 Add map zoom and pan controls

Estimated time: 4 hours

Goal: add standard camera controls without conflating camera movement with origin selection. Routing remains world-space; pan/zoom must only change view state.

12.6.1 Define camera model and transform boundaries

Estimated time: 45 min

Tasks

12.6.2 Re-separate camera movement from origin selection

Estimated time: 35 min

Tasks

12.6.3 Desktop/laptop gesture plan

Estimated time: 55 min

Tasks

12.6.4 Mobile/tablet gesture plan

Estimated time: 55 min

Tasks

12.6.5 Rendering and UI integration

Estimated time: 30 min

Tasks

12.6.6 State persistence, tests, and verification

Estimated time: 20 min

Tasks

12.7 Configurable walk/bike speeds

Not in the original plan — added from user feedback (“we should probably also add walking/cycling speed options”). Estimated time: 2 hours

Tasks

12.8 Single departure date+time control, and Public transit grouped with Transport modes

Not in the original plan — added from user feedback questioning the departure-time UX and asking “is transit as much a movement mode as ferries?”. Estimated time: 1.5 hours

Tasks


Architectural Notes

On Web Workers (point 7)

Web Workers are not planned at any phase. The routing loop is time-sliced via requestAnimationFrame (Phase 7.2), which gives adequate UI responsiveness without the complexity of cross-thread ArrayBuffer transfer, Worker lifecycle management, or the risk of needing SharedArrayBuffer (which requires specific COOP/COEP HTTP headers). If profiling after Phase 8 reveals that even 8 ms slices cause dropped frames (unlikely on a modern device for a 30-min isochrone), a Worker can be added then — but there is no basis for scheduling that work now.

On future region support

The pipeline is parameterised from Phase 3.2 onward: --epsg, --input, --output flags on all pipeline scripts. The binary header stores the EPSG code so the JS client knows which projection was used. As of Phase 10.6, the actual way to add a region is data_pipeline/regions.json (one entry: relation selector, EPSG, admin level) plus ./data_pipeline/region-data.py fetch|build --only <id> — see docs/region-data-pipeline.md for the full onboarding walkthrough. Optionally add a transit feed (GTFS static/GTFS-RT first, with NeTEx/SIRI adapters planned in Phase 11). No code changes are needed for regions using any UTM zone or national grid projection supported by pyproj.


Total Estimated Development Time (MVP: Phases 1–10)

Phase Description Estimated Time
1 Project setup + vanilla JS runtime 2 h
2 Data exploration + schema design + writer 2.5 h
3 OSM extraction + graph build 4.5 h
4 Binary export + validation 1.25 h
5 Web client shell + boundary basemap + loader 2.5 h
6 Pixel grid + canvas rendering 1.75 h
7 Progress indication 1.25 h
8 Routing engine 2.5 h
9 Map interaction 1.5 h
10 Build + deploy 1.25 h

MVP total: ~21 hours for a junior developer

Post-MVP adds approximately 36–40 hours of development:


Expected Outputs

MVP artifacts

Post-MVP additions


Note On Public Polygons

Public polygons (parks, greens, woods, recreation areas) are useful for context and optional future area-aware routing, but movement inside them is neither always free nor always represented by dense internal paths. Densely wooded and otherwise inaccessible sub-areas exist; in other cases only sparse walkable tracks are mapped. The routing model must therefore treat polygon-level walkability as conditional and constrained, not uniformly traversable.

Realized (partially) in Phase 10.7: forest and airport polygons are now rendered as basemap context (see 10.7.2). This is rendering-only — the caveat above still holds in full: neither forest interiors nor airport grounds (“mostly, but not entirely, non-reachable”) feed into the routing graph or edge costs in any way.


Final Verification (Run At End)