Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/plan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- [planner.md](planner.md) -- journey planner (jos + bus/trolley/tram) on the
reserved `/planner` route. Branch `feat/planner-prototype`.
113 changes: 113 additions & 0 deletions docs/plan/planner.md
Original file line number Diff line number Diff line change
@@ -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)).
81 changes: 80 additions & 1 deletion src/lib/data/gtfs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -434,6 +434,85 @@ export interface GtfsRepo {
* report a meaningful success / no-op status.
*/
deleteFeedCache(feed: Feed): Promise<number>;

/**
* 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<PlannerJourney[]>;
}

/** 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`.
Expand Down
172 changes: 172 additions & 0 deletions src/lib/domain/raptor.test.ts
Original file line number Diff line number Diff line change
@@ -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<RaptorLeg, { kind: 'transit' }>;
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));
});
});
Loading
Loading