feat: basic GTFS pathways support #128

Merged
maxtkc merged 52 commits from 96-gtfs-pathways into main 2026-05-24 22:33:41 +00:00
Owner

Closes #96

Closes #96
maxtkc self-assigned this 2026-05-24 22:33:32 +00:00
Single focusedObject discriminated union drives all focus/expansion state.
Station expansion is now derived via deriveExpandedStation() rather than
maintained as a separate field. applyFocusedObject() handles layer transitions
atomically on every focus change.
- Replace solid blue station circle with white fill + black stroke + X overlay
- Add stops-station-x symbol layer centered on each station
- Add station_id derived property to each stop GeoJSON feature
- Change expanded-station stops filter to use station_id
- Include stops-station-x in clearAllLayers and setStopsFilter
Replaces the flat "Child Stops" list in the station view with three labeled
sections — Entrances/Exits, Platforms, Generic Nodes — using renderStopReference
from entity-references. Boarding areas (location_type=4) are excluded here per
GTFS spec (they live under their parent platform). Removes bespoke child-stop-btn
handler now that STOP_REF_ROW wiring in page-content-renderer covers it.
The basemaps registered in basemap-styles.ts are all raster-only with
no glyphs URL, so the stops-station-x symbol layer's text-field silently
failed on every basemap. Switch to a pre-rendered canvas icon registered
via map.addImage, which has no font/glyphs dependency.

Also add diagnostic logs to setFocusedStop (entry, post-set readback)
and upgrade the catch from console.debug to console.warn so silent
failures surface. These logs help debug why focused circles aren't
visibly growing on click; they'll be trimmed once the root cause lands.
MapLibre throws when queryRenderedFeatures is called with a layers list
where any layer is missing. The pathways-clickarea/pathways-lines layers
only exist when a station is expanded, so any click before then crashed
the navigation handler. Wrap the call in a helper that filters to the
currently-existing layers and returns an empty array if none remain.

Applied the same helper to the route-features query for consistency
(routes layers are also dynamically added).
Setting feature.id from a string property (like 'place-jfk') gets coerced
to 0 by the vector-tile encoder MapLibre uses internally for GeoJSON
sources. setFeatureState then stores state under the string id while the
rendered feature has id 0, so the focused branch of the circle-radius
case never fires for stations with non-numeric ids. The station stays at
its small unfocused radius and surrounding child stops cluster on top of
it, making it appear to disappear on click.

Switch both stops and pathways sources to use promoteId, which tells
MapLibre to use the named property as the feature id while preserving
string values. Drop the now-redundant manual id injection.

