Implement GTFS Pathways #96
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#96
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
This feature adds full GTFS station-hierarchy and pathways support to the editor. Stops with a
parent_stationare hidden on the main map until the parent station is selected; selecting a station zooms in and reveals all child stops with distinct icons perlocation_type. Pathways (walkways, stairs, escalators, etc.) are drawn as lines between child stops inside a station and are selectable for editing. A two-stop "add pathway" tool mirrors the existing "add stop" button. Levels are managed in a standalone modal launched from the nav bar, andlevel_idon stops becomes a dropdown. The approach leans on the existing modal system,showModal, the MapLibre source/layer pattern already used for stops and routes, and the existingpatchManager-gated write flow.Relevant Context
Types / spec already complete:
src/gtfs-spec/files/pathways.ts— full field spec forpathways.txtsrc/gtfs-spec/files/levels.ts— full field spec forlevels.txtsrc/types/gtfs.ts—PathwaysandLevelstype aliases,GTFS_TABLES.PATHWAYS / LEVELSsrc/utils/gtfs-primary-keys.tslines 172-182 — primary keys already declaredDatabase (IndexedDB via
idb):src/modules/gtfs-database.ts—GTFSStoreNameunion (lines 40-58),GTFSDBSchemainterface (lines 67-152),addIndexesForTable()(lines 405-508), schema version = 8 (line 175)stopsobject store already has indexes onlocation_type(line 428) andparent_station(lines 429-430)Map rendering:
src/modules/layer-manager.ts—addStopsLayer()(lines 87-141),addStopsBackgroundLayer()(lines 176-210),addStopsClickAreaLayer()(lines 211-230). All stops currently rendered identically.src/modules/interaction-handler.ts—handleNavigationClick()(lines 150-187), drag logic (lines 289-400),handleAddStopClick()(lines 192-284). Stop creation hardcodeslocation_type: 0andparent_station: ''(lines 234-241).src/modules/map-controller.ts— map state, ownslayerManagerandinteractionHandlerStop detail panel:
src/modules/stop-view-controller.ts—renderStopView()(lines 39-69),renderStopProperties()(lines 74-101). Uses genericrenderEntityFields— all stop fields already render, but no relational sections for children/pathways.UI / modals:
src/modules/modal-utils.ts—showModal(options)(lines 19-104)src/modules/ui.ts—toggleAddStopMode()(lines 1242-1253),updateMapToolButtonState()src/index.html— add-stop button at lines 433-452src/modules/gtfs-parser.ts—createStop()(lines 1157-1185)Search:
src/modules/search-controller.tsline 187 — already useslocation_typefor emoji (🚉 vs 🚏); no other use in modulesPhase 1: Database Schema + Import Foundation
Add
pathwaysandlevelsas first-class IndexedDB object stores so the rest of the app can read/write them, and ensure CSV import/export round-trips correctly.src/modules/gtfs-database.ts— add'pathways'and'levels'to theGTFSStoreNameunion (after line 58)GTFSDBSchemainterface — add store definitions:addIndexesForTable()— addcase 'pathways':(indexes:from_stop_id,to_stop_id,pathway_mode) andcase 'levels':(index:level_index)onupgradeneededfor existing users)pathways.txtandlevels.txtand verify rows appear in the IndexedDB viewer (DevTools → Application → IndexedDB)Gotcha: The database
initialize()method loops throughGTFS_FILESto create stores dynamically. Confirmpathways.txtandlevels.txtare already inGTFS_FILES(they should be, per the spec index), otherwise add them.Phase 2: Levels Management UI
A modal accessible from the nav bar lets users create, edit, and delete levels. The
level_idfield in the stop editor becomes a dropdown populated from the levels table.src/index.html— add a "Levels" nav button (near the existing nav items), e.g.<button id="levels-btn" class="btn btn-sm">Levels</button>src/modules/levels-controller.ts— a controller class with:showLevelsModal()— fetches all levels from DB, renders them in ashowModalcall as an editable table (level_id, level_index, level_name) with Add / Delete row actions. Each cell edit triggerspatchManager.recordUpdate()+ DB write.addLevel()— creates a new level viashowModal(level_id, level_index, level_name fields), callsgtfsParser-styleinsertRows+patchManager.recordInsert()deleteLevel(level_id)— removes from DB +patchManager.recordDelete()getLevelOptions()— returnsArray<{value: string, label: string}>for dropdown uselevels-btnclick →levelsController.showLevelsModal()insrc/index.tssrc/modules/stop-view-controller.ts— override the rendering of thelevel_idfield: afterrenderEntityFieldsruns, replace thelevel_idtext input with a<select>populated fromlevelsController.getLevelOptions(). On change, write via the patch system.Gotcha: If no levels exist, the
level_iddropdown should show an empty option plus a hint "Add levels via nav bar".Implementation notes:
LevelsControlleris wired throughindex.ts → browseNavigation.setLevelsController() → PageContentRenderer dependency → StopViewDependencies.getLevelOptions. The levels button in the nav is hidden on mobile (md:flex).level_idselect uses a regex replace on the HTML string returned byrenderEntityFields, targetingdata-field="level_id". The resulting<select>keeps all data attributes so the existingattachFormPatchListenersbridge handles change events automatically.showLevelsModalshows the table then chains toshowAddLevelModalfor creation — no inline cell editing (add/delete only). This is simpler and sufficient for the use case.savedLevel*pattern inshowAddLevelModalpre-fills inputs if the modal is re-opened (after a validation failure), but sinceshowModalalways creates fresh DOM this pattern doesn't actually matter in practice — left in as harmless.Phase 3: Stop Hierarchy on the Map
Child stops (location_type ≠ 1, with a
parent_station) are hidden on the main map. Clicking a station zooms in and reveals its children with distinct icons. Clicking elsewhere returns to normal view.3a: Default rendering — hide child stops
layer-manager.tsaddStopsBackgroundLayer()andaddStopsClickAreaLayer()— add a MapLibre filter expression. Filter stored asactiveStopsFilteron LayerManager; default shows empty-parent-station stops and all stations (location_type=1).location_typeviacircle-colorcase expressions: station=blue (#3b82f6), entrance=amber (#f59e0b), generic node=purple (#8b5cf6), boarding area=green (#10b981), platform=white. Fixed existing bug where comparisons used string'1'instead of number1.3b: Station-expanded view state
expandedStationId: string | nulltoMapControllerexpandStation(stationId)— filters map to station+children only, flies to bounding box;collapseStation()— resets to default filterhandleStopClick()inMapControllerchecks location_type; if station, calls expand/collapse before navigating to stop view (both expand and navigate happen together)onEmptyClickcallback callscollapseStation();updateMap()also resets filter on feed reloadonStopClick(wired viaContentRendererDependencies.onStopClick)3c: Stop creation in station context
handleAddStopClick()— checksgetExpandedStationIdcallback; if a station is expanded, shows location_type dropdown (Platform/Entrance/Generic Node/Boarding Area) and pre-fillsparent_station; modal title changes to "New Child Stop"Implementation notes:
parent_stationfield added to GeoJSON properties increateStopsGeoJSONso filters can operate on itFilterSpecificationimported from maplibre-gl for proper typing;setStopsFilter(filter: FilterSpecification | null)on LayerManagersetGetExpandedStationIdcallback pattern used in InteractionHandler to avoid circular dependency with MapControllerPhase 4: Pathway Visualization
When a station is expanded, draw pathway lines between its child stops. Pathway lines are selectable and open the pathway in the editor panel.
layer-manager.ts— addaddPathwaysLayer():from_stopandto_stopcoordinates from the stops source, create a line feature with all pathway fields as properties'pathways'and a line layer'pathways-lines'pathway_modewith different colors (green=walkway, orange=stairs, cyan=moving sidewalk, purple=escalator, blue=elevator, red=fare gate, gray=exit gate)'pathways-clickarea'layer-manager.ts— addupdatePathwaysLayer(stationId)called when a station is expanded: builds pathway GeoJSON for the expanded station, adds layers underneath stops; alsoclearPathwaysLayer()andrebuildPathwaysSource(stationId)for cleanup and drag updatesinteraction-handler.ts— inhandleNavigationClick(), add a query against'pathways-clickarea'. If a pathway feature is hit, emit anonPathwayClick(pathway_id)callback (stop clicks take priority)src/modules/pathway-view-controller.ts— renders pathway detail in the right panel:renderEntityFields(GTFSSchemas['pathways.txt'], pathway, 'pathways.txt', pathway_id)for all fieldshandleDeletePathwayin page-content-renderer)from_stop_id/to_stop_iddisplayed as clickable buttons linking to stop viewsImplementation notes:
{ type: 'pathway'; pathway_id: string }added toPageStateunion inpage-state.ts;isPageState(),pageStateToURL(),urlToPageState(), andgetBreadcrumbs()all updated accordinglynavigateToPathway(pathway_id)added tonavigation-actions.tsmap-controller.ts:expandStation()callslayerManager.updatePathwaysLayer(stationId);collapseStation()callslayerManager.clearPathwaysLayer();handleStopDragComplete()callslayerManager.rebuildPathwaysSource(expandedStationId)if a station is expanded;handlePathwayClick()navigates viapageStateManagerpage-content-renderer.ts:PathwayViewControllerinstantiated and wired;renderPathway()case added to switch;handleDeletePathway()does DB delete + patch recordattachFormPatchListenersin page-content-renderer handlesdata-table="pathways.txt"fields automatically — no extra wiring needed for inline field editsline-dasharraydata-driven expression removed (MapLibre compatibility); colors alone differentiate modesPhase 5: Pathway Creation
An "Add Pathway" button in the map toolbar, enabled only when a stop is selected, lets the user pick a second stop to connect.
src/index.html— add<button id="add-pathway-btn" class="btn btn-sm btn-square join-item tooltip" disabled>next to the add-stop button, with an appropriate icon (e.g. a line/connection icon)src/modules/ui.ts— addtoggleAddPathwayMode(): mirrorstoggleAddStopMode(), updates button active state; disable the button when no station is expandedADD_PATHWAYto the map interaction mode enum (alongsideNAVIGATEandADD_STOP) ininteraction-handler.tsinteraction-handler.ts— newhandleAddPathwayClick()method with two-click flow:from_stop_idinaddPathwayFirstStopId, shows info notification "From: X. Now click the second stop."showModalforpathway_mode+is_bidirectional; on confirm callsgtfsParser.createPathway()+ firesonPathwayCreatedcallback; exits ADD_PATHWAY modeaddPathwayFirstStopIdstop-view-controller.ts— non-station stops show a "Pathways" section with connected pathways (queried via twoqueryRowscalls:from_stop_id+to_stop_id), each linking to the pathway view viaonPathwayClickmapController.expandedStationIdis non-null viaonStationExpandChangecallbackImplementation notes:
createPathway()added toGTFSParser(mirrorscreateStop): handlesgtfsDatainit,insertRows,patchManager.recordInsert, andupdatePathwaysFileContentonStationExpandChangecallback added toMapControllerCallbacks; called fromexpandStation()andcollapseStation(); wired inUIController.setupMapCallbacks()to callupdateMapToolButtonState()onPathwayCreatedadded toInteractionCallbacks;MapController.handlePathwayCreated()rebuilds pathways layer and navigates viapageStateManageronPathwayClickpassed intoStopViewDependenciesfrompage-content-renderer.tsOriginal Issue
needs lots of fixing up, but it's moving the right direction
Summary
The current implementation maintains two separate pieces of map state —
expandedStationId(which station is showing its children) andcurrentHighlight({ type, id }) — that are kept in sync manually and breed special-casing throughoutMapController. In practice the expanded station is always derivable from the focused object (e.g. a child stop'sparent_station, a pathway'sfrom_stop_id'sparent_station), so the two fields should be one. This refactor introduces a singleFocusedObjectdiscriminated union to replace both; derives station expansion from it; and adds proper visual feedback for node clicks (enlarged in-place, same color) and pathway clicks (widened in-place, same color) using MapLibre feature state, which avoids the current approach of creating separate highlight sources/layers that hardcode white.Tradeoff considered: we could keep the separate
stops-highlightlayer and just fix its color. Feature state is cleaner — no source duplication, no layer ordering issues — but requires MapLibre to support feature state on the source (it does, and the source already setsid: stop_idon each feature).Relevant Context
State being replaced:
MapController.expandedStationId: string | null(line 64,map-controller.ts)MapController.currentHighlight: { type: 'none' | 'route' | 'stop' | 'trip'; id: string | null }(lines 67–70)MapController.expandStation()/collapseStation()(lines 892, 964) — these become internalsKey callsites:
page-content-renderer.tsline 653:mapController.highlightStop(stop_id)— the external entry point for focusing a stop from the panelpage-content-renderer.tsline 486:mapController.highlightRoute(route_id)interaction-handler.tsline 265:setGetExpandedStationId(() => this.expandedStationId)— the IH reads expanded station to contextualise new-stop creation; this becomes a getter overderiveExpandedStation()Layer plumbing:
layer-manager.tsaddStopsBackgroundLayer()(line 197) — paint expressions drive stop circle size bylocation_type; needs feature-state-aware radius + stroke-widthlayer-manager.tshighlightStop()(line 296) — creates a separatestops-highlightsource+layer with hardcoded white; to be retired for single-stop focus (trip path stops still usestops-highlight, so the trip variant stays)layer-manager.tsbuildPathwaysGeoJSON()(line 593) — features lack a GeoJSONid; needed for feature statelayer-manager.tsupdatePathwaysLayer()(line 657) —pathways-linespaint needs feature-state-awareline-widthInvariants:
'stops'source uses string IDs becausebuildStopsGeoJSONalready setsid: feature.properties?.stop_idon each feature (lines 126–131).'pathways'source is only present when a station is expanded;setFocusedPathwaymust guard against the source being absent.highlightTrip) usesstops-highlightsource/layer for the path stops — keep that untouched. Only the single-stophighlightStop()path is replaced.Phase 1: Unified Focus State
Goal: one field (
focusedObject) drives all map focus/expansion state. No moreexpandedStationId+currentHighlightduality.src/modules/map-controller.ts: definetype FocusedObject = { type: 'stop'; id: string } | { type: 'pathway'; id: string } | { type: 'route'; id: string } | { type: 'trip'; id: string } | { type: 'none' }(export it —InteractionHandlerdoesn't need it butgetCurrentHighlightcallers may)expandedStationId: string | null = nullandcurrentHighlight: {...}fields; addprivate focusedObject: FocusedObject = { type: 'none' }private deriveExpandedStation(): string | null:type === 'stop': look up stop; iflocation_type === 1returnstop.stop_id; else ifparent_stationis non-empty returnparent_station; else return nulltype === 'pathway': look up pathway, then look upfrom_stop_idstop; return itsparent_stationif non-empty, elsefrom_stop_iditself if it's a station, else nullprivate applyFocusedObject(obj: FocusedObject): void:const oldStation = this.deriveExpandedStation()this.focusedObject = objconst newStation = this.deriveExpandedStation()oldStation !== newStation:newStation: calllayerManager.setStopsFilter([...station+children filter...])andlayerManager.updatePathwaysLayer(newStation); fly to station bounds (extract fromexpandStationlogic)layerManager.setStopsFilter(null)andlayerManager.clearPathwaysLayer()callbacks.onStationExpandChange?.()handleStopClick: removelocationType === 1special-case; callapplyFocusedObject({ type: 'stop', id: stop_id })before navigationhandlePathwayClick: callapplyFocusedObject({ type: 'pathway', id: pathway_id })handleRouteClick: callapplyFocusedObject({ type: 'route', id: route_id })highlightStop(stop_id): callapplyFocusedObject({ type: 'stop', id: stop_id })(visual part arrives in phase 2)highlightTrip(trip_id): callapplyFocusedObject({ type: 'trip', id: trip_id })then existinglayerManager.highlightTripfor path stopshighlightRoute(route_id): callapplyFocusedObject({ type: 'route', id: route_id })then existing route renderer highlightclearHighlights(): callapplyFocusedObject({ type: 'none' }); still calllayerManager.clearHighlights()(for trip-path stops-highlight) androuteRenderer.clearHighlight()public getExpandedStationId(): string | nullgetter returningderiveExpandedStation()(replaces directthis.expandedStationIdreads); updateinitializeModulesline:this.interactionHandler.setGetExpandedStationId(() => this.getExpandedStationId())getCurrentHighlight(): derive fromfocusedObject(map type through;'pathway'has no prior analog — return{ type: 'none', id: null }or add 'pathway' to the return type)updateMap(): replacethis.expandedStationId = nullwiththis.focusedObject = { type: 'none' }(noapplyFocusedObjectcall needed here; layers are being fully rebuilt anyway)expandStation()andcollapseStation()— or keep private if still useful as helpers called fromapplyFocusedObjectonEmptyClickinsetupModuleCallbacks: simplify to justthis.clearHighlights(); this.callbacks.onEmptyClick?.()(no separatecollapseStation()sinceclearHighlights→applyFocusedObject({ type: 'none' })handles collapse)focusedObjectinstead ofcurrentHighlightGotcha:
handlePathwayCreatedcallslayerManager.rebuildPathwaysSource(this.expandedStationId!)— change tothis.deriveExpandedStation(). Same forhandleStopDragComplete(line 1023).Phase 2: Feature-State Node Highlighting
Goal: the focused stop grows in-place (larger circle + thicker stroke) using MapLibre feature state. Color stays the same per
location_type. The old single-stopstops-highlightsource/layer is removed.src/modules/layer-manager.ts: addprivate focusedStopId: string | null = nullpublic setFocusedStop(stop_id: string | null): void:this.focusedStopId !== nulland source exists:map.setFeatureState({ source: 'stops', id: this.focusedStopId }, { focused: false })this.focusedStopId = stop_idstop_id !== nulland source exists:map.setFeatureState({ source: 'stops', id: stop_id }, { focused: true })addStopsBackgroundLayer: replace thecircle-radiuspaint expression with a feature-state-aware outer wrapper: Where<focused-sizes>mirrors the existing type-based case but with values17 / 8 / 8 / 11 / options.radius * 1.7for types1/2/3/4/defaultrespectively. Also apply tocircle-stroke-width:['case', ['boolean', ['feature-state', 'focused'], false], 4, options.strokeWidth]MapController.applyFocusedObject()(from phase 1): add calls tolayerManager.setFocusedStop(obj.type === 'stop' ? obj.id : null)LayerManager.clearHighlights(): addthis.setFocusedStop(null)(clears focus when trips are highlighted, etc.)LayerManager.highlightStop()— the method can remain but should just delegate tosetFocusedStopand skip the source/layer creation. Thestops-highlightsource+layer in that method is only created for the single-stop case; the trip path case lives inaddTripHighlightLayers. So: guthighlightStop()body and replace withthis.setFocusedStop(stop_id); the layer-ordering and routing logic for trips stays inaddTripHighlightLayersuntouched.Gotcha:
setFocusedStopsets feature state by string ID. The 'stops' source already assignsid: feature.properties?.stop_idto each GeoJSON feature (line 129). NopromoteIdconfig is needed. But confirmstop_idstrings are used as the feature ID (not coerced to numbers) — MapLibre string feature IDs work fine withsetFeatureState.Phase 3: Feature-State Pathway Highlighting
Goal: the focused pathway grows wider in-place using MapLibre feature state on the 'pathways' source. Color stays the same per
pathway_mode.LayerManager.buildPathwaysGeoJSON(): addid: pw.pathway_idto each feature object (as a top-level property alongsidetype,geometry,properties):private focusedPathwayId: string | null = nulltoLayerManagerpublic setFocusedPathway(pathway_id: string | null): void:focusedPathwayId !== null:map.setFeatureState({ source: 'pathways', id: this.focusedPathwayId }, { focused: false })this.focusedPathwayId = pathway_idpathway_id !== nulland source exists:map.setFeatureState({ source: 'pathways', id: pathway_id }, { focused: true })updatePathwaysLayer: changepathways-linespaintline-widthfrom3to:clearPathwaysLayer(): callthis.setFocusedPathway(null)before removing layers/sourceMapController.applyFocusedObject()(phase 1): addlayerManager.setFocusedPathway(obj.type === 'pathway' ? obj.id : null)alongside thesetFocusedStopcallDiscovery: Added an extra
else if (newStation)branch inapplyFocusedObjectto handle the case where the station stays expanded but you click a different pathway within it — pathway highlight updates without any station transition.Gotcha:
setFocusedPathwayis called fromapplyFocusedObjecton every focus change, but the 'pathways' source only exists when a station is expanded. The try/catch insetFocusedPathwayhandles the "not yet added" case. When the focused object is a pathway and the source doesn't exist yet, the pathway focus highlighting will be applied correctly becauseupdatePathwaysLayeris called (inapplyFocusedObject) after setting the newfocusedPathwayId, and the feature IDs are present in the built GeoJSON, so subsequentsetFeatureStatecalls will succeed once the source exists.Actually — order matters:
applyFocusedObjectcallsupdatePathwaysLayerto add the source, then immediately sets the feature state. So: ensuresetFocusedPathwayis called afterupdatePathwaysLayerwithinapplyFocusedObject.Post-merge note (merged main 2026-05-11): Resolved conflicts in
gtfs-database.ts(kept bothPathways/Levelsfrom branch andRiderCategories/FareMedia/FareProductsfrom main),map-controller.ts(mergedPathways+Agencyimports and agency-helpers), andstop-view-controller.ts(mergedLevelOption/escapeAttrfrom branch withnormalizeAgencyIdfrom main). All phases 1–3 checklist items remain accurate.Summary
Polish pass on the GTFS pathways feature. The station/pathways infrastructure from PRIOR_PLAN.md (DB, modal levels editor, expansion filter, pathway lines/clickarea) and PRIOR_PLAN2.md (unified
FocusedObject+ MapLibre feature-state-driven highlighting) is in place. This plan addresses the remaining UX gaps:stop_lat/stop_lonare currently silently dropped from the map and from pathway endpoints. Keep them in the station's child list and route their pathway endpoints up through the nearest ancestor that has coords.Home → Station → Platform; clicking a boarding area should produceHome → Station → Platform → Boarding Area. Empty-click while a child/pathway is focused walks one level up (back to its parent_station); empty-click on the station itself clears focus.STOP_REF_ROW/PATHWAY_REF_ROW+renderStopReference/renderPathwayReferenceinentity-references.ts(mirroringrenderRouteReference,renderServiceReference) and switch stop view to them.location_type=2), Platforms (location_type=0), Generic Nodes (location_type=3). Boarding areas (location_type=4) do NOT appear here — they live under their parent platform, per GTFS spec.from_stop_id == stop_id) and incoming (to_stop_id == stop_id). Row primary content = pathway mode label + the other stop ("Stairs to platform_a"); row click → pathway page; right-side "View Stop" button → the other stop. Mirrors the route-reference pattern (clickable row + explicit View button).We also need to verify that focused-stop and focused-pathway feature-state visual growth (already implemented in PRIOR_PLAN2) still reads correctly with the new station-X icon — the X symbol's
text-sizewill need to grow alongside the circle.Tradeoff considered for the station icon: could use a raster
map.addImage()for the X (more pixel-perfect at all zooms) but text labels with a Unicode ✕ glyph are simpler, scale viatext-size, and avoid bundling an asset. Going with text.Relevant Context
Map rendering (idle station icon, hierarchy filter, pathway routing through ancestor):
src/modules/layer-manager.ts:addStopsBackgroundLayer()(lines 199–262) — circle paint expressions: change station case (location_type === 1) to white fill + black stroke, smaller radius (~6 idle / ~10 focused vs current 10/17).createStopsGeoJSON()(lines 165–194) — properties currently includeparent_station. Need a new derivedstation_idproperty (the topmost station ancestor) so the expanded-station filter can include grandchildren (boarding areas).setStopsFilter()(lines 291–299) called frommap-controller.applyFocusedObjectwithparent_stationfilter. Change to filter onstation_id(climbs full chain).addStopsLayer()(lines 106–160) — currentlyvalidStopsfilter drops coord-less stops. Need to keep coord-less stops in the underlying data list (for the panel) but not emit a Point feature for them. Either pre-split or just include them with a sentinel and filter in the layer paint. Decision: keepvalidStopsas-is for map rendering (no-coord stops still don't render as circles), but havebuildPathwaysGeoJSONfall back to ancestor coords for those endpoints. The panel queries the DB directly, so coord-less stops will appear there regardless.buildPathwaysGeoJSON()(lines 606–665) —coordMaponly contains stops with coords. For a missing endpoint, walkparent_stationup the chain to find an ancestor with coords; use those coords as the endpoint.stops-station-xsymbol layer rendering text ✕ centered on each station feature. Painttext-color: #000000,text-size: ['case', ['boolean', ['feature-state', 'focused'], false], 18, 10],text-allow-overlap: true,text-ignore-placement: true. Filter:['==', ['get', 'location_type'], 1]. Insert abovestops-backgroundso the X paints on top of the white circle.Hierarchy depth (breadcrumbs + empty-click navigate-up):
src/modules/page-state-manager.ts:BreadcrumbLookupinterface (lines 28–33) — currentlygetStopName(stop_id). AddgetStopAncestors(stop_id): Promise<Array<{ stop_id: string; stop_name: string }>>returning the chain from outermost (closest-to-station) to immediate parent (excludingstop_iditself).buildBreadcrumbs()(lines 250–409):case 'stop'(lines 344–356) — replace flatHome → StopwithHome → [ancestor breadcrumbs...] → Stop. Each ancestor breadcrumb is{ label: ancestorName, pageState: { type: 'stop', stop_id: ancestorId } }.case 'pathway'(lines 375–388) — look up the pathway'sfrom_stop_id, get its ancestors + itself, then appendPathway Xas the leaf. Adds a new lookup; see below.src/modules/gtfs-breadcrumb-lookup.ts(existing file — confirm via Read) — implementation ofBreadcrumbLookup. AddgetStopAncestorsthat queriesstopstable, followsparent_stationrecursively (max ~5 hops as safety), returns the chain ordered station-first.getPathwayFromStopId(pathway_id)(or expose the pathway row). Simplest: extendBreadcrumbLookupwithgetPathwayAncestors(pathway_id): Promise<Array<{stop_id, stop_name}>>that internally queries pathways → finds from_stop_id → callsgetStopAncestors+ appends from_stop itself.src/modules/map-controller.ts:setupModuleCallbacksonEmptyClick(lines 305–308) — currently callsclearHighlights()unconditionally. Replace withhandleEmptyClick():focusedObject.type === 'stop': look up the stop; ifparent_stationis non-empty, callapplyFocusedObject({type:'stop', id: parent_station})AND navigate via pageStateManager. Else (station or root stop): clearHighlights + onEmptyClick callback.focusedObject.type === 'pathway': look up pathway, find from_stop_id's parent_station; navigate there. (If somehow no parent, clear.)Related-object renderers (shared helpers):
src/utils/entity-references.ts— currently exportsrenderRouteReference,renderServiceReference,ROUTE_REF_ROW,SERVICE_REF_ROW,ENTITY_REF_BTN. AddrenderStopReferenceandrenderPathwayReference, plusSTOP_REF_ROW,PATHWAY_REF_ROW.src/utils/entity-display.ts— already hasgetStopDisplay. No pathway display helper exists; pathways don't have apathway_namefield — display is mode label + endpoints.src/modules/page-content-renderer.tsaddEventListeners(lines 708–797) — currently wiresROUTE_REF_ROWandSERVICE_REF_ROWclicks. Add wiring forSTOP_REF_ROW(callsdependencies.onStopClick) andPATHWAY_REF_ROW(callsdependencies.onPathwayClick).Stop view restructuring:
src/modules/stop-view-controller.ts:renderStopView()(lines 63–133) — current decision tree: if station, show child stops + timetables; else show pathways + timetables. Expand to:renderChildStopsSection()(lines 341–387) — replace with threerenderTypedChildSection()calls keyed by location_type. Each usesrenderStopReference.renderPathwaysSection()(lines 300–336) — split intorenderPathwaysOutSection()andrenderPathwaysInSection(), both usingrenderPathwayReference. Each row label = "{mode} {to|from} {other stop display}".getChildStops()(lines 235–247) — keep as-is (returnsparent_station == station_id).getConnectedPathways()(lines 269–295) — refactor to return{out: Pathways[], in: Pathways[]}.PATHWAY_MODE_LABELS(lines 249–257) andLOCATION_TYPE_LABELS(lines 259–264) — keep; move PATHWAY_MODE_LABELS to a shared util if cleaner (it's also duplicated inpathway-view-controller.tslines 12–20).addEventListeners()(lines 459–509) —child-stop-btnandpathway-view-btnhandlers are bespoke; will be replaced by the globalSTOP_REF_ROW/PATHWAY_REF_ROWwiring in page-content-renderer. Delete the bespoke handlers.Invariants to preserve:
patchManager.record*— this plan doesn't add new DB writes, only renders, queries, and map filter changes.text-sizepaint expression also readsfeature-state.focusedso the X grows with the circle.stop_idas the GeoJSON featureid(layer-manager.ts line 131) — keep that. Same for "pathways" withpathway_id(line 649).Phase 1: Idle station icon (white + ✕) and ancestor-aware stops filter
Goal: Stations idle as slightly-larger white circles with a centered black ✕. The filter that shows "the station + its full descendant tree" works for grandchildren (boarding areas) too.
src/modules/layer-manager.tscreateStopsGeoJSON(): add a derived propertystation_idto each feature'sproperties. Compute it by walkingparent_stationup the chain: for a stop withlocation_type=1,station_id = stop_id; otherwise climbparent_stationuntil reaching alocation_type=1ancestor (or hit a coord-less root); cap at 5 hops as a safety. Store empty string if no ancestor station is found.addStopsBackgroundLayer(): update thecircle-radiusandcircle-colorcase expressions so stations are:circle-color: #ffffff(white fill),circle-stroke-color: #000000,circle-stroke-width: 2,circle-radius: 6circle-radius: 10(vs current focused 17 — smaller, "slightly bigger than non-station focused size" which isradius * 1.7 ≈ 6.8)addStationXLayer()called fromaddStopsLayer()after the background layer is added. It adds asymbollayer with idstops-station-x:source: 'stops'filter: ['==', ['get', 'location_type'], 1]layout:text-field: '✕',text-allow-overlap: true,text-ignore-placement: true,text-anchor: 'center',text-size: ['case', ['boolean', ['feature-state', 'focused'], false], 16, 10],text-font: ['Open Sans Regular', 'Arial Unicode MS Regular']paint:text-color: '#000000'clearAllLayers()andsetStopsFilter()so filter updates and teardowns include it.setStopsFilter(): extend to also updatestops-station-xlayer's filter (compose with the location_type=1 base filter — when active filter is the default, the station-x filter is just['==', ['get', 'location_type'], 1]; when active filter is the station-expandedstation_id == Xfilter, combine withall:['all', ['==', ['get', 'location_type'], 1], ...activeStopsFilter]).MapController.applyFocusedObject()(src/modules/map-controller.ts): change the station-expanded filter from filtering onparent_stationto filtering on the newstation_idderived property (usesany+stop_id == newStation || station_id == newStationto also include the station itself).layer-manager.tsclearAllLayers(): add'stops-station-x'tolayersToRemove.Discoveries:
createStopsGeoJSONwas updated to accept an optionalallStopsparameter sostation_idtraversal can walk stops that lack coords (which are excluded fromvalidStops). Both callers (addStopsLayerandupdateStopsData) now pass the full stops array. Thecircle-stroke-colorbecame a case expression so stations get#000000while other stop types keep the default stroke.Gotcha: the station's circle paint now has
circle-color: #ffffff. The existing focused state for stations should still grow it (radius 10) but the color should NOT change. Thecircle-colorcase expression already only checkslocation_type, notfeature-state.focused, so this is unchanged — just confirm the case order.Gotcha 2:
text-fontmay not be available depending on basemap. If MapLibre throws on missing glyph, fall back to omittingtext-fontor providing a definitely-shipped font. Check the active basemap'sglyphsURL first.Phase 2: Coord-less nodes routed through ancestor coords for pathway endpoints
Goal: A pathway endpoint that has no
stop_lat/stop_lon(typically an entrance or generic node with missing data) still draws — its line endpoint snaps to the nearest ancestor that has coords.src/modules/layer-manager.tsbuildPathwaysGeoJSON()(lines 606–665):parentByStopId: Map<string, string>where value isparent_station(skipping empty strings).resolveCoord(stop_id: string): [number, number] | nullthat returnscoordMap.get(stop_id)if present; otherwise walksparentByStopIdup the chain (cap 5 hops) and returns the first ancestor's coord, or null if none found.const from = coordMap.get(pw.from_stop_id)/coordMap.get(pw.to_stop_id)calls withresolveCoord(pw.from_stop_id)/resolveCoord(pw.to_stop_id).Gotcha: The pathway line will visually appear to start/end at the ancestor station. That's intentional. If both endpoints resolve to the same ancestor (degenerate zero-length line), still emit it — MapLibre will simply not render anything visible but it should not crash. (Optional safety: filter out zero-length lines by comparing coords; not required.)
Phase 3: Breadcrumb depth and empty-click navigate-up
Goal: Selecting a child stop or pathway shows the full ancestor chain in the breadcrumbs. Empty-click while a child/pathway is focused walks one level up rather than clearing focus entirely.
src/modules/page-state-manager.tsBreadcrumbLookupinterface (lines 28–33): add Each returns the chain ordered outermost-first (topmost station first), EXCLUDING the leaf object itself.buildBreadcrumbs():case 'stop'(lines 344–356): ifgetStopAncestorsis present, fetch ancestors, prepend each as a breadcrumb ({ label: ancestor.stop_name, pageState: { type: 'stop', stop_id: ancestor.stop_id } }) betweenHomeand the leaf stop. Leaf stop entry unchanged.case 'pathway'(lines 375–388): fetchgetPathwayAncestors(pathway_id)to get the chain (station → ... → from_stop), prepend each. Leaf stays asPathway {pathway_id}. If lookup returns empty, fall back to flatHome → Pathway X.src/modules/gtfs-breadcrumb-lookup.ts(Read first to confirm shape): implementgetStopAncestors:parent_station, return[].stops.txtfor the parent's row, push{stop_id, stop_name}, follow itsparent_station. Cap at 5 iterations. Reverse the list before returning so outermost ancestor is first.gtfs-breadcrumb-lookup.tsgetPathwayAncestors:pathways.txtfor the row bypathway_id. If not found, return[].getStopAncestors(from_stop_id). Then push{stop_id: from_stop_id, stop_name}as the last ancestor (since the pathway's "parent" is the from-stop). Return.src/modules/map-controller.ts: replace theonEmptyClickarrow at lines 305–308 with a call to a new private methodhandleEmptyClick(). Logic: Wire this viaonEmptyClick: () => { void this.handleEmptyClick(); }insetupModuleCallbacks.Gotcha: Going from a pathway → its from-stop (not its from-stop's parent) is one level up. From the from-stop, another empty-click then goes to its parent_station. This matches the user's mental model where pathway depth = stop depth + 1.
Gotcha 2: The
onEmptyClickcallback used to callthis.callbacks.onEmptyClick?.()(passing the empty click up to UI). Keep that for the cleared case (top-level), but NOT when we navigate-up — the panel will re-render via the page state change naturally.Phase 4: Shared
renderStopReferenceandrenderPathwayReferencehelpersGoal: Stop view's child-list and pathway-list rows use the same
entity-references.tshelpers as agency/service views.src/utils/entity-references.ts:STOP_REF_ROW = 'stop-ref-row'andPATHWAY_REF_ROW = 'pathway-ref-row'constants.renderStopReference(stop: Record<string, unknown>, opts: { locationTypeLabel?: string; viewLabel?: string }): string: UsesrenderCardLabel(getStopDisplay(stop as Record<string, string>))for the label. IfviewLabelis passed, render a "View" button with classENTITY_REF_BTNanddata-stop-idso existing wiring picks it up; otherwise the whole row is the click target.renderPathwayReference(pathway: Record<string, unknown>, opts: { modeLabel: string; otherStop?: Record<string, unknown>; direction: 'to' | 'from'; viewStopButton?: boolean }): string:"{modeLabel} {direction} {otherStop display}", e.g. "Stairs to platform_a (platform_a)".otherStopis undefined (orphan reference), just show the other stop id from the pathway row.data-pathway-id="..."on the row.viewStopButtonis true, render a "View Stop" button with classENTITY_REF_BTNanddata-stop-id="<other_stop_id>"so the same wiring picks it up.src/modules/page-content-renderer.tsaddEventListeners()(lines 708–797): add two new selector blocks:STOP_REF_ROWclicks → calldependencies.onStopClick(stop_id).PATHWAY_REF_ROWclicks → calldependencies.onPathwayClick(pathway_id)(only if defined).ENTITY_REF_BTNhandler (lines 746–755) to handledata-stop-id(calldependencies.onStopClick) in addition to the existingdata-service-idcase.e.stopPropagation()already prevents the row click from also firing.Gotcha:
ENTITY_REF_BTNis reused across route/service/stop "View" buttons. The handler currently checksdata-service-id. Add anif (stop_id)branch first, fall through toservice_idcase. Keepe.stopPropagation()so the row click handler doesn't also fire.Phase 5: Station view with three grouped child-stop sections
Goal: The station view (
location_type=1) displays its children in three labeled sections: Entrances/Exits, Platforms, Generic Nodes. Boarding areas are NOT shown here.src/modules/stop-view-controller.ts:groupChildrenByLocationType(children: Stops[]): { entrances: Stops[]; platforms: Stops[]; genericNodes: Stops[] }. Match by integerlocation_type: 2 → entrances, 0 → platforms, 3 → genericNodes. Skip 4 (boarding areas) silently — they don't belong here.renderChildStopsSection()with three calls torenderTypedChildSection(title: string, children: Stops[]): string. Each usesrenderStopReferencefromentity-references.ts. Sections are omitted if their group is empty.renderStopView()station branch to callrenderChildStopsSections(childStops)..child-stop-btnevent handler; globalSTOP_REF_ROWwiring in page-content-renderer covers it.LOCATION_TYPE_LABELS(now unused).Gotcha: Empty groups produce an empty section (don't render the heading). If a station has only platforms, only that section appears.
Phase 6: Non-station stop view — Pathways Out / In + Boarding Areas (if platform)
Goal: Non-station stop view shows two pathway sections (out + in), with rich labels. Platform stops additionally show their boarding-area children.
src/modules/stop-view-controller.ts:getConnectedPathways()(lines 269–295) to return{ out: Pathways[]; in: Pathways[] }. Currently de-dups across both — keep separate from now on. Also handle bidirectional pathways: a pathway withis_bidirectional === 1andfrom_stop_id == stop_idshows in BOTH Out and In sections (because traffic flows both ways); similarly ifto_stop_id == stop_id. Decision: for simplicity in v1, treat the row direction strictly byfrom/toregardless of bidirectionality. The mode label is the visual cue. (Revisit if confusing.)renderPathwaySection(title: string, pathways: Pathways[], direction: 'to' | 'from', currentStopId: string, otherStopLookup: Map<string, Stops>): stringthat:otherStopId = direction === 'to' ? pathway.to_stop_id : pathway.from_stop_id. Look upotherStopinotherStopLookup. Render withrenderPathwayReference({ modeLabel, otherStop, direction, viewStopButton: true }).otherStopLookuponce per render: collect allfrom_stop_id/to_stop_idfromout + inlists, batch-query stops table (already O(N) — using existingqueryRowswith a stop_id-by-stop_id loop is fine for typical pathway counts).renderBoardingAreasSection(platformId: string): Promise<string>: querystopsforparent_station == platform_id, filter tolocation_type === 4, render as a section usingrenderStopReference. Empty → skip.renderStopView()non-station branch (line 123):locationType === 0(platform): render Boarding Areas section first, then Pathways Out, then Pathways In, then timetables..pathway-view-btnhandler inaddEventListeners()(lines 473–484); the globalPATHWAY_REF_ROWwiring in page-content-renderer takes over for the row, and theENTITY_REF_BTNhandler takes over for the "View Stop" button.Gotcha — boarding area parent: Per GTFS spec, a boarding area's
parent_stationis the platform_id (not the station_id). So when we list boarding areas, we queryparent_station == platform_id(not the full station chain). The Phase 1station_idderived property on stops is still the top-level station, used only for the map filter.Gotcha — pathway label: Use the pathway mode label (e.g., "Stairs") + direction word + other stop display. Example primary text:
"Stairs to Platform A (platform_a)". If the other stop has nostop_name, falls back to(stop_id)only.Gotcha — eventListener cleanup: the existing
pathway-view-btnselector andchild-stop-btnselector handlers instop-view-controller.tsadd listeners on individual elements. Removing those without replacing breaks current behavior — make sure to land Phase 4 (which adds the global row-click wiring) before / together with Phase 5 and 6.Notes / sequencing
Summary
A follow-up polish pass on the GTFS pathways feature (after PRIOR_PLAN, PRIOR_PLAN2, and CURRENT_PLAN). Several visual and navigation regressions emerged during use, plus a few small gaps. The biggest issue: clicking a stop now does NOTHING visually — the circle stays at 1× (no growth at all), even though the feature-state-driven paint expression and
setFocusedStopcalls look correct on inspection. Additionally, the station ✕ overlay symbol layer isn't rendering at all — likely a missing-glyphs / wrong-font problem with the current basemaps. Both of these need to be debugged in-browser, not just edited blindly. We also have a broken pathway-row primary click (the dependency was added but never wired inbrowse-navigation.ts), missing stop_id in breadcrumbs/pathway labels (only name shown — and stop names are often non-descriptive), and a feed-fit bounds calculation that gets dragged toward (0,0) by null-island stops.The plan is split into six small phases. They are mostly independent — the dependency graph is shallow.
Tradeoffs considered:
"Name<br>id"(currentrenderCardLabel) vs"Name (id)"inline. The user picked inline because breadcrumbs and pathway-row text are single-line contexts where the stacked form doesn't fit cleanly. Stop list rows will switch from stacked to inline too for consistency.flyToStationalready exists but only fits direct children (parent_station == stationId). Using the newstation_idderived property (added in CURRENT_PLAN Phase 1) lets it include grandchildren (boarding areas) for free.Relevant Context
Stop highlight regression (Phase 1):
src/modules/layer-manager.ts:addStopsBackgroundLayer()(lines 239–307) — currentcircle-radiuscase expression: focused location_type=0 →options.radius * 1.7≈ 6.8; unfocused →options.radius= 4. Other location types also have small growth (5→8 for entrances/generic nodes, 7→11 for boarding areas, 6→10 for stations).setFocusedStop()(lines 588–610) — sets feature-statefocused: true/falseviamap.setFeatureState. Works correctly; the issue is purely the magnitude of the radius change in the paint expression.defaultHighlightOptions(lines 54–59) —radius: 8was the OLD highlight size (when the separatestops-highlightlayer existed). That layer was replaced in commitc3496c6by feature-state. We can revisit the multipliers to roughly match the old visual size (4 → 8, i.e. 2×) without re-introducing a separate layer.addStationXLayer()(lines 313–344) — focusedtext-size: 16, unfocused: 10. Should grow with the circle proportionally.stops-backgroundcircle stroke-width grows on focus fromoptions.strokeWidth(2) to 4. Keep this — it amplifies the focused look.Null-coord stops (Phase 2):
src/modules/map-controller.tsapplyFocusedObject()(lines 610–639) — callslayerManager.setFocusedStop(obj.id)for stop type. For a null-coord stop the map feature doesn't exist;setFeatureStatesilently no-ops (the try/catch insetFocusedStopswallows errors).src/modules/layer-manager.tscreateStopsGeoJSON()(lines 167–234) — already filters out coord-less stops from rendered features (via thevalidStopsfilter inaddStopsLayerline 116). The full stops list IS passed asallStopssostation_idcan climb the chain even for invisible stops. Good — no schema change needed for this phase.handleEmptyClickat map-controller.ts lines 783–812) — already navigates toparent_stationwhen focused stop has one. For null-coord stops this means: empty-click on the map → return to parent station focus. That matches the user's mental model.Inline stop labels (Phase 3):
src/utils/entity-display.ts:getStopDisplay()(lines 20–29) — returns{primary: name, secondary: id}when name exists, else{primary: id}.renderCardLabel()(lines 53–58) — produces stacked<span>name<br><span class="text-xs opacity-60">id</span></span>.renderOptionLabel()(lines 63–68) — already produces inline"name (id)". Reuse this for breadcrumbs and inline references; no new helper needed.src/utils/entity-references.ts:renderStopReference()(lines 134–149) — currently uses stackedrenderCardLabel. Switch to inline so list rows are tighter and the id is on the same line.renderPathwayReference()(lines 159–180) — primary text builds viagetStopDisplay(otherStop).primary, losing the id. Switch torenderOptionLabel(getStopDisplay(otherStop))so it shows"Stairs to Platform A (TS_P1)".src/modules/page-state-manager.ts:buildBreadcrumbs()case 'stop'(lines 350–371) — ancestor labels useancestor.stop_nameand leaf usesstopNamefromgetObjectName. Both need to be"{stop_name} ({stop_id})"(or juststop_idwhen no name). Easiest: changeBreadcrumbLookup.getStopAncestorsto return objects with adisplayfield already formatted, OR havebuildBreadcrumbsformat inline.getObjectName()(lines 441–466) — for type 'stop' returns just the name. Either change this to return the inline form, or compute it in buildBreadcrumbs from the lookup result.src/modules/gtfs-breadcrumb-lookup.tsgetStopName()(lines 72–89) — returns plainstop_name. PlusgetStopAncestors(lines 94–133) andgetPathwayAncestorsalready return{stop_id, stop_name}rows. We can format inline insidebuildBreadcrumbs(cleaner) or change the lookup to returndisplaystrings.Pathway primary click (Phase 4):
src/modules/browse-navigation.ts(lines 327–357) — sets up thedependenciespassed toPageContentRenderer. HasonStopClick,onRouteClick, etc. — but noonPathwayClick. That's the bug.navigateToPathwayalready exists insrc/modules/navigation-actions.ts:92.src/modules/page-content-renderer.ts(lines 198, 776–784) — already wiresonPathwayClickthrough toStopViewControllerand adds the row click listener. The handler simply skips if undefined, which is why pathway-card primary click silently does nothing.Station click zooms to descendants (Phase 5):
src/modules/map-controller.tsflyToStation()(lines 555–605) — filter iss.stop_id === stationId || s.parent_station === stationId. Only direct children. Switch to: use the samestation_idderived property computation we already do for the layer-manager filter — climb each stop'sparent_stationchain to find its top-most station ancestor, include if it matchesstationId.applyFocusedObject(lines 610–639) —flyToStationis called only whenoldStation !== newStation. So clicking an already-focused station (e.g., re-clicking via panel) doesn't re-fit. Decision: add a publicfocusOnStation(stationId)path that always fits, used when the user explicitly clicks a station via the map or a reference. The internal applyFocusedObject path keeps its current "only on change" behavior to avoid mid-interaction re-flying.oldStation !== newStationguard. Rejected — that would causeapplyFocusedObjectto re-fly every time the user clicks a child stop within the expanded station, which is annoying. Keep the guard, but add an explicit fly call on direct station map-click.fitMapToData lat/lon=0 filter (Phase 6):
src/modules/map-controller.tsfitMapToData()(lines 457–492) — filter:lat !== null && lon !== null && !isNaN(lat) && !isNaN(lon). Need to also excludelat === 0 && lon === 0.Invariants to preserve:
patchManager.record*(no DB changes in this plan).setFocusedStopfor a null-coord stop should NOT throw; it should silently no-op for the missing feature and apply focused state to the nearest coord-having ancestor instead (Phase 2 logic).Phase 1: Debug and fix broken stop-focus visual + missing station ✕
Goal: Two real visual bugs to fix here, not just a magnitude tweak.
stops-backgroundreadsfeature-state.focused, andsetFocusedStopcallsmap.setFeatureState({source:'stops', id: stop_id}, {focused: true}). Something in that chain is broken — the focused case branch never evaluates to true. The radius math (1.7× vs 2×) is irrelevant if the focused branch isn't firing.stops-station-xsymbol layer is being added (visible inaddStopsLayer→addStationXLayer) but the glyph doesn't render. Most likely cause: the active basemap's style has noglyphsURL, OR doesn't ship the'Open Sans Regular'/'Arial Unicode MS Regular'fonts referenced in the layer'stext-font. MapLibre silently drops symbol layers when no font can render the text.This phase is debug-driven — instrument first, then fix. Do NOT batch the radius tweaks with the bug fix; we want to isolate "the focused state is now firing" from "the size jump is now bigger." After the bug fix, the user must verify before we move to magnitude adjustments or Phase 2.
1a. Diagnose: why isn't
feature-state.focusedflipping the circle?Possible root causes (verify each with dipsticks, don't assume):
idset increateStopsGeoJSON(feature.id = properties.stop_id) doesn't match whatsetFeatureStateis targeting (e.g. type mismatch, oridgot stripped during a latersetDatacall).'case'expression's outer condition['boolean', ['feature-state', 'focused'], false]is being short-circuited becausesetFeatureStateerrored silently (the try/catch insetFocusedStopswallows the error).stopssource doesn't havepromoteIdset, but features have a top-levelidfield, which IS the correct way to enable feature-state per MapLibre docs. Confirm theidis in fact present aftersetData(it gets re-added each time inupdateStopsData).Steps:
setFocusedStop()(src/modules/layer-manager.ts:588), addconsole.log('[LayerManager] setFocusedStop', { prev: this.focusedStopId, next: stop_id, hasSource: !!this.map.getSource('stops') })at the top andconsole.log('[LayerManager] setFocusedStop applied')after the try block. Also log inside the catch with the full error.setFeatureState, immediately callthis.map.getFeatureState({source:'stops', id: stop_id})and log the result. Confirm it returns{focused: true}. If not, the call silently failed.setFocusedStopIS being called with the right id.getFeatureStatereturns{focused: true}right after.circle-radiusexpression onstops-backgroundwith a flat20(literal). If circles become huge → the source/layer is healthy and the issue is purely the expression. If they don't change at all → the layer isn't actually painting that source.['case', ['boolean', ['feature-state', 'focused'], false], 20, 4](no nested case). If THAT works on focus, the nested case is the bug — possibly a syntax issue (MapLibre's case expressions are picky about even/odd argument counts).stopssource/layer exists.caseexpression mis-structured. Counted: outercasehas condition + true-branch + false-branch (3 args). Each innercasehas 5 conditions/values + 1 fallback = 11 args (which is odd-formed forcasesincecaseneedscase, cond1, val1, cond2, val2, ..., default). 11 = 1 (case) + 52 (pairs) + 1 (default) = 12. Hmm, count the actual lines: 4['==',…]-value pairs plus the fallback = 42 + 1 = 9 args. Plus the'case'head = 10 elements. That looks valid. But verify by simplifying and re-introducing complexity incrementally.stop_idis numeric-looking but stored as string,setFeatureState({id: '123'})and the feature'sid: 123(number) won't match. Confirm by inspecting the GeoJSON output.updateStopsDatais called after a click,setDatashould preserve states but verify.[LayerManager] setFocusedStoplog at info level since the project's style is to log at state transitions per CLAUDE.md).1b. Diagnose: why isn't the station ✕ rendering?
addStationXLayer()(src/modules/layer-manager.ts:313), after adding the layer, logconsole.log('[LayerManager] addStationXLayer added', { layerExists: !!this.map.getLayer('stops-station-x'), styleHasGlyphs: !!this.map.getStyle().glyphs }).Inspect Map(or MapLibre debug tools): isstops-station-xlisted in layers? If yes but no rendering — font issue.style.glyphsis undefined or empty: that's the bug. The active basemap doesn't supply a glyph endpoint, so MapLibre can't render any symbol text. Fix one of two ways:circlelayer that overlays a thin black "cross" using two perpendicular lines via a custom rendering trick — too complex. Better: use a small black inner circle withcircle-radius2 as the X-substitute. Or pre-render a ✕ as an SVG icon, register it withmap.addImage('station-x', img), and switch totext-field-less symbol withicon-image: 'station-x'. Icons don't require glyph URLs.glyphsURL. Checksrc/modules/basemap-styles.tsandbasemap-control.tsto see which styles are registered. If any are bare{version:8,sources:{},layers:[]}skeleton styles, addglyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'(or whatever the project's font CDN is) and use a font available there.1c. Tune magnitudes only after the bug is fixed and verified by the user
These tweaks ONLY matter once the focused branch is actually firing. Hold this checkbox group until the user confirms 1a is working.
circle-radiuscase branch values inaddStopsBackgroundLayer():options.radius * 2(8 — was 6.8)addStationXLayer()(or whatever rendering approach 1b lands on): bump focused size by ~25% so the ✕ scales with the bigger focused station circle.circle-stroke-widthfrom 4 → 5.layer-manager.ts(around lines 800–815, wherepathways-linesusesfeature-state.focusedforline-width). If subtle, bump.1d. User verification gate
Gotcha: The
options.radiusis passed as 4 frommap-controller.updateMap(line 425). Usingoptions.radius * 2keeps the multiplier explicit, but the absolute values for other location types are literals — don't accidentally regress those.Gotcha 2: The clickarea radius (
options.clickAreaRadius= 15) is the click hit-test area; it doesn't affect the visible circle. Don't touch it.Gotcha 3: The
try/catchinsetFocusedStopusesconsole.debugfor errors, which is suppressed by default in many browsers. While debugging, temporarily upgrade toconsole.warnto ensure silent failures surface.Phase 2: Null-coord stops keep parent station focused on map
Goal: When the user navigates to a stop that has no
stop_lat/stop_lon(typically an entrance or generic node with missing data), the map should keep the nearest coord-having ancestor (usually the parent station) highlighted so there's visual feedback about which part of the map this stop belongs to. The user can still see the stop's properties in the panel, and the breadcrumb chain still works. Empty-clicking returns focus to the parent station.src/modules/layer-manager.ts, add a helperprivate resolveCoordHavingStop(stop_id: string): string | nullthat:parent_stationup the chain (cap 5 hops). Return the first ancestor with valid coords.nullif no ancestor has coords.buildPathwaysGeoJSONalready does for pathway endpoints. Consider extracting a shared utilsrc/utils/stop-coord-resolver.tsif the logic looks duplicated.src/modules/layer-manager.tssetFocusedStop(stop_id): whenstop_idis non-null, route throughresolveCoordHavingStop(stop_id). Set the feature-statefocused: trueon the RESOLVED id (the ancestor with coords), not on the originalstop_id. Also updatethis.focusedStopIdto track the resolved id so unfocusing later works correctly.stop_idthey care about; the layer manager handles the redirect internally.stop_lat/stop_lon. Navigate to the entrance from the parent station's child list. The station circle should grow on the map. The breadcrumb chain showsHome → Station → Entrance. The properties panel shows the entrance's fields. Empty-click on the map should navigate back to the station.Gotcha:
applyFocusedObjectseparately drives station expansion viaderiveExpandedStation. That logic ALREADY climbs to the parent station for non-station child stops, so expansion behavior is correct regardless of coords. Phase 2 only fixes the visual-focus aspect (which circle grows).Gotcha 2: The
lat=0 && lon=0case is the user's main null-island concern (Phase 6). TheresolveCoordHavingStophelper should treat (0,0) as "invalid coords" alongside null/NaN — otherwise a null-island stop would visually look "valid" to this resolver but get clipped from the bounds calc, which is inconsistent.Gotcha 3: When unfocusing,
setFocusedStop(null)already sets the prior focused id's state to false. Since we tracked the resolved id, this still works. But if the same ancestor is re-focused later (e.g. user clicks a different child of the same station), we end up doing set-false-then-set-true on the same id — fine, just a no-op visually.Phase 3: Inline "Name (id)" stop labels in references and breadcrumbs
Goal: Stop labels in lists, pathway-row text, and breadcrumbs all show both the name and id in a compact inline form. Stop names are often non-descriptive (e.g., generic "Platform" or empty) so the id is critical for disambiguation.
src/utils/entity-references.tsrenderStopReference(): replacerenderCardLabel(getStopDisplay(stop))withrenderOptionLabel(getStopDisplay(stop))wrapped in<div class="font-medium truncate">…</div>so it matches the visual weight of other reference rows. ImportrenderOptionLabelfromentity-display.src/utils/entity-references.tsrenderPathwayReference(): changeotherDisplaycomputation fromgetStopDisplay(otherStop).primarytorenderOptionLabel(getStopDisplay(otherStop)). Falls back tootherStopIdif nootherStoprow is found. Result:"Stairs to Platform A (TS_P1)".src/modules/page-state-manager.tsbuildBreadcrumbs()case 'stop':"{ancestor.stop_name} ({ancestor.stop_id})"ifstop_nameis non-empty, else justancestor.stop_id.stopNameviagetObjectName, but we don't have the id-only fallback path). Easiest: replace thestopName = await getObjectName(...)line with a direct lookup that returns{stop_name, stop_id}and format inline. Or extendBreadcrumbLookupwithgetStopDisplay(stop_id): Promise<string>that does the inline formatting.buildBreadcrumbsusing existingstop_id(we already have it) and the result ofgetStopName(the name). If name ===Stop ${stop_id}(the fallback), don't append the id again — show justStop ${stop_id}.src/modules/page-state-manager.tsbuildBreadcrumbs()case 'pathway': ancestor labels in the chain go through the same inline format. The leafPathway ${pageState.pathway_id}is fine as-is (pathways don't have names).Home → Station Name (S1) → Platform A (TS_P1) → BA Name (BA1).Gotcha — getStopDisplay fallback:
getStopDisplayreturns{primary: id}(no secondary) when name is empty.renderOptionLabelthen returns just the id. Good — no"(id)"duplication.Gotcha — stacked label callers:
renderCardLabelis still used in stop-properties header (renderStopProperties, stop-view-controller.ts line 218). That's the big detail header where stacked form is fine. Don't change it. OnlyrenderStopReferenceandrenderPathwayReferenceswitch to inline.Gotcha — agency/route/service refs: Other reference renderers (
renderRouteReference,renderServiceReference) userenderCardLabeland stack the secondary line. Leave them alone — only stops need the inline form because stop ids are typically the disambiguator.Phase 4: Wire onPathwayClick in browse-navigation so primary pathway-row click works
Goal: Clicking the body of a pathway row in the stop view (anywhere except the "View Stop" button) navigates to the pathway page. Currently this silently no-ops.
src/modules/browse-navigation.ts(the dependencies block around lines 327–357): importnavigateToPathwayfrom./navigation-actions.js. AddonPathwayClick: (pathway_id: string) => navigateToPathway(pathway_id)to the dependencies object passed toPageContentRenderer.PageContentRendereralready forwardsonPathwayClicktoStopViewControllerand wires thePATHWAY_REF_ROWclick listener.Gotcha: The "View Stop" button inside the same row uses
e.stopPropagation()on its handler, so it won't also trigger the row's pathway-click. Confirm by clicking the button — should navigate to the other stop, not the pathway.Phase 5: Clicking a station zooms to all descendant stops (incl. boarding areas)
Goal: When the user clicks a station (on the map, or via a reference), the map fits to the bounding box of the station + all descendants (platforms, entrances, generic nodes, AND boarding areas). Currently
flyToStationonly includes direct children.src/modules/map-controller.tsflyToStation()(lines 555–605): replace the filters.stop_id === stationId || s.parent_station === stationIdwith one that includes all descendants. Easiest approach:stopById = new Map(stops.map(s => [s.stop_id, s]))lookup.parent_stationchain (cap 5 hops). Include the stop ifstationIdappears anywhere in the chain (or equals the stop's own id).resolveStationIdlogic thatlayer-manager.createStopsGeoJSONalready implements — copy or extract to a shared util. (Recommendation: extractresolveStationId(stop, stopById): string | nullto a shared utilsrc/utils/stop-hierarchy.tsso both call sites stay in sync.)flyToStation(): also exclude stops with invalid coords (lat/lon null/NaN/both-0), consistent with Phase 6. A descendant entrance with no coords shouldn't break the bounds.Gotcha — re-clicking same station:
applyFocusedObject(the only caller offlyToStation) skips the fly whenoldStation === newStation. So re-clicking a focused station does NOT re-fit. This is intentional (avoids jarring re-fly when navigating to children within an expanded station). If the user reports they want re-click to re-fit too, add an explicit "always fly" path later — but defer that until requested.Gotcha — shared helper: If extracting
resolveStationIdto a util, update bothlayer-manager.createStopsGeoJSONand the newflyToStationfilter to use it. Keep the 5-hop safety cap.Phase 6: Skip lat=0,lon=0 stops in fitMapToData bounds
Goal: A feed with placeholder/null-island stops at (0,0) shouldn't drag the whole-feed bounds out into the ocean off west Africa.
src/modules/map-controller.tsfitMapToData()(lines 457–492): extend thevalidStopsfilter to also exclude stops wherestop_lat === 0 && stop_lon === 0. Keep the existing null/NaN guards.Gotcha: Do NOT use
lat === 0 || lon === 0— that would drop valid stops on the equator (lat=0) or prime meridian (lon=0). The conjunction (&&) treats only the (0,0) point as the null sentinel.Gotcha — consistency with Phase 2: Phase 2's
resolveCoordHavingStophelper uses the same "(0,0) is invalid" rule. Phase 5's updatedflyToStationalso uses it. Three places — consider extractinghasValidCoords(stop): booleantosrc/utils/stop-hierarchy.ts(or wherever Phase 5'sresolveStationIdlands).Notes / sequencing
src/utils/stop-hierarchy.tswithhasValidCoords,resolveStationId,resolveCoordHavingStopduring whichever of these phases lands first.Name (id)at each step.