Implement GTFS Pathways #96

Open
opened 2026-04-21 23:46:23 +00:00 by maxtkc · 4 comments
Owner

Summary

This feature adds full GTFS station-hierarchy and pathways support to the editor. Stops with a parent_station are hidden on the main map until the parent station is selected; selecting a station zooms in and reveals all child stops with distinct icons per location_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, and level_id on 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 existing patchManager-gated write flow.

Relevant Context

Types / spec already complete:

  • src/gtfs-spec/files/pathways.ts — full field spec for pathways.txt
  • src/gtfs-spec/files/levels.ts — full field spec for levels.txt
  • src/types/gtfs.tsPathways and Levels type aliases, GTFS_TABLES.PATHWAYS / LEVELS
  • src/utils/gtfs-primary-keys.ts lines 172-182 — primary keys already declared

Database (IndexedDB via idb):

  • src/modules/gtfs-database.tsGTFSStoreName union (lines 40-58), GTFSDBSchema interface (lines 67-152), addIndexesForTable() (lines 405-508), schema version = 8 (line 175)
  • stops object store already has indexes on location_type (line 428) and parent_station (lines 429-430)

Map rendering:

  • src/modules/layer-manager.tsaddStopsLayer() (lines 87-141), addStopsBackgroundLayer() (lines 176-210), addStopsClickAreaLayer() (lines 211-230). All stops currently rendered identically.
  • src/modules/interaction-handler.tshandleNavigationClick() (lines 150-187), drag logic (lines 289-400), handleAddStopClick() (lines 192-284). Stop creation hardcodes location_type: 0 and parent_station: '' (lines 234-241).
  • src/modules/map-controller.ts — map state, owns layerManager and interactionHandler

Stop detail panel:

  • src/modules/stop-view-controller.tsrenderStopView() (lines 39-69), renderStopProperties() (lines 74-101). Uses generic renderEntityFields — all stop fields already render, but no relational sections for children/pathways.

UI / modals:

  • src/modules/modal-utils.tsshowModal(options) (lines 19-104)
  • src/modules/ui.tstoggleAddStopMode() (lines 1242-1253), updateMapToolButtonState()
  • src/index.html — add-stop button at lines 433-452
  • src/modules/gtfs-parser.tscreateStop() (lines 1157-1185)

Search:

  • src/modules/search-controller.ts line 187 — already uses location_type for emoji (🚉 vs 🚏); no other use in modules

Phase 1: Database Schema + Import Foundation

Add pathways and levels as first-class IndexedDB object stores so the rest of the app can read/write them, and ensure CSV import/export round-trips correctly.

  • In src/modules/gtfs-database.ts — add 'pathways' and 'levels' to the GTFSStoreName union (after line 58)
  • In GTFSDBSchema interface — add store definitions:
    pathways: { key: string; value: Pathways; indexes: { from_stop_id: string; to_stop_id: string; pathway_mode: number } }
    levels:   { key: string; value: Levels;   indexes: { level_index: number } }
    
  • In addIndexesForTable() — add case 'pathways': (indexes: from_stop_id, to_stop_id, pathway_mode) and case 'levels': (index: level_index)
  • Bump DB schema version from 8 → 9 (triggers onupgradeneeded for existing users)
  • Smoke-test: import a GTFS feed that contains pathways.txt and levels.txt and verify rows appear in the IndexedDB viewer (DevTools → Application → IndexedDB)

Gotcha: The database initialize() method loops through GTFS_FILES to create stores dynamically. Confirm pathways.txt and levels.txt are already in GTFS_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_id field in the stop editor becomes a dropdown populated from the levels table.

  • In 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>
  • Create src/modules/levels-controller.ts — a controller class with:
    • showLevelsModal() — fetches all levels from DB, renders them in a showModal call as an editable table (level_id, level_index, level_name) with Add / Delete row actions. Each cell edit triggers patchManager.recordUpdate() + DB write.
    • addLevel() — creates a new level via showModal (level_id, level_index, level_name fields), calls gtfsParser-style insertRows + patchManager.recordInsert()
    • deleteLevel(level_id) — removes from DB + patchManager.recordDelete()
    • getLevelOptions() — returns Array<{value: string, label: string}> for dropdown use
  • Wire levels-btn click → levelsController.showLevelsModal() in src/index.ts
  • In src/modules/stop-view-controller.ts — override the rendering of the level_id field: after renderEntityFields runs, replace the level_id text input with a <select> populated from levelsController.getLevelOptions(). On change, write via the patch system.

Gotcha: If no levels exist, the level_id dropdown should show an empty option plus a hint "Add levels via nav bar".

Implementation notes:

  • LevelsController is wired through index.ts → browseNavigation.setLevelsController() → PageContentRenderer dependency → StopViewDependencies.getLevelOptions. The levels button in the nav is hidden on mobile (md:flex).
  • The level_id select uses a regex replace on the HTML string returned by renderEntityFields, targeting data-field="level_id". The resulting <select> keeps all data attributes so the existing attachFormPatchListeners bridge handles change events automatically.
  • showLevelsModal shows the table then chains to showAddLevelModal for creation — no inline cell editing (add/delete only). This is simpler and sufficient for the use case.
  • The savedLevel* pattern in showAddLevelModal pre-fills inputs if the modal is re-opened (after a validation failure), but since showModal always 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

  • In layer-manager.ts addStopsBackgroundLayer() and addStopsClickAreaLayer() — add a MapLibre filter expression. Filter stored as activeStopsFilter on LayerManager; default shows empty-parent-station stops and all stations (location_type=1).
  • Differentiate stop symbols by location_type via circle-color case 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 number 1.

3b: Station-expanded view state

  • Add expandedStationId: string | null to MapController
  • expandStation(stationId) — filters map to station+children only, flies to bounding box; collapseStation() — resets to default filter
  • handleStopClick() in MapController checks location_type; if station, calls expand/collapse before navigating to stop view (both expand and navigate happen together)
  • onEmptyClick callback calls collapseStation(); updateMap() also resets filter on feed reload
  • Station stop view shows "Child Stops" section; child stop rows have View buttons that call onStopClick (wired via ContentRendererDependencies.onStopClick)

3c: Stop creation in station context

  • handleAddStopClick() — checks getExpandedStationId callback; if a station is expanded, shows location_type dropdown (Platform/Entrance/Generic Node/Boarding Area) and pre-fills parent_station; modal title changes to "New Child Stop"

Implementation notes:

  • parent_station field added to GeoJSON properties in createStopsGeoJSON so filters can operate on it
  • FilterSpecification imported from maplibre-gl for proper typing; setStopsFilter(filter: FilterSpecification | null) on LayerManager
  • setGetExpandedStationId callback pattern used in InteractionHandler to avoid circular dependency with MapController

Phase 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.

  • In layer-manager.ts — add addPathwaysLayer():
    • Build a GeoJSON FeatureCollection of LineStrings: for each pathway row, fetch the from_stop and to_stop coordinates from the stops source, create a line feature with all pathway fields as properties
    • Add MapLibre source 'pathways' and a line layer 'pathways-lines'
    • Style by pathway_mode with different colors (green=walkway, orange=stairs, cyan=moving sidewalk, purple=escalator, blue=elevator, red=fare gate, gray=exit gate)
    • Add a wider invisible click-area line layer 'pathways-clickarea'
  • In layer-manager.ts — add updatePathwaysLayer(stationId) called when a station is expanded: builds pathway GeoJSON for the expanded station, adds layers underneath stops; also clearPathwaysLayer() and rebuildPathwaysSource(stationId) for cleanup and drag updates
  • In interaction-handler.ts — in handleNavigationClick(), add a query against 'pathways-clickarea'. If a pathway feature is hit, emit an onPathwayClick(pathway_id) callback (stop clicks take priority)
  • Create src/modules/pathway-view-controller.ts — renders pathway detail in the right panel:
    • Uses renderEntityFields(GTFSSchemas['pathways.txt'], pathway, 'pathways.txt', pathway_id) for all fields
    • Shows a delete button (patch-gated via handleDeletePathway in page-content-renderer)
    • from_stop_id / to_stop_id displayed as clickable buttons linking to stop views

Implementation notes:

  • { type: 'pathway'; pathway_id: string } added to PageState union in page-state.ts; isPageState(), pageStateToURL(), urlToPageState(), and getBreadcrumbs() all updated accordingly
  • navigateToPathway(pathway_id) added to navigation-actions.ts
  • map-controller.ts: expandStation() calls layerManager.updatePathwaysLayer(stationId); collapseStation() calls layerManager.clearPathwaysLayer(); handleStopDragComplete() calls layerManager.rebuildPathwaysSource(expandedStationId) if a station is expanded; handlePathwayClick() navigates via pageStateManager
  • page-content-renderer.ts: PathwayViewController instantiated and wired; renderPathway() case added to switch; handleDeletePathway() does DB delete + patch record
  • attachFormPatchListeners in page-content-renderer handles data-table="pathways.txt" fields automatically — no extra wiring needed for inline field edits
  • line-dasharray data-driven expression removed (MapLibre compatibility); colors alone differentiate modes

Phase 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.

  • In 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)
  • In src/modules/ui.ts — add toggleAddPathwayMode(): mirrors toggleAddStopMode(), updates button active state; disable the button when no station is expanded
  • Add ADD_PATHWAY to the map interaction mode enum (alongside NAVIGATE and ADD_STOP) in interaction-handler.ts
  • In interaction-handler.ts — new handleAddPathwayClick() method with two-click flow:
    • First click: stores from_stop_id in addPathwayFirstStopId, shows info notification "From: X. Now click the second stop."
    • Second click: opens showModal for pathway_mode + is_bidirectional; on confirm calls gtfsParser.createPathway() + fires onPathwayCreated callback; exits ADD_PATHWAY mode
    • Mode change away from ADD_PATHWAY clears addPathwayFirstStopId
  • In stop-view-controller.ts — non-station stops show a "Pathways" section with connected pathways (queried via two queryRows calls: from_stop_id + to_stop_id), each linking to the pathway view via onPathwayClick
  • Enable the "Add Pathway" button only when mapController.expandedStationId is non-null via onStationExpandChange callback

Implementation notes:

  • createPathway() added to GTFSParser (mirrors createStop): handles gtfsData init, insertRows, patchManager.recordInsert, and updatePathwaysFileContent
  • onStationExpandChange callback added to MapControllerCallbacks; called from expandStation() and collapseStation(); wired in UIController.setupMapCallbacks() to call updateMapToolButtonState()
  • onPathwayCreated added to InteractionCallbacks; MapController.handlePathwayCreated() rebuilds pathways layer and navigates via pageStateManager
  • onPathwayClick passed into StopViewDependencies from page-content-renderer.ts
  • Pathways section only renders when there are connected pathways (empty = section hidden)
  • Exit gate (mode 7) + bidirectional validation in the modal

Original Issue

This is probably a big step, but it would be super cool.

We need to leverage location_type much more. If the stop has a parent_station, it should be hidden behind the parent_station unless the station is selected. When the parent_station is selected, we should zoom in and show all of the child stops. The child stops should also be selectable/movable/etc as the parent stations are. When a station is selected, we should use different symbols or something to show the station, the generic nodes, and the boarding areas.

Then, we should show pathways with direct lines between each of the nodes. These should be selectable as well, opening them up in the browser.

We should have an add pathway button that is greyed out when a stop is not selected. This should allow the selecting of two stops and be similar to the add stop button.

We should also support levels, in that the level_id can be a dropdown. Lets have levels be a modal that gets opened from the nav bar.

