diff --git a/docs/plan/README.md b/docs/plan/README.md index 3e8cde34..feba8d3d 100644 --- a/docs/plan/README.md +++ b/docs/plan/README.md @@ -24,5 +24,5 @@ instead — that's the long-lived record of intent. ## Active plans -None. If you're looking for ongoing work, check the open issues on this -repo and on the producer / adapter repos. \ No newline at end of file +- [planner.md](planner.md) -- journey planner (jos + bus/trolley/tram) on the + reserved `/planner` route. Branch `feat/planner-prototype`. \ No newline at end of file diff --git a/docs/plan/planner.md b/docs/plan/planner.md new file mode 100644 index 00000000..2ac7bf2b --- /dev/null +++ b/docs/plan/planner.md @@ -0,0 +1,113 @@ +# Plan: Planner (map-first journey planning) + +Status: **draft / not started in app**. Work branch: `feat/planner-prototype`. +Fills the reserved `/planner` route (see +[system-overview.md](../architecture/system-overview.md) -- "Phase 8"). + +Scope: **jos + transport public** (bus / trolleybus / tram). From A to B -> +ranked itineraries with which line, transfers, board/arrival times, wait at +transfers, total duration, and walking to/from/between stops. **No car/bike.** + +## What the prototype already proved (de-risking) + +A throwaway Python prototype (outside the repo, `~/Downloads/n3ary/planner-proto/`) +runs against the real Cluj GTFS and validated end-to-end, so the app work is +integration, not research: + +- **RAPTOR** over GTFS gives clean, few-transfer itineraries in <0.5 s. +- Multi-modal labels (tram=route_type 0, trolley=11, bus=3) -- all present in the feed. +- Real alternatives (distinct by line-combination), date/service handling (LV/S/D). +- Real pedestrian walking (via Valhalla) makes access/egress **feasible** -- the + key learning: straight-line walking wrongly proposes crossing fields; street + distance fixes it and keeps valid farther-stop options. +- Address/POI search via Photon; reverse geocoding (coords -> street) via Nominatim. + +## Architecture decisions (confirm before coding) + +Neary is an offline-first static PWA; the planner should honor that. + +1. **Transit routing -> client-side RAPTOR in the GTFS worker.** No new backend; + runs over the SQLite already in OPFS; works offline; feed-agnostic. (Recommended.) +2. **Pedestrian walking (access / egress / transfer).** + - MVP (ship in B): straight-line via existing `getStopsNear`, tight radius. + - Real streets (C): a routing service (self-host Valhalla/OSRM on the Hetzner + VM that already serves gtfs-rt, or an online API). Keep the walk-cost behind + an interface so B ships without it and C swaps it in. +3. **Destination search.** + - MVP (ship in B): stop search (reuse `searchStops`) + tap-on-map. + - Addresses/POI ("Regina Maria") (C): a geocoder (self-host Photon/Nominatim or + online) -- inherently needs a service, conflicts with pure-offline; decide in C. + +Everything network-dependent (walk routing, geocoding) is a **C** concern; **B** +ships a fully offline planner (stop->stop + straight-line walk) that is already useful. + +## Implementation (files, concrete) + +### B1 -- Engine (pure TS, unit-tested first) +- `src/lib/domain/raptor.ts` -- pattern preprocessing (group trips by identical + stop sequence) + RAPTOR rounds + journey reconstruction. Pure, framework-free, + feed-agnostic. Mirror the validated prototype (`planner-proto/plan.py`). +- `src/lib/domain/raptor.test.ts` -- fixtures + expected itineraries (vitest), + same cases the prototype exercises (direct, 1-transfer, wait times, service days). +- Types: `Journey`, `Leg = WalkLeg | TransitLeg` -- colocate in + `src/lib/domain/types.ts` (where the Vehicle union lives). + +### B2 -- Worker query + RPC +- `src/lib/workers/gtfs/queries/planner.ts` -- `planJourney(db, opts)`: load + patterns (cache per feed, like `shapeCache.ts`), pull active services + (`activeServices.ts`), run `domain/raptor.ts`, hydrate stops/routes for legs. +- Wire into `src/lib/workers/gtfs.worker.ts` (`api.planJourney`) and add the + signature to `GtfsRepo` in `src/lib/data/gtfs/types.ts`. Main thread calls it + via `getGtfsRepo()` (`src/lib/data/gtfs/repo.ts`) -- never the worker directly. +- Walk-cost interface: `planJourney` takes access/egress candidate stops + (`getStopsNear`) with a pluggable cost fn (haversine now; street later). + +### B3 -- UI +- `src/routes/planner/+page.svelte` -- from/to inputs, time/day, results list, + Leaflet map (reuse the map setup from `src/routes/map` + + `src/lib/composables/useRouteMapView.svelte.ts`). +- Draw transit legs along the real shape via `getShapeForRouteDir` (nicer than the + prototype's stop-polyline); walk legs dashed. +- Origin defaults to GPS (`src/lib/stores/gps`), like `getStationBoardsNear`. +- Match theme/i18n (`src/lib/i18n`), bits-ui primitives, lucide icons. + +### B4 -- Polish / integration +- Fold live delays into times via the existing reconciled pipeline + (`reconciledVehiclesStore` / `livePipeline.ts`) -- schedule -> live arrivals. +- Deep-linkable `/planner?from=...&to=...` for shareability (path-based like other drill-downs). + +## Non-functional +- RAPTOR runs in the worker; UI never blocks. Target < 1 s per query; pattern + preprocessing once per feed load. +- Feed-agnostic: no Cluj branches (see [feed-agnostic.md](../standards/feed-agnostic.md)). +- Tests: unit-test `domain/raptor.ts` heavily (it's pure); see + [testing.md](../standards/testing.md). + +## Open questions +- Walk cost in B: pure straight-line, or a lightweight on-device penalty for + barriers? (Prototype showed straight-line alone is misleading.) +- How many alternatives to show, and ranking (arrival vs transfers vs walk). +- Where address/POI search lands (C) and whether to self-host the geocoder. + +## Milestones +- **B1** [x] engine + tests -- `src/lib/domain/raptor.ts` (+ `raptor.test.ts`, 10 cases). +- **B2** [x] worker RPC -- `queries/planner.ts` + `plannerNetwork.ts` + `planJourney` on GtfsRepo. +- **B3** [x] `/planner` UI on Leaflet; transit legs drawn on real `shapes.txt` geometry (offline). +- **B4** (wait) deep links [x] (`?from&fl&to&tl&t&d`, shareable/restorable). Live times DEFERRED + (owner decision): global reconciled ETA is origin-relative + matched by + (route,dir,tripStart) not trip_id, so correct per-leg live requires reusing the + station-board recompute -- do it with in-app verification, not blind. +- **C** street walk geometry + address/POI geocoding. Needs a **hosting decision** + (self-host Valhalla + Photon/Nominatim on the Hetzner VM vs online APIs vs + offline stop-only). Fully prototyped in `~/Downloads/n3ary/planner-proto` + (Valhalla pedestrian route geometry + Photon/Nominatim suggest/reverse). + +## Verification note +Verified in this env only in isolation (strict `tsc` on the pure/`.ts` files; +RAPTOR unit tests pass). The `.svelte` page and the full `npm run check` / `npm test` +need the repo's private `@n3ary/gtfs-spec` (NPM_TOKEN) -- run locally to confirm: +`export NPM_TOKEN=... && pnpm install && npm run check && npm test && npm run dev`. + +At completion: distill decisions into `docs/specs/planner.md`, open issues for +C items, delete this plan (see +[issue-plan-lifecycle.md](../standards/issue-plan-lifecycle.md)). diff --git a/src/lib/data/gtfs/types.ts b/src/lib/data/gtfs/types.ts index 7a82fa4d..69f21536 100644 --- a/src/lib/data/gtfs/types.ts +++ b/src/lib/data/gtfs/types.ts @@ -7,7 +7,7 @@ */ import type { Feed } from '$lib/data/feeds'; -import type { Network, Route, RouteTag, Station, Vehicle } from '$lib/domain/types'; +import type { Network, Route, RouteTag, Station, Vehicle, VehicleType } from '$lib/domain/types'; import type { NearyFeedConfig } from '$lib/workers/gtfs/queries/feedConfig'; import type { ReconcileStats } from '$lib/domain/reconcile'; @@ -434,6 +434,85 @@ export interface GtfsRepo { * report a meaningful success / no-op status. */ deleteFeedCache(feed: Feed): Promise; + + /** + * Plan journeys from (fromLat, fromLon) to (toLat, toLon) departing at + * `departMin` minutes since local midnight on `localDate` ("YYYYMMDD"). + * Walk + transit (bus/trolley/tram) only. Returns up to `maxResults` + * itineraries, ranked fastest-arrival with a mild transfer penalty. + * + * Runs client-side RAPTOR (src/lib/domain/raptor.ts) over the bound + * feed's SQLite -- no network, works offline. The RAPTOR graph is built + * lazily on first call and cached per feed (plannerNetwork.ts), so + * views that never plan a journey pay nothing. + * + * Access/egress walking is straight-line (Haversine) in this cut; the + * distances feed into arrival ranking. Street-accurate walking is a + * follow-up that swaps the walk-cost source without touching RAPTOR. + */ + planJourney(opts: PlanJourneyOptions): Promise; +} + +/** Input to {@link GtfsRepo.planJourney}. Coordinates are WGS84; the + * caller resolves addresses / GPS / map taps to lat-lon before calling. */ +export interface PlanJourneyOptions { + fromLat: number; + fromLon: number; + toLat: number; + toLon: number; + /** "YYYYMMDD" in feed-local time. */ + localDate: string; + /** Minutes since local midnight for the desired departure. */ + departMin: number; + maxResults?: number; +} + +/** One stop on a planner leg. `time` is seconds since local midnight + * (may exceed 86400 for after-midnight trips). */ +export interface PlannerStopTime { + id: string; + name: string; + lat: number; + lon: number; + time: number; +} + +export type PlannerLeg = + | { + kind: 'walk'; + variant: 'access' | 'egress' | 'transfer'; + meters: number; + seconds: number; + from: { lat: number; lon: number; name?: string }; + to: { lat: number; lon: number; name?: string }; + } + | { + kind: 'transit'; + routeId: string; + routeShortName: string; + routeColor: string; + routeType: VehicleType; + headsign: string | null; + /** Seconds waited at the boarding stop; 0 for the first vehicle. */ + waitSec: number; + board: PlannerStopTime; + alight: PlannerStopTime; + /** Board -> alight inclusive, in stop order. */ + stops: PlannerStopTime[]; + /** Real road geometry for this leg, clipped from shapes.txt between + * board and alight. Absent when the feed carries no shape for the + * trip -- draw through `stops` as a fallback. */ + shape?: Array<{ lat: number; lon: number }>; + }; + +/** One itinerary from {@link GtfsRepo.planJourney}. Times are seconds + * since local midnight. */ +export interface PlannerJourney { + departTime: number; + arriveTime: number; + durationSec: number; + transfers: number; + legs: PlannerLeg[]; } /** Per-stop assembled vehicles, as pushed by `subscribeStationBoards`. diff --git a/src/lib/domain/raptor.test.ts b/src/lib/domain/raptor.test.ts new file mode 100644 index 00000000..b11c0167 --- /dev/null +++ b/src/lib/domain/raptor.test.ts @@ -0,0 +1,172 @@ +// Pins RAPTOR's core guarantees on tiny synthetic networks: direct vs +// transfer, transfer wait time, the transfer penalty in ranking, +// service-day filtering, and street-agnostic footpath transfers. These are +// the semantics the app relies on; the SQLite hydration is tested elsewhere. + +import { describe, expect, it } from 'vitest'; +import { buildPatterns, buildFootpaths, plan, type TripInput, type RaptorNetwork, type RaptorLeg } from './raptor'; + +type TransitLeg = Extract; +const isTransit = (l: RaptorLeg): l is TransitLeg => l.kind === 'transit'; + +const t = (h: number, m: number, s = 0) => h * 3600 + m * 60 + s; + +// A--B--C--D roughly along a line; E sits a few metres from C for the footpath case. +const STOPS = [ + { id: 'A', lat: 46.77, lon: 23.59 }, + { id: 'B', lat: 46.775, lon: 23.595 }, + { id: 'C', lat: 46.78, lon: 23.6 }, + { id: 'D', lat: 46.785, lon: 23.605 }, + { id: 'E', lat: 46.7801, lon: 23.6001 }, +]; + +/** R1 A->B->C, R2 C->D, R3 A->D direct. R2 also has a weekend-only run. */ +function baseTrips(): TripInput[] { + return [ + { tripId: 'R1a', serviceId: 'WD', routeId: 'R1', + stops: [ + { stopId: 'A', arr: t(9, 0), dep: t(9, 0) }, + { stopId: 'B', arr: t(9, 5), dep: t(9, 5) }, + { stopId: 'C', arr: t(9, 10), dep: t(9, 10) }, + ] }, + { tripId: 'R1b', serviceId: 'WD', routeId: 'R1', + stops: [ + { stopId: 'A', arr: t(9, 20), dep: t(9, 20) }, + { stopId: 'B', arr: t(9, 25), dep: t(9, 25) }, + { stopId: 'C', arr: t(9, 30), dep: t(9, 30) }, + ] }, + { tripId: 'R2a', serviceId: 'WD', routeId: 'R2', + stops: [ + { stopId: 'C', arr: t(9, 15), dep: t(9, 15) }, + { stopId: 'D', arr: t(9, 25), dep: t(9, 25) }, + ] }, + { tripId: 'R2wknd', serviceId: 'WE', routeId: 'R2', + stops: [ + { stopId: 'C', arr: t(9, 11), dep: t(9, 11) }, + { stopId: 'D', arr: t(9, 18), dep: t(9, 18) }, + ] }, + { tripId: 'R3a', serviceId: 'WD', routeId: 'R3', + stops: [ + { stopId: 'A', arr: t(9, 2), dep: t(9, 2) }, + { stopId: 'D', arr: t(9, 28), dep: t(9, 28) }, + ] }, + ]; +} + +function net(trips: TripInput[], footpaths = new Map()): RaptorNetwork { + return { ...buildPatterns(trips), footpaths }; +} + +describe('buildPatterns', () => { + it('groups trips with the same stop sequence into one pattern', () => { + const { patterns, routesAtStop } = buildPatterns(baseTrips()); + // R1a + R1b share A->B->C; R2a + R2wknd share C->D; R3a is A->D. + expect(patterns).toHaveLength(3); + const r1 = patterns.find((p) => p.stops.join() === 'A,B,C')!; + expect(r1.trips.map((t) => t.tripId)).toEqual(['R1a', 'R1b']); // sorted by first departure + expect(routesAtStop.get('C')?.length).toBe(2); // A->B->C (idx 2) and C->D (idx 0) + }); + + it('drops trips with non-finite times', () => { + const { patterns } = buildPatterns([ + { tripId: 'bad', serviceId: 'WD', routeId: 'X', + stops: [{ stopId: 'A', arr: NaN, dep: t(9, 0) }, { stopId: 'B', arr: t(9, 5), dep: t(9, 5) }] }, + ]); + expect(patterns).toHaveLength(0); + }); +}); + +describe('plan', () => { + const query = (over = {}) => ({ + access: [{ stopId: 'A', seconds: 0, meters: 0 }], + egress: [{ stopId: 'D', seconds: 0, meters: 0 }], + departTime: t(9, 0), + activeServices: new Set(['WD']), + ...over, + }); + + it('finds the direct 0-transfer journey', () => { + const js = plan(net(baseTrips()), query()); + const direct = js.find((j) => j.transfers === 0); + expect(direct).toBeDefined(); + const transit = direct!.legs.filter((l) => l.kind === 'transit'); + expect(transit).toHaveLength(1); + expect(transit[0]).toMatchObject({ routeId: 'R3', boardStop: 'A', alightStop: 'D', alightTime: t(9, 28) }); + }); + + it('finds a 1-transfer journey with the correct wait at the transfer stop', () => { + const js = plan(net(baseTrips()), query()); + const via = js.find((j) => j.transfers === 1); + expect(via).toBeDefined(); + const transit = via!.legs.filter(isTransit); + expect(transit.map((l) => l.routeId)).toEqual(['R1', 'R2']); + expect(via!.arriveTime).toBe(t(9, 25)); + // arrive C 09:10, board R2 09:15 -> wait 5 min. + expect(transit[1].waitSec).toBe(t(0, 5)); + expect(transit[1].boardStop).toBe('C'); + }); + + it('ranks the direct option above a slightly-faster transfer (transfer penalty)', () => { + const js = plan(net(baseTrips()), query()); + // Transfer arrives 09:25 (earlier) but costs a 5-min penalty; direct 09:28 wins. + expect(js[0].transfers).toBe(0); + expect(js[0].arriveTime).toBe(t(9, 28)); + }); + + it('excludes trips whose service is not active', () => { + const weekday = plan(net(baseTrips()), query()); + expect(weekday.find((j) => j.transfers === 1)!.arriveTime).toBe(t(9, 25)); + // With the weekend service active, the earlier R2wknd (dep C 09:11) is catchable. + const weekend = plan(net(baseTrips()), query({ activeServices: new Set(['WD', 'WE']) })); + expect(weekend.find((j) => j.transfers === 1)!.arriveTime).toBe(t(9, 18)); + }); + + it('adds access and egress walk time to the itinerary', () => { + const js = plan(net(baseTrips()), query({ + access: [{ stopId: 'A', seconds: 120, meters: 150 }], + egress: [{ stopId: 'D', seconds: 180, meters: 220 }], + })); + const direct = js.find((j) => j.transfers === 0)!; + expect(direct.legs[0]).toMatchObject({ kind: 'walk', variant: 'access', toStop: 'A', seconds: 120 }); + expect(direct.legs.at(-1)).toMatchObject({ kind: 'walk', variant: 'egress', fromStop: 'D', seconds: 180 }); + expect(direct.arriveTime).toBe(t(9, 28) + 180); // egress walk pushes arrival + expect(direct.departTime).toBe(t(9, 2)); // first vehicle boarding + }); + + it('returns nothing transit-only walkable (no vehicle) journeys', () => { + // egress at A, the same as access -> a pure walk, which must not be returned. + const js = plan(net(baseTrips()), query({ egress: [{ stopId: 'A', seconds: 0, meters: 0 }] })); + expect(js.every((j) => j.legs.some((l) => l.kind === 'transit'))).toBe(true); + }); +}); + +describe('footpath transfers', () => { + it('builds symmetric short-walk transfers between nearby stops', () => { + const foot = buildFootpaths(STOPS, { radiusM: 200 }); + const cToE = foot.get('C')?.find((f) => f.to === 'E'); + expect(cToE).toBeDefined(); + expect(cToE!.meters).toBeLessThan(200); + expect(foot.get('E')?.some((f) => f.to === 'C')).toBe(true); + }); + + it('uses a footpath to transfer between routes at different stops', () => { + // R2 now departs from E (a short walk from C), not C itself. + const trips = baseTrips().map((tr) => + tr.tripId === 'R2a' + ? { ...tr, stops: [{ stopId: 'E', arr: t(9, 16), dep: t(9, 16) }, { stopId: 'D', arr: t(9, 26), dep: t(9, 26) }] } + : tr, + ); + const footpaths = buildFootpaths(STOPS, { radiusM: 200 }); + const js = plan({ ...buildPatterns(trips), footpaths }, { + access: [{ stopId: 'A', seconds: 0, meters: 0 }], + egress: [{ stopId: 'D', seconds: 0, meters: 0 }], + departTime: t(9, 0), + activeServices: new Set(['WD']), + }); + const via = js.find((j) => j.transfers === 1 && j.legs.some((l) => l.kind === 'walk' && l.variant === 'transfer')); + expect(via).toBeDefined(); + const walk = via!.legs.find((l) => l.kind === 'walk' && l.variant === 'transfer')!; + expect(walk).toMatchObject({ fromStop: 'C', toStop: 'E' }); + expect(via!.arriveTime).toBe(t(9, 26)); + }); +}); diff --git a/src/lib/domain/raptor.ts b/src/lib/domain/raptor.ts new file mode 100644 index 00000000..059a8b26 --- /dev/null +++ b/src/lib/domain/raptor.ts @@ -0,0 +1,425 @@ +/* + * RAPTOR journey planner -- pure, framework-free, feed-agnostic. + * + * Round-based transit routing over GTFS: round k yields the earliest + * arrival reachable with at most k vehicles, so we naturally get the + * "fastest with N transfers" family instead of one convoluted path. + * Prefer this over a plain Connection-Scan because CSA's per-stop labels + * reconstruct phantom transfers (validated the difference in the Python + * prototype behind this port). + * + * This module knows nothing about SQLite, stop names, coordinates, or + * route colours. It operates on plain arrays and returns itineraries keyed + * by stop/trip/route IDs plus times; the worker query layer hydrates those + * into UI-facing legs. That keeps the algorithm unit-testable on tiny + * synthetic networks with no DB and no DOM. + * + * Times are seconds since local midnight and MAY exceed 86400 (GTFS encodes + * after-midnight trips as 25:10:00 etc.) -- never wrap them here. + */ + +import { haversineMeters } from '@n3ary/gtfs-spec/shape'; + +// -- Inputs to preprocessing --------------------------------------------- + +export interface StopTimeInput { + stopId: string; + /** seconds since midnight; may exceed 86400 */ + arr: number; + dep: number; +} +export interface TripInput { + tripId: string; + serviceId: string; + routeId: string; + /** in stop_sequence order */ + stops: StopTimeInput[]; +} +export interface StopPoint { + id: string; + lat: number; + lon: number; +} + +// -- Preprocessed network ------------------------------------------------ + +export interface RaptorTrip { + tripId: string; + serviceId: string; + routeId: string; + /** [arr, dep] per stop index, parallel to the pattern's stops */ + times: ReadonlyArray; +} +export interface Pattern { + stops: readonly string[]; + /** sorted ascending by departure at the first stop */ + trips: readonly RaptorTrip[]; + /** depAt[stopIdx][tripIdx] = departure time; used for the earliest-trip search */ + depAt: readonly number[][]; +} +export interface Footpath { + to: string; + seconds: number; + meters: number; +} +export interface RaptorNetwork { + patterns: readonly Pattern[]; + /** stopId -> [patternIdx, stopIdxWithinPattern][] */ + routesAtStop: ReadonlyMap>; + footpaths: ReadonlyMap; +} + +// -- Query + result ------------------------------------------------------ + +export interface StopWalk { + stopId: string; + seconds: number; + meters: number; +} +export interface PlanQuery { + access: readonly StopWalk[]; + egress: readonly StopWalk[]; + /** seconds since midnight */ + departTime: number; + activeServices: ReadonlySet; + maxRounds?: number; + transferPenaltySec?: number; + maxResults?: number; +} +export type RaptorLeg = + | { + kind: 'walk'; + variant: 'access' | 'egress' | 'transfer'; + /** null for the origin (access) / destination (egress) endpoint */ + fromStop: string | null; + toStop: string | null; + seconds: number; + meters: number; + } + | { + kind: 'transit'; + tripId: string; + routeId: string; + boardStop: string; + alightStop: string; + boardTime: number; + alightTime: number; + stopIds: readonly string[]; + /** per stop: departure, except the alight stop which is arrival */ + stopTimes: readonly number[]; + /** seconds waited at the boarding stop; 0 for the first vehicle */ + waitSec: number; + }; +export interface RaptorJourney { + departTime: number; + arriveTime: number; + durationSec: number; + transfers: number; + legs: readonly RaptorLeg[]; +} + +// -- Defaults ------------------------------------------------------------ + +const DEFAULTS = { + maxRounds: 5, + transferPenaltySec: 300, + maxResults: 4, + walkSpeedMps: 1.3, + transferRadiusM: 200, + minTransferSec: 60, +} as const; + +// -- Preprocessing ------------------------------------------------------- + +/** Group trips into patterns (identical stop sequence). Trips with a + * missing arr/dep are dropped -- an incomplete row can't be scanned. */ +export function buildPatterns(trips: readonly TripInput[]): Pick { + type MutablePattern = { stops: string[]; trips: RaptorTrip[]; depAt: number[][] }; + const patterns: MutablePattern[] = []; + const routesAtStop = new Map>(); + const byKey = new Map(); + + for (const trip of trips) { + if (trip.stops.some((s) => !Number.isFinite(s.arr) || !Number.isFinite(s.dep))) continue; + const stops = trip.stops.map((s) => s.stopId); + const key = JSON.stringify(stops); + let pi = byKey.get(key); + if (pi === undefined) { + pi = patterns.length; + byKey.set(key, pi); + patterns.push({ stops, trips: [], depAt: [] }); + stops.forEach((stopId, idx) => { + const list = routesAtStop.get(stopId); + const entry = [pi as number, idx] as const; + if (list) list.push(entry); + else routesAtStop.set(stopId, [entry]); + }); + } + patterns[pi].trips.push({ + tripId: trip.tripId, + serviceId: trip.serviceId, + routeId: trip.routeId, + times: trip.stops.map((s) => [s.arr, s.dep] as const), + }); + } + + for (const p of patterns) { + p.trips.sort((a, b) => a.times[0][1] - b.times[0][1]); + p.depAt = p.stops.map((_, i) => p.trips.map((tr) => tr.times[i][1])); + } + return { patterns, routesAtStop }; +} + +/** Symmetric walking transfers between nearby stops, via a spatial grid so + * we don't do an O(n^2) sweep. Straight-line -- the app can replace these + * with street distances later without touching the algorithm. */ +export function buildFootpaths( + stops: readonly StopPoint[], + opts: { radiusM?: number; walkSpeedMps?: number; minTransferSec?: number } = {}, +): Map { + const radius = opts.radiusM ?? DEFAULTS.transferRadiusM; + const speed = opts.walkSpeedMps ?? DEFAULTS.walkSpeedMps; + const minTransfer = opts.minTransferSec ?? DEFAULTS.minTransferSec; + const cell = radius / 111_000; + const grid = new Map(); + const key = (gx: number, gy: number) => `${gx}:${gy}`; + for (const s of stops) { + const k = key(Math.round(s.lat / cell), Math.round(s.lon / cell)); + const bucket = grid.get(k); + if (bucket) bucket.push(s); + else grid.set(k, [s]); + } + const foot = new Map(); + for (const s of stops) { + const gx = Math.round(s.lat / cell); + const gy = Math.round(s.lon / cell); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (const o of grid.get(key(gx + dx, gy + dy)) ?? []) { + if (o.id === s.id) continue; + const d = haversineMeters(s.lat, s.lon, o.lat, o.lon); + if (d > radius) continue; + const list = foot.get(s.id); + const fp: Footpath = { to: o.id, seconds: Math.round(d / speed) + minTransfer, meters: Math.round(d) }; + if (list) list.push(fp); + else foot.set(s.id, [fp]); + } + } + } + } + return foot; +} + +// -- Algorithm ----------------------------------------------------------- + +type Label = + | { type: 'access'; seconds: number; meters: number } + | { type: 'foot'; from: string; seconds: number; meters: number } + | { type: 'trip'; patternIdx: number; tripIdx: number; boardIdx: number; thisIdx: number }; + +/** first index in `sorted` whose value is >= target */ +function lowerBound(sorted: readonly number[], target: number): number { + let lo = 0; + let hi = sorted.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (sorted[mid] < target) lo = mid + 1; + else hi = mid; + } + return lo; +} + +function earliestTrip(p: Pattern, stopIdx: number, ready: number, active: ReadonlySet): number { + const deps = p.depAt[stopIdx]; + for (let t = lowerBound(deps, ready); t < p.trips.length; t++) { + if (active.has(p.trips[t].serviceId)) return t; + } + return -1; +} + +export function plan(net: RaptorNetwork, query: PlanQuery): RaptorJourney[] { + const maxRounds = query.maxRounds ?? DEFAULTS.maxRounds; + const penalty = query.transferPenaltySec ?? DEFAULTS.transferPenaltySec; + const maxResults = query.maxResults ?? DEFAULTS.maxResults; + const active = query.activeServices; + + const tau: Array> = [new Map()]; + const labels: Array> = [new Map()]; + for (const a of query.access) { + const t = query.departTime + a.seconds; + if (t < (tau[0].get(a.stopId) ?? Infinity)) { + tau[0].set(a.stopId, t); + labels[0].set(a.stopId, { type: 'access', seconds: a.seconds, meters: a.meters }); + } + } + let marked = new Set(tau[0].keys()); + + for (let k = 1; k <= maxRounds; k++) { + const tk = new Map(tau[k - 1]); + const lk = new Map(labels[k - 1]); + tau.push(tk); + labels.push(lk); + const prev = tau[k - 1]; + + // routes to scan this round, each from its earliest marked stop + const scan = new Map(); + for (const s of marked) { + const rs = net.routesAtStop.get(s); + if (!rs) continue; + for (const [pi, idx] of rs) { + const cur = scan.get(pi); + if (cur === undefined || idx < cur) scan.set(pi, idx); + } + } + + const newMarked = new Set(); + for (const [pi, start] of scan) { + const p = net.patterns[pi]; + let tripIdx = -1; + let boardIdx = -1; + for (let i = start; i < p.stops.length; i++) { + const s = p.stops[i]; + if (tripIdx >= 0) { + const arr = p.trips[tripIdx].times[i][0]; + if (arr < (tk.get(s) ?? Infinity)) { + tk.set(s, arr); + lk.set(s, { type: 'trip', patternIdx: pi, tripIdx, boardIdx, thisIdx: i }); + newMarked.add(s); + } + } + const pr = prev.get(s); + if (pr !== undefined && (tripIdx < 0 || pr <= p.trips[tripIdx].times[i][1])) { + const nt = earliestTrip(p, i, pr, active); + if (nt >= 0 && (tripIdx < 0 || nt < tripIdx)) { + tripIdx = nt; + boardIdx = i; + } + } + } + } + + for (const s of [...newMarked]) { + const base = tk.get(s)!; + for (const f of net.footpaths.get(s) ?? []) { + if (base + f.seconds < (tk.get(f.to) ?? Infinity)) { + tk.set(f.to, base + f.seconds); + lk.set(f.to, { type: 'foot', from: s, seconds: f.seconds, meters: f.meters }); + newMarked.add(f.to); + } + } + } + + marked = newMarked; + if (marked.size === 0) break; + } + + return enumerate(net, tau, labels, query, penalty, maxResults); +} + +function enumerate( + net: RaptorNetwork, + tau: Array>, + labels: Array>, + query: PlanQuery, + penalty: number, + maxResults: number, +): RaptorJourney[] { + const egressMap = new Map(query.egress.map((e) => [e.stopId, e])); + // Keep the best (lowest total arrival) journey per line-combination so + // the user sees real alternatives (line 42 vs 19), not near-duplicates. + const bySig = new Map(); + + for (let k = 1; k < tau.length; k++) { + for (const e of query.egress) { + const arrAtStop = tau[k].get(e.stopId); + if (arrAtStop === undefined || !Number.isFinite(arrAtStop)) continue; + const legs = reconstruct(net, labels, k, e.stopId, egressMap); + const transit = legs.filter((l): l is Extract => l.kind === 'transit'); + if (transit.length === 0) continue; + addWaits(legs); + const total = arrAtStop + e.seconds; + const depart = transit[0].boardTime; + const sig = transit.map((l) => `${l.routeId}@${l.boardStop}`).join('>'); + const existing = bySig.get(sig); + if (!existing || total < existing.arriveTime) { + bySig.set(sig, { + departTime: depart, + arriveTime: total, + durationSec: total - depart, + transfers: transit.length - 1, + legs, + }); + } + } + } + + return [...bySig.values()] + .sort((a, b) => a.arriveTime + a.transfers * penalty - (b.arriveTime + b.transfers * penalty)) + .slice(0, maxResults); +} + +function reconstruct( + net: RaptorNetwork, + labels: Array>, + round: number, + egressStop: string, + egressMap: Map, +): RaptorLeg[] { + const legs: RaptorLeg[] = []; + const ew = egressMap.get(egressStop)!; + legs.push({ kind: 'walk', variant: 'egress', fromStop: egressStop, toStop: null, seconds: ew.seconds, meters: ew.meters }); + + let s = egressStop; + let k = round; + for (let guard = 0; guard < 500; guard++) { + const l = labels[k].get(s); + if (!l) break; + if (l.type === 'access') { + legs.push({ kind: 'walk', variant: 'access', fromStop: null, toStop: s, seconds: l.seconds, meters: l.meters }); + break; + } + if (l.type === 'foot') { + legs.push({ kind: 'walk', variant: 'transfer', fromStop: l.from, toStop: s, seconds: l.seconds, meters: l.meters }); + s = l.from; + continue; + } + const p = net.patterns[l.patternIdx]; + const trip = p.trips[l.tripIdx]; + const stopIds: string[] = []; + const stopTimes: number[] = []; + for (let idx = l.boardIdx; idx <= l.thisIdx; idx++) { + stopIds.push(p.stops[idx]); + stopTimes.push(idx === l.thisIdx ? trip.times[idx][0] : trip.times[idx][1]); + } + legs.push({ + kind: 'transit', + tripId: trip.tripId, + routeId: trip.routeId, + boardStop: p.stops[l.boardIdx], + alightStop: p.stops[l.thisIdx], + boardTime: trip.times[l.boardIdx][1], + alightTime: trip.times[l.thisIdx][0], + stopIds, + stopTimes, + waitSec: 0, + }); + s = p.stops[l.boardIdx]; + k -= 1; + } + legs.reverse(); + return legs; +} + +/** Fill transit `waitSec` at transfers (not the first vehicle -- the rider + * times their departure to catch it). */ +function addWaits(legs: RaptorLeg[]): void { + let ready: number | null = null; + for (const l of legs) { + if (l.kind === 'walk') { + if (l.variant === 'transfer' && ready !== null) ready += l.seconds; + } else { + if (ready !== null) l.waitSec = Math.max(0, l.boardTime - ready); + ready = l.alightTime; + } + } +} diff --git a/src/lib/workers/gtfs.worker.ts b/src/lib/workers/gtfs.worker.ts index 2926c1b8..948227de 100644 --- a/src/lib/workers/gtfs.worker.ts +++ b/src/lib/workers/gtfs.worker.ts @@ -44,6 +44,7 @@ import { getNetworks } from './gtfs/queries/networks'; import { getTags } from './gtfs/queries/routeTags'; import { getFeedConfig } from './gtfs/queries/feedConfig'; import { getStationBoard, getStationBoardsNear } from './gtfs/queries/stationBoards'; +import { planJourney } from './gtfs/queries/planner'; import { getDeparturesFromStop, getOriginRoutesAtStop, getStopsByIds, getStopsNear, searchStops } from './gtfs/queries/stops'; import { getWeeklySchedule } from './gtfs/queries/weeklySchedule'; import { getRoutesThroughStations as getRoutesThroughStationsImpl, getStationsPage as getStationsPageImpl } from './gtfs/queries/favoritesQueries'; @@ -204,6 +205,11 @@ const api: GtfsRepo = { ); }, + // -- Journey planner ------------------------------------------------- + async planJourney(opts) { + return planJourney(await ensureDb(), opts); + }, + // ── Per-route views ───────────────────────────────────────────────── async getRouteSchedule(routeId, directionId, localDate, fromMin, windowMinutes) { return getRouteSchedule( diff --git a/src/lib/workers/gtfs/bootstrap.ts b/src/lib/workers/gtfs/bootstrap.ts index f0f4b46d..ef2a8b91 100644 --- a/src/lib/workers/gtfs/bootstrap.ts +++ b/src/lib/workers/gtfs/bootstrap.ts @@ -20,6 +20,7 @@ import sqlite3InitModule, { import type { Feed } from '$lib/data/feeds'; import { feedDbFiles, opfsFileFor, pruneStaleFeedFiles } from '../opfsFilenames'; import { shapeCache } from './shapeCache'; +import { clearPlannerNetwork } from './plannerNetwork'; import { resetLiveSnapshot, stopLiveTimer } from './livePipeline'; import { state } from './state'; @@ -586,6 +587,8 @@ export function closeCurrent(): void { // Shape polylines are feed-scoped — invalidate so the next feed // can't see stale entries from this one. shapeCache.clear(); + // Same for the planner's RAPTOR graph. + clearPlannerNetwork(); // Live-pipeline state is feed-scoped too: stop the timer and drop // the cached snapshot so the next feed doesn't briefly broadcast // stale vehicles. diff --git a/src/lib/workers/gtfs/plannerNetwork.ts b/src/lib/workers/gtfs/plannerNetwork.ts new file mode 100644 index 00000000..e9bab44a --- /dev/null +++ b/src/lib/workers/gtfs/plannerNetwork.ts @@ -0,0 +1,147 @@ +/* + * Planner network cache -- builds the RAPTOR graph (patterns + footpaths) + * from the bound feed's SQLite once, then reuses it across planJourney + * calls. Feed-scoped like shapeCache: `closeCurrent()` in bootstrap.ts + * calls clearPlannerNetwork() on every feed switch so one feed's patterns + * can't leak into the next. + * + * Built lazily -- only the first planJourney() pays the preprocessing, so + * routes that never open the planner (Stations, Schedule, Map) don't carry + * its cost. The heavy lifting (grouping trips into patterns) lives in the + * pure `domain/raptor.ts`; this module is just the SQLite -> RAPTOR adapter + * plus the metadata maps the query layer needs to hydrate legs. + */ + +import type { Database } from '@sqlite.org/sqlite-wasm'; +import { selectAll } from './sqlHelpers'; +import { buildFootpaths, buildPatterns, type RaptorNetwork, type TripInput } from '$lib/domain/raptor'; +import { vehicleTypeFromGtfs, type VehicleType } from '$lib/domain/types'; + +export interface RouteMeta { + shortName: string; + color: string; + type: VehicleType; +} +export interface StopMeta { + name: string; + lat: number; + lon: number; +} +export interface LatLon { + lat: number; + lon: number; +} +export interface PlannerNetwork { + net: RaptorNetwork; + routeMeta: ReadonlyMap; + tripHeadsign: ReadonlyMap; + /** trip_id -> shape_id, for drawing the real road geometry of a leg. */ + tripShape: ReadonlyMap; + /** shape_id -> ordered polyline (shape_pt_sequence). */ + shapes: ReadonlyMap; + stops: ReadonlyMap; +} + +const WALK_SPEED_MPS = 1.3; +const TRANSFER_RADIUS_M = 200; + +let cache: { db: Database; value: PlannerNetwork } | null = null; + +/** GTFS "HH:MM:SS" (may exceed 24h, e.g. "25:13:00") -> seconds since + * midnight. Returns NaN for blank/malformed so the caller can drop it. */ +function parseSeconds(t: string | null): number { + if (!t) return NaN; + const p = t.split(':'); + if (p.length < 2) return NaN; + return Number(p[0]) * 3600 + Number(p[1]) * 60 + Number(p[2] ?? 0); +} + +/** Build (or return cached) RAPTOR network for the given DB handle. Keyed + * on the handle itself: a feed switch opens a new Database, so the stale + * entry never matches. */ +export function getPlannerNetwork(db: Database): PlannerNetwork { + if (cache && cache.db === db) return cache.value; + const value = build(db); + cache = { db, value }; + return value; +} + +export function clearPlannerNetwork(): void { + cache = null; +} + +interface TripRow { trip_id: string; route_id: string; service_id: string; trip_headsign: string | null; shape_id: string | null } +interface RouteRow { route_id: string; route_short_name: string | null; route_color: string | null; route_type: number | null } +interface StopRow { stop_id: string; stop_name: string | null; stop_lat: number; stop_lon: number } +interface StopTimeRow { trip_id: string; stop_id: string; arrival_time: string | null; departure_time: string | null } +interface ShapeRow { shape_id: string; shape_pt_lat: number; shape_pt_lon: number } + +function build(db: Database): PlannerNetwork { + const routeMeta = new Map(); + for (const r of selectAll(db, `SELECT route_id, route_short_name, route_color, route_type FROM routes;`)) { + routeMeta.set(r.route_id, { + shortName: r.route_short_name ?? '?', + color: r.route_color ? `#${r.route_color}` : '#666666', + type: vehicleTypeFromGtfs(r.route_type), + }); + } + + const tripRoute = new Map(); + const tripService = new Map(); + const tripHeadsign = new Map(); + const tripShape = new Map(); + for (const t of selectAll(db, `SELECT trip_id, route_id, service_id, trip_headsign, shape_id FROM trips;`)) { + tripRoute.set(t.trip_id, t.route_id); + tripService.set(t.trip_id, t.service_id); + tripHeadsign.set(t.trip_id, t.trip_headsign); + if (t.shape_id) tripShape.set(t.trip_id, t.shape_id); + } + + const shapes = new Map(); + for (const s of selectAll( + db, + `SELECT shape_id, shape_pt_lat, shape_pt_lon FROM shapes ORDER BY shape_id, shape_pt_sequence;`, + )) { + const pts = shapes.get(s.shape_id); + const p = { lat: s.shape_pt_lat, lon: s.shape_pt_lon }; + if (pts) pts.push(p); + else shapes.set(s.shape_id, [p]); + } + + const stops = new Map(); + const stopPoints: Array<{ id: string; lat: number; lon: number }> = []; + for (const s of selectAll( + db, + `SELECT stop_id, stop_name, stop_lat, stop_lon FROM stops WHERE stop_lat IS NOT NULL AND stop_lon IS NOT NULL;`, + )) { + stops.set(s.stop_id, { name: s.stop_name ?? s.stop_id, lat: s.stop_lat, lon: s.stop_lon }); + stopPoints.push({ id: s.stop_id, lat: s.stop_lat, lon: s.stop_lon }); + } + + // Group stop_times (ordered by trip + sequence) into per-trip inputs. + const byTrip = new Map(); + for (const st of selectAll( + db, + `SELECT trip_id, stop_id, arrival_time, departure_time FROM stop_times ORDER BY trip_id, stop_sequence;`, + )) { + const routeId = tripRoute.get(st.trip_id); + const serviceId = tripService.get(st.trip_id); + if (routeId === undefined || serviceId === undefined) continue; + let trip = byTrip.get(st.trip_id); + if (!trip) { + trip = { tripId: st.trip_id, serviceId, routeId, stops: [] }; + byTrip.set(st.trip_id, trip); + } + const arr = parseSeconds(st.arrival_time); + const dep = parseSeconds(st.departure_time); + trip.stops.push({ + stopId: st.stop_id, + arr: Number.isFinite(arr) ? arr : dep, + dep: Number.isFinite(dep) ? dep : arr, + }); + } + + const { patterns, routesAtStop } = buildPatterns([...byTrip.values()]); + const footpaths = buildFootpaths(stopPoints, { radiusM: TRANSFER_RADIUS_M, walkSpeedMps: WALK_SPEED_MPS }); + return { net: { patterns, routesAtStop, footpaths }, routeMeta, tripHeadsign, tripShape, shapes, stops }; +} diff --git a/src/lib/workers/gtfs/queries/planner.ts b/src/lib/workers/gtfs/queries/planner.ts new file mode 100644 index 00000000..dbc80ce5 --- /dev/null +++ b/src/lib/workers/gtfs/queries/planner.ts @@ -0,0 +1,118 @@ +/* + * planJourney -- walk + transit journey planning over the bound feed. + * + * Thin adapter: gather access/egress candidate stops (getStopsNear), + * resolve the day's active services (activeServicesOn), run the pure + * RAPTOR engine (domain/raptor.ts) against the cached network + * (plannerNetwork.ts), then hydrate the ID-only result into UI legs with + * names, coordinates, route metadata, and vehicle mode. + * + * All the routing logic is in the pure engine so it's unit-tested there; + * this file only does DB fetches + shaping. + */ + +import type { Database } from '@sqlite.org/sqlite-wasm'; +import { plan, type RaptorJourney, type StopWalk } from '$lib/domain/raptor'; +import type { PlanJourneyOptions, PlannerJourney, PlannerLeg, PlannerStopTime } from '$lib/data/gtfs/types'; +import { activeServicesOn } from '../activeServices'; +import { getPlannerNetwork, type LatLon, type PlannerNetwork } from '../plannerNetwork'; +import { getStopsNear } from './stops'; + +const WALK_SPEED_MPS = 1.3; +const CANDIDATE_RADIUS_M = 700; +const MAX_CANDIDATES = 8; + +export function planJourney(db: Database, opts: PlanJourneyOptions): PlannerJourney[] { + const network = getPlannerNetwork(db); + const active = new Set(activeServicesOn(db, opts.localDate)); + + const access = toWalks(getStopsNear(db, opts.fromLat, opts.fromLon, CANDIDATE_RADIUS_M, MAX_CANDIDATES)); + const egress = toWalks(getStopsNear(db, opts.toLat, opts.toLon, CANDIDATE_RADIUS_M, MAX_CANDIDATES)); + if (access.length === 0 || egress.length === 0) return []; + + const journeys = plan(network.net, { + access, + egress, + departTime: opts.departMin * 60, + activeServices: active, + maxResults: opts.maxResults ?? 4, + }); + + return journeys.map((j) => hydrate(j, network, opts)); +} + +function toWalks(stops: ReadonlyArray<{ id: string; distance?: number }>): StopWalk[] { + return stops.map((s) => { + const meters = Math.round(s.distance ?? 0); + return { stopId: s.id, seconds: Math.round(meters / WALK_SPEED_MPS), meters }; + }); +} + +/** The trip's shape between board and alight, by projecting each stop onto + * the nearest shape point. Gives a road-following polyline per leg instead + * of a straight line through stops. Undefined when the feed has no shape. */ +function clipShape(net: PlannerNetwork, tripId: string, boardId: string, alightId: string): LatLon[] | undefined { + const shapeId = net.tripShape.get(tripId); + const shp = shapeId ? net.shapes.get(shapeId) : undefined; + if (!shp || shp.length < 2) return undefined; + const nearest = (id: string): number => { + const m = net.stops.get(id); + if (!m) return 0; + let bi = 0; + let bd = Infinity; + for (let i = 0; i < shp.length; i++) { + const d = (shp[i].lat - m.lat) ** 2 + (shp[i].lon - m.lon) ** 2; + if (d < bd) { + bd = d; + bi = i; + } + } + return bi; + }; + const bi = nearest(boardId); + const ai = nearest(alightId); + const seg = bi <= ai ? shp.slice(bi, ai + 1) : shp.slice(ai, bi + 1).reverse(); + return seg.length >= 2 ? seg.map((p) => ({ lat: p.lat, lon: p.lon })) : undefined; +} + +function hydrate(j: RaptorJourney, net: PlannerNetwork, opts: PlanJourneyOptions): PlannerJourney { + const stopPoint = (stopId: string, time: number): PlannerStopTime => { + const m = net.stops.get(stopId); + return { id: stopId, name: m?.name ?? stopId, lat: m?.lat ?? 0, lon: m?.lon ?? 0, time }; + }; + const named = (stopId: string) => { + const m = net.stops.get(stopId); + return { lat: m?.lat ?? 0, lon: m?.lon ?? 0, name: m?.name ?? stopId }; + }; + + const legs: PlannerLeg[] = j.legs.map((leg): PlannerLeg => { + if (leg.kind === 'walk') { + const from = + leg.fromStop === null ? { lat: opts.fromLat, lon: opts.fromLon } : named(leg.fromStop); + const to = leg.toStop === null ? { lat: opts.toLat, lon: opts.toLon } : named(leg.toStop); + return { kind: 'walk', variant: leg.variant, meters: leg.meters, seconds: leg.seconds, from, to }; + } + const meta = net.routeMeta.get(leg.routeId); + return { + kind: 'transit', + routeId: leg.routeId, + routeShortName: meta?.shortName ?? '?', + routeColor: meta?.color ?? '#666666', + routeType: meta?.type ?? 'bus', + headsign: net.tripHeadsign.get(leg.tripId) ?? null, + waitSec: leg.waitSec, + board: stopPoint(leg.boardStop, leg.boardTime), + alight: stopPoint(leg.alightStop, leg.alightTime), + stops: leg.stopIds.map((id, i) => stopPoint(id, leg.stopTimes[i])), + shape: clipShape(net, leg.tripId, leg.boardStop, leg.alightStop), + }; + }); + + return { + departTime: j.departTime, + arriveTime: j.arriveTime, + durationSec: j.durationSec, + transfers: j.transfers, + legs, + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b2c7ffc4..973f3eda 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,7 +3,7 @@ import '$lib/styles/app.css'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { Heart, MapPin, Settings } from 'lucide-svelte'; + import { Heart, MapPin, Route, Settings } from 'lucide-svelte'; import { AppLayout, type HeaderHealth } from '$lib/ui'; import { usePwa } from '$lib/composables/usePwa.svelte'; import { useBackgroundSuspend } from '$lib/composables/useBackgroundSuspend.svelte'; @@ -75,16 +75,18 @@ // (/map/..., /schedule/...). Issue #203. // ── Nav + title ────────────────────────────────────────────────────── - type NavValue = '/' | '/favorites' | '/settings'; + type NavValue = '/' | '/planner' | '/favorites' | '/settings'; const NAV_ITEMS = [ { value: '/', label: 'Stations', icon: stationsIcon }, + { value: '/planner', label: 'Planner', icon: plannerIcon }, { value: '/favorites', label: 'Favorites', icon: favoritesIcon }, { value: '/settings', label: 'Settings', icon: settingsIcon }, ] as const; const TITLES: Record = { '/': 'Stations', + '/planner': 'Planner', '/favorites': 'Favorites', '/settings': 'Settings', }; @@ -143,6 +145,7 @@ {#snippet stationsIcon()}{/snippet} +{#snippet plannerIcon()}{/snippet} {#snippet favoritesIcon()}{/snippet} {#snippet settingsIcon()}{/snippet} diff --git a/src/routes/planner/+page.svelte b/src/routes/planner/+page.svelte new file mode 100644 index 00000000..86183d94 --- /dev/null +++ b/src/routes/planner/+page.svelte @@ -0,0 +1,474 @@ + + + +{#snippet modeIcon(t: string)} + {#if t === 'tram'}{:else}{/if} +{/snippet} + +
+ +
+ +
+ + onSearchInput('from', e.currentTarget.value)} + onfocus={() => (activeField = 'from')} + /> + +
+ +
+ + onSearchInput('to', e.currentTarget.value)} + onfocus={() => (activeField = 'to')} + /> + +
+ + + {#if activeField && results.length} +
+ {#each results as s (s.id)} + + {/each} +
+ {/if} + + +
+ + + +
+ +

+ Start defaults to your location. Tap the map to set start then destination, or search a stop. +

+ {#if error}

{error}

{/if} +
+ + +
+ + + {#each journeys as j, i (i)} + {@const transit = j.legs.filter((l) => l.kind === 'transit')} + + {/each} +
diff --git a/src/routes/planner/+page.ts b/src/routes/planner/+page.ts new file mode 100644 index 00000000..b8e2d1e4 --- /dev/null +++ b/src/routes/planner/+page.ts @@ -0,0 +1,4 @@ +// The planner hydrates on the client (map + GTFS worker); there's no +// server data and no literal hrefs for the crawler to resolve. Opt out +// of prerender the same way /favorites and /station/[id] do. +export const prerender = false;