Skip to content

Planner view: map-first itineraries (build on header search overlay) #104

Description

@ciotlosm

Summary

Build the planner view: a map-first surface where a rider picks an origin
and destination from the loaded GTFS feed and gets a vehicle-only itinerary
(transit legs with optional walking between them).

The placeholder is already gone — src/routes/+page.svelte is now the
Stations view; there is no /planner route. The bottom-nav slot is free.

Goal

A rider picks an origin and a destination, and the planner returns
vehicle-only itineraries (transit legs composed of trips, stops, and
transfers from the loaded feed). No walking-route generation, no car
routing.

What it should be

  • Map-first surface. Default view is a map centred on the user
    (GPS if opted in) or feedsStore.byId(boundFeedId).center as
    fallback — that field is always present per the neary-gtfs schema,
    and Stations already uses this anchor.
  • Stop markers on the map. Nearby stops render as tappable markers
    so the rider can set origin / destination by clicking.
  • Side-panel stop picker. A pair of StopPicker slots (From / To)
    in a side <aside> for typed search. Same selection contract as the
    map — whichever surface the user prefers writes to the same origin
    / destination state.
  • Tap or pin-drop on the map. Clicking a stop marker sets origin
    or destination (whichever slot is active). Dropping a pin at an
    arbitrary location snaps to the nearest stop within a configurable
    radius (default 500 m, matches getStopsNearby).
  • Vehicle-only itineraries. Routes are composed of transit legs
    only. Walking between stops is surfaced as walk N min from stop X to stop Y but never the primary movement.
  • Line swaps allowed. The itinerary can chain trips with a transfer
    at a shared stop. Use transfers.txt when present; fall back to a
    configurable minimum-walk-time at any shared stop. Cluj does not
    currently publish transfers.txt, so the fallback is the path that
    ships first.
  • Live awareness. When live data is available for a leg, surface
    it (current bus position, predicted ETA at boarding stop). When not,
    fall back to the static schedule. Use the same reconciler logic as
    the rest of the app (src/lib/domain/reconcile.ts +
    docs/specs/live-data-pipeline.md).

What it should NOT be

  • A walking / cycling / driving router. Out of scope. If we ever need
    that, it goes behind a separate provider — not in this planner.
  • A turn-by-turn navigator. The planner's job ends at "here is the
    next leg you should take".

Implementation: build on PRs #137 + #139

