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.
Estimated time: 30 min
Tasks
/data_pipeline, /web, /docsPLAN.md, README.mdEstimated time: 45 min
Tasks
python -m venv .venv)requests, pyproj, numpy, struct (stdlib)requirements.txtEstimated time: 45 min
Decision: Use native browser ES modules. No Node.js build toolchain, no bundler, no npm scripts.
Estimated time: 15 min
Tasks
<script type="module" src="./src/app.js"> in index.htmlpython -m http.server)Estimated time: 15 min
Tasks
/web/index.html)/web/src/ without transpilationEstimated time: 15 min
Tasks
/web/src/ for ES module source files/web/index.html + /web/src/ as deployable source (no build output dir required)src/app.js with a single console.logSchema is designed after data is understood, not before.
Estimated time: 45 min
Estimated time: 15 min
Tasks
docs/berlin_overpass_routing_query.ql with data_pipeline/fetch-data.sh (superseded by the generalized docs/overpass_routing_query.sh + region-data.py fetch in Phase 10.6)/data_pipeline/input/berlin-routing.osm.jsonEstimated time: 30 min
Tasks
highway=* values present in the Overpass JSON extractEstimated time: 45 min
Informed by exploration above.
[ 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
| 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).
| 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) |
| 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.
| 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) |
| 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).
Estimated time: 45 min
Estimated time: 25 min
Tasks
BinaryWriter class wrapping bytearraywrite_u8, write_u16, write_u32, write_i32, write_f32, write_f64pad_to(alignment) — fills to next multiple of alignmentEstimated time: 20 min
Tasks
Estimated time: 1 hour
Estimated time: 20 min
Tasks
/data_pipeline/input/berlin-routing.osm.jsonway objects with a pedestrian-usable highway tagaccess, foot, oneway, oneway:foot, sidewalkbarrier=*, highway=crossing, railway=level_crossing, entrance=*Estimated time: 20 min
Tasks
node elements whose IDs are in the reference set{osm_id: (lat, lon)}Estimated time: 20 min
Tasks
Estimated time: 30 min
Tasks
pyproj.Transformer.from_crs("EPSG:4326", "EPSG:25833")origin_easting, origin_northing as minimum cornergrid_width_px = ceil((max_e - min_e) / 10), grid_height_px = ceil((max_n - min_n) / 10)(x_m, y_m) offsets from origin (i32, max value ~50 000 for Berlin → fits in int32 with large margin)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.
Estimated time: 1 hour
Estimated time: 25 min
Tasks
oneway:foot=yes ways emit only forward edgeaccess=private/no, foot=no) and keep sidewalk=* metadata for later refinementscrossing, level_crossing, entrance, barrier) so later routing logic can apply penalties/filters without re-parsing OSMEstimated time: 20 min
Tasks
round(dist_m / 1.39) secondsEstimated time: 15 min
Tasks
first_edge_index and edge_count per nodeEstimated time: 1 hour 30 min
Simplification reduces node count by ~60–70 %, shrinking the binary graph and speeding up routing.
Estimated time: 10 min
Tasks
is_stop_attachmentEstimated time: 20 min
Tasks
is_stop_attachment, not a dead-endEstimated time: 30 min
Tasks
Estimated time: 30 min
Tasks
Estimated time: 30 min
Tasks
Estimated time: 45 min
Estimated time: 15 min
Tasks
N_stops = 0, N_tedges = 0flags bit 0 = 0 (no transit)Estimated time: 20 min
Tasks
Estimated time: 10 min
Tasks
Estimated time: 30 min
Tasks
Estimated time: 30 min
Tasks
index.html with: <canvas id="map">, time-of-day input (default 08:00), loading overlay <div id="loading">src/app.js via <script type="module">Estimated time: 1 hour
Use data_pipeline/output/berlin-district-boundaries-canvas.json generated from OSM administrative boundaries. OSM attribution remains required (© OpenStreetMap contributors).
Estimated time: 25 min
Tasks
berlin-district-boundaries-canvas.jsoncoordinate_space (x_origin, y_origin, width, height, axis info) and features[].pathsEstimated time: 35 min
Tasks
canvas#boundaries)Estimated time: 1 hour
Estimated time: 25 min
Tasks
fetch('graph-walk.bin') with response.body stream readerContent-LengthEstimated time: 35 min
Tasks
DataViewInt32Array view for coordinates, Uint32Array for edge indicesUint32Array for targets, Uint16Array for costsBerlin 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.
Estimated time: 30 min
Tasks
Uint8ClampedArray of size grid_width_px × grid_height_px × 4 (RGBA)clearGrid(): fill alpha to 0 (fully transparent)setPixel(x_px, y_px, r, g, b, a): bounds-checked writeEstimated time: 20 min
Tasks
px_x = floor(node.x_m / 10), px_y = floor(node.y_m / 10)Uint16Array nodePixelX[N], nodePixelY[N]Estimated time: 45 min
Estimated time: 20 min
Tasks
timeToColour(seconds) returns [r, g, b]Estimated time: 25 min
Tasks
dist[i] < Infinity, call setPixel with colour mapped from travel timeputImageData to canvas (composited over boundary basemap using a second canvas layer with globalAlpha)Estimated time: 20 min
Tasks
canvas#boundaries (bottom) + canvas#isochrone (top, position: absolute)putImageData for the current pixel gridRouting 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.
Estimated time: 30 min
Tasks
<div style="width: X%">)Estimated time: 45 min
Estimated time: 30 min
Tasks
requestAnimationFrame to yieldEstimated time: 15 min
Tasks
Estimated time: 45 min
Tasks
MinHeap class: push(nodeIndex, cost), pop() → {nodeIndex, cost}, decreaseKey(nodeIndex, newCost)Float64Array for costs, Int32Array for node indices, Int32Array for position lookup (required for decreaseKey)Estimated time: 1 hour
Estimated time: 20 min
Tasks
Float32Array dist[N_nodes] initialised to InfinityUint8Array settled[N_nodes] initialised to 0dist[source] = 0; push source to heapEstimated time: 25 min
Tasks
dist[source] + edge_cost < dist[target]time_limit_secondsEstimated time: 15 min
Tasks
Estimated time: 20 min
Tasks
// POST-MVP: run CSA here, then re-run Dijkstra from transit-reached stopsN_stops from header; if 0, skips silentlyEstimated time: 45 min
Estimated time: 20 min
Tasks
easting = origin_easting + px * 10, northing = origin_northing + (grid_height - 1 - py) * 10 (y-axis inversion so north remains up on canvas)Estimated time: 25 min
Tasks
Estimated time: 30 min
Tasks
canvas.addEventListener('click', ...) reads pixel coordinatescancelled flag checked each time-slice), clear pixel grid, start freshEstimated time: 20 min
Tasks
Estimated time: 20 min
Tasks
.bin file: gzip -9 berlin_graph.bin → berlin_graph.bin.gz_headers file) to serve with Content-Encoding: gzip if available; JS runtime also supports raw .gz payloads without this header..gz file and decompresses before binary parsing.Estimated time: 20 min
Tasks
index.html loads src/app.js as ES module without bundlingEstimated time: 30 min
Tasks
/web/ static files plus graph-walk.bin.gzEstimated 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.
Estimated time: 45 min
Tasks
version = 2) and document backward compatibility policy (v1 read support in tooling; web runtime can require v2 once migrated)mode_mask, maxspeed_kph, road_class_id)walk_s, bike_s, car_s)Estimated time: 1 hour
Tasks
bicycle, cycleway, oneway:bicyclemotor_vehicle, vehicle, onewaymaxspeed, maxspeed:forward, maxspeed:backwardjunction, access, service, surface, tracktype (for fallback speed heuristics)WayCandidate into adjacency/export stages (no silent dropping)% edges with explicit maxspeed)Estimated time: 1 hour 15 min
Tasks
maxspeed parser (numeric + unit variants, e.g. 50, 30 mph, walk)maxspeed:forward/backward) on directed edgesEstimated time: 50 min
Tasks
mode_mask != 0 for all exported edges0 < maxspeed_kph <= 200)Estimated time: 40 min
Tasks
Estimated time: 2 hours 30 min
Tasks
MinHeap.popInto(...) and switching expandOne() to reuse a single pop entry objectexpandOne() (edgeTraversalCostSeconds[edgeIndex]) with direct compute-on-NaN fallback to avoid per-edge helper call overheadBenchmark note (2026-03-11):
decrease-key ~132 ms avg vs duplicate-push ~136 ms avg.Estimated time: 5 hours
Tasks
walk+bike, walk+car, all) with fixed seeds and publish per-phase timing outputs.SharedArrayBuffer + COOP/COEP) and document deployment implications.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.
Estimated time: 40 min
Tasks
data_pipeline/regions.json holds one array of region entries (not per-file manifests) with fields: id, name, localizedNames, graphFileName, boundaryFileName, locationRelation, subdivisionAdminLevel, subdivisionDiscoveryModes, epsg, graphBinaryFileName, graphSummaryFileName, boundaryResolution, boundaryUnits, coastal, coastSource — parsed and validated by load_region_specs() in region_pipeline.py--only <id>[,<id>...] limits processing to specific regions; default (no --only) processes every region in the fileEstimated time: 45 min
Tasks
docs/overpass_routing_query.sh and docs/overpass_boundary_query.sh are location-agnostic templates parameterized by --location-label, --location-relation, --subdivision-admin-level, --subdivision-discovery-modesregion_pipeline.render_query() renders the concrete query text per region before fetching (logged to stderr for reproducibility/debugging, not written to a persisted .ql file per location)locationRelation selector is used as-is (relation-id-first where the region config specifies one, e.g. Berlin/Athens/London/Portsmouth), with name/wikidata guard tags where availableEstimated time: 45 min
Tasks
data_pipeline/region-data.py fetch --only <id> --components routing,boundary replaces the old Berlin-only fetch-data.sh; components also accept way/ways/boundaries aliasesset -euo pipefail) preserved in the query templates; fetch_overpass_json() fails loudly on non-2xx/empty responses and saves the failed query + response for debugging--input-dir/--output-dir override the base directory, not per-file paths)Estimated time: 25 min
Tasks
data_pipeline/input/<id>-routing.osm.json, <id>-district-boundaries.osm.json; outputs as data_pipeline/output/<id>-graph.bin(.gz), <id>-district-boundaries-canvas.json, <id>-graph-summary.jsongraph-walk.bin(.gz) graph filenames (configured via graphFileName/graphBinaryFileName in its region entry) since that is what the deployed web runtime references by defaultEstimated time: 25 min
Tasks
region-data.py fetch|build|all --only <id> resolves relation selector, epsg, admin level, and file paths from regions.json — no per-script --location flag needed since the CLI itself is the location-aware entrypoint--epsg/output naming stay explicit per-region-entry (not overridable per-invocation); this matches the deterministic/reproducible-build goal better than ad hoc manual overridesEstimated time: 20 min
Tasks
osm_json_survey.py/overpass_survey.py operate on whatever input file is passed in, with no hardcoded Berlin relation IDsEstimated time: 25 min
Tasks
data_pipeline/tests/test_region_pipeline.py covers manifest (region spec) validation, test_fetch_queries.py covers query rendering for both discovery modes, both use Paris/London/Luxembourg fixtures throughout (not Berlin-only)docs/region-data-pipeline.md (the docs/locations.md filename in the original plan didn’t end up matching what shipped); legacy Berlin filenames are called out explicitly as a compatibility path, not hiddenCurrently 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:
athens — the routing query had been returning zero elements because the selector filtered on name="Athens"/Q1524, but rel(1370736) carries name="Δήμος Αθηναίων"/Q1224979. Corrected; the fixed selector returns 73,846 elements. Note the relation is the municipality (9 x 10 km), not the Athens urban area.portsmouth — the earlier boundary failure was a transient Overpass dispatcher timeout and succeeded on retry. Its grid is 206 x 248 km because its ferries reach the Isle of Wight, France and the Channel Islands; the viewport still frames the city, which is what decoupling the viewport from the grid (12.x) was for.adelaide — rescoped from the CBD council (Q1094063, 4 x 5 km) to metropolitan Adelaide (rel(11381689), Q5112, 38 x 86 km), and built with its CC BY 4.0 GTFS folded in.nairobi — built; 83,143 nodes over 50 x 32 km.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.
Estimated time: 2 hours
Tasks
data_pipeline/src/isochrone_pipeline/water_polygons.py: clip the OSM coastline water-polygon shapefile (osmdata.openstreetmap.de, ~900 MB) to a region’s bboxdata_pipeline/.cache/water-polygons/ (gitignored) so it is fetched once total, not once per region build"coastal": true (+ optional "coastSource") field on regions.json entries, threaded through region_pipeline.py’s run_build_pipeline as include_coast/coast_source — opt-in specifically because of the external download cost, unlike everything else in this phasewater_features; rendered as a filled layer and exported to SVG (isochrone-sea group)Estimated time: 2.5 hours
Tasks
docs/overpass_boundary_query.sh with an unconditional .naturalArea binding (independent of subdivision discovery mode) selecting way-tagged natural=wood/landuse=forest/natural=water/waterway=river|canal|stream/aeroway=aerodrome — always fetched, no per-region flag, since it rides the same Overpass request as admin boundaries rather than an external downloadforest_features, inland_water_features, waterway_features (with a navigable flag derived from the boat tag: yes/permissive/designated → navigable, no → not, otherwise canals default navigable and rivers/streams default not), and airport_features in boundary_canvas.pyunits/the render transformer, which is None in degrees-mode builds) to drop digitizing-noise sliversEstimated time: 1.5 hours
Tasks
_stitch_ways_into_rings in boundary_canvas.py: greedily reassemble a relation’s outer/inner member ways (frequently split into dozens of segments — the Thames alone is 31 outer + 58 inner) into closed rings by matching shared endpointsnatural=water and aeroway=aerodrome only, and verified live against London (~7 s round trip) — a broad relation-typed area scan (boundary=administrative) was previously found too expensive for London specifically (see 10.6’s subdivisionDiscoveryModes note), so this is a deliberate scope limit, not an oversightnatural=wood/landuse=forest (ways only) — no evidence yet that forest/wood is commonly relation-mapped enough to need itVerification 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.
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
route=ferry) as a separate pass alongside the walkable-network extraction, carrying the duration= tag through for duration-aware speed costing — osm_graph_extract.py.EDGE_MODE_WATER_BIT) and duration-aware or fallback speed — adjacency.py.edge_cost_seconds and the JS computeEdgeTraversalCostSeconds reference) — ferry legs cost at the baked ferry speed regardless of which boarding mode bit matched, since a ferry crossing takes the same time whether you walked, biked, or drove onto it.#mode-select option (now a checkbox in the “Transport modes” group, see 12.8) and rebuild every deployed region’s graph with ferry edges included.grid_width_px/grid_height_px header fields cap extent at 65,535 × 10m/pixel ≈ 655km) — the fixed cutoff was too small for regions whose own coastline exceeds 80km (e.g. Cyprus) — select_ferry_ways_within_grid_budget in osm_graph_extract.py.web/src/core/viewport.js’s resolveFitScale/createDefaultMapViewport.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.
Estimated time: 45 min
Tasks
docs/transit_feed_registry.md or JSON) with per-region metadata: provider, licence URL, update cadence, timezone, and feed format — docs/transit_feed_registry.md surveys candidate GTFS sources and expected licences for all 16 configured regions, ranks them, and records the one-feed-per-region structural limit. Entries are unverified leads, explicitly flagged as needing confirmation at fetch time; Berlin’s and Adelaide’s have been fetched and verified against their publishers.transitFeed block in regions.json (RegionSpec.transit_feed) plus a transit/gtfs fetch+build component (region-data.py fetch|build --components transit), rather than the originally-sketched --region/--transit-feed/--transit-format CLI flags.Estimated time: 3 hours
Estimated time: 1 hour 15 min
Tasks
stops.txt, trips.txt, stop_times.txt, calendar.txt, calendar_dates.txt, routes.txt, agency.txt — data_pipeline/src/isochrone_pipeline/gtfs_transit.py.25:10:00, etc.) into service-day-relative seconds.Estimated time: 45 min
Tasks
Estimated time: 1 hour
Tasks
Estimated time: 2 hours
Tasks
TransitStop/TransitConnection dataclasses in gtfs_transit.py (a trimmed subset: stops + connections derived from trips/stop_times, not separately-persisted routes/services/transfers/agencies tables).epsg) for stop geometry, seconds-since-midnight for times, compact integer indices for stop/route joins. Service-day bitmask field is populated per connection from its real GTFS weekday pattern and consulted at query time (see 11.5’s day-mask indexing entry below).findNearestNodeIndexForModeFromSpatialIndex’s technique), with a 300m attach-radius cutoff and drop-count logging.graph-walk.bin), no intermediate canonical-table dump.Estimated time: 1 hour 30 min
Tasks
arrival/departure non-decreasing along each trip) — not explicitly validated; malformed source rows would surface as CSA scan anomalies rather than a build-time check.dropped_stop_count in graph-binary-summary.json’s transit block).trip -> route/service, connection -> stops/trip) — not done as a standalone gate; would fail loudly downstream instead.graph-binary-summary.json’s transit block reports parsed_stop_count, attached_stop_count, dropped_stop_count, total_stop_count_in_feed, raw_connection_count, date_range, final_stop_count, final_connection_count (Berlin: 7,670 of 10,688 in-extent stops attached, 42,078 in the full feed, 1,892,441 final connections spanning every weekday-recurring service in the feed’s 2026-08-04–2026-12-12 calendar window).Estimated time: 2 hours
Tasks
connections sorted by departure time — a build-time invariant relied on by runConnectionScanFromWalkingReachableStops’s early-exit scan.runConnectionScanFromWalkingReachableStops in web/src/app.js filters each connection’s service_day_mask against the query date’s ISO weekday bit. calendar_dates.txt single-date exceptions (e.g. holiday schedules) are still out of scope — they can’t be expressed as a weekly bitmask and would need a per-date table instead.n_stops/n_tedges/stop_table_offset header fields, flags bit 0 (has_transit), 24-byte stop records and 20-byte transit-edge records in graph_binary.py/binary_reader.py.Estimated time: 2 hours
Tasks
parseGraphBinary parses stop/transit-edge tables into typed-array views, gated on graph.header.nStops > 0 (zero-cost for every non-Berlin region).runConnectionScanFromWalkingReachableStops (pass 1 → CSA scan) feeds a new WASM compute_travel_time_field_multi_source export (pass 2, origin + transit-improved stops as seeds) in runWalkingIsochroneFromSourceNode.#departure-datetime (type="datetime-local") input constrained to the feed’s actual calendar window, and a Public transit checkbox grouped alongside Walk/Bike/Car/Ferry in the “Transport modes” checkbox group (not a separate control), wired via getTransitOptionsFromShell/updateTransitControlAvailability.nStops === 0 (every non-Berlin region today) short-circuits to the unchanged single-pass walk/bike/car/ferry behavior, and the transit checkbox is hidden rather than shown-and-inert.Estimated time: 1 hour
Tasks
transitFeed block to regions.json and rerunning fetch|build --components graph,transit by reading the code, not a doc.data_pipeline/tests/test_gtfs_transit.py builds a small synthetic GTFS fixture (_write_gtfs_fixture) covering a normal day, a past-midnight trip, an out-of-extent stop, and a calendar_dates.txt exception; none of the pipeline unit tests depend on the real Berlin feed.#routing-disclaimer-transit (VBB/CC BY line) is shown/hidden by updateTransitControlAvailability alongside the transit checkbox (graph.header.nStops > 0), and the SVG export’s copyrightNotice reads both disclaimer elements’ live text, combining them into one line only when the transit line isn’t hidden.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.
Estimated time: 45 min
Tasks
45m-60m, not 45m+Decision note: use equal-width segmentation across the cycle (five 20% bands) for predictable looping behavior and clearer legend interpretation.
Estimated time: 1 hour
Tasks
Estimated time: 2 hours
Tasks
Estimated time: 45 min
Tasks
Estimated time: 1 hour
Tasks
node=<graphNodeId>) after successful routingnode from URL and restore that start node if valid for current graphhistory.replaceState, preserve other params/hash)modes=), colour cycle (cycle=), departure date+time (departure=), and walk/bike speed (walkKph=/bikeKph=) — web/src/core/coords.js’s parse*FromLocationSearch/persist*ToLocation pairs, applied on init and on each control’s change event in web/src/ui/orchestration.js.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.
Estimated time: 45 min
Tasks
Estimated time: 35 min
Tasks
Estimated time: 55 min
Tasks
wheel) zooms in/out around the pointer position, so the location under the cursor stays visually anchored.grab/grabbing) to make the mode obvious.Estimated time: 55 min
Tasks
touch-action / pointer-event handling), while leaving top/bottom bars usable.Estimated time: 30 min
Tasks
Estimated time: 20 min
Tasks
x, y, z) after the interaction model is stable; do not bundle that into the first implementation step.Not in the original plan — added from user feedback (“we should probably also add walking/cycling speed options”). Estimated time: 2 hours
Tasks
<details> sub-section of the options panel, since they’re rarely changed — #speed-settings in web/index.html.walking_speed_m_s/bike_cruise_speed_kph params on the Rust precompute_edge_costs export (wasm/routing-kernel/src/lib.rs) and the JS reference implementation (computeEdgeTraversalCostSeconds in web/src/core/routing.js); the walk-mode cost is rescaled by the user’s speed relative to the graph’s build-time WALKING_SPEED_M_S (the speed the data pipeline assumed when it baked walking_cost_seconds from real edge geometry), not the other way round — only the walk/bike cost derivations change, ferry/car costs are unaffected.(allowedModeMask, walkingSpeedMps, bikeCruiseSpeedKph), not just the mode mask, so a speed change can’t silently reuse a stale precomputed cost array for the same mode.runConnectionScanFromWalkingReachableStops in web/src/app.js), for consistency between the routing kernel and the transit-stop-attachment estimate.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
<input type="datetime-local"> (#departure-datetime) — the date input previously had no change listener at all, so changing the date silently left the isochrone stale; a single input with one listener fixes that structurally rather than by remembering to wire a second listener.<select multiple> to a checkbox fieldset (Walk/Bike/Car/Ferry), and fold the previously-separate “Public transit” checkbox into the same group as a peer, since a normal user has no reason to think of transit as different in kind from the others.allowedModeMask computation (it’s a boolean CSA-augmentation flag, not an edge-mode bit) while still including it in the shared URL-persisted checkbox-group state (see 12.5) — getAllowedModeMaskFromShell’s mask loop and “nothing selected” fallback in web/src/ui/orchestration.js explicitly filter it out.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.
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.
| 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:
regions.json + region-data.py, different file layout than originally planned — see 10.6 note)berlin_graph.bin.gz — compressed walking-only binary graph/data_pipeline/input/berlin-routing.osm.json — Overpass JSON extract for Berlin routing build/data_pipeline/output/berlin-district-boundaries-canvas.json — simplified boundary basemap JSON/docs/berlin_district_boundaries_query.ql — Overpass query for Berlin district boundaries (superseded by the generalized docs/overpass_boundary_query.sh in Phase 10.6)/web/index.html/web/src/app.js — vanilla JS module entrypoint/data_pipeline/ — Python pipeline scriptsberlin_graph.bin.gz schema v2 with per-edge mode mask + speed metadata (bike/car/walk support)data_pipeline/regions.json, one array, not per-file manifests) and region-data.py CLI (fetch/build/all) that renders location-agnostic Overpass queries per regiondata_pipeline/input/<id>-routing.osm.json / <id>-district-boundaries.osm.json, data_pipeline/output/<id>-graph.bin(.gz) / <id>-district-boundaries-canvas.json (flat, slug-prefixed — not the nested <slug>/... directories originally planned); Berlin keeps its legacy graph-walk.bin(.gz) filenames as a compatibility alias--include-coast, opt-in per region because of a ~900 MB external download, cached locally after first use)stops/routes/trips/connections/services/transfers) independent of source formatGTFS, GTFS-RT, NeTEx, SIRI scaffold)Water edge mode mask bit through the full pipeline (extraction, adjacency, WASM/JS routing kernels), grid-size-budgeted ferry inclusion, and a boundary-fit default viewport decoupled from the full (ferry-widened) routing gridnode/modes/cycle paramsPublic 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.
Content-Encoding: gzip is being served correctly in deployed environment (check DevTools Network tab)