The "breadcrumbs" element is always present, on top of the page.
It shows you where you are on the lab.
It enables you to reach any page on the lab.
R&D review, September 2026. Scope: the OTP 2 transmodel trip query as deployed at BMC (OTP 2.9 for the two recipes since 2026-09-10; the older planner-v2 stays on 2.6), the planner-v2-1 recipe that exposes it one to one, and the mode-selection mockups. Legend for the "exposed" columns: yes = available today · partly = with a limitation · no = not available. The "→ snippet" buttons put the example in the query string on the right.
A trip is [access → transit → egress] computed together with [direct]. Each street slot takes one StreetMode; the transit part takes a set of TransportMode (optionally narrowed by submodes). On top of that, OTP accepts who may operate the legs (banned / whitelisted authorities, lines, rental networks), where the trip must pass (pass-through points, via trips), when (date-time, arrive-by, search window, paging), for whom (wheelchair, speeds, reluctances, slacks, transfer limits) and how many results (numTripPatterns, itinerary filters). Everything below is a way of asking a traveller one of those questions.
| StreetMode | Meaning | access | direct | egress | In the presets? | |
|---|---|---|---|---|---|---|
foot | walk | yes | yes | yes | default | |
bicycle | own bike, kept for the whole trip | yes | yes | yes | Bike | |
bike_park | own bike, left at the stop | yes | yes | no | Bike & park | |
bike_rental | shared bike (docked or free floating) | yes | yes | yes | Rental bike | |
scooter_rental | shared scooter | yes | yes | yes | Rental scooter (secondary), Scooter mode in 4a/4b | |
car | own car, door to door | no | yes | no | Car (2 queries), Just drive | |
car_park | own car, park & ride | yes | yes | no | Car P&R (direct foot, see the filter section) | |
car_pickup | taxi, kiss & ride, drop-off | yes | yes | yes | custom only (asymmetric) | |
car_rental | shared car | yes | yes | yes | Rental car | |
carpool | shared ride with a driver (OTP 2.9, no carpool data in the BMC graph yet) | yes | yes | yes | none, custom only | |
flexible | on-demand transport (flex data) | yes | yes | yes | On-demand transport (secondary) |
Gaps this table showed, now addressed in the mockups:
bike_rental ×3 gave foot → rail → foot → bicycle(rented) → foot), and car_pickup only needs a direct foot to keep its transit shapes.directMode), Just drive and Just cycle (transportModes= empty), On-demand transport (where flex data exists).Verified live on the BMC OTP2 (2026-09-09, Grand-Place → Meiser, next morning 8:00):
| Query | Result | |
|---|---|---|
bicycle / bicycle / bicycle | 5 itineraries: the pure bike trip and 4 bike → rail → bike. One query gives both shapes. | |
foot / car / foot | 1 itinerary: car only. The transit alternatives are gone. | |
foot / car / foot + itineraryFiltersDebug=listAll | 48 itineraries: the car trip plus the transit ones tagged transit-vs-street-filter. | |
car_park / car / foot | 1 itinerary: car only, no park & ride shape. | |
car_park / foot / foot | 4 itineraries: car → bus → walk park & ride shapes plus a walk. | |
car_pickup / foot / foot | drop-off shapes (foot → car → foot → bus → foot) plus transit. | |
bike_park / bicycle / foot | bike direct plus bike → rail park shapes. | |
scooter_rental ×3, Antwerp Centraal → MAS | 5 itineraries: foot → scooter(lime) direct and scooter → tram, bus → scooter shapes. One query, both shapes. | |
scooter_rental ×3, Brussels | walking and transit only: no scooter feed in the Brussels part of the graph (Voi bikes are there, Lime scooters only in Antwerp). |
OTP's itinerary filter (removeTransitWithHigherCostThanBestOnStreetOnly, on by default, server-wide) drops every transit itinerary that costs more than the best street-only one. A fast direct mode (car, taxi) therefore hides the transit shapes of the same query; bike does not, at city distances.
foot / foot / foot and foot / car / foot with transportModes= empty; the car preset does both in one document. foot as direct mode, otherwise the direct car kills the shape the preset is about.itineraryFiltersDebug=listAll returns the filtered itineraries tagged in systemNotices; a client can keep them and hide the tag. Product-grade version: a keepTransitAlternatives flag that maps to listAll and strips the notices server side.scooter_rental and car_rental returned no rental legs, OTP silently falls back to walking; scooters do show up in Antwerp (Lime). A preset should say so when its vehicle is not available; the rental network lists are how a customer would scope that.OTP 2 defaults the three street modes to foot when the modes input is omitted altogether, and leaves the missing ones unset when some are given (an unset slot produces no itinerary of that shape). A first version of planner-v2-1 polyfilled the missing slots with foot; that was dropped: the API keeps the OTP 2 semantics and only rejects the combinations that cannot return anything.
| Traveller intent | What to send | Result | |
|---|---|---|---|
| OTP 2 defaults | no mode parameter at all | foot / foot / foot | |
| Transit only, no door to door itinerary | accessMode=foot&egressMode=foot, no directMode | transit itineraries only (verified live) | |
| Door to door only | directMode=bicycle, nothing else | no transit at all, the direct itinerary only | |
| Street only, with transit explicitly off | directMode=car&transportModes= | direct itinerary only | |
| Transit modes without a first/last mile | transportModes=rail alone | 400: accessMode and egressMode required with transportModes | |
| One of access / egress alone | accessMode=bike_rental alone | 400: they go together |
The preset recipe follows the same model: its transit-only preset leaves the direct slot unset. No special value such as none is needed.
| OTP offers | planner-v2-1 | Mockups | Sensible exposure |
|---|---|---|---|
transportModes bus / tram / metro / rail | yes | restrict (view 1b) | Checkboxes in a settings screen, rarely per search. Default: all. |
transportSubModes (e.g. rail: local, regionalRail, longDistance, international) | no | no | Worth exposing for rail: "no IC/ICE", "regional trains only" are real requests (fares). Needs a look at which submodes the BMC graph actually carries. |
Empty transportModes = no transit | yes | via the "just drive / just cycle" presets | A "no transit" switch next to the mode row, or the direct-only presets. |
| OTP offers | planner-v2-1 | UX pattern | Verdict | |
|---|---|---|---|---|
banned.authorities | yes (bannedAgencies, BMC ids) | settings: operator checkboxes (TEC, De Lijn, STIB-MIVB, SNCB) | keep | |
whiteListed.authorities | yes, whiteListedAgencies (BMC ids) | "only SNCB" for a partner app | done, verified live (only STIB-MIVB lines came back) | |
banned.lines / whiteListed.lines | yes, bannedLines / whiteListedLines (OTP ids, nmbssncb:gr:nmbssncb:1064) | "avoid line 71" after a disruption, or a partner that only sells some lines | done, verified live (the banned line left the results) | |
banned.quays / quaysHard | no, on purpose | "avoid this station" | not implemented in OTP 2.9 either (the schema says so in the field description); nothing to expose until the engine does | |
banned.serviceJourneys | yes, bannedServiceJourneys (OTP ids, nmbssncb:gt:nmbssncb:…) | hide one specific trip | done, verified live | |
banned.rentalNetworks / whiteListed.rentalNetworks | yes, bannedRentalNetworks / whiteListedRentalNetworks (GBFS network ids: voi, villo, lime, cambio) | "I have a Villo subscription, not Dott" | done, verified live: whitelisting voi gave Voi legs only, banning it switched to Villo | |
filters (select / not, on lines, authorities, transport modes) | no, on purpose | a JSON object, and when present OTP ignores banned, whiteListed and transportModes (schema description); the separate params above express the same without JSON | ||
relaxTransitGroupPriority | yes, relaxTransitGroupPriorityRatio (≥ 1) and relaxTransitGroupPriorityConstant (seconds) | keep competing operators in the results | done; the OTP Cost scalar must be sent as a string in variables (an integer makes OTP throw) |
| OTP offers | planner-v2-1 | UX pattern | Verdict | |
|---|---|---|---|---|
from / to as coordinates | yes, fromPlace / toPlace, required | geocoder | keep | |
from / to as a place id (stop, station) | works, undocumented: fromPlaceId / toPlaceId next to the required coordinates take precedence (OTP keeps the coordinates as fallback if the place is unknown). Kept out of the doc so fromPlace / toPlace stay plainly required (OpenAPI cannot express "one of these two"); documented the day a consumer needs it | "from Brussels-Central", departure boards, station pages | verified live: itineraries start with the train, no walking leg | |
passThroughPoints | yes, repeat the param once per point, comma separated alternative place ids per point | "via Gare du Midi" | done, verified live: every itinerary passed the quay | |
viaTrip (separate query: stops with min/max slack, per segment modes) | no | multi-stop trips, "drop the kids then work" | out of scope for v2-1 (different query), worth a planner-via recipe later |
| OTP offers | planner-v2-1 | UX pattern | Verdict | |
|---|---|---|---|---|
dateTime, arriveBy | yes | depart at / arrive by | keep | |
searchWindow | yes | rarely user-facing | keep for integrators | |
pageCursor (next / previous) | yes, one cursor in both recipes (the preset recipe wraps the cursors of its queries in it, 9c) | "earlier" / "later" buttons | keep: the one-cursor story is the main UX win over planner-v2 | |
timetableView | yes | timetable vs "best trips" toggle | expose in advanced settings only | |
bookingTime | no | on-demand booking | only with flexible |
These map naturally to a profile screen (saved once) rather than to the search form; the user preferences of the mockups are exactly that.
| OTP offers | planner-v2-1 | UX pattern | |
|---|---|---|---|
wheelchairAccessible | yes | accessibility switch; also should drive foot speed and stairs avoidance (not exposed by OTP transmodel) | |
walkSpeed, bikeSpeed | yes | "walking pace: slow / normal / fast" presets mapping to m/s, never a raw number | |
walkReluctance, waitReluctance | yes | "less walking" / "less waiting" sliders, or three radio presets | |
maximumTransfers, maximumAdditionalTransfers, transferPenalty, transferSlack | yes | "fewer transfers" preference, "I need more time to transfer" (accessibility) | |
boardSlack*, alightSlack* | yes, json or split per transit mode | ops tuning, not traveller UI | |
bicycleOptimisationMethod, triangleFactors | yes | "safest / flattest / fastest route" for cyclists (Bike preset only) | |
useBikeRentalAvailabilityInformation | yes | "only stations with bikes available now" switch | |
maxDirectDurationForMode, maxAccessEgressDurationForMode | both yes, json or split per street mode (maxAccessEgressDurationFoot=PT10M; no …Car variant, car is direct only) | "walk at most 15 min to the stop", the single most requested preference in planner UIs. Caveat from OTP itself: both are performance caps that can only lower the server defaults (45 min access/egress, 4 h direct), a higher value is an OTP error (verified live: PT10H for foot gives "Invalid duration for mode WALK. The value 10h is not greater than the default 45m"), and they are hard cut-offs: a 5 min foot cap on a suburban origin returns no itinerary at all (noTransitConnection) where the uncapped search had five. Pair the control with an empty-result fallback; the soft lever stays walkReluctance | |
accessEgressPenalty | no | ops tuning | |
ignoreRealtimeUpdates, include*Cancellations | yes | debug / ops, hide from travellers | |
numTripPatterns, itineraryFilters.* | numTripPatterns and debug yes, cost limits no; in the preset recipe numTripPatterns is a budget split between the queries (9d) | integrators only | |
locale (OTP side, for names) | BMC locale handles translations | keep ours |
| Traveller intent | Covered by | Note |
|---|---|---|
| Walk & transit | all options | default |
| Own bike / own car, kept all the way | presets, toggles | own car = 2 queries (filter section) |
| Own vehicle left at the stop (P&R, B&R) | presets, "park" toggle | return-trip reversal missing everywhere |
| Shared bike / car | presets, "rent" toggle | network choice: subscriptions (1a) |
| Shared scooter | secondary preset, 4a/4b mode | rental only; no scooter data near the test origin |
| Dropped off (taxi, kiss & ride) | custom | direct must stay foot |
| Different first and last mile | park presets, otherwise custom | rental modes walk by themselves when nothing is available, so bike_rental both sides already covers "walk, then a rental bike" |
| Street only, no transit | Just drive, Just cycle | transportModes= empty |
| Transit only | secondary preset, planner-v2-1 by omitting directMode | unset slots section |
| On-demand transport | secondary preset | only where flex data exists |
| Multi-stop, via | not expressible | separate recipe |
| Avoid an operator / line / station / trip | operators, lines, trips, rental networks (ban and whitelist) | stations: not implemented by OTP 2.6 |
| Accessibility | API yes, mockups yes (1a user preferences) | global settings screen, applies to every query |
| Comfort preferences (walking, transfers, pace, caps) | API yes, mockups yes (1a: 16 controls in collapsed categories, each one parameter) | subscriptions = the ten GBFS networks of the manifest OTP 2 reads + the four GTFS operators, mapped to whiteListedRentalNetworks / whiteListedAgencies only when the traveller asks to hide the rest, since the OTP 2 trip query has no "preferred" operator, only banned / whiteListed |
Same parameters as planner-v2-1 minus accessMode, directMode, egressMode (transportModes stays: it narrows the transit part of every query of the preset), plus:
| param | role |
|---|---|
modePreset | one of the ids below, default public-transit; expands to one or several trip queries in a single OTP2 GraphQL document (planMany), every other parameter shared |
pageCursor | the same parameter as in planner-v2-1: one opaque cursor pages the whole preset, whatever its number of queries (9c) |
numTripPatterns | the same parameter too, read as a budget for the transit part of the merged list and split between the transit queries (9d) |
The response adds modePreset (id, label in the requested locale, queries keys, skippedQueries on the next pages) and the cursor pair of planner-v2-1 (plan.nextPageCursor / plan.previousPageCursor, also cursors.trip), itineraries merged and sorted like planner-v2.
| id | queries (access / direct / egress) | |
|---|---|---|
public-transit | walk: foot / foot / foot | |
bike | bike: bicycle / bicycle / bicycle | |
car | walk: foot / foot / foot · carOnly: foot / car / foot, no transit | |
bike-park-ride | bikePark: bike_park / bicycle / foot | |
car-park-ride | carPark: car_park / foot / foot | |
bike-rental | bikeRental: bike_rental / bike_rental / bike_rental | |
car-rental | carRental: car_rental / car_rental / car_rental | |
scooter-rental | scooter: scooter_rental / scooter_rental / scooter_rental | |
transit-only | transitOnly: foot / (unset) / foot | |
car-only | carOnly: foot / car / foot, no transit | |
bike-only | bikeOnly: foot / bicycle / foot, no transit | |
on-demand | flexible: flexible / flexible / flexible | |
bike-multimodal | bike: bicycle / bicycle / bicycle · bikePark: bike_park / bicycle / foot · bikeRental: bike_rental / bike_rental / bike_rental | |
car-multimodal | carOnly: foot / car / foot, no transit · carPark: car_park / foot / foot · carRental: car_rental / car_rental / car_rental | |
multimodal | walk: foot / foot / foot · bike: bicycle / bicycle / bicycle · bikePark: bike_park / bicycle / foot · bikeRental: bike_rental / bike_rental / bike_rental · scooter: scooter_rental / scooter_rental / scooter_rental · carOnly: foot / car / foot, no transit · carPark: car_park / foot / foot · carRental: car_rental / car_rental / car_rental · carPickup: car_pickup / foot / foot · flexible: flexible / flexible / flexible (demo only, about 17 s live) |
Labels exist in en, fr, nl, de. Verified live: car-multimodal merges car, park & ride and rental car itineraries from one document; transit-only returns transit itineraries and no walk-only one: leaving the direct slot unset is the transit-only mechanism, in both recipes.
mergeOtp2TripResponses) dedupes on every leg's mode + aimed times + end points (or otp leg id for transit legs): two queries of a preset returning the same itinerary (bike-multimodal: the direct bicycle trip of the own bike and the parked bike queries) keep one. Fixed 2026-09-10: the key was overridden by the legs' own id, null on street legs in OTP 2.9, so planner-v2 and the presets showed the duplicate.transit query of the multimodal profile sends modes: { transportModes } without access/egress modes, which OTP2 treats as no access at all: that query returns zero itineraries (verified live with the same variables the production builder sends). Flagged as a separate task.What the client sees: planner-v2-1-preset pages exactly like planner-v2-1. One pageCursor parameter in, plan.nextPageCursor / plan.previousPageCursor (also cursors.trip) out, whatever the number of OTP 2 queries behind the preset. "Earlier" and "Later" are one link each, the client never learns the query keys. Try it: then page with the Earlier / Later buttons of the planner page.
What OTP 2 gives us (verified live on the staging OTP 2.9, 2026-09-10, Brussels Central → Leuven, next day 08:00)
| Query | Cursor | Given the cursor of the transit query | Consequence |
|---|---|---|---|
foot / foot / foot (transit) | a next and a prev cursor, window 60 min on page 1 then 40 min | page 2: 08:31 → 09:11, page 3: 08:43 → 09:26, "prev": 07:43 → 08:20 | pages by itself |
foot / car / foot, transportModes= empty (street only) | none: nextPageCursor null, metadata null | 0 itineraries | cannot be paged, and must not receive a cursor |
two transit queries of one preset (bike and bikeParkRide) | one pair each, windows that drift apart (40 min vs 60 min on page 2) | not tried: a cursor carries the "page cut" of its own query (departure, arrival, transfers and cost of the last itinerary of the page), another query would drop or repeat itineraries | one OTP cursor per transit query, no sharing |
An OTP 2 cursor is a base64 token, readable once decoded: 2|NEXT_PAGE|2026-09-11T06:31:42Z||40m|STREET_AND_ARRIVAL_TIME|false|<cut departure>|<cut arrival>|1|3804|41045|. The direct street search still runs on the next pages (OTP's RoutingWorker always calls it), but the paging filter removes the street itinerary because it sorts before the cut: inside one query OTP itself treats the door to door trip as a first page thing.
The policy
| Rule | Why |
|---|---|
One opaque cursor per response: base64url of { v, preset, dir, cursors: { <queryKey>: <otp cursor> }, dateTime } | the client keeps one contract for both recipes; the query keys and the number of queries can change without breaking anyone; it is versioned (v) and bound to its modePreset (another preset is a 400) |
| Every transit query pages with its own OTP cursor | the only correct use of an OTP cursor |
A transit query without a cursor in the bundle reruns from dateTime, the start of the page\'s time window (OTP metadata.nextDateTime / prevDateTime of the first transit query) | an empty page or a preset that changed between two deploys keeps that query in step with the others instead of restarting it from page 1 (verified live: the rerun query answered 08:41 → 09:17 next to the paged ones) |
Street-only queries (transportModes: []: the direct car or bike trip) run on the first page only and are listed in modePreset.skippedQueries | OTP has no traffic: the car trip at 08:44 is the car trip at 08:00; rerunning it is a wasted query and a row that jumps around the list; and it is what OTP does inside one query |
Presets without transit (-only, or transportModes= empty) return no cursor | nothing to page |
Why this and not…
pageCursors): the client had to know which preset runs which queries, the contract changed with every preset edit, a single pageCursor was refused on multi query presets, and it was JSON in a query string.searchWindow on every query to align the pages: OTP takes the window from the cursor when paging, and a shared window would still not share the cut.What stays imperfect
bike-multimodal was 08:42 → 09:15 for the own bike and 08:29 → 08:37 for the parked bike). The merged list is still sorted, and planner-v2 lives with the same drift.For the UX options
Multi query presets (car, bike-multimodal, car-multimodal, the toggle designs) page like single query ones, so their remaining cost is the query count: about 2 s for car and 7 s for bike-multimodal live, against 1 to 2 s for one query. Option pages that used to warn about "as many cursors as queries" now only weigh latency; how many rows a multi query preset shows is the budget rule of 9d.
Source
src/utils/api/otp/preset-page-cursor.ts (encode, decode, build), parsePlannerV21PresetParams in src/utils/api/routing/parse-planner-v2-1-params.ts (which queries run on a page), routingPlannerV21PresetFactory.ts (the response), the pageCursor description of the preset OpenAPI.packages/data-services/src/routing/planner-v2-1/preset-page-cursor.ts and planner-v2-1.server.ts, same format and policy.What the client sees: numTripPatterns keeps its name and its meaning of "at most this many transit itineraries", but on the preset recipe it is the budget of the merged list, whatever the number of OTP 2 queries behind the preset. Omitted, the recipe picks a sane default; the split is reported in modePreset.numTripPatterns. Try it:
The problem
OTP's numItineraries is per query. Passed through unchanged, a preset returned it once per query (three times 5 for bike-multimodal), and omitted it was OTP's server value of 50 per query, tamed only by the search window (12 to 15 rows live). The obvious fix, cutting the merged list to N after the merge, is wrong with paging: an OTP cursor encodes the page cut at the last itinerary OTP returned, so a row trimmed from page 1 never shows up on page 2 either. The only safe knob is what each query asks OTP for.
The policy
| Rule | Why |
|---|---|
numTripPatterns is the budget of the transit part of the merged first page | one number, one meaning, whatever the preset |
Street-only queries (transportModes: [], the direct car or bike trip) always ask for 1, on top of the budget | OTP returns one direct trip for them at most; they do not compete with the transit shapes |
The transit queries split the budget in preset order (the bare vehicle first, the variants after): floor(N / t) each, the remainder to the first ones, never below 2 | two is the least a query with its own direct trip plus a transit alternative needs, and it keeps OTP's dynamic search window from collapsing |
| Omitted: 4 for one transit query (the planner-v2-1 default too), 3 per transit query otherwise | four rows is the single query default we wanted; three per shape is the diversity floor, two reads thin once one of them is the direct trip |
The split covers the whole preset and is reported in modePreset.numTripPatterns | the next pages ask each query for the same count as the first one (the cursor rule already requires identical parameters), and the client sees what it asked for without knowing the rule |
| Preset | Queries | Omitted | numTripPatterns=4 | numTripPatterns=8 |
|---|---|---|---|---|
public-transit | 1 transit | 4 | 4 | 8 |
car | transit + car | 4 + 1 | 4 + 1 | 8 + 1 |
bike-multimodal | 3 transit | 3, 3, 3 | 2, 2, 2 (floor) | 3, 3, 2 |
car-multimodal | car + 2 transit | 1 + 3, 3 | 1 + 2, 2 | 1 + 4, 4 |
multimodal | car + 9 transit (demo) | 1 + 9 × 3 | 1 + 9 × 2 | 1 + 9 × 2 |
Why a budget per shape and not a global top N
Caveats
numTripPatterns is a maximum for OTP, not a target: a short search window returns fewer, and the dedupe across queries can lower the total (the direct bike of the own bike and parked bike queries counts once).planner-v2-1, the single triplet recipe, defaults to 4 as well: the one default the recipe injects over OTP's semantics (OTP's own is 50, too many for a planner ui). Pass numTripPatterns explicitly for anything else.Source
src/utils/api/otp/preset-num-trip-patterns.ts (the split and the defaults, with its test), applied in parsePlannerV21PresetParams, reported by routingPlannerV21PresetFactory.ts, described on the preset's numTripPatterns parameter in the OpenAPI.packages/data-services/src/routing/planner-v2-1/preset-num-trip-patterns.ts, used by planner-v2-1.server.ts.whiteListedAgencies, bannedLines / whiteListedLines, bannedServiceJourneys, bannedRentalNetworks / whiteListedRentalNetworks, fromPlaceId / toPlaceId (undocumented), passThroughPoints, relaxTransitGroupPriorityRatio / Constant, maxAccessEgressDurationForMode (+ per-mode split), all as separate params (no JSON), in both recipes (enforced by a test), transportModes included in the preset recipe since 2026-09-10. Still open: keepTransitAlternatives (one-query car preset, maps to listAll and strips the notices).via replaces passThroughPoints, bikePreferences groups the bike settings, carpool is a new street mode, greenways is gone from the bike optimisation methods); rail transportSubModes once we know what the graph carries; filters deliberately not exposed (JSON, and it disables the banned/whitelisted params).