The header station search overlay (PR #137) and its iOS zoom fix
(PR #139) deliver most of the picker surface. The planner folds
those in rather than building a parallel search stack.

Important

The picker is the overlay. Do not rebuild the input UI.

Reuse, don't duplicate

Need Already shipped Source
Diacritic-insensitive stop search searchStops (NFD + strip combining marks) src/lib/workers/gtfs/queries/stops.ts:77-122
GPS-or-feed-center anchor anchor derived src/lib/ui/HeaderSearchOverlay.svelte:64-69
Parent-station rollup rollupByParent src/lib/workers/gtfs/queries/stops.ts:177-207
Schedule-bearing filter (no dead-end hits) EXISTS stop_times clause src/lib/workers/gtfs/queries/stops.ts:155-160
150 ms input debounce overlay effect HeaderSearchOverlay.svelte:75-85
Per-feed route catalogue cache overlay effect HeaderSearchOverlay.svelte:103-123
Batched stop→routes chip fetch getRoutesForStops + post-fetch effect HeaderSearchOverlay.svelte:193-224
StopSearchCard selectable onselect: (stopId: string) => void prop src/lib/ui/StopSearchCard.svelte:25
iOS Safari font-size pitfall text-base on mobile PR #139
GPS opt-in entry point locationStore.enable() src/lib/stores/locationStore.svelte.ts:93-96

Required refactors before the planner can reuse these

  1. Make HeaderSearchOverlay's selection overridable.
    At src/lib/ui/HeaderSearchOverlay.svelte:226-229, selectStop
    currently does onclose(); goto('/station/${id}'). Add an optional
    onselect?: (stopId: string) => void prop. Default behaviour
    stays unchanged so the global header icon still routes to
    /station/[id].

  2. Extract a shared <Map /> Svelte component.
    Leaflet is currently used inline in the route map page
    (src/routes/map/route/[id]/[[selected]]/+page.svelte:585-625).
    Lift the L.map(...) setup, marker layer, and polyline layer
    into src/lib/ui/Map.svelte so planner + route map share one
    implementation. Expose onclick({latlng}) as a prop so the
    planner can handle pin-drop.

  3. Add plannerSelectionStore.svelte.ts.
    Mirror the searchOverlayStore pattern
    (src/lib/stores/searchOverlayStore.svelte.ts, 20 lines). Holds
    { origin: StopSelection | null, destination: StopSelection | null, setOrigin, setDestination, swap, clear }. Persist to URL via
    SvelteKit $app/state for shareable itineraries.

Note

These three refactors are small (combined diff < 200 lines) and
unblock the planner cleanly. The planner page then composes them —
no new search code.

Genuinely new work (not extraction)

  • Itinerary algorithm. Naive BFS over (route, trip, stop_times)
    with a findItineraries(originStopId, destStopId, options): Itinerary[]
    interface, so RAPTOR / CSA can replace it later without rewriting
    consumers. Cluj-scale (~80 routes, ~2k stops) makes naive BFS
    tractable in a single SQL pass on OPFS-SQLite.
  • transfers.txt consumer. No transfers handling exists today
    (grep -rn "transfers" src/ is empty). Implement the
    minimum-walk-time fallback first (Cluj's actual case); add
    transfers.txt parsing as a follow-up.
  • Walking-between-stops labelling. StopWithDistance.distance
    is already populated by searchStops / getStopsNear — use it
    directly to label inter-stop walks.

Design decisions (resolved by recent work)

Question Resolution
Algorithm Naive BFS behind findItineraries(origin, dest, opts) interface; swap in RAPTOR later if profiling demands
transfers.txt absent (Cluj's case) Default 120 s minimum-walk-time at shared stops; per-feed override in feeds.json as planner.transferMinWalkSeconds (fits PR #133's feed-config-extraction pattern)
Pin-drop snap radius Default 500 m, matches getStopsNearby; per-feed override planner.pinSnapRadiusMeters if a feed needs it
Caching In-memory LRU keyed by (originStopId, destStopId, timeBucket). OPFS-SQLite already serves the raw trip data; itineraries don't need to persist
Origin / Destination state New plannerSelectionStore.svelte.ts (mirror of searchOverlayStore). URL-persisted via $app/state

Out of scope for the first cut

  • Multi-modal routing (bike + transit, etc.).
  • Saved itineraries.
  • Reverse routing ("when do I need to leave to arrive by X?") — file
    as a follow-up once the forward case is solid.

Acceptance criteria

  • Origin / Destination slots: each has a StopPicker (the
    reusable HeaderSearchOverlay body) and a map marker slot.
  • Tapping a stop marker on the map sets origin (or destination,
    whichever is active). Dropping a pin snaps to the nearest stop
    via repo.getStopsNear(lat, lon, radius, 1).
  • When both slots are filled, the planner renders at least one
    itinerary with transit legs only.
  • Itineraries chain trips across transfers using either
    transfers.txt (when present) or the configured
    planner.transferMinWalkSeconds fallback.
  • Walking between two stops is labelled walk N min from stop X to stop Y, never rendered as a primary leg.
  • Live data: when reconciler output is available for a leg,
    surface predicted ETA at the boarding stop; otherwise static
    schedule.
  • GPS-opt-in flow uses the same locationStore.enable() entry
    point as Stations.
  • Feed-centre fallback (no GPS) uses
    feedsStore.byId(boundFeedId).center per PR feat(stations): GPS opt-in flow + header station search overlay #137's pattern.
  • URL state: /planner?origin=<id>&destination=<id> survives
    reload and is shareable.
  • No new console.log in production code.
  • npm run check && npm test && npm run build all pass.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or improvement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions