Show direction of trip on map #43
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#43
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
Currently
createRouteFeatures()emits one GeoJSON feature per trip — a route with 100 trips sharing the same shape_id creates 100 identical stacked lines, with zero direction indication. This plan fixes both problems in three phases: (1) deduplicate to one feature per unique shape per route; (2) run a segment proximity algorithm to split each shape into labeled runs (one-way forward, one-way backward, bidirectional); (3) add MapLibre symbol layers with auto-rotated arrow glyphs showing direction. No offset lines (deferred). Scope: same-route shapes only.Approach chosen: arrows along the line (
symbol-placement: 'line'), not offset parallel lines. Algorithm: segment midpoint snapping + antiparallel angle detection, O(S) total where S = total shape points across all unique shapes.Relevant Context
Primary file:
src/modules/route-renderer.tscreateRouteFeatures()(line 171) — the method to refactor. Currently: oneRouteFeatureper trip.initializeMapLayers()(line 86) — where new MapLibre layers are added.renderRoutes()(line 369) — where the GeoJSON source is populated.RouteFeatureinterface (line 11) — needsdirection_typeandshape_idadded to properties.Config:
src/config.ts— add tunables for proximity threshold, angle tolerance, snap resolution.Types: All GTFS types are
Record<string, any>. Fields used:trip.shape_id,trip.direction_id,trip.route_idshape.shape_pt_lon,shape.shape_pt_lat,shape.shape_pt_sequenceMapLibre:
symbol-placement: 'line'places symbols along a line, auto-rotating to the line's bearing. Usetext-fieldwith Unicode glyphs — no bitmap icons needed. The existing'routes'source (GeoJSONSource) can carry the new segment features alongside the existing ones, or a second source'route-segments'can be added. Prefer a second source for clean separation (background lines stay on'routes', arrows sit on'route-segments').No existing tests to worry about. Manual testing only.
Phase 1: Deduplicate trips → unique shapes
Goal: Eliminate the primary source of visual clutter. A route with 100 trips all sharing
shape_id='S001'currently creates 100 identical stacked lines; after this phase it creates 1.Steps:
createRouteFeatures(), after buildingtripsByRoute, build a second index:uniqueShapesByRoute: Map<route_id, Map<shape_id, { direction_id: string | undefined }>>— for each route, track each uniqueshape_idand thedirection_idof the first trip that uses it.routeTrips.forEach((trip) => {...})loop: instead of iterating over all trips, iterate over the deduplicateduniqueShapesByRoute.get(route_id)entries. One feature per unique shape.shape_id(fallback to stop connections): deduplicate bytrip_id(no change needed there — each trip's stop sequence is unique).shape_idanddirection_idtoRouteFeature.propertiesinterface.feat(route-renderer): deduplicate trips to unique shapes per routeGotcha:
direction_idin GTFS is0or1(integer or string — be defensive). Store asstring | undefined. If two trips share the sameshape_idbut differentdirection_idvalues (unusual but valid), record both as separate features; key the dedup map onshape_id + '/' + direction_id.Phase 2: Segment proximity classification
Goal: For each route with opposing shapes, split each shape into labeled runs:
'forward'(one-way in this shape's direction),'backward'(one-way in the opposing shape's direction — only on the opposing shape's features), or'both'(bidirectional corridor, present on both shapes but deduplicated to one feature).Config additions (
src/config.ts)CONFIG:The algorithm
For each route with ≥2 unique shapes, run pairwise comparison between all shape pairs. In practice almost all routes have exactly 2 (one per direction_id). For routes with only 1 unique shape, all segments are
'forward'(skip proximity detection).Build segment index for shape B (helper function
buildSegmentIndex):(coords[i], coords[i+1])in shape B:midLon = (lon1+lon2)/2,midLat = (lat1+lat2)/2angle = Math.atan2(lat2-lat1, lon2-lon1) * 180/Math.PI(−180 to 180)cellLat = Math.round(midLat / SNAP_DEG), same for lonMap<string, SegmentEntry[]>at key`${cellLat},${cellLon}`Classify each segment of shape A (helper function
classifySegments):(A[i], A[i+1]):d = sqrt((Δlat*111000)² + (Δlon*111000*cos(midLat))²)— good enough for <200m)|angle_A - angle_B|, normalize to [0°, 180°], check if it's near 180° (i.e.,|diff - 180| < ANGLE_TOLERANCE)PROXIMITY_MAND antiparallel → this segment of A is'both'; mark it, record which B segment matched (to exclude from B's'backward'set later)'forward''backward'for unmatched segments and skipping already-matched segments (those become'both'and are already captured in A's run).Stitch segments into runs (helper function
stitchRuns):[fwd, fwd, both, both, fwd]→ 3 features:[P0..P2]=forward,[P2..P4]=both,[P4..P5]=forward.'both'runs: emit only once (from shape A). When processing shape B's segments, skip the already-emitted bidirectional runs.New
RouteFeatureproperties:direction_type: 'forward' | 'backward' | 'both'toRouteFeature.properties.RouteFeatureinterface accordingly.Output of
createRouteFeatures()becomes these stitched run features, not full shapes. The'routes'source still gets the full deduplicated shapes (for the background line layer — keeproutes-backgroundrendering all shapes without per-run splitting, for simplicity). The per-run features go into a new source'route-segments'.Actually, simplification: just use the per-run features for BOTH the background lines and the arrows. Remove the old full-shape features. The stitched runs reconstruct the full shape perfectly (no visual gaps).
feat(route-renderer): classify segments as one-way or bidirectionalGotcha: Circular routes (start ≈ end of the same shape) may have unusual direction_id patterns. If a route has
direction_id=0with a shape that is nearly identical todirection_id=1's shape (even in the same direction), don't mark it as antiparallel — the angle check protects against this. Tolerance ±30° means a pair is only antiparallel if the angle difference is 150°–180°.Gotcha: Some routes have only
direction_id=0(nodirection_id=1). In that case all segments are'forward'. No proximity detection needed.Gotcha: For the multi-shape-pair case (>2 unique shapes per route, e.g. branching routes), run the pairwise comparison for all pairs. A segment matched by any opposing shape is
'both'.Phase 3: Arrow rendering
Goal: Add MapLibre symbol layers showing direction arrows along each route segment.
New MapLibre source and layers
In
initializeMapLayers(), add a second GeoJSON source'route-segments':Add layer
'routes-arrows'on source'route-segments':Note on glyphs:
text-fieldwith Unicode characters requires the map's style to include glyph sources. If the current map style doesn't include Noto Sans Regular (or equivalent), the arrows will silently not render. Check the active style'sglyphsURL — if missing, fall back to using a simple line arrow via a separate raster image, or restrict to ASCII'>','<'characters.Fallback approach if glyphs are unavailable: Use
'>'for forward and'<'for backward (ASCII, always available),'<>'for both. These are less visually refined but guaranteed to work.In
renderRoutes(), populate'route-segments'source with the per-run features fromcreateRouteFeatures().Add
'route-segments'to thedestroy()cleanup (remove source and layer).Add
'routes-arrows'to the highlight logic inhighlightRoute()/clearHighlight()— arrows should remain visible on highlighted routes.Commit:
feat(route-renderer): add directional arrow symbols on route segmentsLayer ordering
The layer order in MapLibre matters (later = on top). Current order:
routes-backgroundroutes-clickarearoutes-highlightNew order:
routes-backgroundroutes-clickarearoutes-arrows← new, above clickarea so arrows are visibleroutes-highlightThe
addLayercalls ininitializeMapLayers()must be in this order.Out of Scope (deferred)
Original Issue