Faster map with large feeds #27
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Large GTFS feeds frequently have 10k+ trips spread across only a few hundred unique shapes. The current
RouteRendereremits one GeoJSON feature per trip, so MapLibre tessellates and rasterizes the same line dozens of times per route — both feed-load time and per-frame paint scale with trip count instead of shape count. We will deduplicate features down to one per(route_id, geometry_key), wheregeometry_keyis the trip'sshape_idin shapes mode and a hash of the orderedstop_idsequence in stops mode (kept structurally parallel to shape handling). On top of dedupe, we will replace today's "rebuild from scratch on undo/redo only" model with patch-driven incremental updates: subscribe to the patch manager, route by table, and surgically rebuild only the affected features. Click semantics simplify — clicks resolve to a route, which is all the UI needs today. Trip-shape edits and (future) shape edits will rerender selectively without touching the rest of the map.The likely 10–100× drop in feature count is the primary win; selective invalidation prevents the same cost from being paid on every edit. A Web Worker offload is documented as a fallback but not implemented in this plan unless dedupe + incremental updates fall short.
Relevant context
Files
src/modules/route-renderer.ts— owns theroutesGeoJSON source and three layers (routes-background,routes-clickarea,routes-highlight).createRouteFeatures()(lines 171–270) is the hot path. Geometry is computed bycreateRouteGeometryFromShape()(275) orcreateRouteGeometryFromStops()(294).setRenderMode('shapes' | 'stops')(360) toggles the source for geometry.src/modules/map-controller.ts— ownsRouteRendererandLayerManager.updateMap()(259) clears and full-rebuilds. Basemap-change handler (180) does the same. This module is the right place to subscribe to patch events and dispatch surgical rerenders.src/modules/patch-manager.ts— emits'change' | 'undo' | 'redo' | 'jump'viaemit()(476). Patches areSingleGTFSPatch(op: 'insert' | 'update' | 'delete', withsource: { table, id }) orBatchGTFSPatch(op: 'batch',ops: SingleGTFSPatch[]). Patch types live atsrc/types/patch.ts.src/modules/layer-manager.ts—highlightTrip()(313) computes its own line fromstop_timesindependently ofRouteRenderer, so dedupe does not affect trip-highlight behavior.src/modules/gtfs-parser.ts—getFileDataSyncTyped<T>(filename)returns shallow-copied row arrays (copy-on-read invariant per CLAUDE.md). Cached indexes derived from these arrays must be invalidated on patches; we cannot rely on aliasing.src/index.ts(170–219) — currently callsmapController.updateMap()only on undo/redo/jump;changeonly refreshesbrowseNavigation. We will keep that wiring butMapControllerwill additionally subscribe to all four events for selective rerender.src/types/gtfs-entities.ts—Trips,Shapes,StopTimes,Stops,Routesrow types.Architectural patterns
patchManager.record*(). Direct DB writes happen only for import / patch replay / snapshot restore. The map can therefore listen to a single source — the patch manager — and trust it to fire for every user-initiated change.GTFSEditorinsrc/index.ts.MapControlleralready receivesgtfsParser; we will additionally passpatchManagerso it can subscribe.console.logwith a[ModuleName]prefix is the convention for state-transition logging; keep adding them at every invalidation/rebuild for debuggability.Highlight layer compatibility
routes-highlightfilters by['==', 'route_id', X]or['in', 'route_id', ...]. After dedupe, multiple features per route share the sameroute_id— the filter still selects all of them, which is the desired behavior. No change needed.Click handling
idbecomes${route_id}::${geometry_key}(string),propertieskeeproute_id(the only fieldInteractionHandlerreads off route features).Phase 1 — Dedupe by
(route_id, geometry_key)Goal. Cut feature count from O(trips) to O(unique route×geometry pairs). Refactor
RouteRendererso geometry indexing is cached (not rebuilt on every render) and so geometry derivation is uniform across'shapes'and'stops'modes — both produce ageometry_keyplus a coordinate array, whichcreateRouteFeaturesconsumes identically.Why first. This is the bulk of the perf win and is independent of the patch wiring. It also creates the data structures (geometry-keyed feature map, indexes) that Phase 2 mutates surgically.
Steps.
route-renderer.ts, changerouteFeaturesfromRouteFeature[]toMap<string, RouteFeature>keyed by${route_id}::${geometry_key}, with stable iteration order maintained by insertion order. UpdategetRouteFeatures()to return[...map.values()].RouteFeature.idfrom${route_id}-${trip.trip_id}to${route_id}::${geometry_key}(use::to avoid collisions with-inside ids). Drop nothing from properties — keeproute_id,route_data,color,route_short_name,route_long_name. Addtrip_ids: string[](the list of trip ids contributing to this feature) so future debugging and possible UI expansion has it; do not expose in click handling yet.RouteRenderer:private shapeIndex: Map<string, [number, number][]> | null = null;— built fromshapes.txt, identical to today's local index.private stopSeqIndex: Map<string, [number, number][]> | null = null;— keys aregeometry_keyfor stop-sequence-derived geometries (see next bullet), values are coordinate arrays.private tripsByGeomKey: Map<string, { route_id: string; trip_ids: Set<string> }> | null = null;— bucket membership: which trips contribute to which feature. Used in Phase 2 to decide when a feature should be added/removed.geometry_keyderivation per trip:trip.shape_idis set and present inshapeIndex, key isshape:${trip.shape_id}.stop_idsequence fromstop_times.txtfor that trip (sorted bystop_sequence). Key isstops:${stop_ids.join('|')}. Cache the resulting coordinate array instopSeqIndexkeyed by the same string. The full join is the key (no hashing) — content is the identity, collisions impossible. If a trip has fewer than 2 valid stop coords, no feature is created.createRouteFeatures()with a method that:shapeIndex,stopSeqIndex,tripsByGeomKeyifnull.geometry_key, and either creates a new feature for that(route_id, geometry_key)or appendstrip.trip_idto the existing bucket. The route's color/name come from the routes lookup, computed once per route.routeFeatures(the Map) andtripsByGeomKey.private invalidateAll(): voidthat nulls the three caches and clearsrouteFeatures. Call fromclearRoutes()and from a basemap re-init.routes-highlightstill works visually (filter byroute_idmatches multiple features with the sameroute_id— exactly what we want).[RouteRenderer]-prefixed logs at: cache build start/end (with row counts), dedupe ratio (features emitted vs trips processed), and sourcesetDatacall.Gotchas.
gtfsParser.getFileDataSyncTypedreturns shallow copies. Do not assume the row arrays we cached are still up-to-date after a patch — that's exactly what Phase 2's invalidation is for. Phase 1 alone still rebuilds caches everyrenderRoutes()call (preserving today's full-rebuild behavior on undo/redo/jump); the lazy caching only matters once Phase 2 controls invalidation.shape_idthat does not exist inshapes.txt. Today the code falls back to stop connections with a warning. Preserve that fallback in the newgeometry_keyderivation: missing-shape trips fall through to the stops-derived key.stops:<joined>key strings can be long for routes with many stops (~50 stops × ~10 chars = ~500 bytes). With a few hundred unique sequences this is negligible RAM. Do not pre-hash; hashing introduces a collision-handling burden for no measurable benefit.geometry_keywe see first. This is acceptable — the user has not relied on cross-route stacking.Phase 2 — Patch-driven incremental updates
Goal. Replace "full rebuild on undo/redo only" with "surgical rebuild on every patch event."
MapControllersubscribes to all four patch events; for each patch, it routes bysource.tableto a targeted invalidation method onRouteRenderer. After invalidation, a single coalescedsetData()is fired on the next animation frame.Why second. Phase 1 establishes the bucket/index data structures this phase mutates. Phase 2 also lets us remove the
updateMap()call fromindex.ts's undo/redo wiring (MapControllerwill handle it via its own subscription), unifying the two paths.Steps.
patchManager: PatchManagertoMapControllerconstructor. Wire it fromsrc/index.tswhereMapControlleris instantiated.MapController, afterrouteRendereris constructed, subscribe topatchManager.on('change' | 'undo' | 'redo' | 'jump', cb). The handler receives aPatchRecord({ version, patch, timestamp }).patchto a flat list ofSingleGTFSPatch: ifpatch.op === 'batch', iteratepatch.ops; otherwise wrap in a single-element array. Then dispatch each bypatch.source.tableto one of:'routes'→routeRenderer.invalidateRoute(id, op)'trips'→routeRenderer.invalidateTrip(id, op, before, after)(we need both before/after to know the old vs newshape_id; forupdate, derive fromforward.changes+ the current row; fordelete, the inverse has the row; forinsert, the forward has it)'shapes'→routeRenderer.invalidateShape(id, op)(note:idhere isshape_id— see gotcha below)'stop_times'→routeRenderer.invalidateStopTimes(trip_id, op)(the patchidfor stop_times is the composite${trip_id}_${stop_sequence}; extracttrip_idfrom it)'stops'→routeRenderer.invalidateStop(stop_id, op)invalidate*method onRouteRenderer, the contract is: update the relevant cached index, recompute affected(route_id, geometry_key)buckets, mutaterouteFeatures(add/remove/replace specific entries), and enqueue asetData()on the next frame. Concrete behaviors:invalidateRoute(route_id, op):update(color/name): mutate theproperties.color/ name fields in-place on every feature with thisroute_id. No geometry work.insert: build features for any trips already intripsByRoute(rare on insert, but handle it).delete: remove every feature whoseroute_idmatches; drop fromtripsByGeomKey.invalidateTrip(trip_id, op, beforeShapeId, afterShapeId):oldKeyfrombeforeShapeId(or stop sequence if missing); computenewKeyfromafterShapeId. Removetrip_idfrom the old bucket; if the bucket becomes empty, delete the feature. Addtrip_idto the new bucket; if the bucket is new, build the feature. IfoldKey === newKey, no-op.insert: onlynewKeyside runs;oldKeyis null.delete: onlyoldKeyside runs.invalidateShape(shape_id, op):shapeIndex.get(shape_id)from currentshapes.txtrows for that id. (Fordelete: remove the entry.) Then for every feature withgeometry_key === 'shape:' + shape_id, replace itsgeometry.coordinates. If the new coords are <2 points, drop the feature and reassign its trips to the stop-sequence fallback (this keeps semantics consistent with initial build).invalidateStopTimes(trip_id, op):geometry_key(in stops mode, or in shapes mode if it had no shape) may have moved. Recompute and call the same bucket-move logic asinvalidateTrip. If shapes-mode and the trip has a valid shape_id, no-op (stop_times don't affect shape-mode geometry).invalidateStop(stop_id, op):stops:key instopSeqIndexthat contains thisstop_id. Replacegeometry.coordinateson every affected feature. (We can keep a reverse indexstopId → Set<geometry_key>to make this O(features-touching-stop) instead of O(all stop-mode features); add it if profiling shows the linear scan hurts.)invalidate*call sets a dirty flag and schedules a singlerequestAnimationFramethat callssource.setData(asFeatureCollection(routeFeatures))and clears the flag. A burst of patches in the same task collapses to one paint.updateMap()call from the undo/redo/jump handler insrc/index.ts. TheMapControllerpatch subscription replaces it. (updateMap()itself stays — initial load and basemap change still call it as a hard reset.)[MapController]-prefixed logs at every dispatch decision:[MapController] patch routes:abc op=update → invalidateRoute, etc.beforevalue for a trip'sshape_idis not directly provided inupdatepatches (only the changed fields). The patch handler must read the row's current state from the parser before the DB write would have applied — butpatchManager.recordUpdatealready callsapplyPatchForwardbefore emitting'change', so by the time we receive the event the new value is live. Solution: computeoldKeyfrompatch.inverse.changes(the recorded before-values) rather than re-reading from the parser. Forinsert, before is null. Fordelete, before is the full record inpatch.inverse.record.Gotchas.
stop_timesprimary key shape: confirm theidformat in this codebase (probably${trip_id}_${stop_sequence}) before writing the extraction logic. If it differs, adjustinvalidateStopTimes.setDataensures both invalidations land in one paint, but the order ofinvalidate*calls matters: ifstop_timesis invalidated before its trip exists intripsByRoute, the bucket-move runs on a non-existent trip. Process the batch's ops in order (insert before update before delete by default already, but follow the order the patch records) and tolerate "trip not yet known" by checking presence.tripsByGeomKeyis the source of truth for which features should exist. After every invalidation, the invariantrouteFeatures.has(key) ⇔ tripsByGeomKey.get(key)?.trip_ids.size > 0must hold. Add an assertion-style check (gated behind a debug flag) that runs in dev to catch leaks.'jump'event but no per-row patches. Treat'jump'as a hard reset: invalidate all caches and trigger a full rebuild (essentially whatupdateMap()does today). Same for thechange-but-from-import-replay scenarios — butchangeis only emitted fromappendAndPush, which is only user-initiated, so we should be safe.Phase 3 (Possible improvement — not implemented now)
Goal if needed. Move feature building off the main thread when feed is very large.
When to do it. After Phase 1+2 ship, profile on a feed with 100k+ trips. If
createRouteFeatures(the cached-build path) blocks the main thread for >100 ms, do this. Patch-driven invalidation is already incremental, so the main-thread cost on edit should be tiny — Worker is only useful for the cold-start build.Implementation sketch (not in this PR).
src/workers/route-feature-builder.worker.ts. Input message:{ routes, trips, shapes, stop_times, stops }(raw row arrays, transferable). Output:{ features: RouteFeature[], tripsByGeomKey: ...serialized..., shapeIndex: ..., stopSeqIndex: ... }.RouteRenderer, replace the synchronous initial build with a WorkerpostMessage; await the response insiderenderRoutes(). Fall back to the synchronous path ifWorkeris unavailable (SSR/test contexts).invalidate*methods on the main thread — they operate on already-built indexes and are O(affected features), no benefit from workerization.structuredClone-friendly types throughout the worker boundary; do not pass class instances. The existing row types are plain objects, so this is mostly free.TransferableArrayBuffers if it becomes a concern.RouteRenderer, lifecycle tied toRouteRenderer.destroy().Original Issue
Extension to #21. Now that we can load the large feeds, the map is very slow. I think it might be because we're drawing every trip/every shape? Maybe we can dedup them or simplify them.