## Summary This feature adds full GTFS station-hierarchy and pathways support to the editor. Stops with a `parent_station` are hidden on the main map until the parent station is selected; selecting a station zooms in and reveals all child stops with distinct icons per `location_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, and `level_id` on 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 existing `patchManager`-gated write flow. ## Relevant Context **Types / spec already complete:** - `src/gtfs-spec/files/pathways.ts` — full field spec for `pathways.txt` - `src/gtfs-spec/files/levels.ts` — full field spec for `levels.txt` - `src/types/gtfs.ts` — `Pathways` and `Levels` type aliases, `GTFS_TABLES.PATHWAYS / LEVELS` - `src/utils/gtfs-primary-keys.ts` lines 172-182 — primary keys already declared **Database (IndexedDB via `idb`):** - `src/modules/gtfs-database.ts` — `GTFSStoreName` union (lines 40-58), `GTFSDBSchema` interface (lines 67-152), `addIndexesForTable()` (lines 405-508), schema version = 8 (line 175) - `stops` object store already has indexes on `location_type` (line 428) and `parent_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 hardcodes `location_type: 0` and `parent_station: ''` (lines 234-241). - `src/modules/map-controller.ts` — map state, owns `layerManager` and `interactionHandler` **Stop detail panel:** - `src/modules/stop-view-controller.ts` — `renderStopView()` (lines 39-69), `renderStopProperties()` (lines 74-101). Uses generic `renderEntityFields` — 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-452 - `src/modules/gtfs-parser.ts` — `createStop()` (lines 1157-1185) **Search:** - `src/modules/search-controller.ts` line 187 — already uses `location_type` for emoji (🚉 vs 🚏); no other use in modules --- ## Phase 1: Database Schema + Import Foundation Add `pathways` and `levels` as first-class IndexedDB object stores so the rest of the app can read/write them, and ensure CSV import/export round-trips correctly. - [x] In `src/modules/gtfs-database.ts` — add `'pathways'` and `'levels'` to the `GTFSStoreName` union (after line 58) - [x] In `GTFSDBSchema` interface — add store definitions: ```ts pathways: { key: string; value: Pathways; indexes: { from_stop_id: string; to_stop_id: string; pathway_mode: number } } levels: { key: string; value: Levels; indexes: { level_index: number } } ``` - [x] In `addIndexesForTable()` — add `case 'pathways':` (indexes: `from_stop_id`, `to_stop_id`, `pathway_mode`) and `case 'levels':` (index: `level_index`) - [x] Bump DB schema version from 8 → 9 (triggers `onupgradeneeded` for existing users) - [x] Smoke-test: import a GTFS feed that contains `pathways.txt` and `levels.txt` and verify rows appear in the IndexedDB viewer (DevTools → Application → IndexedDB) **Gotcha:** The database `initialize()` method loops through `GTFS_FILES` to create stores dynamically. Confirm `pathways.txt` and `levels.txt` are already in `GTFS_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_id` field in the stop editor becomes a dropdown populated from the levels table. - [x] In `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>` - [x] Create `src/modules/levels-controller.ts` — a controller class with: - `showLevelsModal()` — fetches all levels from DB, renders them in a `showModal` call as an editable table (level_id, level_index, level_name) with Add / Delete row actions. Each cell edit triggers `patchManager.recordUpdate()` + DB write. - `addLevel()` — creates a new level via `showModal` (level_id, level_index, level_name fields), calls `gtfsParser`-style `insertRows` + `patchManager.recordInsert()` - `deleteLevel(level_id)` — removes from DB + `patchManager.recordDelete()` - `getLevelOptions()` — returns `Array<{value: string, label: string}>` for dropdown use - [x] Wire `levels-btn` click → `levelsController.showLevelsModal()` in `src/index.ts` - [x] In `src/modules/stop-view-controller.ts` — override the rendering of the `level_id` field: after `renderEntityFields` runs, replace the `level_id` text input with a `<select>` populated from `levelsController.getLevelOptions()`. On change, write via the patch system. **Gotcha:** If no levels exist, the `level_id` dropdown should show an empty option plus a hint "Add levels via nav bar". **Implementation notes:** - `LevelsController` is wired through `index.ts → browseNavigation.setLevelsController() → PageContentRenderer dependency → StopViewDependencies.getLevelOptions`. The levels button in the nav is hidden on mobile (md:flex). - The `level_id` select uses a regex replace on the HTML string returned by `renderEntityFields`, targeting `data-field="level_id"`. The resulting `<select>` keeps all data attributes so the existing `attachFormPatchListeners` bridge handles change events automatically. - `showLevelsModal` shows the table then chains to `showAddLevelModal` for creation — no inline cell editing (add/delete only). This is simpler and sufficient for the use case. - The `savedLevel*` pattern in `showAddLevelModal` pre-fills inputs if the modal is re-opened (after a validation failure), but since `showModal` always 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 - [x] In `layer-manager.ts` `addStopsBackgroundLayer()` and `addStopsClickAreaLayer()` — add a MapLibre filter expression. Filter stored as `activeStopsFilter` on LayerManager; default shows empty-parent-station stops and all stations (location_type=1). - [x] Differentiate stop symbols by `location_type` via `circle-color` case 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 number `1`. ### 3b: Station-expanded view state - [x] Add `expandedStationId: string | null` to `MapController` - [x] `expandStation(stationId)` — filters map to station+children only, flies to bounding box; `collapseStation()` — resets to default filter - [x] `handleStopClick()` in `MapController` checks location_type; if station, calls expand/collapse before navigating to stop view (both expand and navigate happen together) - [x] `onEmptyClick` callback calls `collapseStation()`; `updateMap()` also resets filter on feed reload - [x] Station stop view shows "Child Stops" section; child stop rows have View buttons that call `onStopClick` (wired via `ContentRendererDependencies.onStopClick`) ### 3c: Stop creation in station context - [x] `handleAddStopClick()` — checks `getExpandedStationId` callback; if a station is expanded, shows location_type dropdown (Platform/Entrance/Generic Node/Boarding Area) and pre-fills `parent_station`; modal title changes to "New Child Stop" **Implementation notes:** - `parent_station` field added to GeoJSON properties in `createStopsGeoJSON` so filters can operate on it - `FilterSpecification` imported from maplibre-gl for proper typing; `setStopsFilter(filter: FilterSpecification | null)` on LayerManager - `setGetExpandedStationId` callback pattern used in InteractionHandler to avoid circular dependency with MapController --- ## Phase 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. - [x] In `layer-manager.ts` — add `addPathwaysLayer()`: - Build a GeoJSON FeatureCollection of LineStrings: for each pathway row, fetch the `from_stop` and `to_stop` coordinates from the stops source, create a line feature with all pathway fields as properties - Add MapLibre source `'pathways'` and a line layer `'pathways-lines'` - Style by `pathway_mode` with different colors (green=walkway, orange=stairs, cyan=moving sidewalk, purple=escalator, blue=elevator, red=fare gate, gray=exit gate) - Add a wider invisible click-area line layer `'pathways-clickarea'` - [x] In `layer-manager.ts` — add `updatePathwaysLayer(stationId)` called when a station is expanded: builds pathway GeoJSON for the expanded station, adds layers underneath stops; also `clearPathwaysLayer()` and `rebuildPathwaysSource(stationId)` for cleanup and drag updates - [x] In `interaction-handler.ts` — in `handleNavigationClick()`, add a query against `'pathways-clickarea'`. If a pathway feature is hit, emit an `onPathwayClick(pathway_id)` callback (stop clicks take priority) - [x] Create `src/modules/pathway-view-controller.ts` — renders pathway detail in the right panel: - Uses `renderEntityFields(GTFSSchemas['pathways.txt'], pathway, 'pathways.txt', pathway_id)` for all fields - Shows a delete button (patch-gated via `handleDeletePathway` in page-content-renderer) - `from_stop_id` / `to_stop_id` displayed as clickable buttons linking to stop views **Implementation notes:** - `{ type: 'pathway'; pathway_id: string }` added to `PageState` union in `page-state.ts`; `isPageState()`, `pageStateToURL()`, `urlToPageState()`, and `getBreadcrumbs()` all updated accordingly - `navigateToPathway(pathway_id)` added to `navigation-actions.ts` - `map-controller.ts`: `expandStation()` calls `layerManager.updatePathwaysLayer(stationId)`; `collapseStation()` calls `layerManager.clearPathwaysLayer()`; `handleStopDragComplete()` calls `layerManager.rebuildPathwaysSource(expandedStationId)` if a station is expanded; `handlePathwayClick()` navigates via `pageStateManager` - `page-content-renderer.ts`: `PathwayViewController` instantiated and wired; `renderPathway()` case added to switch; `handleDeletePathway()` does DB delete + patch record - `attachFormPatchListeners` in page-content-renderer handles `data-table="pathways.txt"` fields automatically — no extra wiring needed for inline field edits - `line-dasharray` data-driven expression removed (MapLibre compatibility); colors alone differentiate modes --- ## Phase 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. - [x] In `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) - [x] In `src/modules/ui.ts` — add `toggleAddPathwayMode()`: mirrors `toggleAddStopMode()`, updates button active state; disable the button when no station is expanded - [x] Add `ADD_PATHWAY` to the map interaction mode enum (alongside `NAVIGATE` and `ADD_STOP`) in `interaction-handler.ts` - [x] In `interaction-handler.ts` — new `handleAddPathwayClick()` method with two-click flow: - First click: stores `from_stop_id` in `addPathwayFirstStopId`, shows info notification "From: X. Now click the second stop." - Second click: opens `showModal` for `pathway_mode` + `is_bidirectional`; on confirm calls `gtfsParser.createPathway()` + fires `onPathwayCreated` callback; exits ADD_PATHWAY mode - Mode change away from ADD_PATHWAY clears `addPathwayFirstStopId` - [x] In `stop-view-controller.ts` — non-station stops show a "Pathways" section with connected pathways (queried via two `queryRows` calls: `from_stop_id` + `to_stop_id`), each linking to the pathway view via `onPathwayClick` - [x] Enable the "Add Pathway" button only when `mapController.expandedStationId` is non-null via `onStationExpandChange` callback **Implementation notes:** - `createPathway()` added to `GTFSParser` (mirrors `createStop`): handles `gtfsData` init, `insertRows`, `patchManager.recordInsert`, and `updatePathwaysFileContent` - `onStationExpandChange` callback added to `MapControllerCallbacks`; called from `expandStation()` and `collapseStation()`; wired in `UIController.setupMapCallbacks()` to call `updateMapToolButtonState()` - `onPathwayCreated` added to `InteractionCallbacks`; `MapController.handlePathwayCreated()` rebuilds pathways layer and navigates via `pageStateManager` - `onPathwayClick` passed into `StopViewDependencies` from `page-content-renderer.ts` - Pathways section only renders when there are connected pathways (empty = section hidden) - Exit gate (mode 7) + bidirectional validation in the modal --- ## Original Issue > This is probably a big step, but it would be super cool. > > We need to leverage location_type much more. If the stop has a parent_station, it should be hidden behind the parent_station unless the station is selected. When the parent_station is selected, we should zoom in and show all of the child stops. The child stops should also be selectable/movable/etc as the parent stations are. When a station is selected, we should use different symbols or something to show the station, the generic nodes, and the boarding areas. > > Then, we should show pathways with direct lines between each of the nodes. These should be selectable as well, opening them up in the browser. > > We should have an add pathway button that is greyed out when a stop is not selected. This should allow the selecting of two stops and be similar to the add stop button. > > We should also support levels, in that the level_id can be a dropdown. Lets have levels be a modal that gets opened from the nav bar.
maxtkc self-assigned this 2026-04-21 23:46:23 +00:00
Author
Owner

needs lots of fixing up, but it's moving the right direction

needs lots of fixing up, but it's moving the right direction
Author
Owner

Summary

