From 6e9be04caafe794c5ba41774e4700a566c6253c8 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Mon, 9 Dec 2024 15:47:12 +0200 Subject: [PATCH 1/6] via point routing mvp --- app/component/itinerary/Itinerary.js | 22 ++- app/component/itinerary/ItineraryDetails.js | 9 ++ .../itinerary/OriginDestinationBar.js | 1 + app/component/itinerary/PlanConnection.js | 2 + app/configurations/config.default.js | 2 +- app/util/legUtils.js | 51 +++++++ app/util/otpStrings.js | 5 +- app/util/planParamUtil.js | 6 + build/schema.graphql | 138 ++++++++++++++++-- .../src/index.js | 44 +++--- .../src/assets/trash.svg | 2 +- .../schema/schema.graphql | 138 ++++++++++++++++-- 12 files changed, 376 insertions(+), 44 deletions(-) diff --git a/app/component/itinerary/Itinerary.js b/app/component/itinerary/Itinerary.js index f23d646275..db8bfd3b83 100644 --- a/app/component/itinerary/Itinerary.js +++ b/app/component/itinerary/Itinerary.js @@ -16,6 +16,7 @@ import RouteNumberContainer from '../RouteNumberContainer'; import { getActiveLegAlertSeverityLevel } from '../../util/alertUtils'; import { getLegMode, + splitLegsAtViaPoints, compressLegs, getLegBadgeProps, isCallAgencyPickupType, @@ -287,7 +288,8 @@ const Itinerary = ( const mobile = bp => !(bp === 'large'); const legs = []; let noTransitLegs = true; - const compressedLegs = compressLegs(itinerary.legs).map(leg => ({ + const splitLegs = splitLegsAtViaPoints(itinerary.legs, intermediatePlaces); + const compressedLegs = compressLegs(splitLegs).map(leg => ({ ...leg, })); let intermediateSlack = 0; @@ -970,6 +972,7 @@ Itinerary.propTypes = { intermediatePlaces: PropTypes.arrayOf(locationShape), hideSelectionIndicator: PropTypes.bool, lowestCo2value: PropTypes.number, + viaPoints: PropTypes.arrayOf(locationShape), }; Itinerary.defaultProps = { @@ -977,6 +980,7 @@ Itinerary.defaultProps = { intermediatePlaces: [], hideSelectionIndicator: true, lowestCo2value: 0, + viaPoints: [], }; Itinerary.contextTypes = { @@ -1021,6 +1025,16 @@ const containerComponent = createFragmentContainer(ItineraryWithBreakpoint, { intermediatePlaces { stop { zoneId + gtfsId + parentStation { + gtfsId + } + } + arrival { + scheduledTime + estimated { + time + } } } route { @@ -1056,6 +1070,9 @@ const containerComponent = createFragmentContainer(ItineraryWithBreakpoint, { name stop { gtfsId + parentStation { + gtfsId + } zoneId alerts { alertSeverityLevel @@ -1075,6 +1092,9 @@ const containerComponent = createFragmentContainer(ItineraryWithBreakpoint, { to { stop { gtfsId + parentStation { + gtfsId + } zoneId alerts { alertSeverityLevel diff --git a/app/component/itinerary/ItineraryDetails.js b/app/component/itinerary/ItineraryDetails.js index bce0faca56..d11ab529c9 100644 --- a/app/component/itinerary/ItineraryDetails.js +++ b/app/component/itinerary/ItineraryDetails.js @@ -508,6 +508,9 @@ const withRelay = createFragmentContainer( } } } + parentStation { + gtfsId + } } } to { @@ -553,6 +556,9 @@ const withRelay = createFragmentContainer( } } } + parentStation { + gtfsId + } } vehicleParking { vehicleParkingId @@ -574,6 +580,9 @@ const withRelay = createFragmentContainer( code platformCode zoneId + parentStation { + gtfsId + } } } realTime diff --git a/app/component/itinerary/OriginDestinationBar.js b/app/component/itinerary/OriginDestinationBar.js index 05d0f22e7b..b11a1c80fc 100644 --- a/app/component/itinerary/OriginDestinationBar.js +++ b/app/component/itinerary/OriginDestinationBar.js @@ -101,6 +101,7 @@ class OriginDestinationBar extends React.Component { let action; if (id === parseInt(id, 10)) { // id = via point index + // item == { ...gtfsId: 'HSL:1000004' } action = 'EditJourneyViaPoint'; const points = [...this.props.viaPoints]; points[id] = { ...item }; diff --git a/app/component/itinerary/PlanConnection.js b/app/component/itinerary/PlanConnection.js index b6f69f3aa4..760cf8b856 100644 --- a/app/component/itinerary/PlanConnection.js +++ b/app/component/itinerary/PlanConnection.js @@ -18,6 +18,7 @@ const planConnection = graphql` $first: Int $before: String $last: Int + $via: [PlanViaLocationInput!] ) { plan: planConnection( dateTime: $datetime @@ -28,6 +29,7 @@ const planConnection = graphql` origin: $fromPlace destination: $toPlace modes: $modes + via: $via preferences: { accessibility: { wheelchair: { enabled: $wheelchair } } street: { diff --git a/app/configurations/config.default.js b/app/configurations/config.default.js index 094a289dd9..ea028c9654 100644 --- a/app/configurations/config.default.js +++ b/app/configurations/config.default.js @@ -784,7 +784,7 @@ export default { itinerary: false, }, - viaPointsEnabled: false, + viaPointsEnabled: true, // Toggling this off shows the alert bodytext instead of the header showAlertHeader: true, diff --git a/app/util/legUtils.js b/app/util/legUtils.js index 50c3b1e064..61c01909b3 100644 --- a/app/util/legUtils.js +++ b/app/util/legUtils.js @@ -184,6 +184,57 @@ export function getInterliningLegs(legs, index) { function bikingEnded(leg1) { return leg1.from.vehicleRentalStation && leg1.mode === 'WALK'; } + +function syntheticEndpoint(originalEndpoint, place) { + return { + ...originalEndpoint, + stop: place.stop, + lat: place.stop.lat, + lon: place.stop.lon, + name: place.stop.name, + }; +} + +export function splitLegsAtViaPoints(originalLegs, viaPlaces) { + const splitLegs = []; + const viaPoints = viaPlaces.map(p => p.gtfsId); + originalLegs.forEach(originalLeg => { + const leg = { ...originalLeg }; + const { intermediatePlaces } = leg; + if (intermediatePlaces) { + let start = 0; + let lastSplit = -1; + intermediatePlaces.forEach((place, i) => { + if ( + viaPoints.includes(place.stop.gtfsId) || + (place.stop.parentStation && + viaPoints.includes(place.stop.parentStation.gtfsId)) + ) { + const leftLeg = { + ...leg, + to: syntheticEndpoint(leg.to, place), + end: place.arrival, + intermediatePlaces: intermediatePlaces.slice(start, i), + }; + leg.intermediatePlace = true; + leg.start = place.arrival; + leg.from = syntheticEndpoint(leg.from, place); + splitLegs.push(leftLeg); + start = i + 1; + lastSplit = i; + } + }); + if (lastSplit >= 0) { + const lastPlace = intermediatePlaces[lastSplit]; + leg.from = syntheticEndpoint(leg.from, lastPlace); + leg.start = lastPlace.arrival; + leg.intermediatePlaces = intermediatePlaces.slice(lastSplit + 1); + } + } + splitLegs.push(leg); + }); + return splitLegs; +} /** * Compresses the incoming legs (affects only legs with mode BICYCLE, WALK or CITYBIKE). These are combined * so that the person will be walking their bicycle and there won't be multiple similar legs diff --git a/app/util/otpStrings.js b/app/util/otpStrings.js index 50d5bcbd6e..ed1e0ec639 100644 --- a/app/util/otpStrings.js +++ b/app/util/otpStrings.js @@ -63,7 +63,10 @@ export function locationToOTP(location) { if (location.lat) { const address = location.address || ''; const slack = location.locationSlack ? `::${location.locationSlack}` : ''; - return `${address}::${location.lat},${location.lon}${slack}`; + const addressParts = location.gtfsId + ? `${address}**${location.gtfsId}` + : address; + return `${addressParts}::${location.lat},${location.lon}${slack}`; } if (location.type === 'SelectFromMap') { return location.type; diff --git a/app/util/planParamUtil.js b/app/util/planParamUtil.js index 836df3e010..b83b17d163 100644 --- a/app/util/planParamUtil.js +++ b/app/util/planParamUtil.js @@ -267,6 +267,11 @@ export function getPlanParams( const intermediateLocations = getIntermediatePlaces({ intermediatePlaces, }); + const via = intermediateLocations.map(loc => ({ + passThrough: { + stopLocationIds: [loc.gtfsId], + }, + })); const distance = estimateItineraryDistance( fromLocation, toLocation, @@ -401,5 +406,6 @@ export function getPlanParams( modes, planType, noIterationsForShortTrips, + via, }; } diff --git a/build/schema.graphql b/build/schema.graphql index 28024656e2..a131b95fc8 100644 --- a/build/schema.graphql +++ b/build/schema.graphql @@ -73,6 +73,9 @@ interface PlaceInterface { "Entity related to an alert" union AlertEntity = Agency | Pattern | Route | RouteType | Stop | StopOnRoute | StopOnTrip | Trip | Unknown +"Rental place union that represents either a VehicleRentalStation or a RentalVehicle" +union RentalPlace = RentalVehicle | VehicleRentalStation + union StopPosition = PositionAtStop | PositionBetweenStops "A public transport agency" @@ -117,23 +120,32 @@ type Alert implements Node { "Alert cause" alertCause: AlertCauseType "Long description of the alert" - alertDescriptionText: String! + alertDescriptionText( + "Returns description with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String! "Long descriptions of the alert in all different available languages" - alertDescriptionTextTranslations: [TranslatedString!]! + alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") "Alert effect" alertEffect: AlertEffectType "hashcode from the original GTFS-RT alert" alertHash: Int "Header of the alert, if available" - alertHeaderText: String + alertHeaderText( + "Returns header with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String "Header of the alert in all different available languages" - alertHeaderTextTranslations: [TranslatedString!]! + alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") "Alert severity level" alertSeverityLevel: AlertSeverityLevelType "Url with more information" - alertUrl: String + alertUrl( + "Returns URL with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String "Url with more information in all different available languages" - alertUrlTranslations: [TranslatedString!]! + alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") "Time when this alert is not in effect anymore. Format: Unix timestamp in seconds" effectiveEndDate: Long "Time when this alert comes into effect. Format: Unix timestamp in seconds" @@ -670,8 +682,10 @@ type Leg { """ headsign: String """ - An identifier for the leg, which can be used to re-fetch transit leg information. + An identifier for the leg, which can be used to re-fetch transit leg information, except leg's fare products. Re-fetching fails when the underlying transit data no longer exists. + **Note:** when both id and fare products are queried with [Relay](https://relay.dev/), id should be queried using a suitable GraphQL alias + such as `legId: id`. Relay does not accept different fare product ids in otherwise identical legs. """ id: String """ @@ -722,6 +736,24 @@ type Leg { pickupBookingInfo: BookingInfo "This is used to indicate if boarding this leg is possible only with special arrangements." pickupType: PickupDropoffType + "Previous legs with same origin and destination stops or stations" + previousLegs( + """ + Transportation modes for which all stops in the parent station are used as possible destination stops + for the previous legs. For modes not listed, only the exact destination stop of the leg is considered. + """ + destinationModesWithParentStation: [TransitMode!], + """ + The number of alternative legs searched. If fewer than the requested number are found, + then only the found legs are returned. + """ + numberOfLegs: Int!, + """ + Transportation modes for which all stops in the parent station are used as possible origin stops + for the previous legs. For modes not listed, only the exact origin stop of the leg is considered. + """ + originModesWithParentStation: [TransitMode!] + ): [Leg!] "Whether there is real-time data about this Leg" realTime: Boolean "State of real-time data" @@ -1173,6 +1205,7 @@ type QueryType { """ Try refetching the current state of a transit leg using its id. This fails when the underlying transit data (mostly IDs) has changed or are no longer available. + Fare products cannot be refetched using this query. """ leg(id: String!): Leg """ @@ -1483,6 +1516,11 @@ type QueryType { "List of routes and agencies which are given lower preference when planning the itinerary" unpreferred: InputUnpreferred, """ + The list of points the itinerary required to pass through. + All locations are visited in the order they are listed. + """ + via: [PlanViaLocationInput!], + """ How much less bad is waiting at the beginning of the trip (replaces `waitReluctance` on the first boarding). Default value: 0.4 """ @@ -1606,7 +1644,9 @@ type QueryType { is in combination of using paging can lead to better performance and to getting a more consistent number of itineraries in each search. """ - searchWindow: Duration + searchWindow: Duration, + "The list of points the itinerary is required to pass through." + via: [PlanViaLocationInput!] ): PlanConnection @async "Get a single rental vehicle based on its ID, i.e. value of field `vehicleId`" rentalVehicle(id: String!): RentalVehicle @@ -1731,6 +1771,17 @@ type QueryType { """ ids: [String] ): [VehicleRentalStation] + "Get all rental vehicles within the specified bounding box" + vehicleRentalsByBbox( + "Northern bound of the bounding box" + maximumLatitude: CoordinateValue!, + "Eastern bound of the bounding box" + maximumLongitude: CoordinateValue!, + "Southern bound of the bounding box" + minimumLatitude: CoordinateValue!, + "Western bound of the bounding box" + minimumLongitude: CoordinateValue! + ): [RentalPlace!]! "Needed until https://github.com/facebook/relay/issues/112 is resolved" viewer: QueryType } @@ -2195,10 +2246,10 @@ type Stoptime { "The stop where this arrival/departure happens" stop: Stop """ - The sequence of the stop in the pattern. This is not required to start from 0 or be consecutive - any + The sequence of the stop in the trip. This is not required to start from 0 or be consecutive - any increasing integer sequence along the stops is valid. - The purpose of this field is to identify the stop within the pattern so it can be cross-referenced + The purpose of this field is to identify the stop within the trip so it can be cross-referenced between it and the itinerary. It is safe to cross-reference when done quickly, i.e. within seconds. However, it should be noted that real-time updates can change the values, so don't store it for longer amounts of time. @@ -2207,6 +2258,15 @@ type Stoptime { even generated. """ stopPosition: Int + """ + The position of the stop in the pattern. This is required to start from 0 and be consecutive along + the pattern, up to n-1 for a pattern with n stops. + + The purpose of this field is to identify the position of the stop within the pattern so it can be + cross-referenced between different trips on the same pattern, as stopPosition can be different + between trips even within the same pattern. + """ + stopPositionInPattern: Int! "true, if this stop is used as a time equalization stop. false otherwise." timepoint: Boolean "Trip which this stoptime is for" @@ -3854,18 +3914,28 @@ input InputModeWeight { BUS: Float "The weight of CABLE_CAR traverse mode. Values over 1 add cost to cable car travel and values under 1 decrease cost" CABLE_CAR: Float + "The weight of CARPOOL traverse mode. Values over 1 add cost to carpool travel and values under 1 decrease cost" + CARPOOL: Float + "The weight of COACH traverse mode. Values over 1 add cost to coach travel and values under 1 decrease cost" + COACH: Float "The weight of FERRY traverse mode. Values over 1 add cost to ferry travel and values under 1 decrease cost" FERRY: Float "The weight of FUNICULAR traverse mode. Values over 1 add cost to funicular travel and values under 1 decrease cost" FUNICULAR: Float "The weight of GONDOLA traverse mode. Values over 1 add cost to gondola travel and values under 1 decrease cost" GONDOLA: Float + "The weight of MONORAIL traverse mode. Values over 1 add cost to monorail travel and values under 1 decrease cost" + MONORAIL: Float "The weight of RAIL traverse mode. Values over 1 add cost to rail travel and values under 1 decrease cost" RAIL: Float "The weight of SUBWAY traverse mode. Values over 1 add cost to subway travel and values under 1 decrease cost" SUBWAY: Float + "The weight of TAXI traverse mode. Values over 1 add cost to taxi travel and values under 1 decrease cost" + TAXI: Float "The weight of TRAM traverse mode. Values over 1 add cost to tram travel and values under 1 decrease cost" TRAM: Float + "The weight of TROLLEYBUS traverse mode. Values over 1 add cost to trolleybus travel and values under 1 decrease cost" + TROLLEYBUS: Float } input InputPreferred { @@ -4079,6 +4149,20 @@ input PlanModesInput { transitOnly: Boolean = false } +""" +One of the listed stop locations must be visited on-board a transit vehicle or the journey must +alight or board at the location. +""" +input PlanPassThroughViaLocationInput { + "The label/name of the location. This is pass-through information and is not used in routing." + label: String + """ + A list of stop locations. A stop location can be a stop or a station. + It is enough to visit ONE of the locations listed. + """ + stopLocationIds: [String!]! +} + "Wrapper type for different types of preferences related to plan query." input PlanPreferencesInput { "Accessibility preferences that affect both the street and transit routing." @@ -4172,6 +4256,40 @@ input PlanTransitModesInput { transit: [PlanTransitModePreferenceInput!] } +""" +A via-location is used to specifying a location as an intermediate place the router must +route through. The via-location is either a pass-through-location or a visit-via-location. +""" +input PlanViaLocationInput @oneOf { + "Board, alight or pass-through(on-board) at the stop location." + passThrough: PlanPassThroughViaLocationInput + "Board or alight at a stop location or visit a coordinate." + visit: PlanVisitViaLocationInput +} + +""" +A visit-via-location is a physical visit to one of the stop locations or coordinates listed. An +on-board visit does not count, the traveler must alight or board at the given stop for it to to +be accepted. To visit a coordinate, the traveler must walk(bike or drive) to the closest point +in the street network from a stop and back to another stop to join the transit network. + +NOTE! Coordinates are NOT supported yet. +""" +input PlanVisitViaLocationInput { + "The label/name of the location. This is pass-through information and is not used in routing." + label: String + """ + The minimum wait time is used to force the trip to stay the given duration at the + via-location before the itinerary is continued. + """ + minimumWaitTime: Duration = "PT0S" + """ + A list of stop locations. A stop location can be a stop or a station. + It is enough to visit ONE of the locations listed. + """ + stopLocationIds: [String!] +} + "What criteria should be used when optimizing a scooter route." input ScooterOptimizationInput @oneOf { "Define optimization by weighing three criteria." diff --git a/digitransit-component/packages/digitransit-component-autosuggest-panel/src/index.js b/digitransit-component/packages/digitransit-component-autosuggest-panel/src/index.js index b08a948e0a..22b77f8e7c 100644 --- a/digitransit-component/packages/digitransit-component-autosuggest-panel/src/index.js +++ b/digitransit-component/packages/digitransit-component-autosuggest-panel/src/index.js @@ -261,6 +261,7 @@ class DTAutosuggestPanel extends React.Component { isEmbedded: PropTypes.bool, showSwapControl: PropTypes.bool, showViapointControl: PropTypes.bool, + showSlackControl: PropTypes.bool, }; static defaultProps = { @@ -297,6 +298,7 @@ class DTAutosuggestPanel extends React.Component { isEmbedded: false, showSwapControl: false, showViapointControl: false, + showSlackControl: false, }; constructor(props) { @@ -566,7 +568,7 @@ class DTAutosuggestPanel extends React.Component { } lang={this.props.lang} sources={this.props.sources} - targets={this.props.targets} + targets={['Stops', 'Stations']} filterResults={this.props.filterResults} getAutoSuggestIcons={this.props.getAutoSuggestIcons} isMobile={this.props.isMobile} @@ -578,24 +580,26 @@ class DTAutosuggestPanel extends React.Component { showScroll={this.props.showScroll} /> - this.handleToggleViaPointSlackClick(i)} - onKeyPress={e => - isKeyboardSelectionEvent(e) && - this.handleToggleViaPointSlackClick(i) - } - aria-label={i18next.t( - isViaPointSlackTimeInputActive(i) - ? 'add-via-duration-button-label-open' - : 'add-via-duration-button-label-close', - { index: i + 1 }, - )} - wide - > - - + {this.props.showSlackControl && ( + this.handleToggleViaPointSlackClick(i)} + onKeyPress={e => + isKeyboardSelectionEvent(e) && + this.handleToggleViaPointSlackClick(i) + } + aria-label={i18next.t( + isViaPointSlackTimeInputActive(i) + ? 'add-via-duration-button-label-open' + : 'add-via-duration-button-label-close', + { index: i + 1 }, + )} + wide + > + + + )} {!isViaPointSlackTimeInputActive(i) && viaPoints[i] && @@ -643,7 +647,7 @@ class DTAutosuggestPanel extends React.Component { index: i + 1, })} > - + ))} diff --git a/digitransit-component/packages/digitransit-component-icon/src/assets/trash.svg b/digitransit-component/packages/digitransit-component-icon/src/assets/trash.svg index a5c3e80a95..ab0e4a3704 100644 --- a/digitransit-component/packages/digitransit-component-icon/src/assets/trash.svg +++ b/digitransit-component/packages/digitransit-component-icon/src/assets/trash.svg @@ -1,6 +1,6 @@ - + diff --git a/digitransit-search-util/packages/digitransit-search-util-query-utils/schema/schema.graphql b/digitransit-search-util/packages/digitransit-search-util-query-utils/schema/schema.graphql index 28024656e2..a131b95fc8 100644 --- a/digitransit-search-util/packages/digitransit-search-util-query-utils/schema/schema.graphql +++ b/digitransit-search-util/packages/digitransit-search-util-query-utils/schema/schema.graphql @@ -73,6 +73,9 @@ interface PlaceInterface { "Entity related to an alert" union AlertEntity = Agency | Pattern | Route | RouteType | Stop | StopOnRoute | StopOnTrip | Trip | Unknown +"Rental place union that represents either a VehicleRentalStation or a RentalVehicle" +union RentalPlace = RentalVehicle | VehicleRentalStation + union StopPosition = PositionAtStop | PositionBetweenStops "A public transport agency" @@ -117,23 +120,32 @@ type Alert implements Node { "Alert cause" alertCause: AlertCauseType "Long description of the alert" - alertDescriptionText: String! + alertDescriptionText( + "Returns description with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String! "Long descriptions of the alert in all different available languages" - alertDescriptionTextTranslations: [TranslatedString!]! + alertDescriptionTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertDescriptionText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertDescriptionText` field.") "Alert effect" alertEffect: AlertEffectType "hashcode from the original GTFS-RT alert" alertHash: Int "Header of the alert, if available" - alertHeaderText: String + alertHeaderText( + "Returns header with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String "Header of the alert in all different available languages" - alertHeaderTextTranslations: [TranslatedString!]! + alertHeaderTextTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertHeaderText` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertHeaderText` field.") "Alert severity level" alertSeverityLevel: AlertSeverityLevelType "Url with more information" - alertUrl: String + alertUrl( + "Returns URL with the specified language, if found, otherwise returns the value with some default language." + language: String + ): String "Url with more information in all different available languages" - alertUrlTranslations: [TranslatedString!]! + alertUrlTranslations: [TranslatedString!]! @deprecated(reason : "Use `alertUrl` instead. `accept-language` header can be used for translations or the `language` parameter on the `alertUrl` field.") "Time when this alert is not in effect anymore. Format: Unix timestamp in seconds" effectiveEndDate: Long "Time when this alert comes into effect. Format: Unix timestamp in seconds" @@ -670,8 +682,10 @@ type Leg { """ headsign: String """ - An identifier for the leg, which can be used to re-fetch transit leg information. + An identifier for the leg, which can be used to re-fetch transit leg information, except leg's fare products. Re-fetching fails when the underlying transit data no longer exists. + **Note:** when both id and fare products are queried with [Relay](https://relay.dev/), id should be queried using a suitable GraphQL alias + such as `legId: id`. Relay does not accept different fare product ids in otherwise identical legs. """ id: String """ @@ -722,6 +736,24 @@ type Leg { pickupBookingInfo: BookingInfo "This is used to indicate if boarding this leg is possible only with special arrangements." pickupType: PickupDropoffType + "Previous legs with same origin and destination stops or stations" + previousLegs( + """ + Transportation modes for which all stops in the parent station are used as possible destination stops + for the previous legs. For modes not listed, only the exact destination stop of the leg is considered. + """ + destinationModesWithParentStation: [TransitMode!], + """ + The number of alternative legs searched. If fewer than the requested number are found, + then only the found legs are returned. + """ + numberOfLegs: Int!, + """ + Transportation modes for which all stops in the parent station are used as possible origin stops + for the previous legs. For modes not listed, only the exact origin stop of the leg is considered. + """ + originModesWithParentStation: [TransitMode!] + ): [Leg!] "Whether there is real-time data about this Leg" realTime: Boolean "State of real-time data" @@ -1173,6 +1205,7 @@ type QueryType { """ Try refetching the current state of a transit leg using its id. This fails when the underlying transit data (mostly IDs) has changed or are no longer available. + Fare products cannot be refetched using this query. """ leg(id: String!): Leg """ @@ -1483,6 +1516,11 @@ type QueryType { "List of routes and agencies which are given lower preference when planning the itinerary" unpreferred: InputUnpreferred, """ + The list of points the itinerary required to pass through. + All locations are visited in the order they are listed. + """ + via: [PlanViaLocationInput!], + """ How much less bad is waiting at the beginning of the trip (replaces `waitReluctance` on the first boarding). Default value: 0.4 """ @@ -1606,7 +1644,9 @@ type QueryType { is in combination of using paging can lead to better performance and to getting a more consistent number of itineraries in each search. """ - searchWindow: Duration + searchWindow: Duration, + "The list of points the itinerary is required to pass through." + via: [PlanViaLocationInput!] ): PlanConnection @async "Get a single rental vehicle based on its ID, i.e. value of field `vehicleId`" rentalVehicle(id: String!): RentalVehicle @@ -1731,6 +1771,17 @@ type QueryType { """ ids: [String] ): [VehicleRentalStation] + "Get all rental vehicles within the specified bounding box" + vehicleRentalsByBbox( + "Northern bound of the bounding box" + maximumLatitude: CoordinateValue!, + "Eastern bound of the bounding box" + maximumLongitude: CoordinateValue!, + "Southern bound of the bounding box" + minimumLatitude: CoordinateValue!, + "Western bound of the bounding box" + minimumLongitude: CoordinateValue! + ): [RentalPlace!]! "Needed until https://github.com/facebook/relay/issues/112 is resolved" viewer: QueryType } @@ -2195,10 +2246,10 @@ type Stoptime { "The stop where this arrival/departure happens" stop: Stop """ - The sequence of the stop in the pattern. This is not required to start from 0 or be consecutive - any + The sequence of the stop in the trip. This is not required to start from 0 or be consecutive - any increasing integer sequence along the stops is valid. - The purpose of this field is to identify the stop within the pattern so it can be cross-referenced + The purpose of this field is to identify the stop within the trip so it can be cross-referenced between it and the itinerary. It is safe to cross-reference when done quickly, i.e. within seconds. However, it should be noted that real-time updates can change the values, so don't store it for longer amounts of time. @@ -2207,6 +2258,15 @@ type Stoptime { even generated. """ stopPosition: Int + """ + The position of the stop in the pattern. This is required to start from 0 and be consecutive along + the pattern, up to n-1 for a pattern with n stops. + + The purpose of this field is to identify the position of the stop within the pattern so it can be + cross-referenced between different trips on the same pattern, as stopPosition can be different + between trips even within the same pattern. + """ + stopPositionInPattern: Int! "true, if this stop is used as a time equalization stop. false otherwise." timepoint: Boolean "Trip which this stoptime is for" @@ -3854,18 +3914,28 @@ input InputModeWeight { BUS: Float "The weight of CABLE_CAR traverse mode. Values over 1 add cost to cable car travel and values under 1 decrease cost" CABLE_CAR: Float + "The weight of CARPOOL traverse mode. Values over 1 add cost to carpool travel and values under 1 decrease cost" + CARPOOL: Float + "The weight of COACH traverse mode. Values over 1 add cost to coach travel and values under 1 decrease cost" + COACH: Float "The weight of FERRY traverse mode. Values over 1 add cost to ferry travel and values under 1 decrease cost" FERRY: Float "The weight of FUNICULAR traverse mode. Values over 1 add cost to funicular travel and values under 1 decrease cost" FUNICULAR: Float "The weight of GONDOLA traverse mode. Values over 1 add cost to gondola travel and values under 1 decrease cost" GONDOLA: Float + "The weight of MONORAIL traverse mode. Values over 1 add cost to monorail travel and values under 1 decrease cost" + MONORAIL: Float "The weight of RAIL traverse mode. Values over 1 add cost to rail travel and values under 1 decrease cost" RAIL: Float "The weight of SUBWAY traverse mode. Values over 1 add cost to subway travel and values under 1 decrease cost" SUBWAY: Float + "The weight of TAXI traverse mode. Values over 1 add cost to taxi travel and values under 1 decrease cost" + TAXI: Float "The weight of TRAM traverse mode. Values over 1 add cost to tram travel and values under 1 decrease cost" TRAM: Float + "The weight of TROLLEYBUS traverse mode. Values over 1 add cost to trolleybus travel and values under 1 decrease cost" + TROLLEYBUS: Float } input InputPreferred { @@ -4079,6 +4149,20 @@ input PlanModesInput { transitOnly: Boolean = false } +""" +One of the listed stop locations must be visited on-board a transit vehicle or the journey must +alight or board at the location. +""" +input PlanPassThroughViaLocationInput { + "The label/name of the location. This is pass-through information and is not used in routing." + label: String + """ + A list of stop locations. A stop location can be a stop or a station. + It is enough to visit ONE of the locations listed. + """ + stopLocationIds: [String!]! +} + "Wrapper type for different types of preferences related to plan query." input PlanPreferencesInput { "Accessibility preferences that affect both the street and transit routing." @@ -4172,6 +4256,40 @@ input PlanTransitModesInput { transit: [PlanTransitModePreferenceInput!] } +""" +A via-location is used to specifying a location as an intermediate place the router must +route through. The via-location is either a pass-through-location or a visit-via-location. +""" +input PlanViaLocationInput @oneOf { + "Board, alight or pass-through(on-board) at the stop location." + passThrough: PlanPassThroughViaLocationInput + "Board or alight at a stop location or visit a coordinate." + visit: PlanVisitViaLocationInput +} + +""" +A visit-via-location is a physical visit to one of the stop locations or coordinates listed. An +on-board visit does not count, the traveler must alight or board at the given stop for it to to +be accepted. To visit a coordinate, the traveler must walk(bike or drive) to the closest point +in the street network from a stop and back to another stop to join the transit network. + +NOTE! Coordinates are NOT supported yet. +""" +input PlanVisitViaLocationInput { + "The label/name of the location. This is pass-through information and is not used in routing." + label: String + """ + The minimum wait time is used to force the trip to stay the given duration at the + via-location before the itinerary is continued. + """ + minimumWaitTime: Duration = "PT0S" + """ + A list of stop locations. A stop location can be a stop or a station. + It is enough to visit ONE of the locations listed. + """ + stopLocationIds: [String!] +} + "What criteria should be used when optimizing a scooter route." input ScooterOptimizationInput @oneOf { "Define optimization by weighing three criteria." From deb1d53efcfa2076694d5a4c44edeed4ab710015 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Tue, 10 Dec 2024 15:12:19 +0200 Subject: [PATCH 2/6] via point as last point works, otp query rerun by changing via point --- app/component/itinerary/ItineraryPage.js | 1 + app/util/legUtils.js | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/app/component/itinerary/ItineraryPage.js b/app/component/itinerary/ItineraryPage.js index a9213835a7..4a5306dcca 100644 --- a/app/component/itinerary/ItineraryPage.js +++ b/app/component/itinerary/ItineraryPage.js @@ -801,6 +801,7 @@ export default function ItineraryPage(props, context) { params.to, query.time, query.arriveBy, + query.intermediatePlaces, ]); useEffect(() => { diff --git a/app/util/legUtils.js b/app/util/legUtils.js index 61c01909b3..9c1aabb9a0 100644 --- a/app/util/legUtils.js +++ b/app/util/legUtils.js @@ -198,18 +198,29 @@ function syntheticEndpoint(originalEndpoint, place) { export function splitLegsAtViaPoints(originalLegs, viaPlaces) { const splitLegs = []; const viaPoints = viaPlaces.map(p => p.gtfsId); + const isViaPointMatch = stop => + stop && + (viaPoints.includes(stop.gtfsId) || + (stop.parentStation && viaPoints.includes(stop.parentStation.gtfsId))); + let isFirstTransitLeg = true; + let nextLegStartsWithIntermediate = false; originalLegs.forEach(originalLeg => { const leg = { ...originalLeg }; const { intermediatePlaces } = leg; + if ( + nextLegStartsWithIntermediate || + (leg.transitLeg && isFirstTransitLeg && isViaPointMatch(leg.from.stop)) + ) { + leg.intermediatePlace = true; + } + if (leg.transitLeg) { + isFirstTransitLeg = false; + } if (intermediatePlaces) { let start = 0; let lastSplit = -1; intermediatePlaces.forEach((place, i) => { - if ( - viaPoints.includes(place.stop.gtfsId) || - (place.stop.parentStation && - viaPoints.includes(place.stop.parentStation.gtfsId)) - ) { + if (isViaPointMatch(place.stop)) { const leftLeg = { ...leg, to: syntheticEndpoint(leg.to, place), @@ -232,6 +243,9 @@ export function splitLegsAtViaPoints(originalLegs, viaPlaces) { } } splitLegs.push(leg); + if (leg.transitLeg && isViaPointMatch(leg.to.stop)) { + nextLegStartsWithIntermediate = true; + } }); return splitLegs; } From dc5f57be9b288a96ecb51afa300a6734ae190267 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Tue, 10 Dec 2024 16:47:29 +0200 Subject: [PATCH 3/6] only add one via point marker per each marker, no matter how many times it matches --- app/util/legUtils.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/util/legUtils.js b/app/util/legUtils.js index 9c1aabb9a0..d55c1d6aa8 100644 --- a/app/util/legUtils.js +++ b/app/util/legUtils.js @@ -195,13 +195,31 @@ function syntheticEndpoint(originalEndpoint, place) { }; } +/** + * Adds intermediate: true to legs if their start point should have a via point + * marker, possibly splitting legs in case the via point belongs in the middle. + * + * @param originalLegs Leg objects from graphql query + * @param viaPlaces Location objects (otpToLocation) from query parameter + * @returns {*[]} + */ export function splitLegsAtViaPoints(originalLegs, viaPlaces) { const splitLegs = []; + // Once a via place is matched, it is used and will not match again. + function includesAndRemove(array, id) { + const index = array.indexOf(id); + if (index >= 0) { + array.splice(index, 1); + return true; + } + return false; + } const viaPoints = viaPlaces.map(p => p.gtfsId); const isViaPointMatch = stop => stop && - (viaPoints.includes(stop.gtfsId) || - (stop.parentStation && viaPoints.includes(stop.parentStation.gtfsId))); + (includesAndRemove(viaPoints, stop.gtfsId) || + (stop.parentStation && + includesAndRemove(viaPoints, stop.parentStation.gtfsId))); let isFirstTransitLeg = true; let nextLegStartsWithIntermediate = false; originalLegs.forEach(originalLeg => { @@ -212,6 +230,7 @@ export function splitLegsAtViaPoints(originalLegs, viaPlaces) { (leg.transitLeg && isFirstTransitLeg && isViaPointMatch(leg.from.stop)) ) { leg.intermediatePlace = true; + nextLegStartsWithIntermediate = false; } if (leg.transitLeg) { isFirstTransitLeg = false; From 618534e6623f70a1b9042b0b3f9a94f21f432ed7 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Wed, 11 Dec 2024 16:00:49 +0200 Subject: [PATCH 4/6] disable via point addition with map popup until we get full via point support in backend --- app/component/map/tile-layer/TileLayerContainer.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/component/map/tile-layer/TileLayerContainer.js b/app/component/map/tile-layer/TileLayerContainer.js index d1a5bdcbc3..c8fe11649b 100644 --- a/app/component/map/tile-layer/TileLayerContainer.js +++ b/app/component/map/tile-layer/TileLayerContainer.js @@ -45,6 +45,7 @@ class TileLayerContainer extends GridLayer { tileSize: PropTypes.number.isRequired, zoomOffset: PropTypes.number.isRequired, locationPopup: PropTypes.string, // all, none, reversegeocoding, origindestination + allowViaPoint: PropTypes.bool, // temporary, until OTP2 handles arbitrary via points onSelectLocation: PropTypes.func, mergeStops: PropTypes.bool, mapLayers: mapLayerShape.isRequired, @@ -70,6 +71,7 @@ class TileLayerContainer extends GridLayer { static defaultProps = { onSelectLocation: undefined, locationPopup: undefined, + allowViaPoint: false, objectsToHide: { vehicleRentalStations: [] }, hilightedStops: undefined, stopsToShow: undefined, @@ -353,6 +355,10 @@ class TileLayerContainer extends GridLayer { let contents; const breakpoint = getClientBreakpoint(); let showPopup = true; + const locationPopup = + this.props.allowViaPoint || this.props.locationPopup !== 'all' + ? this.props.locationPopup + : 'origindestination'; if (typeof this.state.selectableTargets !== 'undefined') { if (this.state.selectableTargets.length === 1) { @@ -434,7 +440,7 @@ class TileLayerContainer extends GridLayer { ) { showPopup = false; } - popup = this.props.locationPopup !== 'none' && ( + popup = locationPopup !== 'none' && ( ); From e5bf041e4559c3204dff94c2983e53db12a9c7f6 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Thu, 12 Dec 2024 13:37:52 +0200 Subject: [PATCH 5/6] better visibility for via points in itinerary legs --- app/util/legUtils.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/util/legUtils.js b/app/util/legUtils.js index d55c1d6aa8..e33dd88876 100644 --- a/app/util/legUtils.js +++ b/app/util/legUtils.js @@ -220,21 +220,17 @@ export function splitLegsAtViaPoints(originalLegs, viaPlaces) { (includesAndRemove(viaPoints, stop.gtfsId) || (stop.parentStation && includesAndRemove(viaPoints, stop.parentStation.gtfsId))); - let isFirstTransitLeg = true; let nextLegStartsWithIntermediate = false; originalLegs.forEach(originalLeg => { const leg = { ...originalLeg }; const { intermediatePlaces } = leg; if ( nextLegStartsWithIntermediate || - (leg.transitLeg && isFirstTransitLeg && isViaPointMatch(leg.from.stop)) + (leg.transitLeg && isViaPointMatch(leg.from.stop)) ) { leg.intermediatePlace = true; nextLegStartsWithIntermediate = false; } - if (leg.transitLeg) { - isFirstTransitLeg = false; - } if (intermediatePlaces) { let start = 0; let lastSplit = -1; From 5f0b06a68cf2741a776a17305b9bf3e3db70cdb0 Mon Sep 17 00:00:00 2001 From: Teemu Kalvas Date: Thu, 12 Dec 2024 15:23:14 +0200 Subject: [PATCH 6/6] do not add two ViaLegs at the same place no matter what --- app/component/itinerary/Itinerary.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/component/itinerary/Itinerary.js b/app/component/itinerary/Itinerary.js index db8bfd3b83..08d0385318 100644 --- a/app/component/itinerary/Itinerary.js +++ b/app/component/itinerary/Itinerary.js @@ -391,9 +391,14 @@ const Itinerary = ( renderBar = false; addition += legLength; // carry over the length of the leg to the next } + // There are two places which inject ViaLegs in this logic, but we certainly + // don't want to add it twice in the same place with the same key, so we + // record whether we added it here at the first place. + let viaAdded = false; if (leg.intermediatePlace) { onlyIconLegs += 1; legs.push(); + viaAdded = true; } if (isLegOnFoot(leg) && renderBar) { const walkingTime = Math.floor(leg.duration / 60); @@ -557,7 +562,8 @@ const Itinerary = ( if ( previousLeg && !previousLeg.intermediatePlace && - connectsFromViaPoint(leg, intermediatePlaces) + connectsFromViaPoint(leg, intermediatePlaces) && + !viaAdded ) { legs.push(); }