Also: icon-size is a layout property and cannot use feature-state data
expressions ("feature-state data expressions are not supported with
layout properties"). The previous case expression made the station-x
layer fail validation entirely, so no station showed an X. Switch to a
single static icon-size of 1.0; the surrounding white circle still
grows on focus via its paint-side feature-state expression.
Drop the canvas-rendered icon + symbol layer in favor of a plain
circle layer (stops-station-dot) filtered to location_type=1 with a
small solid black fill. circle-radius is a paint property so it can
use feature-state directly, giving us a focused/unfocused size case
(2.5 / 4) the icon approach couldn't have. No glyphs URL, no
addImage, no canvas — just one MapLibre primitive.

Rename references throughout (clearAllLayers list, setStopsFilter
composed filter, the color comment on stops-background). Same visual
role: marks stations as visually distinct from regular stops.
Phase 1c magnitude pass after focus-state lookup was fixed:
- station r 10→12, entrance/generic-node 8→10, boarding-area 11→13,
  default-stop *1.7→*2 (≈6.8→8) on the focused circle-radius branch.
- station inner-dot r 4→5 on focus so it scales with the bigger circle.
- circle-stroke-width 4→5 on focus for a slightly bolder outline.
- Pathway line-width (3 unfocused, 6 focused) already reads well; left alone.

Also trim the setFocusedStop diagnostics now that the underlying
feature-state bug is fixed: drop the verbose readback, drop hasSource
from the entry log, and early-return when prev === next so no-op calls
don't spam. Apply the same shape to setFocusedPathway and upgrade its
caught-error log from console.debug to console.warn (silent failures
shouldn't hide in debug).
Disambiguates platforms, entrances, and boarding areas that share names
under a parent station. The rule now lives in getStopDisplay, so breadcrumbs,
cards, dropdowns, search results, and pathway endpoints stay consistent;
stations and standalone stops show just the name.
Empty stop_lat/stop_lon slipped past the map's `!isNaN(...)` filter
(isNaN('') is false), so coord-less stops landed at (0,0). Per spec,
generic nodes and boarding areas may omit coords and inherit from
parent_station; entrances/platforms with empty coords should also
fall back gracefully.

Introduce a shared resolver that returns either the stop's own coords
or a deterministic position on a 30m circle around the nearest
coord-having ancestor (orphan siblings grouped by resolved ancestor,
sorted by stop_id for stable layout). Stops with no resolvable
position are skipped with a warning. Map stops, the station-expanded
pathway lines, and the validator now share this resolver, so dots
and pathway endpoints land on the same fanned-out positions.

Validator: relax the blanket "lat/lon required" rule. Error only
when location_type 0/1/2 has no coord-having ancestor; warn when
those types will render via an ancestor; warn on orphaned 3/4 stops.
Range checks unchanged when a value is present.
Two related data-integrity bugs:

1. `generateCSVFromRows` hand-rolled CSV via `headers.join(',')` and
   `String(value)` with no quoting, so any field containing a comma,
   double-quote, or newline shifted every column to its right on save.
   On reload, stops with commas in `stop_name`/`stop_desc`/`municipality`/
   etc. came back with `location_type` holding values from other columns
   (URLs, latitudes, municipality names), breaking station rendering,
   pathway resolution, and stop-view labels.

   Switch to `Papa.unparse` (already imported) and take the field set
   from the union of all row keys so columns added after the original
   import survive the round-trip. Drop the now-unused
   `formatFieldForExport` helper.

2. `MapController.fitMapToData` filtered stops with `!isNaN(stop_lat)`,
   but `isNaN('')` is false, so empty-string coords slipped through
   and `LngLatBounds.extend(['', ''])` coerced them to [0, 0]. The
   bounds blew open to cover the equator on initial load. Replace with
   the shared `hasValidCoords` helper.

Existing IDB blobs are already corrupted by (1); users must Reset and
re-import to recover. Subsequent saves are clean.
Pathway navigation from the side panel or URL hash never called any
map-focus method, so the pathway wasn't highlighted and the station
wasn't expanded. Stop navigation expanded the parent station correctly
but then flew to the child stop's coordinates, overriding the station
fit.

- Add highlightPathway() to MapController and call it from renderPathway
- Wire navigateToPathway through browse-navigation's onPathwayClick
- Skip the trailing flyTo in highlightStop when a station is expanded
- Hoist the station/stop focus zoom into CONFIG.STOP_FOCUS_ZOOM
flyToStation's filter only rejected null/undefined, so empty-string
stop_lat/stop_lon slipped through and Number('') coerced them to 0.
A station with any coord-less child then dragged the fitBounds
viewport down to the equator. Same bug class as 868c902 — switch to
the shared hasValidCoords helper.
Replaces the fixed 30m circular fan around the parent station with
Tutte's barycentric embedding over pathways.txt: coord-having siblings
are pinned at their real coords, and each coord-less stop iteratively
moves to the centroid of its pathway neighbors. A stop wired between
two pinned stops lands on the midpoint; chains spread evenly.

No-edge coord-less stops stack at the station center. Coord-less stops
in pathway components disconnected from any pinned sibling return null
and are skipped + warned by callers.
Replaces two separate `stops` iterations (each calling `findCoordAncestor`)
with one loop that classifies stops inline, deferring pinned candidates to a
small post-processing step.
Reset focusedObject to none before replaying applyFocusedObject on basemap
change so the old→new station comparison triggers station re-expansion
(including updatePathwaysLayer). Previously the pathway case was silently
skipped because oldStation === newStation when focusedObject was unchanged.
StopViewController now handles its own timetable row clicks directly
rather than relying on page-content-renderer's SERVICE_REF_ROW
delegation, removing the implicit coupling between the two modules.
Attaching via container.addEventListener accumulated N handlers after N
pathway navigations (container is reused, only innerHTML replaced). Now
querySelectorAll attaches directly to the rendered buttons, matching the
stop-link-btn pattern.
Stops at lat=0 or lon=0 were silently skipped from flyTo / fitBounds.
Cache buildStopCoordResolver across same-cycle render calls via a
dirty-flag in LayerManager; invalidate at each mutation site in
MapController. Also replace Array.shift() in BFS with an index pointer
(O(1) dequeue).
maxtkc merged commit 40ac027124 into main 2026-05-24 22:33:41 +00:00
maxtkc deleted branch 96-gtfs-pathways 2026-05-24 22:33:41 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book!128
No description provided.