The current implementation maintains two separate pieces of map state — expandedStationId (which station is showing its children) and currentHighlight ({ type, id }) — that are kept in sync manually and breed special-casing throughout MapController. In practice the expanded station is always derivable from the focused object (e.g. a child stop's parent_station, a pathway's from_stop_id's parent_station), so the two fields should be one. This refactor introduces a single FocusedObject discriminated 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-highlight layer 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 sets id: stop_id on 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 internals

Key callsites:

  • page-content-renderer.ts line 653: mapController.highlightStop(stop_id) — the external entry point for focusing a stop from the panel
  • page-content-renderer.ts line 486: mapController.highlightRoute(route_id)
  • interaction-handler.ts line 265: setGetExpandedStationId(() => this.expandedStationId) — the IH reads expanded station to contextualise new-stop creation; this becomes a getter over deriveExpandedStation()

Layer plumbing:

  • layer-manager.ts addStopsBackgroundLayer() (line 197) — paint expressions drive stop circle size by location_type; needs feature-state-aware radius + stroke-width
  • layer-manager.ts highlightStop() (line 296) — creates a separate stops-highlight source+layer with hardcoded white; to be retired for single-stop focus (trip path stops still use stops-highlight, so the trip variant stays)
  • layer-manager.ts buildPathwaysGeoJSON() (line 593) — features lack a GeoJSON id; needed for feature state
  • layer-manager.ts updatePathwaysLayer() (line 657) — pathways-lines paint needs feature-state-aware line-width

Invariants:

  • Feature state on the 'stops' source uses string IDs because buildStopsGeoJSON already sets id: feature.properties?.stop_id on each feature (lines 126–131).
  • The 'pathways' source is only present when a station is expanded; setFocusedPathway must guard against the source being absent.
  • Trip highlighting (highlightTrip) uses stops-highlight source/layer for the path stops — keep that untouched. Only the single-stop highlightStop() path is replaced.

Phase 1: Unified Focus State

Goal: one field (focusedObject) drives all map focus/expansion state. No more expandedStationId + currentHighlight duality.

  • In src/modules/map-controller.ts: define type FocusedObject = { type: 'stop'; id: string } | { type: 'pathway'; id: string } | { type: 'route'; id: string } | { type: 'trip'; id: string } | { type: 'none' } (export it — InteractionHandler doesn't need it but getCurrentHighlight callers may)
  • Remove expandedStationId: string | null = null and currentHighlight: {...} fields; add private focusedObject: FocusedObject = { type: 'none' }
  • Add private deriveExpandedStation(): string | null:
    • type === 'stop': look up stop; if location_type === 1 return stop.stop_id; else if parent_station is non-empty return parent_station; else return null
    • type === 'pathway': look up pathway, then look up from_stop_id stop; return its parent_station if non-empty, else from_stop_id itself if it's a station, else null
    • all other types: return null
  • Add private applyFocusedObject(obj: FocusedObject): void:
    • Capture const oldStation = this.deriveExpandedStation()
    • Set this.focusedObject = obj
    • Capture const newStation = this.deriveExpandedStation()
    • If oldStation !== newStation:
      • If newStation: call layerManager.setStopsFilter([...station+children filter...]) and layerManager.updatePathwaysLayer(newStation); fly to station bounds (extract from expandStation logic)
      • Else: call layerManager.setStopsFilter(null) and layerManager.clearPathwaysLayer()
      • Fire callbacks.onStationExpandChange?.()
  • Refactor handleStopClick: remove locationType === 1 special-case; call applyFocusedObject({ type: 'stop', id: stop_id }) before navigation
  • Refactor handlePathwayClick: call applyFocusedObject({ type: 'pathway', id: pathway_id })
  • Refactor handleRouteClick: call applyFocusedObject({ type: 'route', id: route_id })
  • Refactor highlightStop(stop_id): call applyFocusedObject({ type: 'stop', id: stop_id }) (visual part arrives in phase 2)
  • Refactor highlightTrip(trip_id): call applyFocusedObject({ type: 'trip', id: trip_id }) then existing layerManager.highlightTrip for path stops
  • Refactor highlightRoute(route_id): call applyFocusedObject({ type: 'route', id: route_id }) then existing route renderer highlight
  • Refactor clearHighlights(): call applyFocusedObject({ type: 'none' }); still call layerManager.clearHighlights() (for trip-path stops-highlight) and routeRenderer.clearHighlight()
  • Expose public getExpandedStationId(): string | null getter returning deriveExpandedStation() (replaces direct this.expandedStationId reads); update initializeModules line: this.interactionHandler.setGetExpandedStationId(() => this.getExpandedStationId())
  • Update getCurrentHighlight(): derive from focusedObject (map type through; 'pathway' has no prior analog — return { type: 'none', id: null } or add 'pathway' to the return type)
  • updateMap(): replace this.expandedStationId = null with this.focusedObject = { type: 'none' } (no applyFocusedObject call needed here; layers are being fully rebuilt anyway)
  • Remove public expandStation() and collapseStation() — or keep private if still useful as helpers called from applyFocusedObject
  • onEmptyClick in setupModuleCallbacks: simplify to just this.clearHighlights(); this.callbacks.onEmptyClick?.() (no separate collapseStation() since clearHighlightsapplyFocusedObject({ type: 'none' }) handles collapse)
  • Basemap change handler (~line 352): restore from focusedObject instead of currentHighlight

Gotcha: handlePathwayCreated calls layerManager.rebuildPathwaysSource(this.expandedStationId!) — change to this.deriveExpandedStation(). Same for handleStopDragComplete (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-stop stops-highlight source/layer is removed.

  • In src/modules/layer-manager.ts: add private focusedStopId: string | null = null
  • Add public setFocusedStop(stop_id: string | null): void:
    • If this.focusedStopId !== null and source exists: map.setFeatureState({ source: 'stops', id: this.focusedStopId }, { focused: false })
    • Set this.focusedStopId = stop_id
    • If stop_id !== null and source exists: map.setFeatureState({ source: 'stops', id: stop_id }, { focused: true })
    • Wrap entire body in try/catch (source may not exist during initialisation)
  • In addStopsBackgroundLayer: replace the circle-radius paint expression with a feature-state-aware outer wrapper:
    'circle-radius': ['case',
      ['boolean', ['feature-state', 'focused'], false],
      <focused-sizes>,   // ~1.7× the normal sizes
      <normal-sizes>,    // current expression
    ]
    
    Where <focused-sizes> mirrors the existing type-based case but with values 17 / 8 / 8 / 11 / options.radius * 1.7 for types 1/2/3/4/default respectively. Also apply to circle-stroke-width: ['case', ['boolean', ['feature-state', 'focused'], false], 4, options.strokeWidth]
  • In MapController.applyFocusedObject() (from phase 1): add calls to layerManager.setFocusedStop(obj.type === 'stop' ? obj.id : null)
  • In LayerManager.clearHighlights(): add this.setFocusedStop(null) (clears focus when trips are highlighted, etc.)
  • Remove the single-stop branch of LayerManager.highlightStop() — the method can remain but should just delegate to setFocusedStop and skip the source/layer creation. The stops-highlight source+layer in that method is only created for the single-stop case; the trip path case lives in addTripHighlightLayers. So: gut highlightStop() body and replace with this.setFocusedStop(stop_id); the layer-ordering and routing logic for trips stays in addTripHighlightLayers untouched.

Gotcha: setFocusedStop sets feature state by string ID. The 'stops' source already assigns id: feature.properties?.stop_id to each GeoJSON feature (line 129). No promoteId config is needed. But confirm stop_id strings are used as the feature ID (not coerced to numbers) — MapLibre string feature IDs work fine with setFeatureState.


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.

  • In LayerManager.buildPathwaysGeoJSON(): add id: pw.pathway_id to each feature object (as a top-level property alongside type, geometry, properties):
    { type: 'Feature', id: pw.pathway_id, geometry: {...}, properties: {...} }
    
  • Add private focusedPathwayId: string | null = null to LayerManager
  • Add public setFocusedPathway(pathway_id: string | null): void:
    • Clear old feature state on 'pathways' source if source exists and focusedPathwayId !== null: map.setFeatureState({ source: 'pathways', id: this.focusedPathwayId }, { focused: false })
    • Set this.focusedPathwayId = pathway_id
    • If pathway_id !== null and source exists: map.setFeatureState({ source: 'pathways', id: pathway_id }, { focused: true })
    • Wrap in try/catch
  • In updatePathwaysLayer: change pathways-lines paint line-width from 3 to:
    ['case', ['boolean', ['feature-state', 'focused'], false], 6, 3]
    
  • In clearPathwaysLayer(): call this.setFocusedPathway(null) before removing layers/source
  • In MapController.applyFocusedObject() (phase 1): add layerManager.setFocusedPathway(obj.type === 'pathway' ? obj.id : null) alongside the setFocusedStop call

Discovery: Added an extra else if (newStation) branch in applyFocusedObject to handle the case where the station stays expanded but you click a different pathway within it — pathway highlight updates without any station transition.

Gotcha: setFocusedPathway is called from applyFocusedObject on every focus change, but the 'pathways' source only exists when a station is expanded. The try/catch in setFocusedPathway handles 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 because updatePathwaysLayer is called (in applyFocusedObject) after setting the new focusedPathwayId, and the feature IDs are present in the built GeoJSON, so subsequent setFeatureState calls will succeed once the source exists.

Actually — order matters: applyFocusedObject calls updatePathwaysLayer to add the source, then immediately sets the feature state. So: ensure setFocusedPathway is called after updatePathwaysLayer within applyFocusedObject.

Post-merge note (merged main 2026-05-11): Resolved conflicts in gtfs-database.ts (kept both Pathways/Levels from branch and RiderCategories/FareMedia/FareProducts from main), map-controller.ts (merged Pathways + Agency imports and agency-helpers), and stop-view-controller.ts (merged LevelOption/escapeAttr from branch with normalizeAgencyId from main). All phases 1–3 checklist items remain accurate.

## Summary The current implementation maintains two separate pieces of map state — `expandedStationId` (which station is showing its children) and `currentHighlight` (`{ type, id }`) — that are kept in sync manually and breed special-casing throughout `MapController`. In practice the expanded station is always derivable from the focused object (e.g. a child stop's `parent_station`, a pathway's `from_stop_id`'s `parent_station`), so the two fields should be one. This refactor introduces a single `FocusedObject` discriminated 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-highlight` layer 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 sets `id: stop_id` on 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 internals **Key callsites:** - `page-content-renderer.ts` line 653: `mapController.highlightStop(stop_id)` — the external entry point for focusing a stop from the panel - `page-content-renderer.ts` line 486: `mapController.highlightRoute(route_id)` - `interaction-handler.ts` line 265: `setGetExpandedStationId(() => this.expandedStationId)` — the IH reads expanded station to contextualise new-stop creation; this becomes a getter over `deriveExpandedStation()` **Layer plumbing:** - `layer-manager.ts` `addStopsBackgroundLayer()` (line 197) — paint expressions drive stop circle size by `location_type`; needs feature-state-aware radius + stroke-width - `layer-manager.ts` `highlightStop()` (line 296) — creates a separate `stops-highlight` source+layer with hardcoded white; to be retired for single-stop focus (trip path stops still use `stops-highlight`, so the trip variant stays) - `layer-manager.ts` `buildPathwaysGeoJSON()` (line 593) — features lack a GeoJSON `id`; needed for feature state - `layer-manager.ts` `updatePathwaysLayer()` (line 657) — `pathways-lines` paint needs feature-state-aware `line-width` **Invariants:** - Feature state on the `'stops'` source uses string IDs because `buildStopsGeoJSON` already sets `id: feature.properties?.stop_id` on each feature (lines 126–131). - The `'pathways'` source is only present when a station is expanded; `setFocusedPathway` must guard against the source being absent. - Trip highlighting (`highlightTrip`) uses `stops-highlight` source/layer for the path stops — keep that untouched. Only the single-stop `highlightStop()` path is replaced. --- ## Phase 1: Unified Focus State Goal: one field (`focusedObject`) drives all map focus/expansion state. No more `expandedStationId` + `currentHighlight` duality. - [x] In `src/modules/map-controller.ts`: define `type FocusedObject = { type: 'stop'; id: string } | { type: 'pathway'; id: string } | { type: 'route'; id: string } | { type: 'trip'; id: string } | { type: 'none' }` (export it — `InteractionHandler` doesn't need it but `getCurrentHighlight` callers may) - [x] Remove `expandedStationId: string | null = null` and `currentHighlight: {...}` fields; add `private focusedObject: FocusedObject = { type: 'none' }` - [x] Add `private deriveExpandedStation(): string | null`: - `type === 'stop'`: look up stop; if `location_type === 1` return `stop.stop_id`; else if `parent_station` is non-empty return `parent_station`; else return null - `type === 'pathway'`: look up pathway, then look up `from_stop_id` stop; return its `parent_station` if non-empty, else `from_stop_id` itself if it's a station, else null - all other types: return null - [x] Add `private applyFocusedObject(obj: FocusedObject): void`: - Capture `const oldStation = this.deriveExpandedStation()` - Set `this.focusedObject = obj` - Capture `const newStation = this.deriveExpandedStation()` - If `oldStation !== newStation`: - If `newStation`: call `layerManager.setStopsFilter([...station+children filter...])` and `layerManager.updatePathwaysLayer(newStation)`; fly to station bounds (extract from `expandStation` logic) - Else: call `layerManager.setStopsFilter(null)` and `layerManager.clearPathwaysLayer()` - Fire `callbacks.onStationExpandChange?.()` - [x] Refactor `handleStopClick`: remove `locationType === 1` special-case; call `applyFocusedObject({ type: 'stop', id: stop_id })` before navigation - [x] Refactor `handlePathwayClick`: call `applyFocusedObject({ type: 'pathway', id: pathway_id })` - [x] Refactor `handleRouteClick`: call `applyFocusedObject({ type: 'route', id: route_id })` - [x] Refactor `highlightStop(stop_id)`: call `applyFocusedObject({ type: 'stop', id: stop_id })` (visual part arrives in phase 2) - [x] Refactor `highlightTrip(trip_id)`: call `applyFocusedObject({ type: 'trip', id: trip_id })` then existing `layerManager.highlightTrip` for path stops - [x] Refactor `highlightRoute(route_id)`: call `applyFocusedObject({ type: 'route', id: route_id })` then existing route renderer highlight - [x] Refactor `clearHighlights()`: call `applyFocusedObject({ type: 'none' })`; still call `layerManager.clearHighlights()` (for trip-path stops-highlight) and `routeRenderer.clearHighlight()` - [x] Expose `public getExpandedStationId(): string | null` getter returning `deriveExpandedStation()` (replaces direct `this.expandedStationId` reads); update `initializeModules` line: `this.interactionHandler.setGetExpandedStationId(() => this.getExpandedStationId())` - [x] Update `getCurrentHighlight()`: derive from `focusedObject` (map type through; `'pathway'` has no prior analog — return `{ type: 'none', id: null }` or add 'pathway' to the return type) - [x] `updateMap()`: replace `this.expandedStationId = null` with `this.focusedObject = { type: 'none' }` (no `applyFocusedObject` call needed here; layers are being fully rebuilt anyway) - [x] Remove public `expandStation()` and `collapseStation()` — or keep private if still useful as helpers called from `applyFocusedObject` - [x] `onEmptyClick` in `setupModuleCallbacks`: simplify to just `this.clearHighlights(); this.callbacks.onEmptyClick?.()` (no separate `collapseStation()` since `clearHighlights` → `applyFocusedObject({ type: 'none' })` handles collapse) - [x] Basemap change handler (~line 352): restore from `focusedObject` instead of `currentHighlight` **Gotcha:** `handlePathwayCreated` calls `layerManager.rebuildPathwaysSource(this.expandedStationId!)` — change to `this.deriveExpandedStation()`. Same for `handleStopDragComplete` (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-stop `stops-highlight` source/layer is removed. - [x] In `src/modules/layer-manager.ts`: add `private focusedStopId: string | null = null` - [x] Add `public setFocusedStop(stop_id: string | null): void`: - If `this.focusedStopId !== null` and source exists: `map.setFeatureState({ source: 'stops', id: this.focusedStopId }, { focused: false })` - Set `this.focusedStopId = stop_id` - If `stop_id !== null` and source exists: `map.setFeatureState({ source: 'stops', id: stop_id }, { focused: true })` - Wrap entire body in try/catch (source may not exist during initialisation) - [x] In `addStopsBackgroundLayer`: replace the `circle-radius` paint expression with a feature-state-aware outer wrapper: ``` 'circle-radius': ['case', ['boolean', ['feature-state', 'focused'], false], <focused-sizes>, // ~1.7× the normal sizes <normal-sizes>, // current expression ] ``` Where `<focused-sizes>` mirrors the existing type-based case but with values `17 / 8 / 8 / 11 / options.radius * 1.7` for types `1/2/3/4/default` respectively. Also apply to `circle-stroke-width`: `['case', ['boolean', ['feature-state', 'focused'], false], 4, options.strokeWidth]` - [x] In `MapController.applyFocusedObject()` (from phase 1): add calls to `layerManager.setFocusedStop(obj.type === 'stop' ? obj.id : null)` - [x] In `LayerManager.clearHighlights()`: add `this.setFocusedStop(null)` (clears focus when trips are highlighted, etc.) - [x] Remove the single-stop branch of `LayerManager.highlightStop()` — the method can remain but should just delegate to `setFocusedStop` and skip the source/layer creation. The `stops-highlight` source+layer in that method is only created for the single-stop case; the trip path case lives in `addTripHighlightLayers`. So: gut `highlightStop()` body and replace with `this.setFocusedStop(stop_id)`; the layer-ordering and routing logic for trips stays in `addTripHighlightLayers` untouched. **Gotcha:** `setFocusedStop` sets feature state by string ID. The 'stops' source already assigns `id: feature.properties?.stop_id` to each GeoJSON feature (line 129). No `promoteId` config is needed. But confirm `stop_id` strings are used as the feature ID (not coerced to numbers) — MapLibre string feature IDs work fine with `setFeatureState`. --- ## 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`. - [x] In `LayerManager.buildPathwaysGeoJSON()`: add `id: pw.pathway_id` to each feature object (as a top-level property alongside `type`, `geometry`, `properties`): ```ts { type: 'Feature', id: pw.pathway_id, geometry: {...}, properties: {...} } ``` - [x] Add `private focusedPathwayId: string | null = null` to `LayerManager` - [x] Add `public setFocusedPathway(pathway_id: string | null): void`: - Clear old feature state on 'pathways' source if source exists and `focusedPathwayId !== null`: `map.setFeatureState({ source: 'pathways', id: this.focusedPathwayId }, { focused: false })` - Set `this.focusedPathwayId = pathway_id` - If `pathway_id !== null` and source exists: `map.setFeatureState({ source: 'pathways', id: pathway_id }, { focused: true })` - Wrap in try/catch - [x] In `updatePathwaysLayer`: change `pathways-lines` paint `line-width` from `3` to: ``` ['case', ['boolean', ['feature-state', 'focused'], false], 6, 3] ``` - [x] In `clearPathwaysLayer()`: call `this.setFocusedPathway(null)` before removing layers/source - [x] In `MapController.applyFocusedObject()` (phase 1): add `layerManager.setFocusedPathway(obj.type === 'pathway' ? obj.id : null)` alongside the `setFocusedStop` call **Discovery:** Added an extra `else if (newStation)` branch in `applyFocusedObject` to handle the case where the station stays expanded but you click a different pathway within it — pathway highlight updates without any station transition. **Gotcha:** `setFocusedPathway` is called from `applyFocusedObject` on every focus change, but the 'pathways' source only exists when a station is expanded. The try/catch in `setFocusedPathway` handles 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 because `updatePathwaysLayer` is called (in `applyFocusedObject`) after setting the new `focusedPathwayId`, and the feature IDs are present in the built GeoJSON, so subsequent `setFeatureState` calls will succeed once the source exists. Actually — order matters: `applyFocusedObject` calls `updatePathwaysLayer` to add the source, then immediately sets the feature state. So: ensure `setFocusedPathway` is called *after* `updatePathwaysLayer` within `applyFocusedObject`. **Post-merge note (merged main 2026-05-11):** Resolved conflicts in `gtfs-database.ts` (kept both `Pathways`/`Levels` from branch and `RiderCategories`/`FareMedia`/`FareProducts` from main), `map-controller.ts` (merged `Pathways` + `Agency` imports and agency-helpers), and `stop-view-controller.ts` (merged `LevelOption`/`escapeAttr` from branch with `normalizeAgencyId` from main). All phases 1–3 checklist items remain accurate.
Author
Owner

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:

  1. Idle station icon — currently a solid blue circle; replace with a slightly-larger white circle + black ✕ overlay so unfocused stations read as a marker rather than competing with stops.
  2. Coord-less nodes — entrances/generic-nodes/platforms that lack stop_lat/stop_lon are 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.
  3. Hierarchy depth in breadcrumbs + navigate-up on empty click — clicking a child stop should produce Home → Station → Platform; clicking a boarding area should produce Home → 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.
  4. Reuse related-object renderers — stop view's child-list and pathway-list rows are bespoke. Add STOP_REF_ROW / PATHWAY_REF_ROW + renderStopReference / renderPathwayReference in entity-references.ts (mirroring renderRouteReference, renderServiceReference) and switch stop view to them.
  5. Station view groupings — split the single "Child Stops" list into three sections: Entrances/Exits (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.
  6. Platform view: Boarding Areas section — when viewing a platform, list its boarding-area children.
  7. Pathways Out / Pathways In — split the existing combined Pathways section into two: outgoing (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-size will 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 via text-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 include parent_station. Need a new derived station_id property (the topmost station ancestor) so the expanded-station filter can include grandchildren (boarding areas).
    • setStopsFilter() (lines 291–299) called from map-controller.applyFocusedObject with parent_station filter. Change to filter on station_id (climbs full chain).
    • addStopsLayer() (lines 106–160) — currently validStops filter 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: keep validStops as-is for map rendering (no-coord stops still don't render as circles), but have buildPathwaysGeoJSON fall 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) — coordMap only contains stops with coords. For a missing endpoint, walk parent_station up the chain to find an ancestor with coords; use those coords as the endpoint.
  • New layer: stops-station-x symbol layer rendering text ✕ centered on each station feature. Paint text-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 above stops-background so the X paints on top of the white circle.

Hierarchy depth (breadcrumbs + empty-click navigate-up):

  • src/modules/page-state-manager.ts:
    • BreadcrumbLookup interface (lines 28–33) — currently getStopName(stop_id). Add getStopAncestors(stop_id): Promise<Array<{ stop_id: string; stop_name: string }>> returning the chain from outermost (closest-to-station) to immediate parent (excluding stop_id itself).
    • buildBreadcrumbs() (lines 250–409):
      • case 'stop' (lines 344–356) — replace flat Home → Stop with Home → [ancestor breadcrumbs...] → Stop. Each ancestor breadcrumb is { label: ancestorName, pageState: { type: 'stop', stop_id: ancestorId } }.
      • case 'pathway' (lines 375–388) — look up the pathway's from_stop_id, get its ancestors + itself, then append Pathway X as the leaf. Adds a new lookup; see below.
  • src/modules/gtfs-breadcrumb-lookup.ts (existing file — confirm via Read) — implementation of BreadcrumbLookup. Add getStopAncestors that queries stops table, follows parent_station recursively (max ~5 hops as safety), returns the chain ordered station-first.
  • For pathway breadcrumbs we also need getPathwayFromStopId(pathway_id) (or expose the pathway row). Simplest: extend BreadcrumbLookup with getPathwayAncestors(pathway_id): Promise<Array<{stop_id, stop_name}>> that internally queries pathways → finds from_stop_id → calls getStopAncestors + appends from_stop itself.
  • src/modules/map-controller.ts:
    • setupModuleCallbacks onEmptyClick (lines 305–308) — currently calls clearHighlights() unconditionally. Replace with handleEmptyClick():
      • If focusedObject.type === 'stop': look up the stop; if parent_station is non-empty, call applyFocusedObject({type:'stop', id: parent_station}) AND navigate via pageStateManager. Else (station or root stop): clearHighlights + onEmptyClick callback.
      • If focusedObject.type === 'pathway': look up pathway, find from_stop_id's parent_station; navigate there. (If somehow no parent, clear.)
      • Else: clearHighlights as before.

Related-object renderers (shared helpers):

  • src/utils/entity-references.ts — currently exports renderRouteReference, renderServiceReference, ROUTE_REF_ROW, SERVICE_REF_ROW, ENTITY_REF_BTN. Add renderStopReference and renderPathwayReference, plus STOP_REF_ROW, PATHWAY_REF_ROW.
  • src/utils/entity-display.ts — already has getStopDisplay. No pathway display helper exists; pathways don't have a pathway_name field — display is mode label + endpoints.
  • src/modules/page-content-renderer.ts addEventListeners (lines 708–797) — currently wires ROUTE_REF_ROW and SERVICE_REF_ROW clicks. Add wiring for STOP_REF_ROW (calls dependencies.onStopClick) and PATHWAY_REF_ROW (calls dependencies.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:
      • Station (loc_type=1): three sections (Entrances/Exits, Platforms, Generic Nodes) + timetables.
      • Platform (loc_type=0): Boarding Areas section + Pathways Out + Pathways In + timetables.
      • Other non-station (loc_type 2/3/4): Pathways Out + Pathways In + timetables.
    • renderChildStopsSection() (lines 341–387) — replace with three renderTypedChildSection() calls keyed by location_type. Each uses renderStopReference.
    • renderPathwaysSection() (lines 300–336) — split into renderPathwaysOutSection() and renderPathwaysInSection(), both using renderPathwayReference. Each row label = "{mode} {to|from} {other stop display}".
    • getChildStops() (lines 235–247) — keep as-is (returns parent_station == station_id).
    • getConnectedPathways() (lines 269–295) — refactor to return {out: Pathways[], in: Pathways[]}.
    • PATHWAY_MODE_LABELS (lines 249–257) and LOCATION_TYPE_LABELS (lines 259–264) — keep; move PATHWAY_MODE_LABELS to a shared util if cleaner (it's also duplicated in pathway-view-controller.ts lines 12–20).
    • addEventListeners() (lines 459–509) — child-stop-btn and pathway-view-btn handlers are bespoke; will be replaced by the global STOP_REF_ROW / PATHWAY_REF_ROW wiring in page-content-renderer. Delete the bespoke handlers.

Invariants to preserve:

  • All user-initiated DB writes still go through patchManager.record* — this plan doesn't add new DB writes, only renders, queries, and map filter changes.
  • Feature-state highlighting on focused stop/pathway must keep working — when the station-X symbol layer is added, ensure its text-size paint expression also reads feature-state.focused so the X grows with the circle.
  • The "stops" source already promotes stop_id as the GeoJSON feature id (layer-manager.ts line 131) — keep that. Same for "pathways" with pathway_id (line 649).
  • Coord-less stop handling: a stop with no coords still has a DB row (queried by name in the panel) but no GeoJSON feature on the map. Pathways referencing it route through its nearest coord-having ancestor.

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.

  • In src/modules/layer-manager.ts createStopsGeoJSON(): add a derived property station_id to each feature's properties. Compute it by walking parent_station up the chain: for a stop with location_type=1, station_id = stop_id; otherwise climb parent_station until reaching a location_type=1 ancestor (or hit a coord-less root); cap at 5 hops as a safety. Store empty string if no ancestor station is found.
  • In addStopsBackgroundLayer(): update the circle-radius and circle-color case expressions so stations are:
    • idle: circle-color: #ffffff (white fill), circle-stroke-color: #000000, circle-stroke-width: 2, circle-radius: 6
    • focused: circle-radius: 10 (vs current focused 17 — smaller, "slightly bigger than non-station focused size" which is radius * 1.7 ≈ 6.8)
    • non-station idle/focused unchanged
  • Add a new method addStationXLayer() called from addStopsLayer() after the background layer is added. It adds a symbol layer with id stops-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'
    • Include this layer id in clearAllLayers() and setStopsFilter() so filter updates and teardowns include it.
  • In setStopsFilter(): extend to also update stops-station-x layer'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-expanded station_id == X filter, combine with all: ['all', ['==', ['get', 'location_type'], 1], ...activeStopsFilter]).
  • In MapController.applyFocusedObject() (src/modules/map-controller.ts): change the station-expanded filter from filtering on parent_station to filtering on the new station_id derived property (uses any + stop_id == newStation || station_id == newStation to also include the station itself).
  • In layer-manager.ts clearAllLayers(): add 'stops-station-x' to layersToRemove.

Discoveries: createStopsGeoJSON was updated to accept an optional allStops parameter so station_id traversal can walk stops that lack coords (which are excluded from validStops). Both callers (addStopsLayer and updateStopsData) now pass the full stops array. The circle-stroke-color became a case expression so stations get #000000 while 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. The circle-color case expression already only checks location_type, not feature-state.focused, so this is unchanged — just confirm the case order.

Gotcha 2: text-font may not be available depending on basemap. If MapLibre throws on missing glyph, fall back to omitting text-font or providing a definitely-shipped font. Check the active basemap's glyphs URL 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.

  • In src/modules/layer-manager.ts buildPathwaysGeoJSON() (lines 606–665):
    • Build a parent-chain lookup: parentByStopId: Map<string, string> where value is parent_station (skipping empty strings).
    • Add a helper resolveCoord(stop_id: string): [number, number] | null that returns coordMap.get(stop_id) if present; otherwise walks parentByStopId up the chain (cap 5 hops) and returns the first ancestor's coord, or null if none found.
    • Replace the const from = coordMap.get(pw.from_stop_id) / coordMap.get(pw.to_stop_id) calls with resolveCoord(pw.from_stop_id) / resolveCoord(pw.to_stop_id).
    • Keep the "return early if either is null" guard — pathways that still can't resolve get skipped.

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.

  • In src/modules/page-state-manager.ts BreadcrumbLookup interface (lines 28–33): add
    getStopAncestors: (stop_id: string) => Promise<Array<{ stop_id: string; stop_name: string }>>;
    getPathwayAncestors: (pathway_id: string) => Promise<Array<{ stop_id: string; stop_name: string }>>;
    
    Each returns the chain ordered outermost-first (topmost station first), EXCLUDING the leaf object itself.
  • In buildBreadcrumbs():
    • case 'stop' (lines 344–356): if getStopAncestors is present, fetch ancestors, prepend each as a breadcrumb ({ label: ancestor.stop_name, pageState: { type: 'stop', stop_id: ancestor.stop_id } }) between Home and the leaf stop. Leaf stop entry unchanged.
    • case 'pathway' (lines 375–388): fetch getPathwayAncestors(pathway_id) to get the chain (station → ... → from_stop), prepend each. Leaf stays as Pathway {pathway_id}. If lookup returns empty, fall back to flat Home → Pathway X.
  • In src/modules/gtfs-breadcrumb-lookup.ts (Read first to confirm shape): implement getStopAncestors:
    • Fetch the stop. If no parent_station, return [].
    • Walk up: at each step query stops.txt for the parent's row, push {stop_id, stop_name}, follow its parent_station. Cap at 5 iterations. Reverse the list before returning so outermost ancestor is first.
    • Cache results in a per-call map if performance matters (probably not — chains are 1–3 deep).
  • In gtfs-breadcrumb-lookup.ts getPathwayAncestors:
    • Query pathways.txt for the row by pathway_id. If not found, return [].
    • Call 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.
  • In src/modules/map-controller.ts: replace the onEmptyClick arrow at lines 305–308 with a call to a new private method handleEmptyClick(). Logic:
    private async handleEmptyClick(): Promise<void> {
      const obj = this.focusedObject;
      const stops = this.gtfsParser?.getFileDataSyncTyped<Stops>('stops.txt') || [];
      const pathways = this.gtfsParser?.getFileDataSyncTyped<Pathways>('pathways.txt') || [];
    
      let parentStopId: string | null = null;
      if (obj.type === 'stop') {
        const stop = stops.find((s) => s.stop_id === obj.id);
        parentStopId = stop?.parent_station ? String(stop.parent_station) : null;
      } else if (obj.type === 'pathway') {
        const pw = pathways.find((p) => p.pathway_id === obj.id);
        const from = pw ? stops.find((s) => s.stop_id === pw.from_stop_id) : null;
        // Pathway's "parent" = its from-stop; navigate up to that stop (not its parent)
        parentStopId = from?.stop_id ?? null;
      }
    
      if (parentStopId) {
        this.applyFocusedObject({ type: 'stop', id: parentStopId });
        if (this.pageStateManager) {
          await this.pageStateManager.setPageState({ type: 'stop', stop_id: parentStopId });
        }
      } else {
        this.clearHighlights();
        this.callbacks.onEmptyClick?.();
      }
    }
    
    Wire this via onEmptyClick: () => { void this.handleEmptyClick(); } in setupModuleCallbacks.

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 onEmptyClick callback used to call this.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 renderStopReference and renderPathwayReference helpers

Goal: Stop view's child-list and pathway-list rows use the same entity-references.ts helpers as agency/service views.

  • In src/utils/entity-references.ts:
    • Add STOP_REF_ROW = 'stop-ref-row' and PATHWAY_REF_ROW = 'pathway-ref-row' constants.
    • Add renderStopReference(stop: Record<string, unknown>, opts: { locationTypeLabel?: string; viewLabel?: string }): string:
      // Renders: <div class="...flex... STOP_REF_ROW" data-stop-id="X">
      //   <stop name (id)> <badge locationTypeLabel> <button "View"/>?>
      // </div>
      
      Uses renderCardLabel(getStopDisplay(stop as Record<string, string>)) for the label. If viewLabel is passed, render a "View" button with class ENTITY_REF_BTN and data-stop-id so existing wiring picks it up; otherwise the whole row is the click target.
    • Add renderPathwayReference(pathway: Record<string, unknown>, opts: { modeLabel: string; otherStop?: Record<string, unknown>; direction: 'to' | 'from'; viewStopButton?: boolean }): string:
      • Primary label: "{modeLabel} {direction} {otherStop display}", e.g. "Stairs to platform_a (platform_a)".
      • If otherStop is undefined (orphan reference), just show the other stop id from the pathway row.
      • Adds data-pathway-id="..." on the row.
      • If viewStopButton is true, render a "View Stop" button with class ENTITY_REF_BTN and data-stop-id="<other_stop_id>" so the same wiring picks it up.
  • In src/modules/page-content-renderer.ts addEventListeners() (lines 708–797): add two new selector blocks:
    • STOP_REF_ROW clicks → call dependencies.onStopClick(stop_id).
    • PATHWAY_REF_ROW clicks → call dependencies.onPathwayClick(pathway_id) (only if defined).
    • Also extend the existing ENTITY_REF_BTN handler (lines 746–755) to handle data-stop-id (call dependencies.onStopClick) in addition to the existing data-service-id case. e.stopPropagation() already prevents the row click from also firing.

Gotcha: ENTITY_REF_BTN is reused across route/service/stop "View" buttons. The handler currently checks data-service-id. Add an if (stop_id) branch first, fall through to service_id case. Keep e.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.

  • In src/modules/stop-view-controller.ts:
    • Add a private helper groupChildrenByLocationType(children: Stops[]): { entrances: Stops[]; platforms: Stops[]; genericNodes: Stops[] }. Match by integer location_type: 2 → entrances, 0 → platforms, 3 → genericNodes. Skip 4 (boarding areas) silently — they don't belong here.
    • Replace renderChildStopsSection() with three calls to renderTypedChildSection(title: string, children: Stops[]): string. Each uses renderStopReference from entity-references.ts. Sections are omitted if their group is empty.
    • Updated renderStopView() station branch to call renderChildStopsSections(childStops).
    • Deleted the bespoke .child-stop-btn event handler; global STOP_REF_ROW wiring in page-content-renderer covers it.
    • Removed 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.

  • In src/modules/stop-view-controller.ts:
    • Refactor 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 with is_bidirectional === 1 and from_stop_id == stop_id shows in BOTH Out and In sections (because traffic flows both ways); similarly if to_stop_id == stop_id. Decision: for simplicity in v1, treat the row direction strictly by from/to regardless of bidirectionality. The mode label is the visual cue. (Revisit if confusing.)
    • Add a helper renderPathwaySection(title: string, pathways: Pathways[], direction: 'to' | 'from', currentStopId: string, otherStopLookup: Map<string, Stops>): string that:
      • Empties → skips the section.
      • Each row: compute otherStopId = direction === 'to' ? pathway.to_stop_id : pathway.from_stop_id. Look up otherStop in otherStopLookup. Render with renderPathwayReference({ modeLabel, otherStop, direction, viewStopButton: true }).
    • Build otherStopLookup once per render: collect all from_stop_id/to_stop_id from out + in lists, batch-query stops table (already O(N) — using existing queryRows with a stop_id-by-stop_id loop is fine for typical pathway counts).
    • Add renderBoardingAreasSection(platformId: string): Promise<string>: query stops for parent_station == platform_id, filter to location_type === 4, render as a section using renderStopReference. Empty → skip.
    • Update renderStopView() non-station branch (line 123):
      • If locationType === 0 (platform): render Boarding Areas section first, then Pathways Out, then Pathways In, then timetables.
      • Else (entrance/exit, generic node, boarding area): render Pathways Out, Pathways In, timetables.
    • Delete the bespoke .pathway-view-btn handler in addEventListeners() (lines 473–484); the global PATHWAY_REF_ROW wiring in page-content-renderer takes over for the row, and the ENTITY_REF_BTN handler takes over for the "View Stop" button.

Gotcha — boarding area parent: Per GTFS spec, a boarding area's parent_station is the platform_id (not the station_id). So when we list boarding areas, we query parent_station == platform_id (not the full station chain). The Phase 1 station_id derived 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 no stop_name, falls back to (stop_id) only.

Gotcha — eventListener cleanup: the existing pathway-view-btn selector and child-stop-btn selector handlers in stop-view-controller.ts add 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

  • Phases 1 and 2 are map-layer-only and can land in either order; both are independent of phases 3–6.
  • Phase 3 (breadcrumb + empty-click) depends on the existing focused-object infra from PRIOR_PLAN2 — should be straightforward. Recommend after Phase 1 so visual confirms the navigation feel.
  • Phase 4 must land before (or with) Phases 5 and 6 because they delete the bespoke handlers.
  • After all phases: smoke-test with a feed that has nested boarding areas, coord-less entrances, and pathways crossing station boundaries to confirm nothing regresses.
## 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: 1. **Idle station icon** — currently a solid blue circle; replace with a slightly-larger white circle + black ✕ overlay so unfocused stations read as a marker rather than competing with stops. 2. **Coord-less nodes** — entrances/generic-nodes/platforms that lack `stop_lat`/`stop_lon` are 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. 3. **Hierarchy depth in breadcrumbs + navigate-up on empty click** — clicking a child stop should produce `Home → Station → Platform`; clicking a boarding area should produce `Home → 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. 4. **Reuse related-object renderers** — stop view's child-list and pathway-list rows are bespoke. Add `STOP_REF_ROW` / `PATHWAY_REF_ROW` + `renderStopReference` / `renderPathwayReference` in `entity-references.ts` (mirroring `renderRouteReference`, `renderServiceReference`) and switch stop view to them. 5. **Station view groupings** — split the single "Child Stops" list into three sections: **Entrances/Exits** (`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. 6. **Platform view: Boarding Areas section** — when viewing a platform, list its boarding-area children. 7. **Pathways Out / Pathways In** — split the existing combined Pathways section into two: outgoing (`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-size` will 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 via `text-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 include `parent_station`. Need a new derived `station_id` property (the topmost station ancestor) so the expanded-station filter can include grandchildren (boarding areas). - `setStopsFilter()` (lines 291–299) called from `map-controller.applyFocusedObject` with `parent_station` filter. Change to filter on `station_id` (climbs full chain). - `addStopsLayer()` (lines 106–160) — currently `validStops` filter 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:** keep `validStops` as-is for map rendering (no-coord stops still don't render as circles), but have `buildPathwaysGeoJSON` fall 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) — `coordMap` only contains stops with coords. For a missing endpoint, walk `parent_station` up the chain to find an ancestor with coords; use those coords as the endpoint. - New layer: `stops-station-x` symbol layer rendering text ✕ centered on each station feature. Paint `text-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 above `stops-background` so the X paints on top of the white circle. **Hierarchy depth (breadcrumbs + empty-click navigate-up):** - `src/modules/page-state-manager.ts`: - `BreadcrumbLookup` interface (lines 28–33) — currently `getStopName(stop_id)`. Add `getStopAncestors(stop_id): Promise<Array<{ stop_id: string; stop_name: string }>>` returning the chain from outermost (closest-to-station) to immediate parent (excluding `stop_id` itself). - `buildBreadcrumbs()` (lines 250–409): - `case 'stop'` (lines 344–356) — replace flat `Home → Stop` with `Home → [ancestor breadcrumbs...] → Stop`. Each ancestor breadcrumb is `{ label: ancestorName, pageState: { type: 'stop', stop_id: ancestorId } }`. - `case 'pathway'` (lines 375–388) — look up the pathway's `from_stop_id`, get its ancestors + itself, then append `Pathway X` as the leaf. Adds a new lookup; see below. - `src/modules/gtfs-breadcrumb-lookup.ts` (existing file — confirm via Read) — implementation of `BreadcrumbLookup`. Add `getStopAncestors` that queries `stops` table, follows `parent_station` recursively (max ~5 hops as safety), returns the chain ordered station-first. - For pathway breadcrumbs we also need `getPathwayFromStopId(pathway_id)` (or expose the pathway row). Simplest: extend `BreadcrumbLookup` with `getPathwayAncestors(pathway_id): Promise<Array<{stop_id, stop_name}>>` that internally queries pathways → finds from_stop_id → calls `getStopAncestors` + appends from_stop itself. - `src/modules/map-controller.ts`: - `setupModuleCallbacks` `onEmptyClick` (lines 305–308) — currently calls `clearHighlights()` unconditionally. Replace with `handleEmptyClick()`: - If `focusedObject.type === 'stop'`: look up the stop; if `parent_station` is non-empty, call `applyFocusedObject({type:'stop', id: parent_station})` AND navigate via pageStateManager. Else (station or root stop): clearHighlights + onEmptyClick callback. - If `focusedObject.type === 'pathway'`: look up pathway, find from_stop_id's parent_station; navigate there. (If somehow no parent, clear.) - Else: clearHighlights as before. **Related-object renderers (shared helpers):** - `src/utils/entity-references.ts` — currently exports `renderRouteReference`, `renderServiceReference`, `ROUTE_REF_ROW`, `SERVICE_REF_ROW`, `ENTITY_REF_BTN`. Add `renderStopReference` and `renderPathwayReference`, plus `STOP_REF_ROW`, `PATHWAY_REF_ROW`. - `src/utils/entity-display.ts` — already has `getStopDisplay`. No pathway display helper exists; pathways don't have a `pathway_name` field — display is mode label + endpoints. - `src/modules/page-content-renderer.ts` `addEventListeners` (lines 708–797) — currently wires `ROUTE_REF_ROW` and `SERVICE_REF_ROW` clicks. Add wiring for `STOP_REF_ROW` (calls `dependencies.onStopClick`) and `PATHWAY_REF_ROW` (calls `dependencies.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: - Station (loc_type=1): three sections (Entrances/Exits, Platforms, Generic Nodes) + timetables. - Platform (loc_type=0): Boarding Areas section + Pathways Out + Pathways In + timetables. - Other non-station (loc_type 2/3/4): Pathways Out + Pathways In + timetables. - `renderChildStopsSection()` (lines 341–387) — replace with three `renderTypedChildSection()` calls keyed by location_type. Each uses `renderStopReference`. - `renderPathwaysSection()` (lines 300–336) — split into `renderPathwaysOutSection()` and `renderPathwaysInSection()`, both using `renderPathwayReference`. Each row label = "{mode} {to|from} {other stop display}". - `getChildStops()` (lines 235–247) — keep as-is (returns `parent_station == station_id`). - `getConnectedPathways()` (lines 269–295) — refactor to return `{out: Pathways[], in: Pathways[]}`. - `PATHWAY_MODE_LABELS` (lines 249–257) and `LOCATION_TYPE_LABELS` (lines 259–264) — keep; move PATHWAY_MODE_LABELS to a shared util if cleaner (it's also duplicated in `pathway-view-controller.ts` lines 12–20). - `addEventListeners()` (lines 459–509) — `child-stop-btn` and `pathway-view-btn` handlers are bespoke; will be replaced by the global `STOP_REF_ROW` / `PATHWAY_REF_ROW` wiring in page-content-renderer. Delete the bespoke handlers. **Invariants to preserve:** - All user-initiated DB writes still go through `patchManager.record*` — this plan doesn't add new DB writes, only renders, queries, and map filter changes. - Feature-state highlighting on focused stop/pathway must keep working — when the station-X symbol layer is added, ensure its `text-size` paint expression also reads `feature-state.focused` so the X grows with the circle. - The "stops" source already promotes `stop_id` as the GeoJSON feature `id` (layer-manager.ts line 131) — keep that. Same for "pathways" with `pathway_id` (line 649). - Coord-less stop handling: a stop with no coords still has a DB row (queried by name in the panel) but no GeoJSON feature on the map. Pathways referencing it route through its nearest coord-having ancestor. --- ## 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. - [x] In `src/modules/layer-manager.ts` `createStopsGeoJSON()`: add a derived property `station_id` to each feature's `properties`. Compute it by walking `parent_station` up the chain: for a stop with `location_type=1`, `station_id = stop_id`; otherwise climb `parent_station` until reaching a `location_type=1` ancestor (or hit a coord-less root); cap at 5 hops as a safety. Store empty string if no ancestor station is found. - [x] In `addStopsBackgroundLayer()`: update the `circle-radius` and `circle-color` case expressions so stations are: - idle: `circle-color: #ffffff` (white fill), `circle-stroke-color: #000000`, `circle-stroke-width: 2`, `circle-radius: 6` - focused: `circle-radius: 10` (vs current focused 17 — smaller, "slightly bigger than non-station focused size" which is `radius * 1.7 ≈ 6.8`) - non-station idle/focused unchanged - [x] Add a new method `addStationXLayer()` called from `addStopsLayer()` after the background layer is added. It adds a `symbol` layer with id `stops-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'` - Include this layer id in `clearAllLayers()` and `setStopsFilter()` so filter updates and teardowns include it. - [x] In `setStopsFilter()`: extend to also update `stops-station-x` layer'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-expanded `station_id == X` filter, combine with `all`: `['all', ['==', ['get', 'location_type'], 1], ...activeStopsFilter]`). - [x] In `MapController.applyFocusedObject()` (`src/modules/map-controller.ts`): change the station-expanded filter from filtering on `parent_station` to filtering on the new `station_id` derived property (uses `any` + `stop_id == newStation || station_id == newStation` to also include the station itself). - [x] In `layer-manager.ts` `clearAllLayers()`: add `'stops-station-x'` to `layersToRemove`. **Discoveries:** `createStopsGeoJSON` was updated to accept an optional `allStops` parameter so `station_id` traversal can walk stops that lack coords (which are excluded from `validStops`). Both callers (`addStopsLayer` and `updateStopsData`) now pass the full stops array. The `circle-stroke-color` became a case expression so stations get `#000000` while 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. The `circle-color` case expression already only checks `location_type`, not `feature-state.focused`, so this is unchanged — just confirm the case order. **Gotcha 2:** `text-font` may not be available depending on basemap. If MapLibre throws on missing glyph, fall back to omitting `text-font` or providing a definitely-shipped font. Check the active basemap's `glyphs` URL 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. - [x] In `src/modules/layer-manager.ts` `buildPathwaysGeoJSON()` (lines 606–665): - Build a parent-chain lookup: `parentByStopId: Map<string, string>` where value is `parent_station` (skipping empty strings). - Add a helper `resolveCoord(stop_id: string): [number, number] | null` that returns `coordMap.get(stop_id)` if present; otherwise walks `parentByStopId` up the chain (cap 5 hops) and returns the first ancestor's coord, or null if none found. - Replace the `const from = coordMap.get(pw.from_stop_id)` / `coordMap.get(pw.to_stop_id)` calls with `resolveCoord(pw.from_stop_id)` / `resolveCoord(pw.to_stop_id)`. - [x] Keep the "return early if either is null" guard — pathways that still can't resolve get skipped. **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. - [x] In `src/modules/page-state-manager.ts` `BreadcrumbLookup` interface (lines 28–33): add ```ts getStopAncestors: (stop_id: string) => Promise<Array<{ stop_id: string; stop_name: string }>>; getPathwayAncestors: (pathway_id: string) => Promise<Array<{ stop_id: string; stop_name: string }>>; ``` Each returns the chain ordered outermost-first (topmost station first), EXCLUDING the leaf object itself. - [x] In `buildBreadcrumbs()`: - `case 'stop'` (lines 344–356): if `getStopAncestors` is present, fetch ancestors, prepend each as a breadcrumb (`{ label: ancestor.stop_name, pageState: { type: 'stop', stop_id: ancestor.stop_id } }`) between `Home` and the leaf stop. Leaf stop entry unchanged. - `case 'pathway'` (lines 375–388): fetch `getPathwayAncestors(pathway_id)` to get the chain (station → ... → from_stop), prepend each. Leaf stays as `Pathway {pathway_id}`. If lookup returns empty, fall back to flat `Home → Pathway X`. - [x] In `src/modules/gtfs-breadcrumb-lookup.ts` (Read first to confirm shape): implement `getStopAncestors`: - Fetch the stop. If no `parent_station`, return `[]`. - Walk up: at each step query `stops.txt` for the parent's row, push `{stop_id, stop_name}`, follow its `parent_station`. Cap at 5 iterations. Reverse the list before returning so outermost ancestor is first. - Cache results in a per-call map if performance matters (probably not — chains are 1–3 deep). - [x] In `gtfs-breadcrumb-lookup.ts` `getPathwayAncestors`: - Query `pathways.txt` for the row by `pathway_id`. If not found, return `[]`. - Call `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. - [x] In `src/modules/map-controller.ts`: replace the `onEmptyClick` arrow at lines 305–308 with a call to a new private method `handleEmptyClick()`. Logic: ```ts private async handleEmptyClick(): Promise<void> { const obj = this.focusedObject; const stops = this.gtfsParser?.getFileDataSyncTyped<Stops>('stops.txt') || []; const pathways = this.gtfsParser?.getFileDataSyncTyped<Pathways>('pathways.txt') || []; let parentStopId: string | null = null; if (obj.type === 'stop') { const stop = stops.find((s) => s.stop_id === obj.id); parentStopId = stop?.parent_station ? String(stop.parent_station) : null; } else if (obj.type === 'pathway') { const pw = pathways.find((p) => p.pathway_id === obj.id); const from = pw ? stops.find((s) => s.stop_id === pw.from_stop_id) : null; // Pathway's "parent" = its from-stop; navigate up to that stop (not its parent) parentStopId = from?.stop_id ?? null; } if (parentStopId) { this.applyFocusedObject({ type: 'stop', id: parentStopId }); if (this.pageStateManager) { await this.pageStateManager.setPageState({ type: 'stop', stop_id: parentStopId }); } } else { this.clearHighlights(); this.callbacks.onEmptyClick?.(); } } ``` Wire this via `onEmptyClick: () => { void this.handleEmptyClick(); }` in `setupModuleCallbacks`. **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 `onEmptyClick` callback used to call `this.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 `renderStopReference` and `renderPathwayReference` helpers Goal: Stop view's child-list and pathway-list rows use the same `entity-references.ts` helpers as agency/service views. - [x] In `src/utils/entity-references.ts`: - Add `STOP_REF_ROW = 'stop-ref-row'` and `PATHWAY_REF_ROW = 'pathway-ref-row'` constants. - Add `renderStopReference(stop: Record<string, unknown>, opts: { locationTypeLabel?: string; viewLabel?: string }): string`: ```ts // Renders: <div class="...flex... STOP_REF_ROW" data-stop-id="X"> // <stop name (id)> <badge locationTypeLabel> <button "View"/>?> // </div> ``` Uses `renderCardLabel(getStopDisplay(stop as Record<string, string>))` for the label. If `viewLabel` is passed, render a "View" button with class `ENTITY_REF_BTN` and `data-stop-id` so existing wiring picks it up; otherwise the whole row is the click target. - Add `renderPathwayReference(pathway: Record<string, unknown>, opts: { modeLabel: string; otherStop?: Record<string, unknown>; direction: 'to' | 'from'; viewStopButton?: boolean }): string`: - Primary label: `"{modeLabel} {direction} {otherStop display}"`, e.g. "Stairs to platform_a (platform_a)". - If `otherStop` is undefined (orphan reference), just show the other stop id from the pathway row. - Adds `data-pathway-id="..."` on the row. - If `viewStopButton` is true, render a "View Stop" button with class `ENTITY_REF_BTN` and `data-stop-id="<other_stop_id>"` so the same wiring picks it up. - [x] In `src/modules/page-content-renderer.ts` `addEventListeners()` (lines 708–797): add two new selector blocks: - `STOP_REF_ROW` clicks → call `dependencies.onStopClick(stop_id)`. - `PATHWAY_REF_ROW` clicks → call `dependencies.onPathwayClick(pathway_id)` (only if defined). - Also extend the existing `ENTITY_REF_BTN` handler (lines 746–755) to handle `data-stop-id` (call `dependencies.onStopClick`) in addition to the existing `data-service-id` case. `e.stopPropagation()` already prevents the row click from also firing. **Gotcha:** `ENTITY_REF_BTN` is reused across route/service/stop "View" buttons. The handler currently checks `data-service-id`. Add an `if (stop_id)` branch first, fall through to `service_id` case. Keep `e.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. - [x] In `src/modules/stop-view-controller.ts`: - Add a private helper `groupChildrenByLocationType(children: Stops[]): { entrances: Stops[]; platforms: Stops[]; genericNodes: Stops[] }`. Match by integer `location_type`: 2 → entrances, 0 → platforms, 3 → genericNodes. Skip 4 (boarding areas) silently — they don't belong here. - Replace `renderChildStopsSection()` with three calls to `renderTypedChildSection(title: string, children: Stops[]): string`. Each uses `renderStopReference` from `entity-references.ts`. Sections are omitted if their group is empty. - Updated `renderStopView()` station branch to call `renderChildStopsSections(childStops)`. - Deleted the bespoke `.child-stop-btn` event handler; global `STOP_REF_ROW` wiring in page-content-renderer covers it. - Removed `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. - [x] In `src/modules/stop-view-controller.ts`: - Refactor `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 with `is_bidirectional === 1` and `from_stop_id == stop_id` shows in BOTH Out and In sections (because traffic flows both ways); similarly if `to_stop_id == stop_id`. **Decision:** for simplicity in v1, treat the row direction strictly by `from`/`to` regardless of bidirectionality. The mode label is the visual cue. (Revisit if confusing.) - Add a helper `renderPathwaySection(title: string, pathways: Pathways[], direction: 'to' | 'from', currentStopId: string, otherStopLookup: Map<string, Stops>): string` that: - Empties → skips the section. - Each row: compute `otherStopId = direction === 'to' ? pathway.to_stop_id : pathway.from_stop_id`. Look up `otherStop` in `otherStopLookup`. Render with `renderPathwayReference({ modeLabel, otherStop, direction, viewStopButton: true })`. - Build `otherStopLookup` once per render: collect all `from_stop_id`/`to_stop_id` from `out + in` lists, batch-query stops table (already O(N) — using existing `queryRows` with a stop_id-by-stop_id loop is fine for typical pathway counts). - Add `renderBoardingAreasSection(platformId: string): Promise<string>`: query `stops` for `parent_station == platform_id`, filter to `location_type === 4`, render as a section using `renderStopReference`. Empty → skip. - Update `renderStopView()` non-station branch (line 123): - If `locationType === 0` (platform): render Boarding Areas section first, then Pathways Out, then Pathways In, then timetables. - Else (entrance/exit, generic node, boarding area): render Pathways Out, Pathways In, timetables. - Delete the bespoke `.pathway-view-btn` handler in `addEventListeners()` (lines 473–484); the global `PATHWAY_REF_ROW` wiring in page-content-renderer takes over for the row, and the `ENTITY_REF_BTN` handler takes over for the "View Stop" button. **Gotcha — boarding area parent:** Per GTFS spec, a boarding area's `parent_station` is the platform_id (not the station_id). So when we list boarding areas, we query `parent_station == platform_id` (not the full station chain). The Phase 1 `station_id` derived 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 no `stop_name`, falls back to `(stop_id)` only. **Gotcha — eventListener cleanup:** the existing `pathway-view-btn` selector and `child-stop-btn` selector handlers in `stop-view-controller.ts` add 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 - Phases 1 and 2 are map-layer-only and can land in either order; both are independent of phases 3–6. - Phase 3 (breadcrumb + empty-click) depends on the existing focused-object infra from PRIOR_PLAN2 — should be straightforward. Recommend after Phase 1 so visual confirms the navigation feel. - Phase 4 must land before (or with) Phases 5 and 6 because they delete the bespoke handlers. - After all phases: smoke-test with a feed that has nested boarding areas, coord-less entrances, and pathways crossing station boundaries to confirm nothing regresses.
Author
Owner

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 setFocusedStop calls 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 in browse-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:

  • For the inline stop label format, considered "Name<br>id" (current renderCardLabel) 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.
  • For null-coord stops (lat/lon === 0 or null): the data model already treats them as navigable but invisible. The map highlight should stay on the nearest coord-having ancestor (typically the parent station) so the user has visual feedback about which area of the map the focused null-stop belongs to. Alternative was to draw a placeholder at the parent's location — rejected as visually noisy and easily confused with the real stop.
  • For "station click zooms to all stops": the existing flyToStation already exists but only fits direct children (parent_station == stationId). Using the new station_id derived 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) — current circle-radius case 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-state focused: true/false via map.setFeatureState. Works correctly; the issue is purely the magnitude of the radius change in the paint expression.
    • defaultHighlightOptions (lines 54–59) — radius: 8 was the OLD highlight size (when the separate stops-highlight layer existed). That layer was replaced in commit c3496c6 by 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) — focused text-size: 16, unfocused: 10. Should grow with the circle proportionally.
  • The stops-background circle stroke-width grows on focus from options.strokeWidth (2) to 4. Keep this — it amplifies the focused look.

Null-coord stops (Phase 2):

  • src/modules/map-controller.ts applyFocusedObject() (lines 610–639) — calls layerManager.setFocusedStop(obj.id) for stop type. For a null-coord stop the map feature doesn't exist; setFeatureState silently no-ops (the try/catch in setFocusedStop swallows errors).
  • src/modules/layer-manager.ts createStopsGeoJSON() (lines 167–234) — already filters out coord-less stops from rendered features (via the validStops filter in addStopsLayer line 116). The full stops list IS passed as allStops so station_id can climb the chain even for invisible stops. Good — no schema change needed for this phase.
  • Empty-click navigate-up (CURRENT_PLAN Phase 3, handleEmptyClick at map-controller.ts lines 783–812) — already navigates to parent_station when 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 stacked renderCardLabel. Switch to inline so list rows are tighter and the id is on the same line.
    • renderPathwayReference() (lines 159–180) — primary text builds via getStopDisplay(otherStop).primary, losing the id. Switch to renderOptionLabel(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 use ancestor.stop_name and leaf uses stopName from getObjectName. Both need to be "{stop_name} ({stop_id})" (or just stop_id when no name). Easiest: change BreadcrumbLookup.getStopAncestors to return objects with a display field already formatted, OR have buildBreadcrumbs format 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.ts getStopName() (lines 72–89) — returns plain stop_name. Plus getStopAncestors (lines 94–133) and getPathwayAncestors already return {stop_id, stop_name} rows. We can format inline inside buildBreadcrumbs (cleaner) or change the lookup to return display strings.

Pathway primary click (Phase 4):

  • src/modules/browse-navigation.ts (lines 327–357) — sets up the dependencies passed to PageContentRenderer. Has onStopClick, onRouteClick, etc. — but no onPathwayClick. That's the bug. navigateToPathway already exists in src/modules/navigation-actions.ts:92.
  • src/modules/page-content-renderer.ts (lines 198, 776–784) — already wires onPathwayClick through to StopViewController and 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.ts flyToStation() (lines 555–605) — filter is s.stop_id === stationId || s.parent_station === stationId. Only direct children. Switch to: use the same station_id derived property computation we already do for the layer-manager filter — climb each stop's parent_station chain to find its top-most station ancestor, include if it matches stationId.
  • applyFocusedObject (lines 610–639) — flyToStation is called only when oldStation !== newStation. So clicking an already-focused station (e.g., re-clicking via panel) doesn't re-fit. Decision: add a public focusOnStation(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.
  • Alternative considered: just remove the oldStation !== newStation guard. Rejected — that would cause applyFocusedObject to 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.ts fitMapToData() (lines 457–492) — filter: lat !== null && lon !== null && !isNaN(lat) && !isNaN(lon). Need to also exclude lat === 0 && lon === 0.

Invariants to preserve:

  • All user-initiated DB writes still go through patchManager.record* (no DB changes in this plan).
  • Feature-state highlighting on focused stop/pathway must keep working.
  • setFocusedStop for 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).
  • The expanded-station filter and pathways-layer logic from CURRENT_PLAN must continue to work; we're only adjusting visual scale and a few callbacks.

Phase 1: Debug and fix broken stop-focus visual + missing station ✕

Goal: Two real visual bugs to fix here, not just a magnitude tweak.

  1. Stops don't grow at all (1×) when clicked. The paint expression on stops-background reads feature-state.focused, and setFocusedStop calls map.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.
  2. No ✕ symbol on station circles. The stops-station-x symbol layer is being added (visible in addStopsLayeraddStationXLayer) but the glyph doesn't render. Most likely cause: the active basemap's style has no glyphs URL, OR doesn't ship the 'Open Sans Regular' / 'Arial Unicode MS Regular' fonts referenced in the layer's text-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.focused flipping the circle?

Possible root causes (verify each with dipsticks, don't assume):

  • The feature id set in createStopsGeoJSON (feature.id = properties.stop_id) doesn't match what setFeatureState is targeting (e.g. type mismatch, or id got stripped during a later setData call).
  • The source was recreated after the layer was added (e.g. basemap-change re-adds layers), and the layer is now pointing at a stale source, OR the feature state lookup is keyed by a different source instance.
  • The 'case' expression's outer condition ['boolean', ['feature-state', 'focused'], false] is being short-circuited because setFeatureState errored silently (the try/catch in setFocusedStop swallows the error).
  • A later layer with the same source/filter is painting OVER the focused circle without honoring feature-state (the clickarea is transparent, so probably not it — but verify).
  • Promote-id mismatch: the stops source doesn't have promoteId set, but features have a top-level id field, which IS the correct way to enable feature-state per MapLibre docs. Confirm the id is in fact present after setData (it gets re-added each time in updateStopsData).

Steps:

  • In setFocusedStop() (src/modules/layer-manager.ts:588), add console.log('[LayerManager] setFocusedStop', { prev: this.focusedStopId, next: stop_id, hasSource: !!this.map.getSource('stops') }) at the top and console.log('[LayerManager] setFocusedStop applied') after the try block. Also log inside the catch with the full error.
  • After calling setFeatureState, immediately call this.map.getFeatureState({source:'stops', id: stop_id}) and log the result. Confirm it returns {focused: true}. If not, the call silently failed.
  • Click a stop and inspect the dev-tools console:
    • Confirm setFocusedStop IS being called with the right id.
    • Confirm getFeatureState returns {focused: true} right after.
    • If both confirm, the issue is in the paint expression evaluation — move to the next dipstick.
  • If the feature-state is set but the circle doesn't grow: temporarily replace the entire circle-radius expression on stops-background with a flat 20 (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.
  • If the expression is the issue: simplify it. Try replacing the outer case with ['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).
  • If the layer isn't painting the right source: log the source name and feature collection length when the layer is added. Confirm only one stops source/layer exists.
  • Once the root cause is identified, fix it. Likely candidates (in rough order of probability):
    • Nested case expression mis-structured. Counted: outer case has condition + true-branch + false-branch (3 args). Each inner case has 5 conditions/values + 1 fallback = 11 args (which is odd-formed for case since case needs case, 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.
    • Promoted id type mismatch — if stop_id is numeric-looking but stored as string, setFeatureState({id: '123'}) and the feature's id: 123 (number) won't match. Confirm by inspecting the GeoJSON output.
    • Source replaced — if updateStopsData is called after a click, setData should preserve states but verify.
  • After fixing, REMOVE the diagnostic console.logs (keep one summary [LayerManager] setFocusedStop log 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?

  • In addStationXLayer() (src/modules/layer-manager.ts:313), after adding the layer, log console.log('[LayerManager] addStationXLayer added', { layerExists: !!this.map.getLayer('stops-station-x'), styleHasGlyphs: !!this.map.getStyle().glyphs }).
  • Click around in dev tools and check Inspect Map (or MapLibre debug tools): is stops-station-x listed in layers? If yes but no rendering — font issue.
  • If style.glyphs is 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:
    • Option A (recommended): drop the symbol-layer approach and replace the ✕ with a second circle layer that overlays a thin black "cross" using two perpendicular lines via a custom rendering trick — too complex. Better: use a small black inner circle with circle-radius 2 as the X-substitute. Or pre-render a ✕ as an SVG icon, register it with map.addImage('station-x', img), and switch to text-field-less symbol with icon-image: 'station-x'. Icons don't require glyph URLs.
    • Option B: ensure all basemaps used in the app include a glyphs URL. Check src/modules/basemap-styles.ts and basemap-control.ts to see which styles are registered. If any are bare {version:8,sources:{},layers:[]} skeleton styles, add glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf' (or whatever the project's font CDN is) and use a font available there.
  • Decision point — ask user: before implementing, surface the chosen approach. If basemaps lack glyphs, Option A (registered icon) is more robust. If glyphs are already present and just the font string is wrong, Option B (fix font list to one that's available) is the lighter fix.
  • After fixing, verify on each basemap option (light/dark, etc.) that the ✕ shows on every station circle. The ✕ should also stay centered as the circle grows on focus.

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.

  • Update focused circle-radius case branch values in addStopsBackgroundLayer():
    • location_type 0 (default stop): options.radius * 2 (8 — was 6.8)
    • location_type 2 (entrance): 10 (was 8)
    • location_type 3 (generic node): 10 (was 8)
    • location_type 4 (boarding area): 13 (was 11)
    • location_type 1 (station): 12 (was 10)
  • In addStationXLayer() (or whatever rendering approach 1b lands on): bump focused size by ~25% so the ✕ scales with the bigger focused station circle.
  • Bump focused circle-stroke-width from 4 → 5.
  • Check the pathway focused state in layer-manager.ts (around lines 800–815, where pathways-lines uses feature-state.focused for line-width). If subtle, bump.

1d. User verification gate

  • STOP and ask the user to verify before moving to Phase 2 or any other phase:
    • Confirm that clicking a stop on a simple feed (no pathways/stations) visibly grows the stop circle.
    • Confirm that station circles show the ✕ overlay on every basemap.
    • Confirm clicking a station grows it AND the ✕ stays centered/legible.
  • Only after explicit user confirmation, proceed to Phase 2. Do not assume the visual bugs are fixed just because the code change compiled.

Gotcha: The options.radius is passed as 4 from map-controller.updateMap (line 425). Using options.radius * 2 keeps 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/catch in setFocusedStop uses console.debug for errors, which is suppressed by default in many browsers. While debugging, temporarily upgrade to console.warn to 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.

  • In src/modules/layer-manager.ts, add a helper private resolveCoordHavingStop(stop_id: string): string | null that:
    • Looks up the stop. If it has valid coords (lat and lon present, not null, not NaN, not both 0), return its own id.
    • Otherwise walk parent_station up the chain (cap 5 hops). Return the first ancestor with valid coords.
    • Return null if no ancestor has coords.
    • Reuse this lookup logic — it's similar to what buildPathwaysGeoJSON already does for pathway endpoints. Consider extracting a shared util src/utils/stop-coord-resolver.ts if the logic looks duplicated.
  • In src/modules/layer-manager.ts setFocusedStop(stop_id): when stop_id is non-null, route through resolveCoordHavingStop(stop_id). Set the feature-state focused: true on the RESOLVED id (the ancestor with coords), not on the original stop_id. Also update this.focusedStopId to track the resolved id so unfocusing later works correctly.
    • Important: the public method signature stays the same. Callers still pass the actual stop_id they care about; the layer manager handles the redirect internally.
  • Smoke-test: create a station with an entrance child that has empty 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 shows Home → Station → Entrance. The properties panel shows the entrance's fields. Empty-click on the map should navigate back to the station.

Gotcha: applyFocusedObject separately drives station expansion via deriveExpandedStation. 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=0 case is the user's main null-island concern (Phase 6). The resolveCoordHavingStop helper 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.

  • In src/utils/entity-references.ts renderStopReference(): replace renderCardLabel(getStopDisplay(stop)) with renderOptionLabel(getStopDisplay(stop)) wrapped in <div class="font-medium truncate">…</div> so it matches the visual weight of other reference rows. Import renderOptionLabel from entity-display.
  • In src/utils/entity-references.ts renderPathwayReference(): change otherDisplay computation from getStopDisplay(otherStop).primary to renderOptionLabel(getStopDisplay(otherStop)). Falls back to otherStopId if no otherStop row is found. Result: "Stairs to Platform A (TS_P1)".
  • In src/modules/page-state-manager.ts buildBreadcrumbs() case 'stop':
    • For each ancestor, label becomes "{ancestor.stop_name} ({ancestor.stop_id})" if stop_name is non-empty, else just ancestor.stop_id.
    • For the leaf stop, fetch the row (we already have stopName via getObjectName, but we don't have the id-only fallback path). Easiest: replace the stopName = await getObjectName(...) line with a direct lookup that returns {stop_name, stop_id} and format inline. Or extend BreadcrumbLookup with getStopDisplay(stop_id): Promise<string> that does the inline formatting.
    • Decision: keep it simple — format inline in buildBreadcrumbs using existing stop_id (we already have it) and the result of getStopName (the name). If name === Stop ${stop_id} (the fallback), don't append the id again — show just Stop ${stop_id}.
  • In src/modules/page-state-manager.ts buildBreadcrumbs() case 'pathway': ancestor labels in the chain go through the same inline format. The leaf Pathway ${pageState.pathway_id} is fine as-is (pathways don't have names).
  • Verify: navigate to a deeply-nested stop (e.g., a boarding area). Breadcrumb should read Home → Station Name (S1) → Platform A (TS_P1) → BA Name (BA1).

Gotcha — getStopDisplay fallback: getStopDisplay returns {primary: id} (no secondary) when name is empty. renderOptionLabel then returns just the id. Good — no "(id)" duplication.

Gotcha — stacked label callers: renderCardLabel is 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. Only renderStopReference and renderPathwayReference switch to inline.

Gotcha — agency/route/service refs: Other reference renderers (renderRouteReference, renderServiceReference) use renderCardLabel and 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.

  • In src/modules/browse-navigation.ts (the dependencies block around lines 327–357): import navigateToPathway from ./navigation-actions.js. Add onPathwayClick: (pathway_id: string) => navigateToPathway(pathway_id) to the dependencies object passed to PageContentRenderer.
  • Verify: in the stop view of a non-station stop with pathways, clicking a pathway row body (not the "View Stop" button) navigates to that pathway's detail page; URL hash updates; breadcrumb shows ancestors.
  • No other changes required — PageContentRenderer already forwards onPathwayClick to StopViewController and wires the PATHWAY_REF_ROW click 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 flyToStation only includes direct children.

  • In src/modules/map-controller.ts flyToStation() (lines 555–605): replace the filter s.stop_id === stationId || s.parent_station === stationId with one that includes all descendants. Easiest approach:
    • Build a stopById = new Map(stops.map(s => [s.stop_id, s])) lookup.
    • For each stop, climb parent_station chain (cap 5 hops). Include the stop if stationId appears anywhere in the chain (or equals the stop's own id).
    • Alternative: reuse the same resolveStationId logic that layer-manager.createStopsGeoJSON already implements — copy or extract to a shared util. (Recommendation: extract resolveStationId(stop, stopById): string | null to a shared util src/utils/stop-hierarchy.ts so both call sites stay in sync.)
  • In 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.
  • Verify: navigate to a station that has nested boarding areas under its platforms. The map should frame the full extent of the station, not just zoom on the platform centroid.

Gotcha — re-clicking same station: applyFocusedObject (the only caller of flyToStation) skips the fly when oldStation === 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 resolveStationId to a util, update both layer-manager.createStopsGeoJSON and the new flyToStation filter 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.

  • In src/modules/map-controller.ts fitMapToData() (lines 457–492): extend the validStops filter to also exclude stops where stop_lat === 0 && stop_lon === 0. Keep the existing null/NaN guards.
  • Verify with a feed containing a stop at (0,0): zoom-to-feed should frame the real stops without including the null island.

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 resolveCoordHavingStop helper uses the same "(0,0) is invalid" rule. Phase 5's updated flyToStation also uses it. Three places — consider extracting hasValidCoords(stop): boolean to src/utils/stop-hierarchy.ts (or wherever Phase 5's resolveStationId lands).


Notes / sequencing

  • Phases 1, 4, and 6 are independent one-liner-ish fixes; land them first for quick wins.
  • Phase 3 (inline labels) is independent and self-contained.
  • Phase 2 (null-coord focus redirect) and Phase 5 (station zoom) share the "resolve coord-having ancestor" / "resolve station id" logic. Recommend extracting a shared util src/utils/stop-hierarchy.ts with hasValidCoords, resolveStationId, resolveCoordHavingStop during whichever of these phases lands first.
  • After all phases land: smoke-test the regression scenarios:
    1. Load a simple feed with no pathways/stations, click a stop → it grows clearly.
    2. Load a station feed with a null-coord entrance, click the entrance reference in the station view → station stays highlighted on map, panel shows entrance properties, breadcrumbs are correct, empty-click on map → station re-focused.
    3. Click a pathway row (not the View Stop button) → navigates to pathway page.
    4. Click a station with nested boarding areas → map zooms to fit the full hierarchy.
    5. Load a feed with a stop at (0,0) → "zoom to feed" frames the real stops only.
    6. Navigate via panel through several stop levels → breadcrumbs show Name (id) at each step.
## 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 `setFocusedStop` calls 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 in `browse-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:** - For the inline stop label format, considered `"Name<br>id"` (current `renderCardLabel`) 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. - For null-coord stops (lat/lon === 0 or null): the data model already treats them as navigable but invisible. The map highlight should stay on the nearest coord-having ancestor (typically the parent station) so the user has visual feedback about which area of the map the focused null-stop belongs to. Alternative was to draw a placeholder at the parent's location — rejected as visually noisy and easily confused with the real stop. - For "station click zooms to all stops": the existing `flyToStation` already exists but only fits direct children (`parent_station == stationId`). Using the new `station_id` derived 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) — current `circle-radius` case 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-state `focused: true/false` via `map.setFeatureState`. Works correctly; the issue is purely the magnitude of the radius change in the paint expression. - `defaultHighlightOptions` (lines 54–59) — `radius: 8` was the OLD highlight size (when the separate `stops-highlight` layer existed). That layer was replaced in commit c3496c6 by 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) — focused `text-size: 16`, unfocused: 10. Should grow with the circle proportionally. - The `stops-background` circle stroke-width grows on focus from `options.strokeWidth` (2) to 4. Keep this — it amplifies the focused look. **Null-coord stops (Phase 2):** - `src/modules/map-controller.ts` `applyFocusedObject()` (lines 610–639) — calls `layerManager.setFocusedStop(obj.id)` for stop type. For a null-coord stop the map feature doesn't exist; `setFeatureState` silently no-ops (the try/catch in `setFocusedStop` swallows errors). - `src/modules/layer-manager.ts` `createStopsGeoJSON()` (lines 167–234) — already filters out coord-less stops from rendered features (via the `validStops` filter in `addStopsLayer` line 116). The full stops list IS passed as `allStops` so `station_id` can climb the chain even for invisible stops. Good — no schema change needed for this phase. - Empty-click navigate-up (CURRENT_PLAN Phase 3, `handleEmptyClick` at map-controller.ts lines 783–812) — already navigates to `parent_station` when 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 stacked `renderCardLabel`. Switch to inline so list rows are tighter and the id is on the same line. - `renderPathwayReference()` (lines 159–180) — primary text builds via `getStopDisplay(otherStop).primary`, losing the id. Switch to `renderOptionLabel(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 use `ancestor.stop_name` and leaf uses `stopName` from `getObjectName`. Both need to be `"{stop_name} ({stop_id})"` (or just `stop_id` when no name). Easiest: change `BreadcrumbLookup.getStopAncestors` to return objects with a `display` field already formatted, OR have `buildBreadcrumbs` format 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.ts` `getStopName()` (lines 72–89) — returns plain `stop_name`. Plus `getStopAncestors` (lines 94–133) and `getPathwayAncestors` already return `{stop_id, stop_name}` rows. We can format inline inside `buildBreadcrumbs` (cleaner) or change the lookup to return `display` strings. **Pathway primary click (Phase 4):** - `src/modules/browse-navigation.ts` (lines 327–357) — sets up the `dependencies` passed to `PageContentRenderer`. Has `onStopClick`, `onRouteClick`, etc. — but **no `onPathwayClick`**. That's the bug. `navigateToPathway` already exists in `src/modules/navigation-actions.ts:92`. - `src/modules/page-content-renderer.ts` (lines 198, 776–784) — already wires `onPathwayClick` through to `StopViewController` and 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.ts` `flyToStation()` (lines 555–605) — filter is `s.stop_id === stationId || s.parent_station === stationId`. Only direct children. Switch to: use the same `station_id` derived property computation we already do for the layer-manager filter — climb each stop's `parent_station` chain to find its top-most station ancestor, include if it matches `stationId`. - `applyFocusedObject` (lines 610–639) — `flyToStation` is called only when `oldStation !== newStation`. So clicking an already-focused station (e.g., re-clicking via panel) doesn't re-fit. **Decision:** add a public `focusOnStation(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. - Alternative considered: just remove the `oldStation !== newStation` guard. Rejected — that would cause `applyFocusedObject` to 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.ts` `fitMapToData()` (lines 457–492) — filter: `lat !== null && lon !== null && !isNaN(lat) && !isNaN(lon)`. Need to also exclude `lat === 0 && lon === 0`. **Invariants to preserve:** - All user-initiated DB writes still go through `patchManager.record*` (no DB changes in this plan). - Feature-state highlighting on focused stop/pathway must keep working. - `setFocusedStop` for 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). - The expanded-station filter and pathways-layer logic from CURRENT_PLAN must continue to work; we're only adjusting visual scale and a few callbacks. --- ## Phase 1: Debug and fix broken stop-focus visual + missing station ✕ Goal: Two real visual bugs to fix here, not just a magnitude tweak. 1. **Stops don't grow at all (1×) when clicked.** The paint expression on `stops-background` reads `feature-state.focused`, and `setFocusedStop` calls `map.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. 2. **No ✕ symbol on station circles.** The `stops-station-x` symbol layer is being added (visible in `addStopsLayer` → `addStationXLayer`) but the glyph doesn't render. Most likely cause: the active basemap's style has no `glyphs` URL, OR doesn't ship the `'Open Sans Regular'` / `'Arial Unicode MS Regular'` fonts referenced in the layer's `text-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.focused` flipping the circle? Possible root causes (verify each with dipsticks, don't assume): - The feature `id` set in `createStopsGeoJSON` (`feature.id = properties.stop_id`) doesn't match what `setFeatureState` is targeting (e.g. type mismatch, or `id` got stripped during a later `setData` call). - The source was recreated after the layer was added (e.g. basemap-change re-adds layers), and the layer is now pointing at a stale source, OR the feature state lookup is keyed by a different source instance. - The `'case'` expression's outer condition `['boolean', ['feature-state', 'focused'], false]` is being short-circuited because `setFeatureState` errored silently (the try/catch in `setFocusedStop` swallows the error). - A later layer with the same source/filter is painting OVER the focused circle without honoring feature-state (the clickarea is transparent, so probably not it — but verify). - Promote-id mismatch: the `stops` source doesn't have `promoteId` set, but features have a top-level `id` field, which IS the correct way to enable feature-state per MapLibre docs. Confirm the `id` is in fact present after `setData` (it gets re-added each time in `updateStopsData`). Steps: - [ ] In `setFocusedStop()` (`src/modules/layer-manager.ts:588`), add `console.log('[LayerManager] setFocusedStop', { prev: this.focusedStopId, next: stop_id, hasSource: !!this.map.getSource('stops') })` at the top and `console.log('[LayerManager] setFocusedStop applied')` after the try block. Also log inside the catch with the full error. - [ ] After calling `setFeatureState`, immediately call `this.map.getFeatureState({source:'stops', id: stop_id})` and log the result. Confirm it returns `{focused: true}`. If not, the call silently failed. - [ ] Click a stop and inspect the dev-tools console: - Confirm `setFocusedStop` IS being called with the right id. - Confirm `getFeatureState` returns `{focused: true}` right after. - If both confirm, the issue is in the paint expression evaluation — move to the next dipstick. - [ ] If the feature-state is set but the circle doesn't grow: temporarily replace the entire `circle-radius` expression on `stops-background` with a flat `20` (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. - [ ] If the expression is the issue: simplify it. Try replacing the outer case with `['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). - [ ] If the layer isn't painting the right source: log the source name and feature collection length when the layer is added. Confirm only one `stops` source/layer exists. - [ ] **Once the root cause is identified, fix it.** Likely candidates (in rough order of probability): - Nested `case` expression mis-structured. Counted: outer `case` has condition + true-branch + false-branch (3 args). Each inner `case` has 5 conditions/values + 1 fallback = 11 args (which is odd-formed for `case` since `case` needs `case, cond1, val1, cond2, val2, ..., default`). 11 = 1 (case) + 5*2 (pairs) + 1 (default) = 12. Hmm, count the actual lines: 4 `['==',…]`-value pairs plus the fallback = 4*2 + 1 = 9 args. Plus the `'case'` head = 10 elements. That looks valid. But verify by simplifying and re-introducing complexity incrementally. - Promoted id type mismatch — if `stop_id` is numeric-looking but stored as string, `setFeatureState({id: '123'})` and the feature's `id: 123` (number) won't match. Confirm by inspecting the GeoJSON output. - Source replaced — if `updateStopsData` is called after a click, `setData` should preserve states but verify. - [ ] After fixing, REMOVE the diagnostic console.logs (keep one summary `[LayerManager] setFocusedStop` log 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? - [ ] In `addStationXLayer()` (`src/modules/layer-manager.ts:313`), after adding the layer, log `console.log('[LayerManager] addStationXLayer added', { layerExists: !!this.map.getLayer('stops-station-x'), styleHasGlyphs: !!this.map.getStyle().glyphs })`. - [ ] Click around in dev tools and check `Inspect Map` (or MapLibre debug tools): is `stops-station-x` listed in layers? If yes but no rendering — font issue. - [ ] If `style.glyphs` is 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: - **Option A (recommended):** drop the symbol-layer approach and replace the ✕ with a second `circle` layer that overlays a thin black "cross" using two perpendicular lines via a custom rendering trick — too complex. Better: use a small black inner circle with `circle-radius` 2 as the X-substitute. Or pre-render a ✕ as an SVG icon, register it with `map.addImage('station-x', img)`, and switch to `text-field`-less symbol with `icon-image: 'station-x'`. Icons don't require glyph URLs. - **Option B:** ensure all basemaps used in the app include a `glyphs` URL. Check `src/modules/basemap-styles.ts` and `basemap-control.ts` to see which styles are registered. If any are bare `{version:8,sources:{},layers:[]}` skeleton styles, add `glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'` (or whatever the project's font CDN is) and use a font available there. - [ ] **Decision point — ask user:** before implementing, surface the chosen approach. If basemaps lack glyphs, Option A (registered icon) is more robust. If glyphs are already present and just the font string is wrong, Option B (fix font list to one that's available) is the lighter fix. - [ ] After fixing, verify on each basemap option (light/dark, etc.) that the ✕ shows on every station circle. The ✕ should also stay centered as the circle grows on focus. ### 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. - [ ] Update focused `circle-radius` case branch values in `addStopsBackgroundLayer()`: - location_type 0 (default stop): `options.radius * 2` (8 — was 6.8) - location_type 2 (entrance): 10 (was 8) - location_type 3 (generic node): 10 (was 8) - location_type 4 (boarding area): 13 (was 11) - location_type 1 (station): 12 (was 10) - [ ] In `addStationXLayer()` (or whatever rendering approach 1b lands on): bump focused size by ~25% so the ✕ scales with the bigger focused station circle. - [ ] Bump focused `circle-stroke-width` from 4 → 5. - [ ] Check the pathway focused state in `layer-manager.ts` (around lines 800–815, where `pathways-lines` uses `feature-state.focused` for `line-width`). If subtle, bump. ### 1d. User verification gate - [ ] **STOP and ask the user to verify** before moving to Phase 2 or any other phase: - Confirm that clicking a stop on a simple feed (no pathways/stations) visibly grows the stop circle. - Confirm that station circles show the ✕ overlay on every basemap. - Confirm clicking a station grows it AND the ✕ stays centered/legible. - [ ] Only after explicit user confirmation, proceed to Phase 2. Do not assume the visual bugs are fixed just because the code change compiled. **Gotcha:** The `options.radius` is passed as 4 from `map-controller.updateMap` (line 425). Using `options.radius * 2` keeps 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/catch` in `setFocusedStop` uses `console.debug` for errors, which is suppressed by default in many browsers. While debugging, temporarily upgrade to `console.warn` to 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. - [ ] In `src/modules/layer-manager.ts`, add a helper `private resolveCoordHavingStop(stop_id: string): string | null` that: - Looks up the stop. If it has valid coords (lat and lon present, not null, not NaN, not both 0), return its own id. - Otherwise walk `parent_station` up the chain (cap 5 hops). Return the first ancestor with valid coords. - Return `null` if no ancestor has coords. - Reuse this lookup logic — it's similar to what `buildPathwaysGeoJSON` already does for pathway endpoints. Consider extracting a shared util `src/utils/stop-coord-resolver.ts` if the logic looks duplicated. - [ ] In `src/modules/layer-manager.ts` `setFocusedStop(stop_id)`: when `stop_id` is non-null, route through `resolveCoordHavingStop(stop_id)`. Set the feature-state `focused: true` on the RESOLVED id (the ancestor with coords), not on the original `stop_id`. Also update `this.focusedStopId` to track the resolved id so unfocusing later works correctly. - **Important:** the public method signature stays the same. Callers still pass the actual `stop_id` they care about; the layer manager handles the redirect internally. - [ ] Smoke-test: create a station with an entrance child that has empty `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 shows `Home → Station → Entrance`. The properties panel shows the entrance's fields. Empty-click on the map should navigate back to the station. **Gotcha:** `applyFocusedObject` separately drives station expansion via `deriveExpandedStation`. 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=0` case is the user's main null-island concern (Phase 6). The `resolveCoordHavingStop` helper 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. - [ ] In `src/utils/entity-references.ts` `renderStopReference()`: replace `renderCardLabel(getStopDisplay(stop))` with `renderOptionLabel(getStopDisplay(stop))` wrapped in `<div class="font-medium truncate">…</div>` so it matches the visual weight of other reference rows. Import `renderOptionLabel` from `entity-display`. - [ ] In `src/utils/entity-references.ts` `renderPathwayReference()`: change `otherDisplay` computation from `getStopDisplay(otherStop).primary` to `renderOptionLabel(getStopDisplay(otherStop))`. Falls back to `otherStopId` if no `otherStop` row is found. Result: `"Stairs to Platform A (TS_P1)"`. - [ ] In `src/modules/page-state-manager.ts` `buildBreadcrumbs()` `case 'stop'`: - For each ancestor, label becomes `"{ancestor.stop_name} ({ancestor.stop_id})"` if `stop_name` is non-empty, else just `ancestor.stop_id`. - For the leaf stop, fetch the row (we already have `stopName` via `getObjectName`, but we don't have the id-only fallback path). Easiest: replace the `stopName = await getObjectName(...)` line with a direct lookup that returns `{stop_name, stop_id}` and format inline. Or extend `BreadcrumbLookup` with `getStopDisplay(stop_id): Promise<string>` that does the inline formatting. - **Decision:** keep it simple — format inline in `buildBreadcrumbs` using existing `stop_id` (we already have it) and the result of `getStopName` (the name). If name === `Stop ${stop_id}` (the fallback), don't append the id again — show just `Stop ${stop_id}`. - [ ] In `src/modules/page-state-manager.ts` `buildBreadcrumbs()` `case 'pathway'`: ancestor labels in the chain go through the same inline format. The leaf `Pathway ${pageState.pathway_id}` is fine as-is (pathways don't have names). - [ ] Verify: navigate to a deeply-nested stop (e.g., a boarding area). Breadcrumb should read `Home → Station Name (S1) → Platform A (TS_P1) → BA Name (BA1)`. **Gotcha — getStopDisplay fallback:** `getStopDisplay` returns `{primary: id}` (no secondary) when name is empty. `renderOptionLabel` then returns just the id. Good — no `"(id)"` duplication. **Gotcha — stacked label callers:** `renderCardLabel` is 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. Only `renderStopReference` and `renderPathwayReference` switch to inline. **Gotcha — agency/route/service refs:** Other reference renderers (`renderRouteReference`, `renderServiceReference`) use `renderCardLabel` and 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. - [ ] In `src/modules/browse-navigation.ts` (the dependencies block around lines 327–357): import `navigateToPathway` from `./navigation-actions.js`. Add `onPathwayClick: (pathway_id: string) => navigateToPathway(pathway_id)` to the dependencies object passed to `PageContentRenderer`. - [ ] Verify: in the stop view of a non-station stop with pathways, clicking a pathway row body (not the "View Stop" button) navigates to that pathway's detail page; URL hash updates; breadcrumb shows ancestors. - [ ] No other changes required — `PageContentRenderer` already forwards `onPathwayClick` to `StopViewController` and wires the `PATHWAY_REF_ROW` click 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 `flyToStation` only includes direct children. - [ ] In `src/modules/map-controller.ts` `flyToStation()` (lines 555–605): replace the filter `s.stop_id === stationId || s.parent_station === stationId` with one that includes all descendants. Easiest approach: - Build a `stopById = new Map(stops.map(s => [s.stop_id, s]))` lookup. - For each stop, climb `parent_station` chain (cap 5 hops). Include the stop if `stationId` appears anywhere in the chain (or equals the stop's own id). - Alternative: reuse the same `resolveStationId` logic that `layer-manager.createStopsGeoJSON` already implements — copy or extract to a shared util. (Recommendation: extract `resolveStationId(stop, stopById): string | null` to a shared util `src/utils/stop-hierarchy.ts` so both call sites stay in sync.) - [ ] In `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. - [ ] Verify: navigate to a station that has nested boarding areas under its platforms. The map should frame the full extent of the station, not just zoom on the platform centroid. **Gotcha — re-clicking same station:** `applyFocusedObject` (the only caller of `flyToStation`) skips the fly when `oldStation === 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 `resolveStationId` to a util, update both `layer-manager.createStopsGeoJSON` and the new `flyToStation` filter 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. - [ ] In `src/modules/map-controller.ts` `fitMapToData()` (lines 457–492): extend the `validStops` filter to also exclude stops where `stop_lat === 0 && stop_lon === 0`. Keep the existing null/NaN guards. - [ ] Verify with a feed containing a stop at (0,0): zoom-to-feed should frame the real stops without including the null island. **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 `resolveCoordHavingStop` helper uses the same "(0,0) is invalid" rule. Phase 5's updated `flyToStation` also uses it. Three places — consider extracting `hasValidCoords(stop): boolean` to `src/utils/stop-hierarchy.ts` (or wherever Phase 5's `resolveStationId` lands). --- ## Notes / sequencing - Phases 1, 4, and 6 are independent one-liner-ish fixes; land them first for quick wins. - Phase 3 (inline labels) is independent and self-contained. - Phase 2 (null-coord focus redirect) and Phase 5 (station zoom) share the "resolve coord-having ancestor" / "resolve station id" logic. Recommend extracting a shared util `src/utils/stop-hierarchy.ts` with `hasValidCoords`, `resolveStationId`, `resolveCoordHavingStop` during whichever of these phases lands first. - After all phases land: smoke-test the regression scenarios: 1. Load a simple feed with no pathways/stations, click a stop → it grows clearly. 2. Load a station feed with a null-coord entrance, click the entrance reference in the station view → station stays highlighted on map, panel shows entrance properties, breadcrumbs are correct, empty-click on map → station re-focused. 3. Click a pathway row (not the View Stop button) → navigates to pathway page. 4. Click a station with nested boarding areas → map zooms to fit the full hierarchy. 5. Load a feed with a stop at (0,0) → "zoom to feed" frames the real stops only. 6. Navigate via panel through several stop levels → breadcrumbs show `Name (id)` at each step.
Sign in to join this conversation.
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#96
No description provided.